2024 年我们一个做消息推送的服务,在一次活动期间突然 OOM 宕机。重启后没多久又挂,反复几次。dump 文件拉下来用 MAT 一分析,真相让人后背发凉:一个线程池的任务队列里,堆了三百多万个待执行任务,几个 G 的堆几乎全被它占满。罪魁是一行看起来人畜无害的 Executors.newFixedThreadPool(10)。投了几天做线程池专项治理,本文复盘这次"无界队列撑爆堆"的实战。
问题背景
业务:消息推送服务,Spring Boot,接活动流量高峰
事故现象:
- 活动开始约 20 分钟后,服务实例陆续 OOM
- 报错:java.lang.OutOfMemoryError: Java heap space
- 重启后能撑十几分钟,再次 OOM,反复
现场排查:
# 1. JVM 启动参数里配了 HeapDumpOnOutOfMemoryError,拿到 dump
$ ls -lh /www/wwwroot/app/dump/
-rw-r--r-- 1 root root 4.2G java_pid12345.hprof
# 2. MAT 打开 dump,看 Dominator Tree
Leak Suspects:
One instance of "java.util.concurrent.LinkedBlockingQueue"
occupies 3,980,000,000 bytes (94.8%)
-> 队列里挂着 3,170,000+ 个 Runnable 任务
# 3. 看这个队列属于谁
LinkedBlockingQueue <- ThreadPoolExecutor <- 推送任务线程池
# 4. 翻代码,线程池是这么建的:
ExecutorService pushPool = Executors.newFixedThreadPool(10);
// 活动期间,上游每秒提交几千个推送任务,
// 但只有 10 个线程在消费 -> 生产远大于消费
// 消费不掉的全堆进队列,队列还是【无界】的 -> 堆被撑爆
根因:
1. Executors.newFixedThreadPool 内部用的是【无界】LinkedBlockingQueue
2. 任务生产速度 >> 消费速度时,任务在队列里无限堆积
3. 队列无界 = 没有任何背压,堆积到把堆吃光为止
4. 团队以为"固定 10 个线程"就是有上限的,忽略了队列没有上限
修复 1:认清 Executors 工厂方法的坑
// === 阿里 Java 开发手册明令禁止用 Executors 创建线程池,原因就在这 ===
// 坑 1:newFixedThreadPool —— 队列无界
public static ExecutorService newFixedThreadPool(int n) {
return new ThreadPoolExecutor(n, n, 0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>()); // 无参 = Integer.MAX_VALUE,几乎无界
}
// 任务堆积时,队列能涨到 21 亿个元素,堆早就爆了。
// 坑 2:newSingleThreadExecutor —— 同样是无界队列
// 只有 1 个线程,消费更慢,堆积更猛。
// 坑 3:newCachedThreadPool —— 线程数无界
public static ExecutorService newCachedThreadPool() {
return new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS,
new SynchronousQueue<Runnable>()); // 来一个任务建一个线程
}
// SynchronousQueue 不存任务,任务一来没有空闲线程就【新建线程】。
// 任务暴增时,线程数能冲到上万 -> 每个线程默认 1MB 栈
// -> OutOfMemoryError: unable to create new native thread
// === 结论 ===
// newFixedThreadPool / newSingleThreadExecutor:任务无界堆积,堆 OOM
// newCachedThreadPool:线程无界创建,栈 OOM
// 两类坑,本质都是"某个东西没有上限"。
// 正确做法:永远用 ThreadPoolExecutor 显式构造,把每个参数都掌控在自己手里。
修复 2:用 ThreadPoolExecutor 显式构造 + 有界队列
// === 自己 new ThreadPoolExecutor,七个参数全部显式指定 ===
ThreadPoolExecutor pushPool = new ThreadPoolExecutor(
20, // corePoolSize:核心线程数
40, // maximumPoolSize:最大线程数
60L, TimeUnit.SECONDS, // keepAliveTime:非核心线程空闲存活时间
new LinkedBlockingQueue<>(2000), // 关键:有界队列,容量 2000
new ThreadFactoryBuilder() // 自定义线程工厂,给线程起名
.setNameFormat("push-pool-%d") // 线程名带业务标识,排查时一眼认出
.build(),
new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略,见修复 3
);
// === 任务进来时,线程池的处理顺序(务必记牢)===
// 1. 当前线程数 < corePoolSize -> 新建核心线程执行
// 2. 核心线程满了 -> 任务进【队列】排队
// 3. 队列也满了 且 线程数 < max -> 新建非核心线程执行
// 4. 队列满了 且 线程数 = max -> 触发【拒绝策略】
//
// 注意这个顺序的反直觉之处:
// 是【队列先满,才会去创建非核心线程】,不是线程先加满。
// 所以如果队列无界,第 2 步永远能塞下,
// 第 3、4 步永远到不了 —— maximumPoolSize 形同虚设!
// 这也解释了为什么 newFixedThreadPool 的 max 没意义。
// === 有界队列的意义:背压 ===
// 队列容量 2000 是一道闸:堆积到 2000 就触发拒绝策略,
// 不会再无限吃内存。生产太快时,系统会"主动喊停",
// 这就是【背压(back pressure)】—— 比默默 OOM 好太多。
修复 3:选对拒绝策略
// === JDK 内置 4 种拒绝策略 ===
// 1. AbortPolicy(默认):直接抛 RejectedExecutionException
// 任务被丢弃 + 抛异常,调用方能感知。但异常没接住会中断业务。
// 2. CallerRunsPolicy:谁提交的任务,谁自己执行
// 线程池满了,提交任务的线程(比如 Tomcat 线程)亲自跑这个任务。
// 妙处:提交线程被占住了,它就没法再接新请求 -> 天然降速 -> 背压。
// 推送、日志这类可以接受变慢的场景,这是首选。
// 3. DiscardPolicy:默默丢弃新任务,不抛异常,不通知
// 最危险 —— 任务丢了你都不知道。除非业务真的能接受丢,否则别用。
// 4. DiscardOldestPolicy:丢掉队列里最老的任务,腾位给新任务
// 适合"只关心最新数据"的场景(如实时行情),老任务丢了无所谓。
// === 我们的选择:CallerRunsPolicy + 自定义日志/计数 ===
class LoggingCallerRunsPolicy implements RejectedExecutionHandler {
@Override
public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
// 先把"被拒绝"这件事记下来,做成可观测指标
rejectCounter.increment();
log.warn("push-pool 已满,任务回退到调用线程执行," +
"queue={}, active={}", e.getQueue().size(),
e.getActiveCount());
if (!e.isShutdown()) {
r.run(); // 调用线程自己跑,产生背压
}
}
}
// === 关键认知 ===
// 拒绝策略不是"出错了",而是系统在【正常地保护自己】。
// 重要的是:被拒绝这件事一定要有日志 + 有监控指标,
// 否则你永远不知道线程池其实早就扛不住了。
修复 4:核心参数怎么定
// === corePoolSize 的经验公式 ===
int cpu = Runtime.getRuntime().availableProcessors();
// CPU 密集型任务(加密、压缩、复杂计算):
// 线程多了只会增加上下文切换开销,线程数 ≈ CPU 核数 + 1
int cpuIntensive = cpu + 1;
// IO 密集型任务(RPC、DB、读写文件 —— 我们的推送属于这类):
// 线程大部分时间在等 IO,可以多开。经验公式:
// 线程数 = CPU核数 * (1 + 平均等待时间/平均计算时间)
// 推送任务:一次推送约 80ms,其中 75ms 在等网络,5ms 在算
// -> cpu * (1 + 75/5) = cpu * 16,8 核机器约 128
// 实际不会真开这么多,结合压测取一个合理值,我们定 40
// === 队列容量怎么定 ===
// 不是越大越好。队列是"缓冲突发流量"用的,不是"囤积任务"用的。
// 太大:任务在队列里排队太久,等轮到执行可能早已超时(无效任务)
// 太小:稍有突发就触发拒绝
// 估算:容量 ≈ 能接受的最大排队延迟 / 单任务平均处理时间 * 线程数
// 我们能接受推送延迟最多 5s,单任务 80ms,40 线程
// -> 5000/80*40 = 2500,取整 2000
// === 别忘了:线程池要优雅关闭 ===
@PreDestroy
public void shutdown() {
pushPool.shutdown(); // 不再接新任务,执行完已提交的
try {
// 等最多 30s,让在途任务跑完
if (!pushPool.awaitTermination(30, TimeUnit.SECONDS)) {
pushPool.shutdownNow(); // 超时则强制中断
}
} catch (InterruptedException ex) {
pushPool.shutdownNow();
Thread.currentThread().interrupt();
}
}
// 不优雅关闭,重启/发版时队列里的任务会被直接丢掉。
修复 5:线程池隔离,不同业务不共用
// === 出事前的坏味道:一个"全局线程池"被到处共用 ===
@Component
public class GlobalPool {
// 推送、发短信、写审计日志、调第三方……全用这一个
public static final ExecutorService POOL =
Executors.newFixedThreadPool(10);
}
// 问题:推送任务突然暴涨,把这个池占满,
// 结果【发短信、写审计】也跟着排队、跟着饿死 ——
// 一个业务的故障,顺着共享线程池传染给了所有业务。
// === 修复:按业务/重要性拆分独立线程池 ===
@Configuration
public class ThreadPoolConfig {
// 推送:量大、可容忍延迟,池给大点,拒绝策略用 CallerRuns
@Bean("pushPool")
public ThreadPoolExecutor pushPool() {
return new ThreadPoolExecutor(20, 40, 60L, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(2000),
namedFactory("push-pool"),
new ThreadPoolExecutor.CallerRunsPolicy());
}
// 短信:量小但重要,池小、队列小,满了就抛异常让上游感知
@Bean("smsPool")
public ThreadPoolExecutor smsPool() {
return new ThreadPoolExecutor(5, 10, 60L, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(200),
namedFactory("sms-pool"),
new ThreadPoolExecutor.AbortPolicy());
}
// 审计日志:可丢,独立池,丢了不影响主流程
@Bean("auditPool")
public ThreadPoolExecutor auditPool() {
return new ThreadPoolExecutor(2, 4, 60L, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(500),
namedFactory("audit-pool"),
new ThreadPoolExecutor.DiscardPolicy());
}
private ThreadFactory namedFactory(String prefix) {
AtomicInteger idx = new AtomicInteger();
return r -> {
Thread t = new Thread(r, prefix + "-" + idx.incrementAndGet());
t.setUncaughtExceptionHandler((thread, ex) ->
log.error("线程 {} 未捕获异常", thread.getName(), ex));
return t;
};
}
}
// 隔离的本质:把故障的"爆炸半径"限制在单个业务内,
// 这就是【舱壁模式(Bulkhead)】—— 一个舱进水,不会沉整艘船。
修复 6:线程池监控告警
// === ThreadPoolExecutor 暴露了一堆可观测的指标,定时采集上报 ===
@Scheduled(fixedRate = 10000)
public void reportPoolMetrics() {
for (Map.Entry<String, ThreadPoolExecutor> e : pools.entrySet()) {
String name = e.getKey();
ThreadPoolExecutor p = e.getValue();
// 活跃线程数 / 当前线程数 / 历史峰值线程数
gauge("pool.active", name, p.getActiveCount());
gauge("pool.size", name, p.getPoolSize());
gauge("pool.largest", name, p.getLargestPoolSize());
// 队列当前堆积量 / 队列剩余容量 —— 最重要的指标
gauge("pool.queue.size", name, p.getQueue().size());
gauge("pool.queue.remaining", name,
p.getQueue().remainingCapacity());
// 累计完成任务数 / 累计提交任务数
gauge("pool.completed", name, p.getCompletedTaskCount());
gauge("pool.task.total", name, p.getTaskCount());
}
}
// 队列堆积量是 OOM 的最早信号 —— 它一路上涨而不回落,
// 就是生产 > 消费,必须在堆爆之前介入。
# 线程池监控告警规则
groups:
- name: thread-pool
rules:
# 1. 队列堆积超过容量 80%(快满了,即将触发拒绝)
- alert: PoolQueueAlmostFull
expr: |
pool_queue_size / (pool_queue_size + pool_queue_remaining) > 0.8
for: 2m
annotations:
summary: "{{ $labels.name }} 队列堆积 > 80%,消费跟不上生产"
# 2. 活跃线程数长期顶满 maximumPoolSize
- alert: PoolActiveSaturated
expr: pool_active >= pool_size and pool_size >= 40
for: 5m
annotations:
summary: "{{ $labels.name }} 线程数顶满,考虑扩容或排查慢任务"
# 3. 任务被拒绝(拒绝策略触发了)
- alert: PoolTaskRejected
expr: increase(pool_rejected_total[5m]) > 0
for: 1m
annotations:
summary: "{{ $labels.name }} 5 分钟内有任务被拒绝,线程池已过载"
# 4. 堆内存使用率持续高位(OOM 前兆)
- alert: HeapUsageHigh
expr: |
jvm_memory_used_bytes{area="heap"}
/ jvm_memory_max_bytes{area="heap"} > 0.85
for: 5m
annotations:
summary: "{{ $labels.instance }} 堆使用率 > 85%,排查内存堆积"
优化效果
指标 治理前 治理后
=============================================================
活动高峰 OOM 次数 反复宕机 0
线程池队列 无界(撑爆堆) 有界 2000,带背压
线程池创建方式 Executors 工厂 ThreadPoolExecutor 显式
拒绝策略 无(默默堆积) CallerRuns + 日志计数
业务隔离 共用一个全局池 推送/短信/审计独立池
线程命名 pool-1-thread-N push-pool-N(可辨识)
线程池可观测性 完全没有 7 类指标 + 4 条告警
故障爆炸半径 一挂全挂 限制在单业务
压测(推送 5000 QPS 突发):
- 治理前:20 分钟内堆积超百万,OOM
- 治理后:队列稳定在 1500 上下波动,触发少量 CallerRuns
背压让上游降速,无 OOM,推送 P99 延迟 1.2s
治理过程:
- dump 分析定位无界队列:0.5 天
- 全量排查 Executors 用法(11 处):1 天
- 改 ThreadPoolExecutor + 有界队列 + 拒绝策略:2 天
- 线程池按业务隔离:1 天
- 监控指标 + 告警接入:1 天
避坑清单
- 禁止用 Executors 工厂方法建线程池,newFixedThreadPool 队列无界会撑爆堆
- newCachedThreadPool 线程数无界,任务暴增会因建不出线程而 OOM
- 永远用 ThreadPoolExecutor 显式构造,七个参数都自己掌控
- 队列必须有界,有界队列才能提供背压,无界 = 没有任何保护
- 记牢任务处理顺序:核心线程满→进队列→队列满才建非核心线程→再满才拒绝
- 队列无界时 maximumPoolSize 永远用不到,形同虚设
- 拒绝策略不是出错,是系统正常自保;CallerRunsPolicy 能产生背压,推荐
- 被拒绝的任务一定要记日志、上指标,否则察觉不到线程池已过载
- 不同业务用独立线程池隔离,避免一个业务拖垮所有业务(舱壁模式)
- 线程池要 @PreDestroy 优雅关闭,并监控队列堆积量——它是 OOM 最早的信号
总结
这次 OOM 事故的根因,藏在一行几乎每个 Java 程序员都写过的代码里 —— Executors.newFixedThreadPool(10)。它的迷惑性在于"固定"这两个字给人一种"有上限、很安全"的错觉,但真正没有上限的不是线程数,而是它背后那个默认无界的 LinkedBlockingQueue。当任务的生产速度超过消费速度时,消费不掉的任务就一股脑涌进这个没有底的队列,一直堆到把整个堆吃光为止。所以这次复盘我最想强调的第一条经验是:看一个线程池安不安全,不要只看它有几个线程,要看"任务多到来不及处理时,多出来的任务去了哪里"——如果答案是"进一个无界队列",那它就是一颗定时炸弹。正确的做法是彻底放弃 Executors 这一系列工厂方法,老老实实用 ThreadPoolExecutor 把七个参数全部显式写出来,尤其是把队列换成有界的,因为有界队列才能在系统扛不住的时候触发拒绝策略,而拒绝策略——特别是 CallerRunsPolicy——能把压力反向传导给任务的提交方,让它自己慢下来,这就是"背压"。背压听起来不那么美好,它意味着系统会变慢、会拒绝一些任务,但请记住:一个会主动喊"我扛不住了"并适当降速的系统,远比一个默默把任务堆到内存里、最后毫无征兆地整体崩溃的系统要健康得多。第二条经验是关于隔离:我们出事前所有异步任务都共用一个全局线程池,推送、短信、审计日志全挤在一起,结果推送流量一暴涨,把池占满,连发短信和写审计都跟着被饿死——一个业务的故障顺着共享资源传染成了全站故障。把线程池按业务、按重要性拆开,就是经典的舱壁模式,让一个舱进水时不至于沉掉整艘船。最后,这次事故还暴露了我们对线程池的运行状态几乎是"睁眼瞎"——ThreadPoolExecutor 其实暴露了活跃线程数、队列堆积量、完成任务数、拒绝次数等一大堆现成的指标,只要定时把它们采集成监控,队列堆积量那条一路上涨却永不回落的曲线,会在堆被撑爆之前的很久就清清楚楚地告诉你:消费已经追不上生产了,该介入了。线程池是 Java 并发里最常用也最容易被低估的组件,用好它的前提,是先承认它不是一个"丢进去就不用管"的黑盒,而是一个需要被精心配置、严格隔离、持续观测的关键基础设施。
—— 别看了 · 2026