跳转到内容
123xiao | 无名键客

《Java开发踩坑实战:定位并解决线程池误用导致的请求堆积与OOM问题》

字数: 0 阅读时长: 1 分钟

背景与问题

线上服务最怕的,不一定是“立刻挂掉”,而是那种慢慢变差的故障:RT 先抖动,接口超时慢慢增多,机器 CPU 不一定高,Full GC 却越来越频繁,最后直接 OutOfMemoryError

我踩过一个非常典型的坑:业务里为了“异步提速”,给每个请求都往线程池里丢任务。代码上线后一开始看着很稳,峰值流量一来,请求开始堆积,JVM 堆持续上涨,最终 OOM。事后复盘才发现,问题不是“线程不够”,而是线程池用错了

这类问题常见于:

  • 使用 Executors.newFixedThreadPool(),默认无界队列
  • 请求处理速度 < 请求进入速度,任务无限堆积
  • 任务里还持有大对象、上下文、HTTP 参数、批量数据
  • 调用方没有限流、降级、超时、拒绝处理机制
  • 异步任务结果无人关心,越积越多

先给结论:线程池不是削峰神器。无界队列 + 持续高流量 + 慢任务,最终大概率就是堆积和 OOM。


现象复现

先看一个非常容易写出来、也非常容易出事的示例。

错误示例:固定线程池 + 无界队列

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class WrongThreadPoolDemo {

    // newFixedThreadPool 底层使用无界 LinkedBlockingQueue
    private static final ExecutorService executor = Executors.newFixedThreadPool(8);

    public static void main(String[] args) throws InterruptedException {
        while (true) {
            // 模拟高并发请求不断进入
            for (int i = 0; i < 1000; i++) {
                executor.submit(() -> {
                    // 模拟每个任务都持有一块较大内存
                    List<byte[]> holder = new ArrayList<>();
                    holder.add(new byte[1024 * 512]); // 512KB

                    // 模拟慢任务,比如下游接口慢 / IO阻塞
                    try {
                        Thread.sleep(2000);
                    } catch (InterruptedException e) {
                        Thread.currentThread().interrupt();
                    }
                });
            }

            Thread.sleep(100);
        }
    }
}

如果用较小堆启动,比如:

java -Xms256m -Xmx256m WrongThreadPoolDemo

很快就可能看到:

Exception in thread "main" java.lang.OutOfMemoryError: Java heap space

问题不是 8 个线程本身吃光了内存,而是队列里排队的任务对象太多了。每个任务还捕获了业务数据,队列就成了“内存仓库”。


核心原理

这个问题要想彻底看懂,必须把 ThreadPoolExecutor 的几个参数串起来。

线程池参数是如何影响堆积的

new ThreadPoolExecutor(
    corePoolSize,
    maximumPoolSize,
    keepAliveTime,
    unit,
    workQueue,
    threadFactory,
    handler
)

关键点:

  • corePoolSize:核心线程数
  • maximumPoolSize:最大线程数
  • workQueue:任务队列
  • handler:拒绝策略

很多人误以为“最大线程数设置大一点就能扛住高并发”,但实际并不是这样。当队列是无界队列时,maximumPoolSize 基本失效。

任务提交流程

flowchart TD
    A[提交任务] --> B{运行线程数 < corePoolSize?}
    B -- 是 --> C[创建核心线程执行]
    B -- 否 --> D{队列能放下?}
    D -- 能 --> E[进入队列等待]
    D -- 不能 --> F{运行线程数 < maximumPoolSize?}
    F -- 是 --> G[创建非核心线程执行]
    F -- 否 --> H[执行拒绝策略]

注意这里的关键分支:

  • 如果 workQueue无界队列
  • 那么“队列能放下”几乎永远是“能”
  • 于是任务会不断进队列
  • 根本走不到“扩容到 maximumPoolSize”和“拒绝策略”

这就是为什么很多人明明把 maximumPoolSize 配到了 200,线上却还是只有核心线程在干活,剩下全在队列里排队。

一个请求为什么会把堆压爆

sequenceDiagram
    participant Client as 客户端
    participant App as 应用线程
    participant Pool as 线程池
    participant Queue as 任务队列
    participant Worker as 工作线程
    participant Downstream as 下游服务

    Client->>App: 发起请求
    App->>Pool: submit(任务)
    Pool->>Queue: 任务入队
    Note over Queue: 队列持续堆积
    Worker->>Downstream: 调用慢接口/慢IO
    Downstream-->>Worker: 响应缓慢
    Queue-->>Pool: 更多任务等待
    Note over App,Queue: 每个任务持有参数/上下文/缓存对象
    Note over Queue: 堆内存上升,GC频繁,最终OOM

这类 OOM 的本质往往不是“泄漏”,而是:

  • 活对象太多
  • 队列里的任务还没执行,GC 也回收不了
  • 请求速度持续大于消费速度
  • 系统最终失去平衡

定位路径

遇到这类故障,不要一上来就说“JVM 不够大”。我更建议按下面路径排查。

1. 看外部现象

常见指标变化:

  • 接口 RT 持续升高
  • 超时率升高
  • 机器负载不一定高
  • 老年代占用持续升高
  • Full GC 次数明显增加
  • 某些线程池队列长度不断增长

如果你有监控,先看这几个图:

  • 线程池 activeCount
  • queue size
  • task submit rate / complete rate
  • JVM heap used
  • GC pause / Full GC count

2. 看线程池状态而不是只看线程数

线上很多人只会看“线程池线程是不是满了”。这远远不够。

应该重点看:

  • 当前线程数
  • 活跃线程数
  • 队列长度
  • 已完成任务数
  • 拒绝任务数
  • 平均执行耗时

一个危险信号是:

  • 活跃线程数接近核心线程数
  • 队列长度持续上涨
  • 完成任务速率低于提交速率

这说明系统已经开始“借内存顶流量”。

3. 看堆转储,确认是不是任务对象堆积

可以用下面命令抓堆:

jmap -dump:live,format=b,file=heap.hprof <pid>

或者:

jcmd <pid> GC.heap_dump heap.hprof

然后用 MAT / VisualVM / YourKit 分析。

重点看:

  • LinkedBlockingQueue$Node
  • FutureTask
  • 业务 Runnable / Callable 实现类
  • 被任务引用的大对象:DTO、请求体、批量 List、字节数组、日志上下文等

如果发现大量对象链路类似:

ThreadPoolExecutor
 -> workQueue
 -> LinkedBlockingQueue
 -> Node
 -> FutureTask
 -> YourBusinessTask
 -> request / payload / bigList

那基本就实锤了:不是传统意义上的内存泄漏,而是线程池排队导致的“业务性堆积”。

4. 看线程栈,确认为什么消费慢

抓线程栈:

jstack <pid> > jstack.txt

重点关注工作线程在干什么:

  • 卡在 HTTP 调用
  • 卡在数据库慢查询
  • 卡在 Redis 超时
  • 卡在锁等待
  • 卡在外部 API
  • 卡在磁盘 IO

如果消费者线程都在慢操作上阻塞,任务当然会越积越多。


实战代码(可运行)

下面给一个更合理的改造版本:有界队列 + 显式拒绝策略 + 监控输出 + 调用方背压

正确示例:可控线程池

import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;

public class SafeThreadPoolDemo {

    private static final AtomicInteger REJECT_COUNT = new AtomicInteger();

    private static final ThreadPoolExecutor EXECUTOR = new ThreadPoolExecutor(
            8,                      // corePoolSize
            16,                     // maximumPoolSize
            60L, TimeUnit.SECONDS,  // keepAliveTime
            new ArrayBlockingQueue<>(200), // 有界队列,防止无限堆积
            new ThreadFactory() {
                private final AtomicInteger idx = new AtomicInteger(1);

                @Override
                public Thread newThread(Runnable r) {
                    Thread t = new Thread(r, "biz-worker-" + idx.getAndIncrement());
                    t.setUncaughtExceptionHandler((thread, ex) ->
                            System.err.println("线程异常: " + thread.getName() + ", " + ex.getMessage()));
                    return t;
                }
            },
            (r, executor) -> {
                REJECT_COUNT.incrementAndGet();
                throw new RejectedExecutionException("线程池已满,触发拒绝策略");
            }
    );

    public static void main(String[] args) throws InterruptedException {
        // 定时打印线程池指标
        ScheduledExecutorService monitor = Executors.newSingleThreadScheduledExecutor();
        monitor.scheduleAtFixedRate(() -> {
            System.out.printf(
                    "poolSize=%d, active=%d, queue=%d, completed=%d, task=%d, reject=%d%n",
                    EXECUTOR.getPoolSize(),
                    EXECUTOR.getActiveCount(),
                    EXECUTOR.getQueue().size(),
                    EXECUTOR.getCompletedTaskCount(),
                    EXECUTOR.getTaskCount(),
                    REJECT_COUNT.get()
            );
        }, 0, 1, TimeUnit.SECONDS);

        // 模拟请求洪峰
        for (int round = 0; round < 100; round++) {
            for (int i = 0; i < 100; i++) {
                try {
                    EXECUTOR.execute(() -> {
                        try {
                            // 模拟慢任务
                            Thread.sleep(500);
                        } catch (InterruptedException e) {
                            Thread.currentThread().interrupt();
                        }
                    });
                } catch (RejectedExecutionException e) {
                    // 这里可以改成快速失败、返回降级结果、打点告警等
                    System.err.println("任务被拒绝: " + e.getMessage());
                }
            }
            Thread.sleep(50);
        }

        Thread.sleep(10000);
        monitor.shutdown();
        EXECUTOR.shutdown();
    }
}

这个版本的核心价值不是“完全不丢任务”,而是:

  • 不让内存无限膨胀
  • 在系统扛不住时及时暴露问题
  • 给调用方明确反馈
  • 为限流、降级、重试提供切入点

为什么这个版本更安全

flowchart LR
    A[请求进入] --> B[线程池]
    B --> C{队列未满?}
    C -- 是 --> D[正常处理]
    C -- 否 --> E[拒绝/降级/快速失败]
    D --> F[下游慢调用]
    E --> G[保护JVM内存]
    F --> H[系统可观测]

线程池满了以后,最怕的是“表面接住,实际排队到死”。
真正健康的系统,应该在超出容量时明确拒绝,而不是悄悄拖死自己。


常见坑与排查

坑 1:直接使用 Executors 工厂方法

最典型的两个:

Executors.newFixedThreadPool(10);
Executors.newSingleThreadExecutor();

它们底层都可能使用无界队列。业务量小的时候没问题,流量一放大就容易出事。

更推荐显式创建:

new ThreadPoolExecutor(...)

这样你会被迫认真思考:

  • 队列多大
  • 最大线程数多大
  • 满了怎么办
  • 任务超时怎么处理

坑 2:把大对象塞进异步任务

比如:

  • 整个请求对象
  • 大批量 List
  • 大 JSON 字符串
  • 文件内容
  • 图片字节数组
  • 带 ThreadLocal 上下文的包装对象

即使任务还没执行,只要它进了队列,这些对象就会一直被引用。

建议

  • 异步任务只传必要字段
  • 大对象尽量转存外部存储后传引用 ID
  • 避免 lambda 不小心捕获外层大对象

例如这个写法就要警惕:

List<byte[]> bigData = loadBigData();
executor.submit(() -> process(bigData));

更稳妥的是:

String taskId = saveToTempStoreAndReturnId();
executor.submit(() -> processByTaskId(taskId));

坑 3:任务执行时间不可控

如果线程池里的任务会访问外部服务,而这些调用没有超时,队列堆积几乎是迟早的。

例如:

  • HTTP 调用未设置 connect/read timeout
  • 数据库查询无超时
  • 锁等待时间过长
  • 死循环重试

排查建议

  • 看线程栈是否卡在外部调用
  • 给每个慢操作设置超时
  • 统计任务执行耗时分布,不只看平均值,要看 P95/P99

坑 4:错误使用拒绝策略

常见拒绝策略:

  • AbortPolicy:直接抛异常
  • CallerRunsPolicy:由提交线程执行
  • DiscardPolicy:静默丢弃
  • DiscardOldestPolicy:丢最旧任务

CallerRunsPolicy 看起来很美:任务不丢,还能“背压”。
但如果提交线程是 Tomcat/Netty 的请求线程,就可能导致:

  • 请求线程被拖慢
  • 整体吞吐下降
  • 上游继续超时

所以它不是万能的,得看场景。

建议

  • 用户实时请求:优先快速失败或降级
  • 可补偿任务:可落库后异步重试
  • 非核心任务:允许丢弃,但必须打点告警

坑 5:线程池混用

一个线程池同时干:

  • 发短信
  • 查数据库
  • 调第三方接口
  • 导出报表
  • 刷缓存

这会导致不同任务相互影响。某一类慢任务就能拖垮全部业务。

建议

按任务特性拆分线程池:

  • CPU 密集型
  • IO 密集型
  • 短任务
  • 长任务
  • 核心链路
  • 非核心链路

止血方案

线上已经出现堆积甚至 OOM 风险时,先别急着“大改架构”,先做止血。

短期止血

  1. 把无界队列改成有界队列
  2. 加拒绝策略
  3. 给下游调用加超时
  4. 临时限流
  5. 非核心异步任务先降级/关闭
  6. 缩小单任务持有数据量
  7. 增加线程池监控和告警
  8. 必要时扩容实例,但这只是争取时间

中期修复

  1. 按业务类型拆线程池
  2. 建立队列长度、拒绝数、耗时监控
  3. 任务提交前做流控
  4. 高风险任务改消息队列削峰
  5. 增加任务幂等和补偿机制

长期治理

  1. 统一线程池管理,禁止业务随意 Executors.xxx
  2. 把线程池参数纳入容量评估
  3. 关键异步链路建立压测基线
  4. 形成“满了怎么办”的标准策略

安全/性能最佳实践

这部分我尽量给出能直接落地的建议。

1. 线程池参数不要拍脑袋

一个常见误区是“线程越多越快”。实际上:

  • CPU 密集型:线程数接近 CPU 核数
  • IO 密集型:可适当更高,但要看阻塞比例
  • 队列大小:要基于峰值流量、平均耗时、最大可接受等待时间评估

一个简单思路:

可承受排队任务数 ≈ 峰值每秒请求数 × 最大允许等待秒数

再结合每个任务平均占用内存,评估队列上限。

2. 必须监控这些指标

至少暴露:

  • poolSize
  • activeCount
  • queueSize
  • taskCount
  • completedTaskCount
  • rejectCount
  • taskExecuteTime
  • taskTimeoutCount

如果这些都没有,出了问题只能靠猜。

3. 异步不等于无限吞吐

线程池只是资源调度工具,不会凭空提高系统处理能力。
如果下游接口每秒只能处理 200 个请求,你往线程池里塞 2000 个任务,只会更快把自己拖垮。

4. 任务要可取消、可超时、可降级

尤其是调用外部资源时:

  • HTTP 设置连接超时、读超时
  • Future.get(timeout, unit) 做等待控制
  • 超时后做取消和打点
  • 非核心任务允许返回兜底结果

5. 谨慎使用 ThreadLocal 传上下文

在线程池场景里,ThreadLocal 还有两个常见坑:

  • 上下文没清理,造成脏数据串请求
  • 上下文对象过大,增加线程常驻内存

建议:

  • 用完立刻 remove()
  • 不要把大对象放进去
  • 对日志 MDC、租户信息、链路追踪信息做明确清理

6. 关键链路优先考虑消息队列,而不是本地堆积

如果业务允许异步最终一致,不要把大量待处理任务直接堆在 JVM 内存里。
更稳妥的方式通常是:

  • 请求快速写入 MQ / 持久化存储
  • 消费端按能力消费
  • 失败可重试、可补偿、可审计

本地线程池适合做短时缓冲,不适合做“大容量任务仓库”。


一个实用的排查清单

线上看到“接口越来越慢 + 堆越来越高”时,我一般按这个顺序查:

flowchart TD
    A[接口RT升高/超时增多] --> B[看JVM堆与GC]
    B --> C[看线程池active/queue/reject]
    C --> D{队列持续增长?}
    D -- 是 --> E[抓heap dump看任务对象]
    E --> F[抓jstack看工作线程阻塞点]
    F --> G[确认下游慢调用/锁/IO]
    G --> H[改有界队列+超时+拒绝策略]
    D -- 否 --> I[继续排查其他内存问题]

这个流程很实用,因为它能把“猜测”变成“证据链”:

  • 指标说明有堆积
  • heap dump 说明堆积在哪里
  • jstack 说明为什么消费不动
  • 参数改造说明怎么止血

总结

线程池误用导致的请求堆积和 OOM,本质上是一个容量失控问题,不是简单的“线程数不够”。

你需要记住这几个关键点:

  1. 无界队列是高风险配置
  2. maximumPoolSize 在无界队列下往往不生效
  3. OOM 很可能来自排队任务持有的大量对象
  4. 真正的修复不是加堆,而是限制堆积、加超时、做拒绝
  5. 线程池必须有监控,没有监控就没有排障基础

如果你现在就想做点实际改进,我建议按优先级做这三件事:

  • 把业务代码里的 Executors.newFixedThreadPool() 全部过一遍
  • 给关键线程池补上有界队列、拒绝策略和监控
  • 给所有外部调用补齐超时配置

最后给一句很实用的经验话:
系统扛不住时,宁可明确失败,也不要悄悄堆积到 OOM。
前者是可控故障,后者往往是全站事故。


分享到:

上一篇
《大模型推理优化实战:从量化、KV Cache 到并发调度的性能提升路径》
下一篇
《从 0 到 1 搭建企业级开源项目评估清单:许可证、社区活跃度与可维护性的实战方法》