线程池没满,JVM 先 OOM 了——Executors 的并发陷阱
场景:订单处理服务使用
Executors.newFixedThreadPool(10)处理消息队列,上线一年某天突然 OOM。线程池没满、拒绝策略没触发——但堆里躺了 870 万条待处理任务。 路径:现象(OOM 现场)→ 还原(最小复现代码)→ 路径(heap dump 分析)→ 解读(LinkedBlockingQueue 无界设计缺陷)→ 标记(代码审查)
一个 newFixedThreadPool(10) 跑了一年没出事,今天 11:33 OOM 了。不是堆太小,不是泄漏——是队列没上限。(上篇讲的 ScheduledThreadPoolExecutor 定时器陷阱也能导致"看不到的积压",这篇换了个更隐蔽的方式)
现象:服务还活着,但再也处理不了新任务
OOM 时刻表
11:30 Old 区 62% → 11:31 78% → 11:32 91% → 11:33 OOM——11 分钟,从正常到爆炸。
| 时间 | 指标 | 变化 |
|---|---|---|
| 11:30:00 | Old 区使用率 | 62% |
| 11:31:20 | Old 区使用率 | 78%,RES 5.2G |
| 11:32:40 | Old 区使用率 | 91%,RES 7.1G |
| 11:33:15 | Full GC 持续失败 | Concurrent Mode Failure |
| 11:33:22 | OutOfMemoryError |
Java heap space |
[11:33:22.511] Exception in thread "pool-3-thread-7"
java.lang.OutOfMemoryError: Java heap space
at java.base/java.util.concurrent.LinkedBlockingQueue$Node.<init>(LinkedBlockingQueue.java:134)
at java.base/java.util.concurrent.LinkedBlockingQueue.offer(LinkedBlockingQueue.java:409)
at java.base/java.util.concurrent.ThreadPoolExecutor.execute(ThreadPoolExecutor.java:1368)
at com.example.OrderProcessor.submit(OrderProcessor.java:45)

堆栈中的关键线索:LinkedBlockingQueue.offer → ThreadPoolExecutor.execute → OrderProcessor.submit。说明队列本身在分配 Node 对象时撑爆了堆。
线程池状态
jstack 显示 10 个 worker 线程全部处于 RUNNABLE(在处理积压任务),线程池没有满,也没有触发拒绝策略——因为队列从没拒绝过入队请求。
"pool-3-thread-1" #11 prio=5 os_prio=0 cpu=12.34ms
java.lang.Thread.State: RUNNABLE
at com.example.OrderHandler.process(OrderHandler.java:78)
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:641)
at java.base/java.lang.Thread.run(Thread.java:1579)
线程没满、没有 BLOCKED、没有死锁——OOM 完全来自 LinkedBlockingQueue 内部链表节点的持续分配。
还原:最小复现代码
Executors 到底创建了什么?
Executors.newFixedThreadPool(10) 的源码(JDK 21):
public static ExecutorService newFixedThreadPool(int nThreads) {
return new ThreadPoolExecutor(nThreads, nThreads,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>());
}
问题在 new LinkedBlockingQueue<Runnable>() 这个无参构造。进去看看(JDK 21):
public LinkedBlockingQueue() {
this(Integer.MAX_VALUE);
}
Integer.MAX_VALUE = 2^31 - 1 ≈ 21.47 亿。每次调用 offer() 都返回 true(只要 JVM 还有内存分配 Node 对象)。

复现代码
public class UnboundedQueueOomDemo {
static final int CORE_POOL_SIZE = 10;
static final int TASK_COUNT = 10_000_000;
public static void main(String[] args) throws InterruptedException {
ExecutorService executor = Executors.newFixedThreadPool(CORE_POOL_SIZE);
CountDownLatch latch = new CountDownLatch(1);
for (int i = 0; i < TASK_COUNT; i++) {
executor.submit(() -> {
try { Thread.sleep(10); }
catch (InterruptedException e) { Thread.currentThread().interrupt(); }
});
}
// 生产者 0 延迟 vs 消费者 10ms/任务
// 10 线程处理上限 ~1000 tasks/s,提交 ~10000 tasks/s
// 队列每秒积压 9000 → 约 3 分钟后 OOM
latch.await(3, TimeUnit.MINUTES);
}
}
复现条件:JDK 8+(行为与 JDK 版本无关——LinkedBlockingQueue 无参构造各版本一致),生产者提交速率 > 消费者处理速率,队列无界 → OOM 只是时间问题。

路径:从 OOM 到根因的 4 步排查
Step 1:jstat 看堆
jstat -gcutil <pid> 2000
看到 Old 区使用率持续上升,Full GC 频繁触发但回收效果微乎其微。
Step 2:heap dump 取现场
jmap -dump:live,format=b,file=/tmp/heap.hprof <pid>
Step 3:MAT 找最大对象
用 Eclipse MAT(或 JDK 自带的 jhat)打开 heap dump,查看 Dominator Tree:
- 最大对象:
LinkedBlockingQueue.Node实例,总引用大小 ~4.2GB - 引用链:
LinkedBlockingQueue→ThreadPoolExecutor→Executors.newFixedThreadPool - 数量:870 万个 Node 对象,每个持有一个
Runnable任务
Step 4:顺着引用链找到根因
也可以在命令行快速验证:
# 命令行走查:不用 GUI
jhat /tmp/heap.hprof # 启动 Web 界面,浏览器查看
# 或直接看 LinkedBlockingQueue 的 Node 数量
jmap -histo:live <pid> | grep 'LinkedBlockingQueue\$Node'
MAT 的 "Path to GC Roots" 功能直接定位到创建线程池的那行代码。

# MAT OQL 快速验证
SELECT * FROM java.util.concurrent.LinkedBlockingQueue$Node
解读:为什么 Executors 的线程池会 bypass 拒绝策略
ThreadPoolExecutor.execute() 提交流程

public void execute(Runnable command) {
int c = ctl.get();
// Step 1: 未达到核心线程数 → 新建线程
if (workerCountOf(c) < corePoolSize) {
if (addWorker(command, true))
return;
c = ctl.get();
}
// Step 2: 入队——问题就在这里
if (isRunning(c) && workQueue.offer(command)) {
// 入队成功 → 返回(无界队列永远走到这里)
int recheck = ctl.get();
if (! isRunning(recheck) && remove(command))
reject(command);
else if (workerCountOf(recheck) == 0)
addWorker(null, false);
}
// Step 3: 入队失败 → 新建线程或拒绝
// —— 对于无界队列,这行代码永远不会执行
else if (!addWorker(command, false))
reject(command);
}
任务提交流程的关键路径是 3 步:
| 步骤 | 条件 | 结果 |
|---|---|---|
| ① 新建线程 | workerCount < corePoolSize |
创建新 worker |
| ② 入队 | workQueue.offer(command) |
无界队列永远返回 true |
| ③ 拒绝 | 入队失败 | 永远不会执行 |
核心洞察:等下——我配了 CallerRunsPolicy 啊!对,你配了。但问一句:offer() 返回过 false 吗?一次都没有。队列的上限是 Integer.MAX_VALUE,只要 JVM 没爆,offer() 永远返回 true。拒绝策略?它连出场的机会都没有。
无界队列 vs 有界队列 + 拒绝策略
| 维度 | Executors.newFixedThreadPool |
手动 new ThreadPoolExecutor |
|---|---|---|
| 工作队列 | LinkedBlockingQueue(无界) |
ArrayBlockingQueue(有界) |
| 队列容量 | Integer.MAX_VALUE |
按业务预估,如 1000 |
offer() 行为 |
永远返回 true | 队列满返回 false |
| 拒绝策略 | 永不触发 | 队列满时触发 |
| OOM 风险 | ✅ 高——队列撑爆堆 | ❌ 低——积压到上限即拒绝 |
手动创建的正确姿势:
// ❌ 禁止
Executors.newFixedThreadPool(10);
// ✅ 推荐
new ThreadPoolExecutor(
10, 10,
0L, TimeUnit.MILLISECONDS,
new ArrayBlockingQueue<>(1000), // 有界队列 + 合理容量
new ThreadPoolExecutor.CallerRunsPolicy() // 或 AbortPolicy
);
并发问题的本质不是代码错了——是代码的执行路径在你的脑子里和 JVM 里不一样。你以为 ThreadPoolExecutor 会保护你,但
offer()永远返回true的时候,它的防护网就是一张纸。
下次你看到项目里有人写 Executors.newFixedThreadPool——拍他肩膀:队列没上限的时候,拒绝策略只是一张纸。
这也是《阿里巴巴 Java 开发手册》把 Executors 列为【强制】禁止的原因——"方便"牺牲了"安全"。
标记:在项目中搜 Executors 的并发陷阱
# 搜索 Executors 工厂方法
grep -rn 'Executors\.newFixedThreadPool\|Executors\.newSingleThreadExecutor' src/ --include='*.java'
# 搜索无参 LinkedBlockingQueue(无参构造 = 无界)
grep -rn 'new LinkedBlockingQueue<>()' src/ --include='*.java'
代码审查对照表
| 禁止写法 | 风险 | 替代方案 |
|---|---|---|
Executors.newFixedThreadPool(n) |
无界队列 OOM | new ThreadPoolExecutor(n, n, ..., new ArrayBlockingQueue<>(capacity), ...) |
Executors.newSingleThreadExecutor() |
无界队列 OOM | new ThreadPoolExecutor(1, 1, ..., new ArrayBlockingQueue<>(capacity), ...) |
new LinkedBlockingQueue<>() |
无界 | new LinkedBlockingQueue<>(capacity) 指定容量 |
new ThreadPoolExecutor(..., new LinkedBlockingQueue<>(), ...) |
无界 | 改用 ArrayBlockingQueue 或指定 LinkedBlockingQueue 容量 |

如果你项目里还有人写 newFixedThreadPool,把这篇甩给他。不用说话。
今天下班前做一件事:git grep 'Executors.new' 你的项目,数数结果。然后想想怎么跟团队说。
上篇讲了 ScheduledThreadPoolExecutor 的排队陷阱,这篇说了 Executors 的无界队列 OOM,下篇我们聊死锁检测三板斧——三把锁两个线程一个环形依赖,比这两篇都绕,也更常见。
📺 公众号「Ai拆代码的曹操」 🌟 知识星球「Ai拆代码的曹操」