线程池核心参数与调优
线程池核心参数与调优
Executors 提供的几个工厂方法看起来很方便,但 newFixedThreadPool 用无界队列、newCachedThreadPool 最大线程数是 Integer.MAX_VALUE,在生产环境都可能引发 OOM。正确做法是直接 new ThreadPoolExecutor(...),显式指定每个参数。本文把七个参数的协作顺序讲清楚,并给出容量估算和监控的实操方案。
一、七个参数与任务流转
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler)
| 参数 | 含义 |
|---|---|
corePoolSize |
核心线程数,默认常驻(可用 allowCoreThreadTimeOut 回收) |
maximumPoolSize |
最大线程数 |
keepAliveTime + unit |
超出核心数的线程空闲存活时间 |
workQueue |
任务队列,决定排队策略 |
threadFactory |
线程工厂,用于命名、设优先级、设守护线程 |
handler |
拒绝策略 |
execute 的提交逻辑是理解线程池的关键:
public void execute(Runnable command) {
if (command == null) throw new NullPointerException();
int c = ctl.get();
// 1. 工作线程数 < corePoolSize,直接新建线程
if (workerCountOf(c) < corePoolSize) {
if (addWorker(command, true))
return;
c = ctl.get();
}
// 2. 核心已满,尝试入队
if (isRunning(c) && workQueue.offer(command)) {
int recheck = ctl.get();
if (!isRunning(recheck) && remove(command))
reject(command);
else if (workerCountOf(recheck) == 0)
addWorker(null, false); // 防止队列有任务但无消费者
}
// 3. 入队失败(队列满),尝试扩容到 maximumPoolSize
else if (!addWorker(command, false))
reject(command); // 4. 仍失败,走拒绝策略
}
顺序可以概括为:核心线程 → 队列 → 非核心线程 → 拒绝。这解释了一个常见困惑:为什么用了有界队列后,线程数迟迟不增长?因为队列没满之前根本不会创建非核心线程。想让线程数快速扩张,队列容量就要设小。
ctl 是一个 AtomicInteger,高 3 位存运行状态,低 29 位存线程数:
private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0));
private static final int COUNT_BITS = Integer.SIZE - 3; // 29
private static final int CAPACITY = (1 << COUNT_BITS) - 1;
这样状态和线程数可以用一次 CAS 原子更新,避免两个字段不一致。
线程状态共有五种,流转关系决定了 shutdown 的行为:
RUNNING ──shutdown()──► SHUTDOWN ──队列空且线程为 0──► TIDYING ──► TERMINATED
│ ▲
└──shutdownNow()──► STOP ─┘
SHUTDOWN 状态仍会执行队列中的任务,但拒绝新任务;STOP 状态直接中断所有工作线程,并清空队列。这就是为什么优雅停机要先 shutdown() 再 awaitTermination(),最后才考虑 shutdownNow()。
addWorker 里的双重检查也值得注意:创建线程前要再次确认线程池状态,避免在关闭过程中新建线程。Worker 继承 AbstractQueuedSynchronizer,用一个不可重入的锁标记"是否正在执行任务",shutdown 时通过 tryLock 判断线程是否空闲,空闲的才能被中断。
二、队列选择
| 队列 | 特性 | 适用场景 |
|---|---|---|
ArrayBlockingQueue |
有界数组,公平性可选 | 需要背压、容量可控 |
LinkedBlockingQueue |
默认容量 Integer.MAX_VALUE |
必须显式指定容量,否则等同无界 |
SynchronousQueue |
不存储元素,直接交给线程 | 高吞吐、任务短、线程数可放大 |
PriorityBlockingQueue |
无界优先级队列 | 任务有优先级,慎防堆积 |
DelayQueue |
延迟队列 | 定时任务、重试调度 |
生产环境的原则是队列必须有界。无界队列在任务生产速度超过消费速度时只会不断堆积,最终 OOM,而且线程数永远停在 corePoolSize,maximumPoolSize 形同虚设。
三、拒绝策略
JDK 内置四种:
| 策略 | 行为 |
|---|---|
AbortPolicy(默认) |
抛 RejectedExecutionException |
CallerRunsPolicy |
由提交任务的线程执行,形成天然背压 |
DiscardPolicy |
静默丢弃,不抛异常 |
DiscardOldestPolicy |
丢弃队列最老的任务,再重试提交 |
DiscardPolicy 和 DiscardOldestPolicy 会静默丢任务,除非业务明确允许,否则不要用。CallerRunsPolicy 会让调用线程(可能是 Tomcat 的 IO 线程)执行任务,虽然能减缓提交速度,但如果任务耗时长,会拖慢整个请求链路。
更稳妥的做法是自定义拒绝策略,记录日志并落盘:
public class LoggingRejectHandler implements RejectedExecutionHandler {
private static final Logger log = LoggerFactory.getLogger(LoggingRejectHandler.class);
@Override
public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
log.error("task rejected, poolSize={}, activeCount={}, queueSize={}",
e.getPoolSize(), e.getActiveCount(), e.getQueue().size());
// 落库、写 MQ、或降级处理
throw new RejectedExecutionException("task rejected");
}
}
四、容量估算
线程数的经典公式来自 Brian Goetz 的《Java 并发编程实战》:
线程数 = CPU 核心数 × 目标 CPU 利用率 × (1 + 等待时间 / 计算时间)
实践中按任务类型简化:
| 任务类型 | 初始值 | 说明 |
|---|---|---|
| CPU 密集型 | N + 1 |
N 为核数,加 1 是为了在偶发缺页时不让 CPU 空转 |
| IO 密集型 | 2N 到 4N |
具体取决于等待占比,IO 阻塞越久,线程数可以越多 |
| 混合型 | 拆分成两个池 | 按 IO 与 CPU 比例拆分,避免相互影响 |
这些只是起点,必须用压测校准。可观测的指标包括:getActiveCount()、getQueue().size()、任务平均耗时、拒绝次数。判断标准:
- 队列长期非空且活跃线程数等于
maximumPoolSize→ 容量不足,或下游变慢。 - 活跃线程数长期远低于
corePoolSize→ 核心线程过多,浪费资源。 - 拒绝次数持续增长 → 需要扩容、限流或加长队列(加长队列只是延后问题)。
JDK 19 起可以用 Thread.ofVirtual() 创建虚拟线程,IO 密集型场景可以大幅降低线程数需求,但 JDK 8/17 项目仍需按上述方式调优。
五、可运行的完整示例
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
public class PoolDemo {
public static void main(String[] args) throws Exception {
int cores = Runtime.getRuntime().availableProcessors();
AtomicInteger seq = new AtomicInteger();
ThreadPoolExecutor pool = new ThreadPoolExecutor(
cores, // corePoolSize
cores * 2, // maximumPoolSize
60L, TimeUnit.SECONDS, // 非核心线程空闲存活
new ArrayBlockingQueue<>(200), // 有界队列
r -> {
Thread t = new Thread(r, "biz-pool-" + seq.incrementAndGet());
t.setDaemon(false);
return t;
},
new ThreadPoolExecutor.CallerRunsPolicy()
);
pool.allowCoreThreadTimeOut(true); // 核心线程也可回收
for (int i = 0; i < 500; i++) {
final int id = i;
pool.execute(() -> {
try {
Thread.sleep(10);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
pool.shutdown();
if (!pool.awaitTermination(30, TimeUnit.SECONDS)) {
pool.shutdownNow();
}
System.out.println("finished");
}
}
线程工厂给线程起有意义的名字,是排查问题时最省力的一步。线程 dump 里看到 biz-pool-3 比 pool-1-thread-3 有用得多。
六、监控与动态调整
ThreadPoolExecutor 暴露了一组 getter,可以直接接入 Micrometer 或 Prometheus:
ThreadPoolExecutor pool = ...;
int poolSize = pool.getPoolSize(); // 当前线程数
int active = pool.getActiveCount(); // 活跃线程数
long completed = pool.getCompletedTaskCount();// 已完成任务数
int queueSize = pool.getQueue().size(); // 队列长度
int largest = pool.getLargestPoolSize(); // 历史峰值线程数
队列长度是最灵敏的指标。可以起一个定时任务,队列持续超过阈值就告警。
setCorePoolSize 和 setMaximumPoolSize 是运行时安全的,可以结合配置中心做动态调整。但注意调小 corePoolSize 只会影响后续,已有线程不会立即销毁,要等空闲回收。
七、并发场景分析
线程池隔离。不同业务共用一个池,一个慢接口会占满所有线程,拖垮其他接口。按业务划分池,至少把核心业务与非核心业务分开。
父子任务死锁。父任务提交子任务到同一个池并等待结果,池满时子任务排不上队,父任务占着线程不放,形成死锁。
// 危险:同一线程池内提交并等待
Future<Integer> f = pool.submit(() -> compute());
f.get(); // 池满时可能永久阻塞
解决方式是父子任务使用不同池,或改用 ForkJoinPool(工作窃取能缓解这个问题)。
submit 吞异常。execute 抛出的异常会传给 UncaughtExceptionHandler,而 submit 会把异常包进 Future,不调用 get() 就永远发现不了。
pool.submit(() -> { throw new RuntimeException("boom"); }); // 异常被吞
pool.execute(() -> { throw new RuntimeException("boom"); }); // 会打印堆栈
用 submit 时务必处理返回的 Future,或在任务内部 try-catch 兜底。
shutdown 与 shutdownNow 的区别。前者停止接收新任务,等待已提交任务执行完;后者中断所有线程并返回未执行的任务列表。只调 shutdown 不 awaitTermination,主线程可能先退出。
八、常见坑 / 最佳实践
- 禁用
Executors的快捷工厂方法。newFixedThreadPool和newSingleThreadExecutor用无界队列,newCachedThreadPool线程数无上限,都可能 OOM。直接new ThreadPoolExecutor。 - 队列必须有界,并配合合适的拒绝策略。无界队列让
maximumPoolSize失去意义。 - 线程工厂必须命名。格式建议
业务名-用途-序号,方便jstack定位。 - 按业务隔离线程池,避免故障扩散。核心链路与非核心链路分开。
submit要处理Future,或改用execute加内部try-catch,避免异常静默丢失。shutdown后要awaitTermination,优雅停机时给足超时时间,再shutdownNow兜底。- 监控队列长度和拒绝次数,这两个指标比 CPU 使用率更早暴露问题。
九、小结
- 任务流转顺序是核心线程 → 队列 → 非核心线程 → 拒绝策略,有界队列会让线程数增长得更快。
ctl用一个AtomicInteger同时保存运行状态和线程数,保证原子更新。- 线程数估算从
N+1(CPU 密集)或2N~4N(IO 密集)起步,用压测和监控校准。 - 队列必须是有界的,拒绝策略要能记录和告警,不要静默丢弃。
- 线程池要按业务隔离,并监控队列长度、活跃线程数和拒绝次数。
submit会吞异常,shutdown后要awaitTermination。
- 点赞
- 收藏
- 关注作者
评论(0)