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

《Java开发踩坑实战:排查并修复线程池误用导致的请求堆积与 OOM 问题》

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

Java开发踩坑实战:排查并修复线程池误用导致的请求堆积与 OOM 问题

线上服务里,线程池几乎无处不在:异步任务、批量处理、消息消费、接口并发隔离……但也正因为太常见,很多问题不是“不会用”,而是“以为自己会用”。

我就踩过一个很典型的坑:接口 QPS 并不算高,CPU 也没打满,但请求响应时间越来越长,最终出现大量超时,JVM 堆内存持续上涨,最后直接 OOM。排查下来,罪魁祸首不是业务代码本身,而是线程池配置和使用方式出了问题

这篇文章不讲大而全的理论,而是按“现象复现 → 定位路径 → 止血方案 → 正确修复”的方式,带你把这个坑走一遍。


背景与问题

先说一个真实场景的抽象版本。

某个聚合接口会并发调用多个下游服务,为了提升吞吐,开发同学用了线程池异步执行。最初压测看起来没问题,但上线后遇到如下现象:

  • 接口 RT 从几十毫秒逐步飙升到几秒
  • Tomcat/Undertow 工作线程没满,但请求就是越来越慢
  • GC 变频繁,老年代持续增长
  • 最后报 java.lang.OutOfMemoryError: Java heap space
  • 重启服务后短暂恢复,流量一上来又复现

这类问题最容易误判成:

  • 下游接口慢
  • 数据库抖动
  • JVM 参数不合理
  • 内存泄漏

这些都可能是诱因,但如果你看到下面这组特征,就要高度怀疑线程池:

  1. 队列长度持续增长
  2. 活跃线程数接近 core/max,但处理速度跟不上入队速度
  3. 任务对象占用大量堆内存
  4. 线程池拒绝策略没有生效,或者根本拒绝不了

最常见的误用之一,就是直接这样写:

ExecutorService executor = Executors.newFixedThreadPool(200);

看着没毛病,实际上这个 API 背后默认使用的是无界队列,这是很多线上堆积问题的起点。


现象复现

我们先故意写一个“有坑”的版本,复现请求堆积和内存上涨。

错误示例:无界队列 + 大对象任务 + 提交速度远高于消费速度

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.*;

public class BadThreadPoolDemo {

    // newFixedThreadPool 底层是无界 LinkedBlockingQueue
    private static final ExecutorService EXECUTOR = Executors.newFixedThreadPool(4);

    public static void main(String[] args) throws InterruptedException {
        long requestId = 0;
        while (true) {
            long currentId = requestId++;
            EXECUTOR.submit(() -> handleRequest(currentId));

            if (currentId % 1000 == 0) {
                System.out.println("submitted: " + currentId);
            }

            // 模拟高并发持续流入
            Thread.sleep(2);
        }
    }

    private static void handleRequest(long requestId) {
        try {
            // 模拟任务里携带较大上下文数据,占用堆内存
            List<byte[]> payload = new ArrayList<>();
            for (int i = 0; i < 8; i++) {
                payload.add(new byte[1024 * 256]); // 256KB * 8 = 2MB
            }

            // 模拟下游慢调用
            Thread.sleep(500);

            if (requestId % 500 == 0) {
                System.out.println("processed: " + requestId + ", thread=" + Thread.currentThread().getName());
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}

如果你给 JVM 一个比较小的堆,例如:

java -Xms256m -Xmx256m BadThreadPoolDemo

大概率很快就能看到堆内存上涨,甚至 OOM。

为什么这个示例容易出问题?

因为这里同时满足了 3 个危险条件:

  • 线程数固定,处理能力有限
  • 任务执行慢
  • 提交速度快,且队列无上限

于是,大量任务会在队列里排队。排队的不只是“一个 Runnable 引用”,而是连同它持有的上下文对象一起被保留在堆里。如果任务闭包里捕获了大对象、请求参数、响应缓冲区、用户上下文,这个占用会非常可怕。


核心原理

要彻底看懂这个问题,得先把线程池的工作机制捋清楚。

ThreadPoolExecutor 的任务处理逻辑

ThreadPoolExecutor 处理任务,大致遵循这个顺序:

  1. 当前运行线程数 < corePoolSize:创建核心线程执行任务
  2. 否则尝试把任务放入队列
  3. 如果队列满了,且运行线程数 < maximumPoolSize:创建非核心线程执行任务
  4. 如果队列也满、线程也到上限:触发拒绝策略

也就是说,队列类型和容量,直接决定线程池在高压下的行为

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

Executors.newFixedThreadPool() 的坑点

它本质上相当于:

new ThreadPoolExecutor(
    nThreads,
    nThreads,
    0L,
    TimeUnit.MILLISECONDS,
    new LinkedBlockingQueue<Runnable>()
);

注意这里的 LinkedBlockingQueue无界的。

这意味着:

  • 队列几乎不会满
  • maximumPoolSize 实际失去意义
  • 拒绝策略基本不会触发
  • 高峰期任务只会不断排队
  • 如果任务积压速度高于消费速度,堆内存就会被吃光

请求堆积是怎么一步步走向 OOM 的?

可以把它理解成一个“慢性失血”过程:

sequenceDiagram
    participant Client as 客户端请求
    participant App as 应用线程
    participant Pool as 线程池
    participant Downstream as 下游服务

    Client->>App: 请求进入
    App->>Pool: submit 异步任务
    Pool-->>App: 快速返回已入队
    App-->>Client: 等待汇总结果/继续处理
    Pool->>Downstream: 执行慢调用
    Downstream-->>Pool: 响应慢

    Note over Pool: 新请求持续进入<br/>旧任务未处理完<br/>队列持续增长
    Note over App,Pool: 堆中保留越来越多待执行任务与上下文对象

这类问题有个很迷惑人的地方:线程池 submit 很快,不代表系统处理得快
它只是“收下了任务”,不代表“及时做完了任务”。

另一个常见误区:线程数越大越好

并不是。

如果任务是 I/O 密集型,线程数可以适度高一些;但如果下游本身已经慢了,继续加线程往往只会:

  • 增加上下文切换
  • 放大下游压力
  • 让排队从“线程池内”转移到“数据库/远程服务端”
  • 在极端情况下引发雪崩

定位路径

线上排查这类问题,我通常按下面顺序来。

1. 先看外部现象

重点关注这些监控:

  • 请求 QPS
  • 响应时间 RT / TP99
  • 超时数
  • JVM 堆使用率
  • Full GC 次数
  • 线程数
  • 下游调用耗时

如果现象是“QPS 没暴涨,但 RT、堆内存、GC 一起上涨”,很像堆积。

2. 看线程池指标

如果你们没有线程池监控,这是非常值得补上的。

最关键的几个指标:

  • poolSize
  • activeCount
  • queueSize
  • completedTaskCount
  • taskCount
  • largestPoolSize

如果看到:

  • activeCount 长期接近上限
  • queueSize 持续上涨不回落
  • completedTaskCount 增长缓慢

那基本可以确定是消费跟不上生产。

3. 看线程栈

使用:

jstack <pid>

重点看:

  • 线程池工作线程在干什么
  • 是否大量阻塞在 HTTP 调用、数据库查询、锁等待
  • 是否有业务线程在 Future.get() 长时间等待

常见现象:

  • 大量线程阻塞在 socketRead
  • 大量线程在调用下游接口
  • 主流程线程在等异步结果,形成“伪异步”

4. 看堆对象

使用:

jmap -histo:live <pid> | head -n 50

或者直接 dump 堆后用 MAT 分析。

你常会看到:

  • java.util.concurrent.FutureTask
  • java.util.concurrent.LinkedBlockingQueue$Node
  • 业务 Runnable/Callable 实现类
  • 被任务持有的大对象

如果某个任务类实例数巨大,几乎就是铁证。

5. 看是否用了默认线程池工厂

重点搜索代码里这些写法:

Executors.newFixedThreadPool(...)
Executors.newCachedThreadPool(...)
Executors.newSingleThreadExecutor()
CompletableFuture.supplyAsync(...)
parallelStream()

因为很多问题不是显式线程池,而是默认线程池被误用了


实战代码(可运行)

下面给一个相对正确、能上线用思路的版本。

目标是:

  • 使用有界队列
  • 明确线程数边界
  • 设置可观测线程名
  • 使用合理拒绝策略
  • 给调用方施加背压
  • 避免无限堆积

改进版线程池实现

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

public class GoodThreadPoolDemo {

    private static final ThreadPoolExecutor EXECUTOR = new ThreadPoolExecutor(
            8,                          // corePoolSize
            16,                         // maximumPoolSize
            60L, TimeUnit.SECONDS,      // keepAliveTime
            new ArrayBlockingQueue<>(200), // 有界队列,防止无限堆积
            new NamedThreadFactory("biz-worker"),
            new ThreadPoolExecutor.CallerRunsPolicy() // 背压到调用方
    );

    public static void main(String[] args) throws InterruptedException {
        startMonitor();

        long requestId = 0;
        while (true) {
            long currentId = requestId++;
            try {
                EXECUTOR.execute(() -> handleRequest(currentId));
            } catch (RejectedExecutionException e) {
                System.err.println("task rejected, requestId=" + currentId);
            }

            Thread.sleep(20);
        }
    }

    private static void handleRequest(long requestId) {
        try {
            // 模拟慢调用
            Thread.sleep(200);

            if (requestId % 100 == 0) {
                System.out.println("processed: " + requestId +
                        ", thread=" + Thread.currentThread().getName());
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }

    private static void startMonitor() {
        ScheduledExecutorService monitor = Executors.newSingleThreadScheduledExecutor(
                new NamedThreadFactory("monitor")
        );

        monitor.scheduleAtFixedRate(() -> {
            System.out.printf(
                    "[monitor] poolSize=%d, active=%d, queue=%d, completed=%d, task=%d%n",
                    EXECUTOR.getPoolSize(),
                    EXECUTOR.getActiveCount(),
                    EXECUTOR.getQueue().size(),
                    EXECUTOR.getCompletedTaskCount(),
                    EXECUTOR.getTaskCount()
            );
        }, 0, 2, TimeUnit.SECONDS);
    }

    static class NamedThreadFactory implements ThreadFactory {
        private final String prefix;
        private final AtomicInteger counter = new AtomicInteger(1);

        NamedThreadFactory(String prefix) {
            this.prefix = prefix;
        }

        @Override
        public Thread newThread(Runnable r) {
            Thread t = new Thread(r, prefix + "-" + counter.getAndIncrement());
            t.setDaemon(false);
            return t;
        }
    }
}

这个版本解决了什么?

  1. 有界队列
    最关键的一步。让系统在高峰期“有上限地拥堵”,而不是“无限制地积压”。

  2. CallerRunsPolicy
    当线程池满了,提交任务的线程自己执行任务,相当于给上游施加自然限流。
    它不是万能的,但在很多同步请求场景下比静默堆积靠谱得多。

  3. 指标打印
    至少先把线程池运行状态暴露出来,别等出事才盲查。

  4. 线程命名
    jstack 排查时非常有用。否则一堆 pool-1-thread-3,定位体验很差。


止血方案

线上已经出现请求堆积甚至 OOM 风险时,先别急着“优雅重构”,优先止血。

临时止血手段

方案一:降低流量入口

  • 网关限流
  • 降级部分非核心功能
  • 关闭高成本接口
  • 缩短超时时间,避免任务长期占用线程

方案二:快速切换到有界线程池

把这种配置:

Executors.newFixedThreadPool(100)

改成显式线程池:

new ThreadPoolExecutor(
    20,
    40,
    60L,
    TimeUnit.SECONDS,
    new ArrayBlockingQueue<>(500),
    new ThreadPoolExecutor.AbortPolicy()
)

注意:队列容量不能拍脑袋,要结合业务峰值和单任务耗时估算。

方案三:减少任务对象体积

我见过不少任务这么写:

executor.submit(() -> process(hugeRequest, hugeContext, hugeList));

如果这些对象很大,而任务又在队列里排队,就等于把大对象长时间挂在堆上。

优化方式:

  • 只传必要字段
  • 提前做轻量化 DTO 转换
  • 不要在异步任务里捕获整个请求上下文
  • 避免把大集合原样带进任务闭包

方案四:超时、熔断、隔离

如果堆积的根因是下游慢:

  • 给下游调用设置连接/读超时
  • 做熔断,别让所有线程一起耗死在慢服务上
  • 不同业务使用不同线程池,避免互相拖垮

常见坑与排查

这一部分我想列得更实战一点,因为很多坑不是“不会写”,而是“看起来合理”。

坑 1:以为 maximumPoolSize 一定会生效

不一定。

如果你用的是无界队列,任务会优先入队,线程数通常只会增长到 corePoolSizemaximumPoolSize 几乎没机会发挥作用。

坑 2:把线程池当成“削峰填谷”的无限缓冲区

线程池可以缓冲短时突刺,但不能替代消息队列,更不能承担无限堆积。

一个判断原则:

  • 短时间突发 + 可快速回落:线程池队列可以兜一下
  • 持续高于处理能力:一定会堆积,早晚出问题

坑 3:submit() 吞异常,误以为任务都成功了

submit() 返回 Future,任务异常不会直接抛到调用线程。
如果你既没 get(),也没统一日志处理,任务失败可能悄无声息。

例如:

executor.submit(() -> {
    throw new RuntimeException("boom");
});

建议:

  • 需要感知异常时,显式处理 Future
  • 或者使用 execute() 配合线程工厂/全局异常记录
  • 对异步框架统一封装日志和监控

坑 4:CallerRunsPolicy 用错场景

它很好,但不是所有场景都适合。

适合:

  • 同步请求线程提交异步任务
  • 希望通过调用方变慢来反向限流

不适合:

  • 事件循环线程
  • Netty I/O 线程
  • 对响应线程时延极其敏感的场景

否则你可能把“线程池压力”直接转移成“主线程阻塞”。

坑 5:一个线程池承载所有业务

典型后果:

  • 低优先级任务把高优先级任务挤死
  • 某个慢下游拖垮整个服务
  • 排查时根本看不出是谁导致队列爆了

更合理的方式是按用途隔离:

  • HTTP 聚合调用池
  • 消息消费池
  • 定时任务池
  • 文件导出池
classDiagram
    class ThreadPoolIsolation {
        +httpAggregationPool
        +messageConsumePool
        +schedulePool
        +exportPool
    }

    class Problem {
        +共享池导致互相影响
        +堆积难定位
        +故障扩散
    }

    ThreadPoolIsolation --> Problem

坑 6:异步里再套异步,层层 submit

比如:

  • Controller 提交线程池
  • Service 里又 CompletableFuture.supplyAsync
  • 下游 SDK 自己也有异步线程池

最终结果是:

  • 线程切换增多
  • 链路复杂
  • 超时边界不清楚
  • 排查非常痛苦

建议链路里明确“谁负责并发,谁负责超时,谁负责回收结果”。


安全/性能最佳实践

这一部分给的是我认为比较“能落地”的建议,不追求绝对标准答案,但够实用。

1. 永远优先显式创建 ThreadPoolExecutor

不建议直接用:

Executors.newFixedThreadPool(...)
Executors.newCachedThreadPool(...)

建议显式指定:

  • 核心线程数
  • 最大线程数
  • 队列容量
  • 线程工厂
  • 拒绝策略

这样你才真正知道系统高压时会怎么表现。

2. 队列必须有界

这是防 OOM 的底线之一。

常见可选项:

  • ArrayBlockingQueue:定长数组实现,简单直接
  • LinkedBlockingQueue(capacity):链表实现,也可以设上限
  • SynchronousQueue:不存储任务,适合直接移交型场景,但配置要谨慎

3. 根据任务类型估算线程数

一个经验原则:

  • CPU 密集型:线程数接近 CPU 核数
  • I/O 密集型:可适当放大,但要结合下游承受能力

不要只看本机 CPU,要看整个调用链。

4. 给每个线程池打监控

至少暴露这些指标:

  • 当前线程数
  • 活跃线程数
  • 队列长度
  • 拒绝次数
  • 任务完成数
  • 平均/分位执行时长

如果可以,再加:

  • 任务等待时长
  • 任务执行超时数
  • 不同业务标签分桶统计

5. 任务不要携带大对象

这是一个非常容易被忽略的内存点。

错误倾向:

  • 捕获整个 HttpServletRequest
  • 捕获大 Map / 大 List
  • 捕获原始响应内容
  • 把用户会话对象整包传递

更好的做法:

  • 异步任务只传必要字段
  • 提前序列化/裁剪
  • 用轻量 DTO
  • 结果及时释放引用

6. 为下游调用设置超时

如果线程池里的任务本质上是远程 I/O,那么超时就是线程回收速度的生命线。

建议至少设置:

  • 连接超时
  • 读超时
  • 总超时
  • 超时后的降级/重试策略

注意:重试本身也会放大线程池压力,不能无限重试。

7. 隔离核心与非核心流量

例如:

  • 核心下单接口一个池
  • 运营报表导出一个池
  • 异步通知一个池

别让“可慢的业务”拖垮“不能慢的业务”。

8. 明确拒绝后的处理策略

拒绝不是失败,而是系统在说:我到极限了

常见策略:

  • AbortPolicy:直接抛异常,适合必须感知失败的场景
  • CallerRunsPolicy:调用方执行,适合做自然背压
  • 自定义策略:打日志、告警、降级、丢弃低优先级任务

关键是:拒绝必须可观测

stateDiagram-v2
    [*] --> 正常处理
    正常处理 --> 队列增长: 流量上升
    队列增长 --> 高压状态: 消费跟不上生产
    高压状态 --> 拒绝任务: 达到上限
    拒绝任务 --> 降级限流: 触发保护
    降级限流 --> 正常处理: 流量恢复
    高压状态 --> OOM风险: 无界堆积

一个更贴近生产的修复思路

如果你现在正在线上处理类似问题,我建议按这个顺序推进:

第一步:补监控

先把线程池指标补起来,不然全靠猜。

第二步:限制堆积

把无界队列改成有界队列,明确拒绝策略。

第三步:缩短任务生命周期

  • 下游超时收紧
  • 避免无意义重试
  • 降低任务内对象体积

第四步:按业务隔离线程池

别让一个慢任务拖垮整个应用。

第五步:评估是否真的需要线程池

有些场景其实更适合:

  • 批处理队列
  • 消息队列削峰
  • Reactor/异步非阻塞模型
  • 本地限流 + 降级

不是所有并发问题都应该靠“多开点线程”解决。


总结

线程池误用导致的请求堆积和 OOM,核心不是“线程不够多”,而是:

  • 生产速度持续大于消费速度
  • 队列没有边界
  • 任务持有了不该长时间保留的对象
  • 高压下缺少拒绝、限流、隔离和监控

最值得记住的几条结论:

  1. 不要迷信 Executors.newFixedThreadPool()
  2. 线上线程池队列尽量有界
  3. 拒绝策略要明确,且要可观测
  4. 异步任务不要捕获大对象
  5. 下游慢调用必须有超时、熔断、隔离
  6. 线程池指标要纳入日常监控

如果你已经遇到“RT 变长、队列上涨、堆内存上涨、最终 OOM”这组连环现象,优先检查线程池,而不是先怀疑 JVM。

很多时候,问题不在于 Java 顶不住,而在于我们给了线程池一个“无限收任务”的权限。
一旦这个口子开了,系统迟早会用 OOM 的方式提醒你:该收手了。


分享到:

上一篇
《前端性能实战:基于 Core Web Vitals 的页面加载优化与排查指南》
下一篇
《Java Web 开发中基于 Spring Boot + Redis 实现接口限流与防刷的实战指南》