背景与问题
线上系统出问题时,最怕的不是直接报错,而是“看起来还能跑,但越来越慢”。
这类问题我踩过一次:接口 RT 持续升高,机器内存一路上涨,Full GC 频繁,最终请求开始大量超时。乍一看像是代码内存泄漏,结果一路排下来,根因竟然是线程池配置和使用方式不当。
典型症状通常是这样的:
- JVM 堆内存持续升高,不容易回落
- 请求处理变慢,接口响应时间越来越长
- 线程池活跃线程数接近上限
- 队列长度不断增长
- Full GC 次数增加,但回收效果一般
- 上游服务重试,进一步放大请求堆积
很多同学第一反应会去查:
- 是不是某个
Map没清理? - 是不是缓存失控?
- 是不是某个对象引用链没断?
这些方向没错,但如果你用了线程池,尤其是下面几种写法,就要高度怀疑:
- 固定线程数 + 无界队列
- 任务执行慢,但提交速度远大于消费速度
- 任务体里持有大对象、请求上下文、响应数据
- 异常处理缺失,导致任务卡死或线程行为异常
- 把 IO 密集型和 CPU 密集型任务混在一个池里
这篇文章就从“现象复现 -> 原理解释 -> 定位路径 -> 修复方案”完整走一遍。
现象复现
先看一个非常常见、也非常危险的线程池写法:
ExecutorService executor = Executors.newFixedThreadPool(20);
这行代码看起来没毛病,甚至很多项目里都这么写。但问题在于:Executors.newFixedThreadPool 底层用的是无界阻塞队列 LinkedBlockingQueue。
这意味着:
- 核心线程数固定 20
- 多余任务不会创建更多线程
- 而是会不断往队列里塞
- 如果任务消费慢,队列就会无限增长
- 队列里的每个任务如果还引用了请求对象、大数组、上下文,内存就会被一点点吃满
一个简化的事故模型
假设:
- 每秒进来 500 个请求
- 线程池只能稳定处理每秒 100 个任务
- 剩下 400 个任务每秒进入队列
- 每个任务平均占用 200KB 上下文数据
那么理论上每秒就可能新增:
400 * 200KB = 80MB
十几秒内内存就能明显抬升。
核心原理
线程池问题本质上不是“线程太多”,而是生产速度 > 消费速度,且没有有效背压。
ThreadPoolExecutor 的关键行为
Java 线程池核心逻辑可以概括为:
- 线程数 < corePoolSize:优先创建核心线程
- 否则尝试把任务放入队列
- 队列满了,如果线程数 < maximumPoolSize:继续创建非核心线程
- 还塞不下:触发拒绝策略
也就是说,队列类型决定了线程池的行为倾向。
- 无界队列:基本不会扩容到
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. 先看线程池指标
最有价值的几个指标:
poolSizeactiveCountqueueSizetaskCountcompletedTaskCountlargestPoolSize- 拒绝次数
- 任务平均执行时长
- 任务等待时长
如果你发现:
activeCount长时间接近满值queueSize持续增长不回落completedTaskCount增长缓慢
那基本就是消费跟不上了。
2. 再看 GC 和堆变化
可以配合这些命令:
jstat -gcutil <pid> 1000
jcmd <pid> GC.heap_info
jmap -histo:live <pid> | head -50
重点观察:
- 老年代使用率是否持续高位
- Full GC 后是否只能回收少量对象
- 是否有大量
Runnable、FutureTask、业务任务对象存活
3. 看线程栈,确认线程到底卡在哪
jstack <pid> > threads.log
重点找:
- 工作线程是不是都在等待外部 IO
- 是不是锁竞争严重
- 是不是任务里有长时间阻塞操作
- 有没有线程被错误吞掉中断信号
4. 必要时做堆转储
jcmd <pid> GC.heap_dump /tmp/heap.hprof
用 MAT 或 VisualVM 看引用链时,如果发现大量对象被:
LinkedBlockingQueueFutureTask- 自定义任务对象
所持有,那就很可能不是“传统泄漏”,而是排队导致的对象滞留。
常见坑与排查
这一节我尽量讲得“接地气”一点,因为很多坑都不是 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. 止血方案要先于完美方案
线上事故时,优先级通常是:
- 限流
- 降级
- 暂停非核心异步任务
- 缩小任务体积
- 临时扩容
- 最后再改线程池参数和代码逻辑
因为真正的根因修复可能需要发版,但业务先得活下来。
止血方案
如果你现在正在线上处理类似事故,我建议按这个顺序操作。
方案一:先控制入口流量
- 接口限流
- 关掉重试风暴
- 熔断慢下游
- 暂停低优先级任务提交
目标是立刻降低任务生产速度。
方案二:减轻单个任务负担
- 去掉任务里不必要的大对象
- 只保留最小入参
- 把非必要字段延迟加载
目标是降低每个排队任务的内存占用。
方案三:调整线程池配置
如果原来是无界队列,尽快切到有界队列,并根据业务选择拒绝策略。
但要注意,改成有界队列后可能会暴露更多拒绝异常,这不是新问题,而是原来问题被“吞在队列里”了。
方案四:业务隔离
把慢任务、长任务、外部依赖强的任务拆出去,避免拖垮核心链路。
一个排查清单
遇到“内存涨 + 请求堆积 + 线程池可疑”时,可以快速过一遍:
[ ] 是否使用了 Executors.newFixedThreadPool/newSingleThreadExecutor
[ ] 队列是否为无界
[ ] 当前队列长度是否持续增长
[ ] activeCount 是否长期打满
[ ] 是否有大量 FutureTask / Runnable 存活
[ ] 任务中是否持有大对象/请求上下文
[ ] 任务是否包含慢 IO / 无超时外调
[ ] 是否混用了不同类型任务
[ ] 拒绝策略是否合理
[ ] 是否有线程池监控和告警
[ ] ThreadLocal 是否及时清理
[ ] submit 的异常是否被处理
总结
线程池问题最容易误导人的地方在于:它常常伪装成“内存泄漏”。
但很多时候,真相不是对象回收不了,而是:
- 任务进得太快
- 执行得太慢
- 队列又没有上限
- 任务还恰好持有大对象
最后就变成了:
请求堆积 -> 队列膨胀 -> 内存暴涨 -> GC 恶化 -> RT 上升 -> 重试增多 -> 更严重堆积
真正有效的修复思路,不是只盯着“把线程数调大”,而是同时处理这几件事:
- 线程池显式配置
- 队列必须有界
- 任务体尽量轻
- 按任务类型拆池
- 设置超时、限流、拒绝与降级策略
- 补齐监控,尽早发现堆积趋势
如果你让我把这篇文章压缩成一句最实用的话,那就是:
在线上服务里,线程池最危险的默认值不是线程数,而是“无界等待”。
只要把这个坑避开,很多“诡异的内存问题”和“越跑越慢”的问题,都会少掉一大半。