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

《Java 开发踩坑实战:排查与修复线程池误用导致的内存暴涨和请求堆积》

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

背景与问题

线上系统出问题时,最怕的不是直接报错,而是“看起来还能跑,但越来越慢”。

这类问题我踩过一次:接口 RT 持续升高,机器内存一路上涨,Full GC 频繁,最终请求开始大量超时。乍一看像是代码内存泄漏,结果一路排下来,根因竟然是线程池配置和使用方式不当

典型症状通常是这样的:

  • JVM 堆内存持续升高,不容易回落
  • 请求处理变慢,接口响应时间越来越长
  • 线程池活跃线程数接近上限
  • 队列长度不断增长
  • Full GC 次数增加,但回收效果一般
  • 上游服务重试,进一步放大请求堆积

很多同学第一反应会去查:

  • 是不是某个 Map 没清理?
  • 是不是缓存失控?
  • 是不是某个对象引用链没断?

这些方向没错,但如果你用了线程池,尤其是下面几种写法,就要高度怀疑:

  1. 固定线程数 + 无界队列
  2. 任务执行慢,但提交速度远大于消费速度
  3. 任务体里持有大对象、请求上下文、响应数据
  4. 异常处理缺失,导致任务卡死或线程行为异常
  5. 把 IO 密集型和 CPU 密集型任务混在一个池里

这篇文章就从“现象复现 -> 原理解释 -> 定位路径 -> 修复方案”完整走一遍。


现象复现

先看一个非常常见、也非常危险的线程池写法:

ExecutorService executor = Executors.newFixedThreadPool(20);

这行代码看起来没毛病,甚至很多项目里都这么写。但问题在于:Executors.newFixedThreadPool 底层用的是无界阻塞队列 LinkedBlockingQueue

这意味着:

  • 核心线程数固定 20
  • 多余任务不会创建更多线程
  • 而是会不断往队列里塞
  • 如果任务消费慢,队列就会无限增长
  • 队列里的每个任务如果还引用了请求对象、大数组、上下文,内存就会被一点点吃满

一个简化的事故模型

假设:

  • 每秒进来 500 个请求
  • 线程池只能稳定处理每秒 100 个任务
  • 剩下 400 个任务每秒进入队列
  • 每个任务平均占用 200KB 上下文数据

那么理论上每秒就可能新增:

400 * 200KB = 80MB

十几秒内内存就能明显抬升。


核心原理

线程池问题本质上不是“线程太多”,而是生产速度 > 消费速度,且没有有效背压

ThreadPoolExecutor 的关键行为

Java 线程池核心逻辑可以概括为:

  1. 线程数 < corePoolSize:优先创建核心线程
  2. 否则尝试把任务放入队列
  3. 队列满了,如果线程数 < maximumPoolSize:继续创建非核心线程
  4. 还塞不下:触发拒绝策略

也就是说,队列类型决定了线程池的行为倾向

  • 无界队列:基本不会扩容到 maximumPoolSize
  • 有界队列:队列满后才有机会继续扩线程
  • 同步队列:几乎不存储任务,强调直接移交

下面这张图能直观看出流程。

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

为什么会表现成“内存泄漏”?

严格说,很多场景并不是真正意义上的“对象永远不可回收”,而是任务在队列里排队,导致它们引用的数据长期存活

比如一个任务对象里带了:

  • 请求参数 request
  • 大响应体 byte[]
  • 用户上下文
  • trace 信息
  • 临时组装的大对象集合

只要任务还在队列中,这些对象就都不会被 GC。

这就形成了一个很像内存泄漏的现象:

  • 对象不是泄漏
  • 但由于队列堆积,生命周期被大幅拉长
  • 最终效果和泄漏非常接近:堆持续增高、GC 吃力、吞吐下降

请求堆积如何一步步放大

sequenceDiagram
    participant Client as 调用方
    participant App as 应用服务
    participant Pool as 线程池
    participant Queue as 任务队列
    participant Worker as 工作线程

    Client->>App: 发起请求
    App->>Pool: 提交异步任务
    Pool->>Queue: 任务入队
    Worker->>Queue: 取任务执行
    Note over Queue: 任务执行慢于提交速度
    Queue-->>Pool: 队列长度持续增长
    Pool-->>App: 请求等待变长
    App-->>Client: 超时/变慢
    Client->>App: 重试
    Note over App,Queue: 重试进一步加剧堆积

实战代码(可运行)

下面我用一个可运行的小例子,演示“错误用法”和“改进用法”。

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

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;

public class BadThreadPoolDemo {

    private static final ExecutorService EXECUTOR = Executors.newFixedThreadPool(4);
    private static final AtomicInteger COUNTER = new AtomicInteger();

    public static void main(String[] args) throws Exception {
        while (true) {
            int id = COUNTER.incrementAndGet();

            // 模拟每个任务都带一块较大的数据
            byte[] payload = new byte[1024 * 256]; // 256KB

            EXECUTOR.submit(() -> {
                try {
                    // 模拟慢任务
                    Thread.sleep(2000);
                    System.out.println("task done: " + id + ", payload=" + payload.length);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            });

            // 高速提交任务,远大于消费速度
            Thread.sleep(10);
        }
    }
}

这个示例会发生什么?

  • 线程池只有 4 个线程
  • 每个任务执行要 2 秒
  • 提交速度却是每 10ms 一个
  • 队列会飞快膨胀
  • 每个任务都持有 256KB 数据
  • 很快就会看到内存占用持续增长

这类问题在线上特别隐蔽,因为业务代码通常不会这么“明显”,但本质一样。


改进示例:显式使用 ThreadPoolExecutor + 有界队列 + 拒绝策略

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

public class GoodThreadPoolDemo {

    private static final AtomicInteger THREAD_ID = new AtomicInteger(1);

    private static final ThreadPoolExecutor EXECUTOR = new ThreadPoolExecutor(
            4, // corePoolSize
            8, // maximumPoolSize
            60L, TimeUnit.SECONDS,
            new ArrayBlockingQueue<>(100), // 有界队列
            r -> {
                Thread t = new Thread(r);
                t.setName("biz-pool-" + THREAD_ID.getAndIncrement());
                return t;
            },
            new ThreadPoolExecutor.CallerRunsPolicy() // 背压
    );

    public static void main(String[] args) throws Exception {
        for (int i = 1; i <= 10000; i++) {
            final int id = i;
            final byte[] payload = new byte[1024 * 64]; // 64KB,尽量减少任务体持有数据

            EXECUTOR.execute(() -> {
                try {
                    Thread.sleep(500);
                    System.out.println(Thread.currentThread().getName() + " process task: " + id);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            });

            if (i % 100 == 0) {
                System.out.printf("poolSize=%d, active=%d, queue=%d, completed=%d%n",
                        EXECUTOR.getPoolSize(),
                        EXECUTOR.getActiveCount(),
                        EXECUTOR.getQueue().size(),
                        EXECUTOR.getCompletedTaskCount());
            }
        }

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

为什么这个版本更稳?

关键在这几件事:

  • 不用 Executors 快捷工厂隐藏默认参数
  • 队列有界,防止无限吞内存
  • 最大线程数可扩容
  • 拒绝策略使用 CallerRunsPolicy,让提交方承担压力,形成天然背压
  • 任务体尽量轻量,避免把大对象长期挂在队列里

推荐的线程池封装方式

项目里最好统一封装,别让大家到处手写。

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

public class ThreadPoolFactory {

    public static ThreadPoolExecutor newBizPool(
            String poolName,
            int core,
            int max,
            int queueSize
    ) {
        AtomicInteger idx = new AtomicInteger(1);

        return new ThreadPoolExecutor(
                core,
                max,
                60L,
                TimeUnit.SECONDS,
                new ArrayBlockingQueue<>(queueSize),
                r -> {
                    Thread t = new Thread(r);
                    t.setName(poolName + "-" + idx.getAndIncrement());
                    t.setUncaughtExceptionHandler((th, ex) ->
                            System.err.println("uncaught exception in " + th.getName() + ": " + ex.getMessage()));
                    return t;
                },
                new ThreadPoolExecutor.AbortPolicy()
        );
    }
}

使用示例:

import java.util.concurrent.ThreadPoolExecutor;

public class App {
    public static void main(String[] args) {
        ThreadPoolExecutor executor = ThreadPoolFactory.newBizPool("order-sync", 8, 16, 200);

        executor.execute(() -> {
            System.out.println("hello thread pool");
        });

        executor.shutdown();
    }
}

定位路径

线上排查这类问题时,我一般不会一上来就 dump 全堆,而是按下面顺序走,效率更高。

1. 先看线程池指标

最有价值的几个指标:

  • poolSize
  • activeCount
  • queueSize
  • taskCount
  • completedTaskCount
  • largestPoolSize
  • 拒绝次数
  • 任务平均执行时长
  • 任务等待时长

如果你发现:

  • activeCount 长时间接近满值
  • queueSize 持续增长不回落
  • completedTaskCount 增长缓慢

那基本就是消费跟不上了。

2. 再看 GC 和堆变化

可以配合这些命令:

jstat -gcutil <pid> 1000
jcmd <pid> GC.heap_info
jmap -histo:live <pid> | head -50

重点观察:

  • 老年代使用率是否持续高位
  • Full GC 后是否只能回收少量对象
  • 是否有大量 RunnableFutureTask、业务任务对象存活

3. 看线程栈,确认线程到底卡在哪

jstack <pid> > threads.log

重点找:

  • 工作线程是不是都在等待外部 IO
  • 是不是锁竞争严重
  • 是不是任务里有长时间阻塞操作
  • 有没有线程被错误吞掉中断信号

4. 必要时做堆转储

jcmd <pid> GC.heap_dump /tmp/heap.hprof

用 MAT 或 VisualVM 看引用链时,如果发现大量对象被:

  • LinkedBlockingQueue
  • FutureTask
  • 自定义任务对象

所持有,那就很可能不是“传统泄漏”,而是排队导致的对象滞留


常见坑与排查

这一节我尽量讲得“接地气”一点,因为很多坑都不是 API 不会用,而是“觉得这样写挺正常”。

坑 1:误用 Executors 工厂方法

问题代码

ExecutorService executor = Executors.newFixedThreadPool(20);

坑点

newFixedThreadPool 使用的是无界队列,适合非常确定任务量稳定、且任务轻量的场景。在线上高并发系统里,风险很大。

建议

直接使用 ThreadPoolExecutor 明确指定:

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

坑 2:任务里捕获大对象

问题代码

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

坑点

只要任务没执行,bigData 就会一直被引用。

建议

  • 传必要字段,不要传整块上下文
  • 大对象尽量在任务内部按需获取
  • 避免闭包无意捕获整个请求对象

例如:

String orderId = request.getOrderId();
executor.submit(() -> process(orderId));

坑 3:异步任务里做阻塞 IO,却按 CPU 池配置

现象

线程数不大,但线程池一直满,队列越来越长。

原因

任务虽然“看起来简单”,但里头可能有:

  • HTTP 调用
  • 数据库慢查询
  • Redis 超时重试
  • 文件读写

这类任务是 IO 密集型,不适合按 CPU 核数简单配置。

建议

  • CPU 密集和 IO 密集任务分池
  • IO 池允许更多线程,但仍要有边界
  • 给下游调用设置超时

坑 4:Future 提交后没人消费结果,也没人处理异常

问题代码

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

坑点

submit 会把异常封装进 Future,如果你不 get(),异常可能悄悄被忽略,排查时很痛苦。

建议

如果不需要返回值,优先用:

executor.execute(task);

如果必须 submit,要统一处理结果和异常。


坑 5:拒绝策略乱用,导致雪崩更严重

常见错误是默认 AbortPolicy 却没接住异常,或使用不合适的策略导致任务直接丢失。

拒绝策略怎么选?

  • AbortPolicy:直接抛异常,适合必须显式感知失败
  • CallerRunsPolicy:调用方自己执行,适合做背压
  • DiscardPolicy:悄悄丢弃,不推荐
  • DiscardOldestPolicy:丢最老任务,适合部分可容忍场景,但要谨慎

我的经验是:

  • 核心业务:优先 AbortPolicy,并配合降级/告警
  • 入口削峰:可以考虑 CallerRunsPolicy
  • 可丢任务:才考虑丢弃策略,但要明确定义可丢边界

坑 6:线程池只建一个,什么活都往里塞

这个坑在线上极常见。

  • 发短信放里面
  • 下单异步通知放里面
  • 导出任务放里面
  • 数据回写放里面
  • 第三方回调重试也放里面

结果就是:一个慢任务把整个池拖死,所有业务一起排队。

建议

至少按业务特征拆池:

  • 核心链路池
  • IO 密集池
  • 定时/重试池
  • 长任务池
flowchart LR
    A[请求入口] --> B[核心业务线程池]
    A --> C[IO密集线程池]
    A --> D[长任务线程池]
    A --> E[重试/补偿线程池]

    B --> F[短平快任务]
    C --> G[外部HTTP/DB/缓存访问]
    D --> H[报表/导出/批处理]
    E --> I[失败重试/延迟任务]

安全/性能最佳实践

这一部分给的不是“放之四海皆准”的口诀,而是更偏实操的建议。

1. 显式定义线程池,不要偷懒

推荐模板:

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

边界条件:

  • 如果任务绝不能阻塞调用线程,不要盲目用 CallerRunsPolicy
  • 如果任务绝不能丢,必须配合重试、落库、消息队列等兜底

2. 队列一定要有界

这是止住内存暴涨最直接的一刀。

建议思路:

  • 先估算单个任务平均内存占用
  • 再反推可接受的最大排队长度
  • 不要只看 TPS,不看任务体积

例如:

单任务平均占用 50KB
可接受排队内存 100MB
则队列上限约 2000

但注意这只是粗估,真实线上还要留出堆内其他对象空间。


3. 任务要“瘦身”

尽量做到:

  • 只传 ID、轻量参数
  • 大对象在执行阶段按需加载
  • 执行完成后及时释放局部大对象引用
  • 避免 ThreadLocal 滥用

一个很隐蔽的风险是 ThreadLocal。线程池线程会复用,如果不 remove(),上下文可能长期挂在线程上。

private static final ThreadLocal<String> TRACE_ID = new ThreadLocal<>();

executor.execute(() -> {
    try {
        TRACE_ID.set("abc123");
        // do work
    } finally {
        TRACE_ID.remove();
    }
});

4. 监控必须补齐

线程池不是“配完就完事”,必须接监控。

至少暴露这些指标:

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

如果可以,再加告警阈值:

  • 队列使用率 > 70%
  • 活跃线程占比 > 80%
  • 拒绝数 > 0
  • 平均排队时间持续升高

5. 给任务加超时与熔断思维

如果任务依赖外部系统,一定要有超时控制。不然线程会被慢调用长期占住。

例如:

  • HTTP 客户端设置连接/读取超时
  • 数据库查询控制慢 SQL
  • 远程调用设置超时和重试上限
  • 避免“无限等待”

否则线程池再大,也只是把问题延后。


6. 止血方案要先于完美方案

线上事故时,优先级通常是:

  1. 限流
  2. 降级
  3. 暂停非核心异步任务
  4. 缩小任务体积
  5. 临时扩容
  6. 最后再改线程池参数和代码逻辑

因为真正的根因修复可能需要发版,但业务先得活下来。


止血方案

如果你现在正在线上处理类似事故,我建议按这个顺序操作。

方案一:先控制入口流量

  • 接口限流
  • 关掉重试风暴
  • 熔断慢下游
  • 暂停低优先级任务提交

目标是立刻降低任务生产速度。

方案二:减轻单个任务负担

  • 去掉任务里不必要的大对象
  • 只保留最小入参
  • 把非必要字段延迟加载

目标是降低每个排队任务的内存占用。

方案三:调整线程池配置

如果原来是无界队列,尽快切到有界队列,并根据业务选择拒绝策略。

但要注意,改成有界队列后可能会暴露更多拒绝异常,这不是新问题,而是原来问题被“吞在队列里”了。

方案四:业务隔离

把慢任务、长任务、外部依赖强的任务拆出去,避免拖垮核心链路。


一个排查清单

遇到“内存涨 + 请求堆积 + 线程池可疑”时,可以快速过一遍:

[ ] 是否使用了 Executors.newFixedThreadPool/newSingleThreadExecutor
[ ] 队列是否为无界
[ ] 当前队列长度是否持续增长
[ ] activeCount 是否长期打满
[ ] 是否有大量 FutureTask / Runnable 存活
[ ] 任务中是否持有大对象/请求上下文
[ ] 任务是否包含慢 IO / 无超时外调
[ ] 是否混用了不同类型任务
[ ] 拒绝策略是否合理
[ ] 是否有线程池监控和告警
[ ] ThreadLocal 是否及时清理
[ ] submit 的异常是否被处理

总结

线程池问题最容易误导人的地方在于:它常常伪装成“内存泄漏”。

但很多时候,真相不是对象回收不了,而是:

  • 任务进得太快
  • 执行得太慢
  • 队列又没有上限
  • 任务还恰好持有大对象

最后就变成了:

请求堆积 -> 队列膨胀 -> 内存暴涨 -> GC 恶化 -> RT 上升 -> 重试增多 -> 更严重堆积

真正有效的修复思路,不是只盯着“把线程数调大”,而是同时处理这几件事:

  1. 线程池显式配置
  2. 队列必须有界
  3. 任务体尽量轻
  4. 按任务类型拆池
  5. 设置超时、限流、拒绝与降级策略
  6. 补齐监控,尽早发现堆积趋势

如果你让我把这篇文章压缩成一句最实用的话,那就是:

在线上服务里,线程池最危险的默认值不是线程数,而是“无界等待”。

只要把这个坑避开,很多“诡异的内存问题”和“越跑越慢”的问题,都会少掉一大半。


分享到:

上一篇
《前端中级实战:基于 React 与 TypeScript 构建可维护的权限控制与动态路由方案》
下一篇
《Web逆向实战:从请求链路分析到关键参数还原的中级方法论》