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

《Java开发踩坑实战:排查并修复线程池误用导致的内存暴涨与请求超时问题》

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

背景与问题

线上问题里,最让人头疼的往往不是“直接报错”,而是系统还能跑,但越跑越慢

我曾经踩过一个很典型的坑:某个 Java 服务发布后,监控先是出现 RT 持续上升,接着 请求超时变多,然后 堆内存一路上涨,Full GC 越来越频繁,最终服务几乎不可用。第一眼看像是“流量涨了”或者“下游变慢了”,但最后定位下来,根因竟然是:线程池使用方式错了

这类问题之所以难查,是因为它表面上会同时表现为:

  • 接口平均响应时间升高
  • 请求超时增加
  • 线程数上涨或线程长时间忙碌
  • 队列积压
  • 堆内存持续增长
  • Full GC 次数增加
  • 最后甚至触发 OOM

很多团队都用线程池,但“用了线程池”不等于“用对了线程池”。尤其是 Executors.newFixedThreadPool()newSingleThreadExecutor() 这种看起来很方便的 API,背后其实埋了不少坑。


背景中的典型现象

先把这个故障链路画出来,后面排查会更清晰。

flowchart TD
    A[请求流量进入] --> B[业务把任务提交到线程池]
    B --> C{任务执行是否及时}
    C -- 否 --> D[队列持续堆积]
    D --> E[大量待执行任务对象滞留堆内存]
    E --> F[GC压力上升]
    F --> G[请求处理更慢]
    G --> H[超时增多]
    H --> D
    C -- 是 --> I[系统稳定]

这个闭环很危险:处理慢 -> 积压更多 -> 占内存更多 -> GC 更重 -> 更慢


现象复现

先用一个可运行的小例子,把问题复现出来。

这个示例故意用了 newFixedThreadPool,它内部默认搭配的是无界队列 LinkedBlockingQueue。当提交速度远大于处理速度时,任务不会被拒绝,而是不断排队,最终把内存吃满。

错误示例:无界队列导致任务堆积

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

public class BadThreadPoolDemo {

    // 看起来很正常,其实风险很大:固定线程数 + 无界队列
    private static final ExecutorService EXECUTOR = Executors.newFixedThreadPool(8);

    public static void main(String[] args) throws Exception {
        for (int i = 0; i < 1000000; i++) {
            final int taskId = i;
            EXECUTOR.submit(() -> {
                // 模拟慢任务,比如调用下游接口 / 数据库慢查询
                try {
                    byte[] payload = new byte[1024 * 100]; // 100KB,模拟任务关联对象
                    TimeUnit.MILLISECONDS.sleep(200);
                    if (taskId % 10000 == 0) {
                        System.out.println("running task: " + taskId + ", payload=" + payload.length);
                    }
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            });

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

这个例子为什么危险?

因为:

  • 线程池只有 8 个工作线程
  • 每个任务执行要 200ms
  • 主线程提交任务速度极快
  • 队列是无界的,提交任务时几乎不会失败
  • 每个待执行任务都可能持有上下文对象、参数对象、缓存数据等引用

结果就是:任务没来得及执行,先在队列里堆成山了


核心原理

要解决线程池问题,不能只记几个参数,得先理解它的调度逻辑。

ThreadPoolExecutor 的关键参数

ThreadPoolExecutor 最重要的是这几个参数:

  • corePoolSize:核心线程数
  • maximumPoolSize:最大线程数
  • keepAliveTime:非核心线程空闲存活时间
  • workQueue:任务队列
  • RejectedExecutionHandler:拒绝策略

线程池接收任务时,大致遵循下面的流程:

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

这里的关键误区在于:

如果你用了无界队列,那么队列几乎永远“能放下任务”,maximumPoolSize 基本就失去了意义。

也就是说:

  • 线程数不会扩到 maximumPoolSize
  • 任务会优先进队列
  • 队列无限增长
  • 内存跟着涨

为什么会引发请求超时?

因为请求线程通常会等待异步任务结果,或者依赖线程池中的某个处理步骤完成。一旦线程池开始积压:

  1. 新任务排队时间变长
  2. 请求等待时间变长
  3. 超过接口超时时间
  4. 上游重试,进一步放大流量
  5. 系统雪崩风险增加

这个过程可以用时序图表示:

sequenceDiagram
    participant Client as 客户端
    participant App as 业务线程
    participant Pool as 线程池
    participant Downstream as 下游服务

    Client->>App: 发起请求
    App->>Pool: 提交异步任务
    Pool-->>App: 任务进入队列等待
    Note over Pool: 队列已积压,大量任务未执行
    App->>App: 等待结果/Future.get()
    Pool->>Downstream: 迟迟才开始处理
    App-->>Client: 超时或响应极慢

定位路径

线上排查时,我一般不是一上来就看代码,而是按“现象 -> 指标 -> 线程 -> 堆 -> 代码”的顺序定位。

1. 先看监控

重点关注:

  • JVM Heap 使用率是否持续上涨
  • Full GC 次数是否明显增加
  • 活跃线程数是否异常
  • 接口 RT、超时率是否同步恶化
  • 线程池的队列长度、活跃线程数、任务拒绝数

如果你们没有线程池指标,这是第一个要补的监控盲区。

2. 再看线程栈

jstack 看线程状态:

  • 线程池工作线程是否大面积阻塞在 IO
  • 是否有大量线程卡在下游调用
  • 请求线程是否在 Future.get()CountDownLatch.await() 等位置等待

如果工作线程都在慢调用上,那排队是必然的。

3. 看堆快照

jmap 或 MAT 分析堆:

  • 是否存在大量 FutureTask
  • 是否存在大量 LinkedBlockingQueue$Node
  • 是否有业务任务对象被队列持有
  • 大对象是否被异步任务闭包引用

这个特征非常典型:不是业务缓存泄漏,而是线程池队列把任务“存活”住了。

4. 回到代码核查线程池创建方式

排查重点:

  • 是否用了 Executors.newFixedThreadPool()
  • 是否用了 Executors.newSingleThreadExecutor()
  • 是否没有设置队列容量
  • 是否没有设置拒绝策略
  • 是否异步任务里做了慢 IO / 重试 / 大对象封装
  • 是否把请求上下文、完整 DTO、批量数据塞进了任务

常见坑与排查

坑 1:误用 Executors 工厂方法

很多人喜欢这么写:

ExecutorService executor = Executors.newFixedThreadPool(16);

问题是它等价于:

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

注意这个 LinkedBlockingQueue<>,默认容量接近无限。

排查建议

  • 搜全项目的 Executors.newFixedThreadPool
  • newSingleThreadExecutor
  • 搜没有容量参数的 LinkedBlockingQueue

坑 2:线程池里执行慢 IO,却按 CPU 密集型参数配置

比如:

  • 核心线程数只配了 4
  • 但每个任务都要查数据库、调 HTTP、发 MQ
  • 每个任务都可能阻塞几百毫秒甚至几秒

这种线程池就很容易形成排队。

排查建议

区分任务类型:

  • CPU 密集型:线程数接近 CPU 核数
  • IO 密集型:线程数可适当高一些,但要结合下游承载能力

别一把梭把所有任务都丢给同一个线程池。


坑 3:Future.get() 无超时等待

线程池积压后,调用方如果还这么写:

String result = future.get();

那问题会被放大。因为调用线程会一直等,最后把 Tomcat/Jetty/Netty 的业务线程也拖死。

正确姿势

String result = future.get(500, TimeUnit.MILLISECONDS);

同时做好超时降级。


坑 4:异步任务捕获大对象

例如:

List<Order> orders = queryHugeOrders();

executor.submit(() -> {
    process(orders);
});

如果任务进了队列没执行,orders 会一直被引用,堆内存就会被这些“待执行任务”拖住。

排查建议

  • 任务只传必要参数
  • 避免捕获超大集合、完整请求对象、文件内容
  • 能在任务内部重新查询的,不要提前把大对象塞进去

坑 5:没有拒绝策略,或者拒绝策略不符合业务

如果队列设成有界但没想清楚拒绝策略,也容易出事故。

常见策略:

  • AbortPolicy:直接抛异常,适合强提醒
  • CallerRunsPolicy:调用线程自己执行,能自然限流,但会拖慢调用方
  • DiscardPolicy:直接丢弃,不适合重要任务
  • DiscardOldestPolicy:丢最老任务,适合某些低价值场景

拒绝不是坏事,失控才是坏事


实战代码(可运行)

下面给一个更合理的实现版本。目标不是“永不拒绝”,而是:容量可控、超时可控、降级可控

改进示例:显式使用 ThreadPoolExecutor + 有界队列

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,                    // keepAliveTime
            TimeUnit.SECONDS,
            new ArrayBlockingQueue<>(200), // 有界队列,防止无限堆积
            new NamedThreadFactory("biz-pool"),
            new ThreadPoolExecutor.CallerRunsPolicy() // 让调用方承担背压
    );

    public static void main(String[] args) throws Exception {
        for (int i = 0; i < 2000; i++) {
            final int taskId = i;
            try {
                Future<String> future = EXECUTOR.submit(() -> doWork(taskId));
                try {
                    String result = future.get(300, TimeUnit.MILLISECONDS);
                    if (taskId % 100 == 0) {
                        System.out.println("result: " + result);
                    }
                } catch (TimeoutException e) {
                    future.cancel(true);
                    System.err.println("task timeout: " + taskId);
                }
            } catch (RejectedExecutionException e) {
                System.err.println("task rejected: " + taskId);
                // 这里可以做降级、快速失败、告警
            }

            if (i % 100 == 0) {
                printStats();
            }
        }

        EXECUTOR.shutdown();
        EXECUTOR.awaitTermination(1, TimeUnit.MINUTES);
    }

    private static String doWork(int taskId) {
        try {
            TimeUnit.MILLISECONDS.sleep(200); // 模拟慢任务
            return "ok-" + taskId;
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return "cancelled-" + taskId;
        }
    }

    private static void printStats() {
        System.out.println(
                "poolSize=" + EXECUTOR.getPoolSize()
                        + ", active=" + EXECUTOR.getActiveCount()
                        + ", queued=" + EXECUTOR.getQueue().size()
                        + ", completed=" + EXECUTOR.getCompletedTaskCount()
        );
    }

    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. Future.get 设置超时

    • 避免请求线程无限等待
  4. 超时后取消任务

    • 减少无效执行占用资源
  5. 打印线程池运行指标

    • 方便本地验证和线上监控接入

止血方案

如果你已经在线上遇到内存暴涨和超时,不一定能立刻重构代码。先止血,再治理。

立刻可做的止血动作

1. 限流

  • 在入口层限流
  • 对高频接口做熔断或降级
  • 暂时关闭低优先级功能

2. 缩短超时

  • 缩短下游调用超时
  • 缩短 Future.get() 等待时间
  • 避免请求线程长期占用

3. 替换线程池配置

  • 从无界队列改成有界队列
  • 明确拒绝策略
  • 将公共线程池拆分为独立业务线程池

4. 减少队列中任务对象体积

  • 只保留必要参数
  • 不要把大集合、大报文直接带入任务

5. 必要时重启,但别把重启当修复

重启只能暂时清空积压,如果根因没改,流量一上来还会复发


安全/性能最佳实践

这里给一些我自己更愿意落地的原则,不是“理论最优”,而是线上更稳。

1. 不直接使用 Executors 默认工厂创建业务线程池

推荐统一使用 ThreadPoolExecutor 显式声明参数。

ThreadPoolExecutor executor = new ThreadPoolExecutor(
    corePoolSize,
    maximumPoolSize,
    60L,
    TimeUnit.SECONDS,
    new ArrayBlockingQueue<>(queueSize),
    threadFactory,
    new ThreadPoolExecutor.AbortPolicy()
);

2. 线程池按业务隔离

不要把:

  • 下单任务
  • 报表任务
  • 推送任务
  • 三方接口调用

全部放进一个线程池。

否则一个慢任务类型就可能拖垮全部业务。

flowchart LR
    A[订单业务线程池] --> X[订单下游]
    B[报表业务线程池] --> Y[数据库]
    C[通知业务线程池] --> Z[短信/邮件服务]

3. 给线程池打监控

至少暴露这些指标:

  • poolSize
  • activeCount
  • queueSize
  • completedTaskCount
  • taskRejectedCount
  • taskTimeoutCount

经验上,队列长度是最早暴露风险的指标之一。


4. 请求超时、线程池超时、下游超时要协同

常见错误是:

  • 接口超时 1s
  • 线程池等待 5s
  • 下游 HTTP 超时 10s

这会导致上游早就失败了,下游还在白跑。

建议按照链路统一预算,例如:

  • 总接口超时:1000ms
  • 线程池等待:200ms
  • 下游调用:500ms
  • 预留重试/序列化/网络抖动:300ms

5. 有界不代表万事大吉

有界队列只是避免“无限膨胀”,但如果:

  • 队列容量过大
  • 任务本身很慢
  • 下游持续抖动

照样会超时。

所以线程池治理一定要结合:

  • 下游超时控制
  • 限流
  • 熔断
  • 重试次数限制
  • 业务降级

6. 任务设计尽量轻量化

一个好的异步任务应该:

  • 参数小
  • 执行时间可预估
  • 失败可重试或可丢弃
  • 有明确超时
  • 不依赖长时间阻塞资源

如果一个任务又大、又慢、又不能丢、还会重试,那线程池迟早出事。


一个简单的排查清单

线上遇到“内存涨 + 超时多 + 线程池可疑”时,可以按这个顺序过一遍:

  1. 是否使用了 Executors.newFixedThreadPool/newSingleThreadExecutor
  2. 队列是否无界
  3. 线程池指标中 queueSize 是否持续增长
  4. 工作线程是否阻塞在慢 IO
  5. 调用方是否在 Future.get() 无限等待
  6. 异步任务是否捕获大对象
  7. 是否缺少拒绝策略和降级处理
  8. 是否多个业务共用同一线程池
  9. 是否存在超时配置不一致
  10. 是否有上游重试放大问题

这个清单看起来普通,但很多线上事故,真就是卡在前两三条。


总结

这次问题的本质,不是“线程池不好用”,而是把线程池当成了无限缓冲区

请记住这几个关键点:

  • Executors.newFixedThreadPool() 默认可能带来无界队列风险
  • 无界队列会让 maximumPoolSize 形同虚设
  • 任务积压不仅会导致内存上涨,还会放大请求超时
  • 真正稳定的方案是:有界队列 + 拒绝策略 + 超时控制 + 业务隔离 + 监控告警

如果要给出最直接的可执行建议,我会优先做这几件事:

  1. 把业务线程池改成显式 ThreadPoolExecutor
  2. 队列一律设上限
  3. 所有 Future.get() 必须带超时
  4. 给线程池补齐监控
  5. 慢任务、重任务、低优先级任务分池隔离
  6. 对关键链路加限流、熔断和降级

最后补一句边界条件:
如果你的任务量非常平稳、任务极轻、并且有严格容量评估,无界队列不一定立刻出事;但在线上复杂业务里,这种前提往往不成立。所以从工程实践看,宁可早拒绝,也不要晚崩溃


分享到:

上一篇
《微服务架构中的分布式事务实战:基于 Saga 模式的设计、落地与排障》
下一篇
《Spring Boot 中基于 Spring Cache + Redis 的多级缓存实战:热点数据一致性与性能优化指南》