Java 线程池核心参数详解:从原理到生产避坑
线程池是 Java 并发编程里最常用也最容易用错的工具之一。用好了,系统吞吐量翻倍;用错了,线上 OOM、任务丢失、线程暴涨......踩坑的代价往往很高。
这篇文章从核心参数讲起,再到拒绝策略怎么选,最后把生产环境里最常见的坑逐个拆开说清楚。
一、线程池核心参数详解
ThreadPoolExecutor 是 Java 线程池的核心实现,它的构造方法长这样:
java
public ThreadPoolExecutor(
int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler
)
七个参数,逐个拆开看。
1. corePoolSize(核心线程数)
线程池长期维持的线程数量。即使这些线程空闲,默认也不会被回收。
很多人以为"线程池里的线程数会随任务量自动伸缩",其实核心线程默认是常驻的 ------除非你调用了 allowCoreThreadTimeOut(true)。
怎么设?
- CPU 密集型任务(如计算、加解密):
corePoolSize = CPU 核数 ± 1 - IO 密集型任务(如数据库访问、RPC 调用):
corePoolSize = CPU 核数 × (1 + 平均等待时间 / 平均计算时间),一般 2×~4× 核数
ini
int corePoolSize = Runtime.getRuntime().availableProcessors() * 2;
2. maximumPoolSize(最大线程数)
线程池最多能创建的线程数。当任务队列满了之后,线程池会继续创建线程,直到达到这个上限。
关键理解:线程数什么时候会从 core 涨到 max?
不是任务一多就涨。实际流程是:
arduino
提交任务 → 核心线程没满?→ 创建核心线程执行
→ 核心线程满了?→ 任务进队列
→ 队列满了?→ 创建非核心线程执行
→ 线程数达到 max?→ 触发拒绝策略
所以 队列的大小直接决定了非核心线程什么时候被创建 。如果用了无界队列,非核心线程永远不会被创建,maximumPoolSize 形同虚设。
3. keepAliveTime + unit(线程存活时间)
非核心线程空闲超过这个时间就会被回收。默认只对非核心线程生效,但可以通过 allowCoreThreadTimeOut(true) 让核心线程也遵守这个规则。
生产建议:对于流量波动大的服务,建议开启核心线程超时回收,避免低峰期空占资源。
4. workQueue(任务队列)
这是最容易踩坑的参数。常见选择:
| 队列类型 | 特点 | 适用场景 |
|---|---|---|
LinkedBlockingQueue |
默认无界,可能 OOM | 不推荐直接使用 |
ArrayBlockingQueue |
有界,需指定容量 | 大多数场景 |
SynchronousQueue |
不存任务,直接交付 | 高吞吐、低延迟 |
PriorityBlockingQueue |
优先级队列 | 任务有优先级区分 |
重点提醒 :Executors.newFixedThreadPool() 和 Executors.newSingleThreadExecutor() 内部用的是无界 LinkedBlockingQueue,任务堆积时直接 OOM。这就是为什么阿里 Java 规约禁止使用 Executors 创建线程池。
5. threadFactory(线程工厂)
用于创建线程,可以自定义线程名、优先级、是否为守护线程等。
java
ThreadFactory factory = new ThreadFactory() {
private final AtomicInteger count = new AtomicInteger(1);
public Thread newThread(Runnable r) {
return new Thread(r, "biz-pool-" + count.getAndIncrement());
}
};
生产价值 :自定义线程名让排查问题时能一眼看出是哪个线程池的线程在干活,而不是看到 pool-1-thread-3 一脸懵。
6. handler(拒绝策略)
任务提交失败时的处理方式,下文单独详讲。
二、拒绝策略怎么选
当线程数达到 maximumPoolSize 且队列也满了,新提交的任务会触发拒绝策略。
JDK 内置了四种:
1. AbortPolicy(默认)------ 直接抛异常
arduino
new ThreadPoolExecutor.AbortPolicy()
抛出 RejectedExecutionException,中断调用者线程。
适用场景:任务不能丢,调用方需要感知失败并重试。大多数在线业务选这个。
2. CallerRunsPolicy ------ 调用者自己跑
arduino
new ThreadPoolExecutor.CallerRunsPolicy()
不让线程池跑,让提交任务的线程自己执行这个任务。
适用场景:任务不能丢,且能接受提交方被阻塞。相当于一种天然的背压机制------提交方跑慢了,生产速度自然就降下来了。适合异步化但要求最终执行的场景。
注意:如果提交方是 Tomcat 的 HTTP 工作线程,用了这个策略可能导致 HTTP 请求被阻塞,连接池耗尽。要慎重。
3. DiscardPolicy ------ 直接丢弃
arduino
new ThreadPoolExecutor.DiscardPolicy()
啥也不干,任务静默丢失。
适用场景:任务可丢,比如非核心的埋点上报、日志收集。但生产环境慎用,出了问题很难排查。
4. DiscardOldestPolicy ------ 丢弃队列里最老的任务
arduino
new ThreadPoolExecutor.DiscardOldestPolicy()
丢弃队列头部的任务(等待最久的那个),然后尝试重新提交当前任务。
适用场景:任务有时效性,旧任务丢了无所谓,新任务更重要。比如实时价格推送。
5. 自定义拒绝策略
生产环境最推荐的方式------记录日志、报警、降级,然后决定怎么处理:
scss
new RejectedExecutionHandler() {
@Override
public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
// 1. 记录被拒绝的任务信息
log.warn("Task rejected, pool status: active={}, queue={}",
executor.getActiveCount(), executor.getQueue().size());
// 2. 上报监控
metrics.counter("thread_pool_rejected").increment();
// 3. 写入降级存储(如 MQ、Redis),后续补偿
fallbackStorage.put(r);
// 4. 或者抛异常让调用方感知
throw new RejectedExecutionException("Task rejected, please retry");
}
};
拒绝策略选择决策树
markdown
任务能不能丢?
├── 不能丢 → 调用方能重试吗?
│ ├── 能 → AbortPolicy + 重试机制
│ └── 不能 → CallerRunsPolicy(注意背压影响)
└── 可以丢 → 丢哪个?
├── 都行 → DiscardPolicy + 监控告警
└── 保新的 → DiscardOldestPolicy
三、生产避坑指南
坑 1:用 Executors 创建线程池
scss
// ❌ 危险!无界队列,可能 OOM
Executors.newFixedThreadPool(10);
Executors.newCachedThreadPool(); // 最大线程数 Integer.MAX_VALUE
arduino
// ✅ 正确:手动创建,明确参数
new ThreadPoolExecutor(
10, 50,
60L, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(1000),
new NamedThreadFactory("biz-pool"),
new ThreadPoolExecutor.AbortPolicy()
);
坑 2:队列设置无界
无界队列 = 内存泄漏的定时炸弹。任务生产速度大于消费速度时,队列无限增长,直到 Full GC 甚至 OOM。
正确做法:根据内存和任务大小估算队列容量,配合拒绝策略形成闭环。
arduino
// 估算:单个任务对象约 1KB,队列最多占 100MB → 容量设 100000
new ArrayBlockingQueue<>(100_000);
坑 3:所有任务共用一个线程池
一个系统中如果 IO 密集和 CPU 密集任务混在一个池子里,CPU 密集任务会拖慢整个池子,IO 密集任务占满线程又会让 CPU 密集任务得不到执行。
正确做法:按业务隔离,不同性质的任务用不同的线程池。
java
// 计算任务池
ThreadPoolExecutor computePool = new ThreadPoolExecutor(
cores, cores * 2, 60, SECONDS,
new ArrayBlockingQueue<>(200), ...);
// IO 任务池
ThreadPoolExecutor ioPool = new ThreadPoolExecutor(
cores * 4, cores * 8, 60, SECONDS,
new ArrayBlockingQueue<>(2000), ...);
坑 4:忘了关闭线程池
应用关闭时如果线程池没 shutdown(),非守护线程会阻止 JVM 退出,导致优雅停机失败。
scss
// 应用关闭钩子中
executor.shutdown();
try {
if (!executor.awaitTermination(30, TimeUnit.SECONDS)) {
executor.shutdownNow();
}
} catch (InterruptedException e) {
executor.shutdownNow();
}
Spring 环境下,用 @PreDestroy 或实现 DisposableBean 来关闭。
坑 5:线程池参数写死,无法动态调整
线上流量波动时,固定的线程数和队列容量往往不够灵活。
方案 :使用支持动态调整的线程池框架,如美团技术团队开源的 DynamicTp,或者基于 ThreadPoolExecutor 的 setCorePoolSize()、setMaximumPoolSize() 方法做运行时调整。
scss
// 运行时动态调整
executor.setCorePoolSize(20);
executor.setMaximumPoolSize(100);
坑 6:任务里抛了异常,线程直接挂了
ThreadPoolExecutor 中如果任务抛出未捕获异常,执行该任务的线程会被销毁,然后线程池创建一个新线程替代。如果异常频繁,线程会不断创建销毁,而且异常信息容易被吞掉。
php
// ❌ 异常被吞
executor.submit(() -> {
throw new RuntimeException("oops"); // 异常存在 Future 里,不 get 就看不到
});
scss
// ✅ 方案 1:用 execute 而不是 submit
executor.execute(() -> {
// 异常会打印到线程的 uncaughtExceptionHandler
});
// ✅ 方案 2:submit + 一定要 get
Future<?> future = executor.submit(task);
try {
future.get();
} catch (ExecutionException e) {
log.error("Task failed", e.getCause());
}
// ✅ 方案 3:任务内部自己 try-catch
executor.execute(() -> {
try {
doWork();
} catch (Exception e) {
log.error("Task error", e);
}
});
坑 7:没有监控
线程池是黑盒,不监控就不知道它什么时候在挣扎。
核心监控指标:
| 指标 | 获取方式 | 告警阈值建议 |
|---|---|---|
| 活跃线程数 | getActiveCount() |
持续 > 80% max |
| 队列堆积 | getQueue().size() |
持续 > 70% 容量 |
| 拒绝次数 | 自定义 handler 中计数 | > 0 就告警 |
| 任务完成数 | getCompletedTaskCount() |
突降告警 |
| 线程池大小 | getPoolSize() |
频繁波动 |
接入 Micrometer/Prometheus 做可视化:
less
@Scheduled(fixedRate = 10000)
public void reportThreadPoolMetrics() {
registry.gauge("threadpool.active", executor.getActiveCount());
registry.gauge("threadpool.queue.size", executor.getQueue().size());
registry.gauge("threadpool.pool.size", executor.getPoolSize());
}
坑 8:使用 CompletableFuture 时默认线程池的陷阱
CompletableFuture 的 supplyAsync() / runAsync() 如果不指定线程池,用的是 ForkJoinPool.commonPool(),这个池子所有 CompletableFuture 共享,且线程数 = CPU 核数 - 1。
scss
// ❌ 用公共池,可能互相影响
CompletableFuture.supplyAsync(() -> doSomething());
// ✅ 指定业务线程池
CompletableFuture.supplyAsync(() -> doSomething(), bizExecutor);
四、一个生产级线程池配置模板
java
@Configuration
public class ThreadPoolConfig {
@Bean("bizTaskExecutor")
public ThreadPoolExecutor bizTaskExecutor() {
int cores = Runtime.getRuntime().availableProcessors();
return new ThreadPoolExecutor(
cores * 2, // corePoolSize
cores * 4, // maximumPoolSize
60L, TimeUnit.SECONDS, // keepAliveTime
new ArrayBlockingQueue<>(2000), // workQueue
new ThreadFactory() { // threadFactory
private final AtomicInteger count = new AtomicInteger(1);
public Thread newThread(Runnable r) {
Thread t = new Thread(r, "biz-worker-" + count.getAndIncrement());
t.setDaemon(false);
return t;
}
},
new RejectedExecutionHandler() { // handler
@Override
public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
log.error("Biz task rejected! active={}, queue={}, pool={}",
executor.getActiveCount(),
executor.getQueue().size(),
executor.getPoolSize());
metrics.counter("biz_pool_rejected").increment();
// 降级:写入延迟队列,后续补偿消费
fallbackQueue.offer(r);
// 或抛异常让调用方感知
throw new RejectedExecutionException("Biz pool overloaded");
}
}
);
}
}
总结
线程池用起来简单,用好很难。记住这几个核心原则:
- 永远手动创建线程池,不用 Executors 快捷方法
- 队列必须有界,配合合理的拒绝策略
- 不同业务隔离线程池,避免互相影响
- 异常要处理,submit 要 get,或者任务内部 try-catch
- 监控不能少,队列堆积和拒绝次数是最重要的两个指标
- 参数要能动态调整,线上情况千变万化
线程池的本质是用空间换时间、用排队换削峰。理解了这个本质,参数的选择就不是死记硬背,而是根据业务特征做权衡取舍。