2024 年我们一个对外提供报表导出的服务,在一个再普通不过的下午突然挂了。挂得很彻底:接口全部超时,健康检查失败,实例被摘流。复盘时发现,触发点只是几个用户同时点了"导出大报表",而真正把服务推下悬崖的,是我们代码里那一个个用 Executors 随手创建出来的线程池——它们的任务队列是无界的,内存被排队任务撑爆;而另一个线程池又因为参数配得离谱,在压力下疯狂创建线程,把机器的线程数顶到了上限。这次事故让我们意识到,线程池这个天天在用的东西,我们其实从来没认真配过。投了几天把全公司的线程池用法梳理了一遍,本文复盘这次实战。
问题背景
业务:报表服务,大量异步任务(导出、计算、推送)用线程池跑
事故现象:
- 几个用户同时导出大报表,服务整体 OOM 挂掉
- 另一批接口表现为线程数爆炸,机器报 unable to create native thread
- 重启后只要并发一上来,很快又挂
现场排查:
# 1. 看代码里线程池怎么建的
ExecutorService pool = Executors.newFixedThreadPool(10);
ExecutorService pool2 = Executors.newCachedThreadPool();
// 全是 Executors 工厂方法,没人关心背后的参数
# 2. heap dump 看 OOM 原因
# LinkedBlockingQueue 里堆了几十万个待执行的导出任务对象,
# 每个任务还引用着一大坨报表数据 -> 队列把堆撑爆
# 3. 线程 dump 看线程爆炸
$ jstack | grep "pool-" | wc -l
# 几千个线程 —— newCachedThreadPool 在压力下无限建线程
根因:
1. newFixedThreadPool 用的是【无界队列】,任务积压撑爆内存
2. newCachedThreadPool 最大线程数是 Integer.MAX_VALUE,
压力下疯狂建线程,直到耗尽系统资源
3. 线程池参数全靠工厂方法默认值,没人真正理解和配置
4. 任务拒绝、异常、监控,统统是空白
修复 1:为什么不要用 Executors 创建线程池
// === Executors 的几个工厂方法,各自藏着一个坑 ===
// 坑 1:newFixedThreadPool / newSingleThreadExecutor
// 它们用的是【无界队列】LinkedBlockingQueue(容量 Integer.MAX_VALUE)
ExecutorService p1 = Executors.newFixedThreadPool(10);
// 线程满了之后,新任务【无限】往队列里堆,
// 任务对象越堆越多 -> 内存被撑爆 -> OOM
// 坑 2:newCachedThreadPool
// 它的最大线程数是 Integer.MAX_VALUE
ExecutorService p2 = Executors.newCachedThreadPool();
// 任务来得比处理得快,就【无限】创建新线程,
// 线程多到耗尽内存 / 触及系统线程上限 -> 崩溃
// 坑 3:newScheduledThreadPool
// 同样有最大线程数无界的问题
// === 结论:不要用 Executors,要自己 new ThreadPoolExecutor ===
// 阿里巴巴 Java 开发手册明确规定:
// 线程池不允许用 Executors 创建,必须用 ThreadPoolExecutor,
// 这样能强制写出每一个参数,让创建者清楚池子的运行规则。
// === 根本原因 ===
// Executors 把参数藏起来了,你以为你创建了一个"线程池",
// 实际上你创建了一个"内存炸弹"或"线程炸弹",而你不知道。
// 凡是有"无界"的地方,在生产环境就是定时炸弹。
修复 2:七个参数,逐个讲清楚
// === ThreadPoolExecutor 的七个核心参数 ===
ThreadPoolExecutor executor = new ThreadPoolExecutor(
8, // 1. corePoolSize 核心线程数
16, // 2. maximumPoolSize 最大线程数
60L, TimeUnit.SECONDS, // 3. keepAliveTime 空闲线程存活时间
new ArrayBlockingQueue<>(200), // 4. workQueue 任务队列(有界!)
new NamedThreadFactory("report"), // 5. threadFactory 线程工厂
new ThreadPoolExecutor.CallerRunsPolicy() // 6. 拒绝策略
);
// === 任务进来后,线程池的处理顺序(一定要记牢)===
// 1. 当前线程数 < corePoolSize -> 直接新建核心线程执行
// 2. 核心线程满了 -> 任务进 workQueue 排队
// 3. 队列也满了 && 线程数 < maximumPool -> 新建"非核心线程"执行
// 4. 队列满 && 线程数已达 maximumPool -> 触发【拒绝策略】
// === 一个极其反直觉的点 ===
// 很多人以为:任务多了,会先把线程加到 maximumPoolSize。
// 错!顺序是【先填满队列,才会去建非核心线程】。
// 所以如果你用【无界队列】,队列永远填不满,
// maximumPoolSize 这个参数就【永远不会生效】 ——
// 这正是 newFixedThreadPool 的陷阱:写了线程数,但其实只有核心线程在干活。
// === keepAliveTime ===
// 非核心线程空闲超过 keepAliveTime 就被回收,
// 让池子在闲时缩回到 corePoolSize,不浪费资源。
// === 线程一定要起有意义的名字 ===
public class NamedThreadFactory implements ThreadFactory {
private final String prefix;
private final AtomicInteger seq = new AtomicInteger(1);
public NamedThreadFactory(String prefix) { this.prefix = prefix; }
public Thread newThread(Runnable r) {
Thread t = new Thread(r, prefix + "-" + seq.getAndIncrement());
t.setDaemon(false);
return t;
}
}
// 默认线程名是 "pool-1-thread-3",出问题时 jstack 根本分不清
// 哪个池;起名 "report-1" 后,一眼就知道是哪个业务的线程。
修复 3:队列必须有界,拒绝策略要选对
// === 队列:永远用有界队列 ===
// 无界队列 = 任务无限积压 = 内存炸弹。
// 有界队列在任务堆积时会"满",从而触发拒绝策略,
// 让系统【尽早暴露问题】,而不是闷头堆到 OOM。
new ArrayBlockingQueue<>(200); // 有界,容量明确
// 队列容量怎么定:能容忍多少任务"短暂排队"。
// 太小:稍有抖动就触发拒绝;太大:积压过多、任务延迟高、占内存。
// === 四种内置拒绝策略 ===
// 1. AbortPolicy(默认):直接抛 RejectedExecutionException
// 适合:任务不能丢、要让调用方明确知道"提交失败"
// 2. CallerRunsPolicy:让【提交任务的线程】自己来执行这个任务
// 效果:提交方被"拖慢",自然形成一种反压(背压),
// 减缓任务提交速度,适合不想丢任务、又想自我保护
// 3. DiscardPolicy:默默丢弃新任务,不报错
// 危险:任务悄无声息地没了,几乎不要用
// 4. DiscardOldestPolicy:丢掉队列里最老的任务,腾位置给新的
// 适合:只关心最新数据的场景
// === 强烈建议:自定义拒绝策略,至少要"留痕 + 告警" ===
RejectedExecutionHandler handler = (task, exec) -> {
// 1. 打日志,记录被拒绝的任务,绝不能让任务无声消失
log.error("线程池任务被拒绝!池状态: active={}, queue={}, 已完成={}",
exec.getActiveCount(), exec.getQueue().size(),
exec.getCompletedTaskCount());
// 2. 上报监控埋点 / 触发告警
metrics.counter("threadpool.rejected").increment();
// 3. 视业务决定:抛异常 / 降级 / 落库稍后重试
throw new RejectedExecutionException("report 线程池已满,请稍后重试");
};
// === 经验 ===
// 任务被拒绝不可怕,可怕的是【你不知道任务被拒绝了】。
// 拒绝策略的核心价值,是把"系统过载"这件事变得可见。
修复 4:核心线程数到底设多少
=== 线程数不是越多越好 ===
线程越多,CPU 在线程间切换(上下文切换)的开销越大,
到某个点之后,加线程不仅不提速,反而更慢。
合理的线程数,取决于任务是哪种类型。
=== 区分两类任务 ===
CPU 密集型:任务大部分时间在做计算(加解密、压缩、复杂运算)
- 线程多了也没用,CPU 核就那么多,多了只是空切换
- 经验值:corePoolSize ≈ CPU 核数 + 1
IO 密集型:任务大部分时间在等待(等数据库、等 RPC、等磁盘)
- 线程等 IO 时不占 CPU,可以多开线程,让 CPU 别闲着
- 经验值:corePoolSize ≈ CPU 核数 / (1 - 阻塞系数)
阻塞系数 = 任务等待时间 / 任务总时间
- 比如任务 90% 时间在等 IO,阻塞系数 0.9,
8 核机器 -> 8 / (1 - 0.9) = 80 个线程
=== 但公式只是起点,不是终点 ===
公式给一个大致范围,真实的值必须靠【压测】确定:
- 逐步加并发,观察吞吐量、响应时间、CPU 使用率
- 吞吐量不再上升、RT 开始恶化的那个点,就是合理上限
- 不同业务的任务特征不同,没有放之四海皆准的数字
=== 一个重要实践:不同业务用不同的线程池 ===
不要全公司共用一个线程池。
导出报表(慢、重)和发通知(快、轻)如果共用一个池,
导出任务会把线程占满,通知任务全被饿死或拒绝。
按业务隔离线程池,一个业务的过载不会拖垮另一个 ——
这就是"线程池隔离 / 舱壁模式"。
修复 5:异常处理与优雅关闭
// === 坑 1:线程池里任务抛了异常,你根本不知道 ===
// 用 execute() 提交,任务里抛的异常会导致线程默默终止,
// 异常栈可能连日志都没有 -> 任务失败了,你毫不知情。
executor.execute(() -> {
doExport(); // 这里抛异常 -> 线程挂掉,异常无人知晓
});
// === 解法 A:任务内部自己 try-catch 兜住所有异常 ===
executor.execute(() -> {
try {
doExport();
} catch (Throwable t) {
log.error("导出任务执行失败", t); // 异常必须落地
}
});
// === 解法 B:用 submit() + 检查 Future ===
Future> future = executor.submit(() -> doExport());
try {
future.get(); // submit 的任务,异常被包在 Future 里,
// 不调 get() 就永远看不到这个异常
} catch (ExecutionException e) {
log.error("任务异常", e.getCause());
}
// === 解法 C:给线程工厂设 UncaughtExceptionHandler 兜底 ===
// t.setUncaughtExceptionHandler((thread, ex) ->
// log.error("线程 {} 未捕获异常", thread.getName(), ex));
// === 坑 2:应用关闭时,线程池没优雅关闭 -> 任务被硬中断 ===
// JVM 退出时,正在跑的任务如果被强杀,可能造成数据不一致。
@PreDestroy
public void shutdown() {
executor.shutdown(); // 不再接收新任务,等存量任务跑完
try {
// 最多等 30s,等存量任务执行完
if (!executor.awaitTermination(30, TimeUnit.SECONDS)) {
executor.shutdownNow(); // 超时了,强制中断剩余任务
}
} catch (InterruptedException e) {
executor.shutdownNow();
Thread.currentThread().interrupt();
}
}
// shutdown():温和关闭,不收新任务,等存量跑完
// shutdownNow():强制关闭,中断正在跑的、返回没跑的任务列表
修复 6:线程池监控告警
// === 线程池要把内部状态暴露成监控指标 ===
// ThreadPoolExecutor 提供了一组只读方法,定时采集即可:
// getPoolSize() 当前线程数
// getActiveCount() 正在执行任务的线程数
// getQueue().size() 队列中等待的任务数 <- 重点
// getCompletedTaskCount() 已完成任务数
// getLargestPoolSize() 历史峰值线程数
// 自定义拒绝策略里再统计 rejectedCount <- 重点
# 线程池监控:队列堆积和任务拒绝是最关键的信号
groups:
- name: thread-pool
rules:
# 1. 任务队列堆积(处理能力跟不上提交速度)
- alert: ThreadPoolQueueBacklog
expr: thread_pool_queue_size > 100
for: 3m
annotations:
summary: "{{ $labels.pool }} 线程池队列堆积,处理能力不足"
# 2. 出现任务拒绝(线程池已过载)
- alert: ThreadPoolRejected
expr: increase(thread_pool_rejected_total[5m]) > 0
annotations:
summary: "{{ $labels.pool }} 线程池出现任务拒绝,已过载"
# 3. 活跃线程数长期顶满最大线程数
- alert: ThreadPoolSaturated
expr: thread_pool_active_threads / thread_pool_max_threads > 0.9
for: 5m
annotations:
summary: "{{ $labels.pool }} 线程池接近饱和,考虑扩容或限流"
优化效果
指标 治理前 治理后
=============================================================
线程池创建 Executors 工厂方法 ThreadPoolExecutor 显式
任务队列 无界,任务积压 OOM 有界 ArrayBlockingQueue
最大线程数 无界,压力下爆炸 明确上限,按压测定
拒绝策略 默认/无,任务静默丢 自定义:留痕 + 告警
线程命名 pool-1-thread-N 按业务命名,jstack 可辨
业务隔离 共用一个池,互相拖死 按业务拆独立线程池
任务异常 默默吞掉,无人知晓 try-catch 落地 + 兜底
优雅关闭 无,关闭硬杀任务 shutdown + awaitTermination
线程池可观测 无 队列/拒绝/饱和度监控
治理过程:
- 定位 OOM 与线程爆炸根因:0.5 天
- Executors 全量替换为 ThreadPoolExecutor:1 天
- 队列有界化 + 自定义拒绝策略:1 天
- 业务线程池隔离 + 压测定参:1.5 天
- 异常处理/优雅关闭 + 监控接入:1 天
避坑清单
- 不要用 Executors 创建线程池,它把无界队列/无界线程数藏在了默认值里
- newFixedThreadPool 用无界队列会积压撑爆内存,newCachedThreadPool 会无限建线程
- 必须用 ThreadPoolExecutor 显式写出七个参数,逼自己理解池子的运行规则
- 任务处理顺序是核心线程→入队→非核心线程→拒绝,先填满队列才建非核心线程
- 用无界队列时 maximumPoolSize 永远不生效,因为队列永远填不满
- 队列必须有界,有界才能在过载时触发拒绝、让问题尽早暴露
- 拒绝策略必须留痕和告警,任务被拒不可怕,不知道被拒才可怕
- 线程数按 CPU 密集型(核数+1)还是 IO 密集型(核数/(1-阻塞系数))估算,再压测校准
- 不同业务用不同线程池隔离,避免一个慢业务把整个池占满拖垮其他业务
- 任务内要 try-catch 兜异常,应用关闭要 shutdown + awaitTermination 优雅停
总结
这次线程池的事故,本质上是一次"我们以为自己在用一个工具,其实并不真正了解它"的教训。线程池太常见了,常见到大家会下意识地写下 Executors.newFixedThreadPool(10) 然后就不再多想——名字里有个 Fixed,有个 10,看起来安全又克制,谁会想到它背后挂着一条容量为二十多亿的无界队列?这就是 Executors 这套工厂方法最大的问题:它以"方便"为名,把线程池里最危险的几个参数藏了起来。它让你以为你创建的是一个有节制的、固定大小的线程池,而实际上,newFixedThreadPool 给你的是一个任务可以无限积压的内存炸弹,newCachedThreadPool 给你的是一个线程可以无限增长的线程炸弹。它们在测试环境、在低流量下都温顺得很,因为队列从没堆满过、线程数从没失控过,可一旦遇到我们这次"几个用户同时导出大报表"这样真实的压力,藏起来的那个"无界"就会瞬间把整个服务掀翻。所以这次治理我们定下的第一条铁律,就是阿里开发手册里那句话:不要用 Executors,必须自己 new ThreadPoolExecutor。这条规则的价值,不在于 ThreadPoolExecutor 有什么魔法,而在于它【强迫】你把七个参数一个一个地写出来——当你不得不亲手填写队列容量、填写最大线程数、填写拒绝策略时,你就再也无法对这些参数视而不见了,你被迫去理解这个池子到底会怎样运转。而理解它的运转,最关键的是记住那个反直觉的任务处理顺序:任务进来,先用核心线程,核心线程不够了不是去加线程,而是先把任务塞进队列排队,只有当队列也塞满了,才会去创建非核心线程直到最大线程数,最后连最大线程数都用尽、队列还是满的,才轮到拒绝策略出场。想通这个顺序,你立刻就能明白 newFixedThreadPool 的陷阱所在:它配了一条永远填不满的无界队列,于是"队列满了才建非核心线程"这一步永远不会发生,maximumPoolSize 这个参数形同虚设——你以为有十个线程在干活,真相是任务在一条无限长的队列里默默地、致命地堆积。除了把队列改成有界的、把参数显式化,这次复盘还沉淀了几条同样重要的认识。一是拒绝策略不是"出错时才用的兜底",它是系统过载的"报警器"——任务被拒绝本身不可怕,真正可怕的是任务被悄无声息地丢弃而你毫不知情,所以拒绝策略里至少要做两件事:把被拒的任务记进日志,以及触发监控告警。二是线程数不是越多越好,要先分清任务是 CPU 密集型还是 IO 密集型,用不同的公式估出一个起点,再用压测去找到那个"吞吐量不再上升、响应时间开始恶化"的真实拐点。三是不要让全公司的异步任务挤在同一个线程池里,又慢又重的报表导出和又快又轻的消息通知如果共用一个池,前者会把线程吃干抹净,后者就被活活饿死,正确的做法是按业务把线程池隔离开,这样一个业务的过载就被关在它自己的舱壁里,不会淹掉整条船。这次之后我对线程池的态度彻底变了:它不是一个调用一下就完事的工具方法,它是一个有自己明确行为规则、需要被认真配置、更需要被持续监控的基础组件——尤其是它的队列堆积数和任务拒绝数这两个指标,它们是这个组件在过载前夕发出的、最诚实的求救信号。
—— 别看了 · 2026