“新建个线程池嘛,Executors.newFixedThreadPool(8) 一行搞定。”
这大概是 Java 工程师最熟悉、却也最容易埋雷的一行代码。阿里《Java 开发手册》里明确写着:
线程池不允许使用 Executors 去创建,而是通过 ThreadPoolExecutor 的方式。
这一条不是洁癖,而是用血淋淋的事故换来的。这一篇就把它讲透。
Executors 真的很方便
JDK 提供了几个现成的工厂方法,省得你背参数:
newFixedThreadPool(n):固定 n 个线程;newSingleThreadExecutor():单线程;newCachedThreadPool():按需创建,空闲回收;newScheduledThreadPool(n):支持定时和周期任务。
方便是方便,但问题藏在他们选用的队列和上限里。
三个工厂方法各自藏着什么坑
把它们的“参数配方”摊开看,陷阱就清楚了:
| 工厂方法 | 队列 / 上限 | 隐患 |
|---|---|---|
| newFixedThreadPool | 无界 LinkedBlockingQueue(Integer.MAX_VALUE) | 任务无限堆积,堆 OOM |
| newSingleThreadExecutor | 无界 LinkedBlockingQueue | 同上,任务堆积导致 OOM |
| newCachedThreadPool | SynchronousQueue + 线程上限 Integer.MAX_VALUE | 无限创建线程,无法创建 native 线程 / 系统资源耗尽 |
共同点是:它们都用无界的方式,把“背压”这件事悄悄抹掉了。 流量正常时一切岁月静好,一旦洪峰到来,要么队列撑爆堆,要么线程撑爆系统。 这就是阿里规约禁止它们的根本原因——风险被默认配置掩盖了。
ThreadPoolExecutor 的七个参数
规约要求你直接用 ThreadPoolExecutor,逼你把每个参数想清楚。它有七个参数:
ThreadPoolExecutor executor = new ThreadPoolExecutor(
4, // 1. corePoolSize 核心线程数
8, // 2. maximumPoolSize 最大线程数
60L, TimeUnit.SECONDS, // 3. keepAliveTime 非核心线程的空闲存活时间
new ArrayBlockingQueue<>(200), // 4. workQueue 任务队列(务必有界!)
namedThreadFactory(), // 5. threadFactory 线程工厂(给线程起名字)
new ThreadPoolExecutor.CallerRunsPolicy() // 6. handler 拒绝策略
);
第 4 和第 6 个参数,正是 Executors 替你“擅自”决定的两个危险点。把它们握在自己手里,
线程池的行为才完全可控。第 5 个 threadFactory 别偷懒用默认的——默认线程名是
pool-1-thread-1 这种,线上出问题看线程栈完全分不清是谁。给线程起有意义的名字:
private static ThreadFactory namedThreadFactory() {
AtomicInteger seq = new AtomicInteger(0);
return r -> {
Thread t = new Thread(r, "order-pool-" + seq.getAndIncrement());
t.setDaemon(false);
return t;
};
}
任务提交的完整流程:一个反直觉的点
很多人对线程池的误解,集中在“新任务来了,先建线程还是先排队”。 实际的执行顺序是这样的:
- 当前线程数 <
corePoolSize?创建新的核心线程来跑这个任务; - 否则,尝试放入队列
workQueue; - 队列也满了,才创建非核心线程,直到
maximumPoolSize; - 连最大线程数也到顶了,触发拒绝策略。
顺序是 核心线程 → 队列 → 最大线程 → 拒绝,不是“核心 → 最大 → 队列”。 这意味着:当你用一个无界队列(如 LinkedBlockingQueue 不传容量)时, 第 2 步永远成功,第 3、4 步永远不会触发——无论你把 maximumPoolSize 设多大都没用! 这正是 newFixedThreadPool 永远只有固定线程的原因,也是无数人“明明配了 max 却不生效”的根源。
拒绝策略:满了怎么办
当线程和队列都满了,JDK 内置四种拒绝策略,对应不同的兜底哲学:
| 策略 | 行为 | 适用场景 |
|---|---|---|
| AbortPolicy(默认) | 抛 RejectedExecutionException | 任务不能丢,宁可让上游感知失败 |
| CallerRunsPolicy | 让提交任务的线程自己执行该任务 | 天然背压,避免压垮下游(推荐) |
| DiscardPolicy | 静默丢弃新任务 | 任务可丢(如日志、监控) |
| DiscardOldestPolicy | 丢弃队列里最老的任务,再试一次 | 只关心最新数据(如行情推送) |
我的默认偏好是 CallerRunsPolicy:它不丢任务,而是让提交者自己干,
相当于把压力反推给上游,形成天然的限流。比简单抛异常更“鲁棒”。
一次无界队列的线上事故
有次线上,一个负责发消息的服务用了 newFixedThreadPool(16)。
平时风平浪静,直到某天下游短信通道变慢,上游请求照常涌入。由于队列无界,
几百万个任务(连同它们捕获的上下文对象)堆在队列里出不去,最终堆内存被打爆,
服务 OOM 重启。简化一下当时的雷区:
// ❌ 雷区:无界队列 + 默认拒绝策略永远不会触发
ExecutorService pool = Executors.newFixedThreadPool(16);
void send(Message msg) {
pool.execute(() -> smsClient.send(msg)); // msg 被闭包捕获,全压在队列里
}
正确的写法,是给队列设上限、给线程设上限、并选一个能背压的拒绝策略:
// ✅ 有界队列 + 合理上限 + 背压策略
ThreadPoolExecutor pool = new ThreadPoolExecutor(
16, 32,
60L, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(500), // 有界:最多排 500 个
namedThreadFactory(),
new ThreadPoolExecutor.CallerRunsPolicy() // 满了让调用方自己发,自动限流
);
void send(Message msg) {
pool.execute(() -> smsClient.send(msg));
}
再补一句线程数怎么估的经验法则(Brian Goetz 给的):
线程数 ≈ CPU 核心数 × 目标利用率 × (1 + 等待时间 / 计算时间)
CPU 密集型任务,等待时间≈0,线程数≈核心数(或 +1);IO 密集型任务等待时间长,
线程数可以开到核心数的几倍甚至几十倍。核心数用
Runtime.getRuntime().availableProcessors() 获取。
线程池用完要关。shutdown() 不再接新任务,但会把已提交的跑完;
shutdownNow() 尝试中断正在跑的任务,返回未执行的任务列表。长生命周期的池(如应用级)
才不需要手动关;方法内临时创建的池,务必在 finally 里关闭。
Executors 不是不能用,而是它的默认值替你做了一个危险的决定:用无界换“不拒绝”。 把这个决定权拿回来,用有界队列 + 合理上限 + 明确的拒绝策略,线程池才真正服务于你, 而不是变成一颗延时引爆的炸弹。