背景与问题
线上问题里,最让人头疼的往往不是“直接报错”,而是系统还能跑,但越跑越慢。
我曾经踩过一个很典型的坑:某个 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 - 任务会优先进队列
- 队列无限增长
- 内存跟着涨
为什么会引发请求超时?
因为请求线程通常会等待异步任务结果,或者依赖线程池中的某个处理步骤完成。一旦线程池开始积压:
- 新任务排队时间变长
- 请求等待时间变长
- 超过接口超时时间
- 上游重试,进一步放大流量
- 系统雪崩风险增加
这个过程可以用时序图表示:
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;
}
}
}
这个版本改进了什么?
-
有界队列
- 防止任务无限堆积
- 内存上限更可控
-
显式拒绝策略
- 用
CallerRunsPolicy形成自然背压 - 高峰期不会无脑吃内存
- 用
-
Future.get 设置超时
- 避免请求线程无限等待
-
超时后取消任务
- 减少无效执行占用资源
-
打印线程池运行指标
- 方便本地验证和线上监控接入
止血方案
如果你已经在线上遇到内存暴涨和超时,不一定能立刻重构代码。先止血,再治理。
立刻可做的止血动作
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. 给线程池打监控
至少暴露这些指标:
poolSizeactiveCountqueueSizecompletedTaskCounttaskRejectedCounttaskTimeoutCount
经验上,队列长度是最早暴露风险的指标之一。
4. 请求超时、线程池超时、下游超时要协同
常见错误是:
- 接口超时 1s
- 线程池等待 5s
- 下游 HTTP 超时 10s
这会导致上游早就失败了,下游还在白跑。
建议按照链路统一预算,例如:
- 总接口超时:1000ms
- 线程池等待:200ms
- 下游调用:500ms
- 预留重试/序列化/网络抖动:300ms
5. 有界不代表万事大吉
有界队列只是避免“无限膨胀”,但如果:
- 队列容量过大
- 任务本身很慢
- 下游持续抖动
照样会超时。
所以线程池治理一定要结合:
- 下游超时控制
- 限流
- 熔断
- 重试次数限制
- 业务降级
6. 任务设计尽量轻量化
一个好的异步任务应该:
- 参数小
- 执行时间可预估
- 失败可重试或可丢弃
- 有明确超时
- 不依赖长时间阻塞资源
如果一个任务又大、又慢、又不能丢、还会重试,那线程池迟早出事。
一个简单的排查清单
线上遇到“内存涨 + 超时多 + 线程池可疑”时,可以按这个顺序过一遍:
- 是否使用了
Executors.newFixedThreadPool/newSingleThreadExecutor - 队列是否无界
- 线程池指标中
queueSize是否持续增长 - 工作线程是否阻塞在慢 IO
- 调用方是否在
Future.get()无限等待 - 异步任务是否捕获大对象
- 是否缺少拒绝策略和降级处理
- 是否多个业务共用同一线程池
- 是否存在超时配置不一致
- 是否有上游重试放大问题
这个清单看起来普通,但很多线上事故,真就是卡在前两三条。
总结
这次问题的本质,不是“线程池不好用”,而是把线程池当成了无限缓冲区。
请记住这几个关键点:
Executors.newFixedThreadPool()默认可能带来无界队列风险- 无界队列会让
maximumPoolSize形同虚设 - 任务积压不仅会导致内存上涨,还会放大请求超时
- 真正稳定的方案是:有界队列 + 拒绝策略 + 超时控制 + 业务隔离 + 监控告警
如果要给出最直接的可执行建议,我会优先做这几件事:
- 把业务线程池改成显式
ThreadPoolExecutor - 队列一律设上限
- 所有
Future.get()必须带超时 - 给线程池补齐监控
- 慢任务、重任务、低优先级任务分池隔离
- 对关键链路加限流、熔断和降级
最后补一句边界条件:
如果你的任务量非常平稳、任务极轻、并且有严格容量评估,无界队列不一定立刻出事;但在线上复杂业务里,这种前提往往不成立。所以从工程实践看,宁可早拒绝,也不要晚崩溃。