线程池原理:核心参数、工作队列与拒绝策略
线程池这东西,说白了就是「复用线程」的容器。我刚开始接触并发编程时,总觉得每次 new Thread() 挺爽的,直到线上服务被频繁创建线程搞到 OOM……嗯,从那以后我再也不敢手撸线程了。
今天咱们就聊聊线程池的三个核心模块:参数怎么配、队列怎么选、拒绝策略怎么用。这些都是我实际项目中踩过的坑,希望能帮你少走弯路。
一、核心参数:七个参数,一个都不能少
Java 的 ThreadPoolExecutor 有七个参数,我习惯把它们分成三组来看:
| 参数 | 作用 | 我的建议 |
|---|---|---|
| corePoolSize | 核心线程数 | 根据 CPU 密集型或 IO 密集型来定 |
| maximumPoolSize | 最大线程数 | 一般不超过 corePoolSize 的 2 倍 |
| keepAliveTime | 空闲线程存活时间 | 默认 60 秒,够用 |
| unit | 时间单位 | 通常用 TimeUnit.SECONDS |
| workQueue | 任务队列 | 重点,下面细说 |
| threadFactory | 线程工厂 | 一定要自定义,方便排查问题 |
| handler | 拒绝策略 | 根据业务场景选 |
核心逻辑:当提交任务时,如果当前线程数小于 corePoolSize,直接创建新线程执行;如果大于 corePoolSize 且队列未满,则放入队列;如果队列满了且线程数小于 maximumPoolSize,创建临时线程;如果都满了,触发拒绝策略。
我个人习惯把 corePoolSize 设为 CPU 核心数 + 1(CPU 密集型)或 2 * CPU 核心数(IO 密集型)。但别死板,线上环境最好压测一下。
二、工作队列机制:三种队列,各有千秋
队列是线程池的「缓冲层」。我见过不少同学因为队列选错,导致系统崩溃。咱们一个一个看:
1. SynchronousQueue
这个队列不存储任务,它直接把任务交给线程。说白了,就是「一手交任务,一手交线程」。适合任务量不大、但要求快速响应的场景。
我曾经在一个实时计算项目里用过它,配合较大的 maximumPoolSize,效果还不错。但要注意,如果任务提交速度超过处理速度,会频繁创建线程,甚至触发拒绝策略。
2. LinkedBlockingQueue
无界队列,可以一直往里面塞任务。看起来很美,但风险很大——队列可能会无限增长,最终撑爆内存。
避坑指南:我曾经接手过一个遗留系统,线程池用了无界队列,结果某次大促时队列里堆积了上千万个任务,直接导致 Full GC 频繁,服务挂了半小时。从那以后,我再也不敢用无界队列了。
3. ArrayBlockingQueue
有界队列,可以指定容量。这是我最推荐的队列类型。容量设置多少?我一般根据任务处理时间和系统负载来算:
// 假设每个任务处理 100ms,系统能接受的最大延迟是 5 秒
// 那么队列容量 = 5000ms / 100ms = 50
ArrayBlockingQueue<Runnable> queue = new ArrayBlockingQueue<>(50);
当然,这只是个粗略估算。实际项目中我会留 20% 的余量,再配合监控告警,这样比较稳妥。
三、拒绝策略详解:四种策略,选对很重要
当线程池和队列都满了,新任务怎么办?JDK 提供了四种策略:
| 策略 | 行为 | 适用场景 |
|---|---|---|
| AbortPolicy | 直接抛异常 | 关键任务,不能丢 |
| CallerRunsPolicy | 调用者线程执行 | 降低任务提交速度 |
| DiscardPolicy | 默默丢弃 | 非重要任务 |
| DiscardOldestPolicy | 丢弃最旧的任务 | 时效性要求高的场景 |
我的经验:大部分场景用 CallerRunsPolicy 比较安全。它让提交任务的线程自己执行,相当于「反压」机制——提交者慢了,自然就减少了任务提交量。我在日志采集系统里就用这个策略,效果很好。
但要注意,CallerRunsPolicy 可能会阻塞调用线程。如果调用线程是 Web 请求线程,那就要小心了,可能会导致请求超时。
四、实战配置示例
说了这么多,咱们看一个完整的配置示例:
ThreadPoolExecutor executor = new ThreadPoolExecutor(
4, // corePoolSize
8, // maximumPoolSize
60, // keepAliveTime
TimeUnit.SECONDS, // unit
new ArrayBlockingQueue<>(100), // workQueue
new ThreadFactoryBuilder()
.setNameFormat("my-pool-%d")
.build(), // threadFactory
new ThreadPoolExecutor.CallerRunsPolicy() // handler
);
这个配置适合大多数 IO 密集型应用。核心线程 4 个,最大 8 个,队列容量 100。当任务量暴增时,先填队列,再扩线程,最后让调用者自己执行。
重要提醒:一定要自定义 ThreadFactory!默认的线程名是 pool-1-thread-1,线上排查问题时根本不知道是哪个线程池。我习惯用 Guava 的 ThreadFactoryBuilder 或者自己实现一个,给线程池起个有意义的名字。
五、监控与调优
线程池配好了不代表万事大吉。我建议至少监控这几个指标:
- 活跃线程数:是否经常达到 maximumPoolSize
- 队列积压:队列长度是否持续增长
- 拒绝次数:触发了多少次拒绝策略
- 任务执行时间:单个任务的平均耗时
我曾经在一个支付系统中发现,线程池的活跃线程数经常飙到最大值,队列也一直在涨。排查后发现是某个第三方接口响应变慢了,导致任务积压。后来加了超时和熔断,问题就解决了。
嗯,线程池的原理其实不复杂,但用好了能大幅提升系统稳定性。记住一句话:参数要压测,队列要有界,策略要选对,监控不能少。
下一章咱们聊聊线程池的源码实现,看看 JDK 到底是怎么管理这些线程的。到时候你会发现,原来线程池的设计思路这么巧妙。