线程池踩坑:无界队列把堆撑爆,一次 OOM 宕机的复盘

活动高峰服务反复 OOM 宕机,dump 分析发现一个线程池队列里堆了三百多万个任务,几个 G 的堆全被它占满。罪魁是 Executors.newFixedThreadPool 默认的无界队列。几天治理:显式构造 ThreadPoolExecutor、有界队列、拒绝策略产生背压、按业务隔离线程池、补齐 7 类监控指标。

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 天

避坑清单

  1. 禁止用 Executors 工厂方法建线程池,newFixedThreadPool 队列无界会撑爆堆
  2. newCachedThreadPool 线程数无界,任务暴增会因建不出线程而 OOM
  3. 永远用 ThreadPoolExecutor 显式构造,七个参数都自己掌控
  4. 队列必须有界,有界队列才能提供背压,无界 = 没有任何保护
  5. 记牢任务处理顺序:核心线程满→进队列→队列满才建非核心线程→再满才拒绝
  6. 队列无界时 maximumPoolSize 永远用不到,形同虚设
  7. 拒绝策略不是出错,是系统正常自保;CallerRunsPolicy 能产生背压,推荐
  8. 被拒绝的任务一定要记日志、上指标,否则察觉不到线程池已过载
  9. 不同业务用独立线程池隔离,避免一个业务拖垮所有业务(舱壁模式)
  10. 线程池要 @PreDestroy 优雅关闭,并监控队列堆积量——它是 OOM 最早的信号

总结

这次 OOM 事故的根因,藏在一行几乎每个 Java 程序员都写过的代码里 —— Executors.newFixedThreadPool(10)。它的迷惑性在于"固定"这两个字给人一种"有上限、很安全"的错觉,但真正没有上限的不是线程数,而是它背后那个默认无界的 LinkedBlockingQueue。当任务的生产速度超过消费速度时,消费不掉的任务就一股脑涌进这个没有底的队列,一直堆到把整个堆吃光为止。所以这次复盘我最想强调的第一条经验是:看一个线程池安不安全,不要只看它有几个线程,要看"任务多到来不及处理时,多出来的任务去了哪里"——如果答案是"进一个无界队列",那它就是一颗定时炸弹。正确的做法是彻底放弃 Executors 这一系列工厂方法,老老实实用 ThreadPoolExecutor 把七个参数全部显式写出来,尤其是把队列换成有界的,因为有界队列才能在系统扛不住的时候触发拒绝策略,而拒绝策略——特别是 CallerRunsPolicy——能把压力反向传导给任务的提交方,让它自己慢下来,这就是"背压"。背压听起来不那么美好,它意味着系统会变慢、会拒绝一些任务,但请记住:一个会主动喊"我扛不住了"并适当降速的系统,远比一个默默把任务堆到内存里、最后毫无征兆地整体崩溃的系统要健康得多。第二条经验是关于隔离:我们出事前所有异步任务都共用一个全局线程池,推送、短信、审计日志全挤在一起,结果推送流量一暴涨,把池占满,连发短信和写审计都跟着被饿死——一个业务的故障顺着共享资源传染成了全站故障。把线程池按业务、按重要性拆开,就是经典的舱壁模式,让一个舱进水时不至于沉掉整艘船。最后,这次事故还暴露了我们对线程池的运行状态几乎是"睁眼瞎"——ThreadPoolExecutor 其实暴露了活跃线程数、队列堆积量、完成任务数、拒绝次数等一大堆现成的指标,只要定时把它们采集成监控,队列堆积量那条一路上涨却永不回落的曲线,会在堆被撑爆之前的很久就清清楚楚地告诉你:消费已经追不上生产了,该介入了。线程池是 Java 并发里最常用也最容易被低估的组件,用好它的前提,是先承认它不是一个"丢进去就不用管"的黑盒,而是一个需要被精心配置、严格隔离、持续观测的关键基础设施。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

分布式事务踩坑:服务拆分后订单成功库存却没扣的复盘

2026-5-20 12:54:26

技术教程

double 算钱算出误差:一笔分账对不上账的复盘

2026-5-20 12:59:36

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索