线程池几乎是每个 Java 后端服务的吞吐上限与事故高发地:newFixedThreadPool 无界队列堆到 OOM、newCachedThreadPool 无限建线程、拒绝策略选错让请求静默丢失、ThreadLocal 在池化线程里泄漏……
这一篇讲清 ThreadPoolExecutor:七个参数、任务进来后的决策顺序、四种拒绝策略、线程数怎么估,以及监控、动态调参、优雅关闭。它和 连接与 I/O 模型 是高并发吞吐的一体两面——一个管线程,一个管连接。
TL;DR
- 别用 Executors 工厂方法建生产线程池:
newFixedThreadPool/newSingleThreadExecutor用无界LinkedBlockingQueue会堆积到 OOM;newCachedThreadPool最大线程数是Integer.MAX_VALUE,会建到打爆。手动new ThreadPoolExecutor并用有界队列。 - 七个参数:核心线程数、最大线程数、空闲存活时间、时间单位、工作队列、线程工厂、拒绝策略。理解它们的交互,才能理解执行流程。
- 执行流程有严格顺序:核心线程未满→建核心线程;满了→进队列;队列满了→建非核心线程直到 max;再满→拒绝策略。队列先于非核心线程被填满——这是最容易配错的一点。
- 线程数经验公式:CPU 密集型 ≈ 核数 + 1;I/O 密集型 ≈ 核数 × (1 + 等待时间/计算时间)。最终以压测校准(呼应 Little’s Law)。
- 四种拒绝策略:AbortPolicy(抛异常,默认)、CallerRunsPolicy(调用者执行,天然背压)、DiscardPolicy(静默丢弃)、DiscardOldestPolicy(丢最老)。
- 两大隐形坑:ThreadLocal 在池化线程复用时必须
remove();不同业务共用一池会互相拖垮,核心链路要独立线程池(舱壁隔离)。
Table of contents
Open Table of contents
1. 为什么要池化线程
线程不是免费的:每个线程约占 512 KB~1 MB 栈内存,创建/销毁要陷内核,线程数超过 CPU 核数数倍后上下文切换开销显著。“来一个请求 new 一个线程”在高并发下会迅速耗尽内存和调度能力(这正是 连接与 I/O 模型 里”一连接一线程”的 C10K 问题)。
线程池的三个价值:复用已创建的线程消除反复创建销毁的开销;限流——用固定线程数 + 有界队列给并发量设上界,避免过载;管理——统一的监控、命名、异常处理、生命周期控制。
2. 七个核心参数
ThreadPoolExecutor 的完整构造器:
public ThreadPoolExecutor(
int corePoolSize, // 1. 核心线程数
int maximumPoolSize, // 2. 最大线程数
long keepAliveTime, // 3. 空闲线程存活时间
TimeUnit unit, // 4. 时间单位
BlockingQueue<Runnable> workQueue, // 5. 工作队列
ThreadFactory threadFactory, // 6. 线程工厂
RejectedExecutionHandler handler) // 7. 拒绝策略
| 参数 | 作用 | 调参要点 |
|---|---|---|
| corePoolSize | 长期保留的线程数,即使空闲也不回收(除非开 allowCoreThreadTimeOut) | 按稳态负载设定 |
| maximumPoolSize | 线程数上限(核心 + 非核心) | 应对突发峰值;用 SynchronousQueue 时才真正生效 |
| keepAliveTime + unit | 非核心线程空闲多久后被回收 | 峰值过后释放资源 |
| workQueue | 核心线程忙不过来时,任务的缓冲队列 | 必须有界,否则 max 形同虚设 |
| threadFactory | 创建线程(可自定义命名、守护、优先级) | 务必自定义线程名,否则排查问题时线程栈全是 pool-1-thread-N |
| handler | 队列满且线程达 max 时怎么办 | 见第 4 节 |
3. 执行流程:队列先于非核心线程
任务 execute(task) 进来后,ThreadPoolExecutor 严格按以下顺序决策:
关键点:任务是先塞满队列,才会创建非核心线程,而不是「先把线程加到 max 再排队」。常见误配置:
// 反例:core=10, max=200, 但用了近乎无界的 LinkedBlockingQueue
new ThreadPoolExecutor(10, 200, 60, SECONDS,
new LinkedBlockingQueue<>()); // 默认容量 Integer.MAX_VALUE
因为队列几乎永远不会满,线程数永远到不了 200——高并发时任务全在队列里堆积,延迟飙升甚至 OOM,maximumPoolSize=200 完全没用。队列容量与 max 要配套设计。
3.1 工作队列怎么选
| 队列 | 特性 | 适用 |
|---|---|---|
ArrayBlockingQueue | 有界、数组、单锁 | 通用,明确容量上界 |
LinkedBlockingQueue | 默认无界(危险)、链表、双锁吞吐高 | 务必显式传容量变有界 |
SynchronousQueue | 不存元素,直接手递手 | 配合大 max,任务直接建线程(如 CachedThreadPool) |
PriorityBlockingQueue | 无界、按优先级出队 | 任务有优先级时 |
DelayedWorkQueue | 延迟到期才出队 | ScheduledThreadPoolExecutor 定时任务 |
4. 四种拒绝策略
队列满 + 线程达 max 时触发。JDK 内置四种:
| 策略 | 行为 | 后果 / 适用 |
|---|---|---|
AbortPolicy(默认) | 抛 RejectedExecutionException | 调用方能感知失败;别吞异常 |
CallerRunsPolicy | 由提交任务的线程自己执行该任务 | 天然背压:提交方被拖慢,上游自然减速。适合不能丢任务的场景 |
DiscardPolicy | 静默丢弃新任务,不抛异常 | 任务无声消失,生产上几乎不该用 |
DiscardOldestPolicy | 丢弃队列最老的任务,再尝试提交新任务 | 只在「最新数据最有价值」(如实时行情)时用 |
推荐:可丢弃的旁路任务用 AbortPolicy + 上层降级;不可丢的核心任务用 CallerRunsPolicy 做背压——把压力反推给调用方,比无界队列堆到 OOM 安全得多。也可实现 RejectedExecutionHandler 自定义(落库重试、告警)。
5. 线程数怎么算
没有万能数字,但有经验起点:
- CPU 密集型(加密、压缩、纯计算):线程数 ≈ CPU 核数 + 1。线程多于核数只会增加切换开销,多出的 1 是为偶发缺页/中断留的余量。
- I/O 密集型(调用 DB/RPC/HTTP,大量等待):线程数 ≈ 核数 × (1 + 平均等待时间 / 平均计算时间)。等待占比越高,能开的线程越多。
举例:8 核机器,某任务平均计算 10ms、等待下游 90ms,则等待/计算 = 9,线程数 ≈ 8 × (1 + 9) = 80。
这本质是 Little’s Law 的应用(并发数 = 吞吐 × 单请求耗时),与 连接与 I/O 模型 里算连接池大小同源。公式给起点,压测定终值——用 性能测试 找到吞吐拐点,别拍脑袋。
6. 生产实践
6.1 手动构造 + 自定义线程工厂
ThreadPoolExecutor bizPool = new ThreadPoolExecutor(
16, 32, // core / max:按压测定
60L, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(1000), // 有界队列,堵住无限堆积
new ThreadFactoryBuilder() // Guava/自实现:给线程命名
.setNameFormat("biz-pool-%d") // 排障时一眼看出是哪个池
.setUncaughtExceptionHandler((t, e) ->
log.error("thread {} died", t.getName(), e))
.build(),
new ThreadPoolExecutor.CallerRunsPolicy()); // 背压
ThreadFactoryBuilder 来自 Guava;没有依赖时手写一个实现 ThreadFactory 即可。给线程命名是最低成本的可观测性投资。
6.2 监控关键指标
线程池的健康度全在这几个数上,务必埋点上报(Micrometer / 自定义):
pool.getActiveCount(); // 正在执行任务的线程数
pool.getPoolSize(); // 当前线程总数
pool.getQueue().size(); // 队列积压 —— 最重要的预警信号
pool.getCompletedTaskCount(); // 已完成任务数
pool.getLargestPoolSize(); // 历史峰值线程数(判断 max 是否够)
// 拒绝次数:JDK 无现成 getter,需在自定义 RejectedExecutionHandler 里自行累计
队列积压持续上升是过载的第一信号;拒绝次数 > 0 说明已经在丢/背压任务,要么扩容要么限流。可参考 可观测性 的思路把这些接入告警。
6.3 动态调参
corePoolSize、maximumPoolSize 支持运行时修改:
pool.setCorePoolSize(24);
pool.setMaximumPoolSize(48);
美团等团队据此做了”动态线程池”:把参数外置到配置中心,结合监控实时调整,无需重启。唯一的遗憾是队列容量(ArrayBlockingQueue)不可动态改,所以有的方案用自定义可变容量队列。
6.4 优雅关闭
服务下线时要让在途任务跑完,别硬杀:
pool.shutdown(); // 不再接新任务,已提交的继续执行
if (!pool.awaitTermination(30, TimeUnit.SECONDS)) {// 等待在途任务
List<Runnable> dropped = pool.shutdownNow(); // 超时:中断线程,返回未执行任务
log.warn("forced shutdown, {} tasks dropped", dropped.size());
}
shutdown() 温和(拒新、跑完存量),shutdownNow() 强硬(中断线程、清空队列并返回未执行任务)。配合容器的 preStop / 优雅停机(见 发布与变更安全)能避免请求被硬切断。
7. 两大隐形坑
7.1 ThreadLocal 在池化线程中的泄漏
线程池的线程是复用的,ThreadLocal 不会随任务结束自动清理。上一个任务写进去的值,会被下一个复用该线程的任务读到——既是数据串味的正确性问题,也是内存泄漏(ThreadLocalMap 的 key 是弱引用但 value 是强引用,线程长存则 value 长存):
private static final ThreadLocal<UserContext> CTX = new ThreadLocal<>();
void handle(Request req) {
try {
CTX.set(loadContext(req));
// ... 业务逻辑
} finally {
CTX.remove(); // 必须!否则值残留到下一个复用此线程的任务
}
}
约定:池化线程里用 ThreadLocal,一定在 finally 里 remove()。跨线程传递上下文(如把 trace id 传给线程池任务)要用 TransmittableThreadLocal(阿里 TTL)之类的方案,普通 InheritableThreadLocal 在池化场景下也会失效。
7.2 共用线程池导致的互相拖垮
多个业务共用一个线程池时,若某个下游(比如非核心的日志上报)变慢,会占满线程/队列,把核心业务(比如竞价)一起饿死。
解法:舱壁隔离(Bulkhead)——为不同职责/下游配独立线程池。 在广告竞价服务里,通常至少拆成:
一个下游抖动只影响它自己的舱室,核心链路的 P99 不受牵连——与 依赖韧性 的舱壁、连接与 I/O 模型 的独立连接池是同一套原则。
线程池定好并发上界之后,共享数据结构本身也要线程安全——ConcurrentHashMap、阻塞队列是下一层地基。
延伸阅读
- 并发容器:CHM 演进、CopyOnWrite、BlockingQueue 选型
- 连接与 I/O 模型:线程与连接的一体两面
- OpenJDK 源码:
java.util.concurrent.ThreadPoolExecutor - Brian Goetz et al. Java Concurrency in Practice, Ch. 6 & 8
- 美团技术团队. Java 线程池实现原理及其在美团业务中的实践