线程池没满,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)

OOM 现场终端输出

堆栈中的关键线索:LinkedBlockingQueue.offerThreadPoolExecutor.executeOrderProcessor.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 对象)。

Executors 源码 + LinkedBlockingQueue 无参构造

复现代码

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 只是时间问题。

复现代码 UnboundedQueueOomDemo

路径:从 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
  • 引用链LinkedBlockingQueueThreadPoolExecutorExecutors.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" 功能直接定位到创建线程池的那行代码。

OOM 排查决策树

# MAT OQL 快速验证
SELECT * FROM java.util.concurrent.LinkedBlockingQueue$Node

解读:为什么 Executors 的线程池会 bypass 拒绝策略

ThreadPoolExecutor.execute() 提交流程

execute → offer → OOM 调用链

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拆代码的曹操」