背景与问题
线上最怕的不是“慢一点”,而是突然全线变慢。我之前就踩过一次很典型的坑:一个原本挺稳定的 Java 接口,在某次流量上涨后开始出现下面这些症状:
- 接口 RT 从几十毫秒飙到几秒
- Tomcat 工作线程逐渐打满
- 下游依赖超时增多,重试放大流量
- JVM 内存持续上涨,Full GC 频繁
- 最后出现接口雪崩,整个服务几乎不可用
排查后发现,根因不是数据库,也不是 Redis,而是一个被误用的线程池。
很多团队会把线程池当成“性能优化开关”——觉得“同步慢,那我就异步”“主线程忙,那我就丢线程池”。但线程池不是无限缓冲区,更不是免费的吞吐倍增器。参数选错、使用姿势不对,线程池本身就会成为雪崩放大器。
这篇文章就按真实排障思路来讲清楚:
- 这个坑是怎么发生的
- 为什么线程池会把接口拖垮
- 如何复现问题
- 如何定位与止血
- 最后怎样改成更稳的写法
现象复现
先看一个很常见、也很危险的写法:
import java.util.concurrent.*;
public class BadThreadPoolDemo {
// 典型误用:线程数不大,但队列无限大
private static final ExecutorService EXECUTOR = new ThreadPoolExecutor(
8,
16,
60L,
TimeUnit.SECONDS,
new LinkedBlockingQueue<>(), // 无界队列
Executors.defaultThreadFactory(),
new ThreadPoolExecutor.AbortPolicy()
);
public static void main(String[] args) throws Exception {
for (int i = 0; i < 1_000_000; i++) {
final int taskId = i;
EXECUTOR.submit(() -> {
try {
// 模拟下游调用变慢
Thread.sleep(200);
byte[] payload = new byte[1024 * 50]; // 每个任务占一点内存
if (taskId % 10000 == 0) {
System.out.println("task=" + taskId + ", payload=" + payload.length);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
}
}
这个代码很“眼熟”:
- 线程池核心线程数 8,看起来不算夸张
- 最大线程数 16,也不算离谱
- 问题出在
LinkedBlockingQueue<>():默认无界 - 外部请求一快于线程池处理速度,任务就会不断堆积
- 每个任务都携带上下文、参数、闭包引用,堆内存自然上涨
这时候接口层面会看到一种非常迷惑的现象:
- CPU 不一定先满
- 线程数也不一定异常夸张
- 但内存涨、RT 涨、超时涨、拒绝少甚至没有拒绝
- 因为任务都“礼貌地排队了”,只是排队排到系统扛不住
核心原理
线程池问题不好排,是因为它经常不是“直接挂”,而是“慢性中毒”。
1. 线程池执行逻辑决定了问题形态
ThreadPoolExecutor 接收任务时,大致遵循这个过程:
flowchart TD
A[任务提交] --> B{工作线程 < corePoolSize?}
B -- 是 --> C[创建核心线程执行]
B -- 否 --> D{队列还能放?}
D -- 是 --> E[任务入队等待]
D -- 否 --> F{工作线程 < maxPoolSize?}
F -- 是 --> G[创建非核心线程执行]
F -- 否 --> H[触发拒绝策略]
关键点在这里:
如果你用的是无界队列,那么队列几乎总是“还能放”。
这意味着线程池很难扩容到maxPoolSize,任务会优先堆在队列里。
所以很多人以为自己配置了:
- core = 8
- max = 64
就能在压力大时自动扩到 64。
实际上,用了无界队列后,可能永远只跑 8 个线程,剩下任务全部排队。
2. 为什么会引发接口雪崩
假设一个接口每秒进来 300 个请求,每个请求都往线程池里扔一个耗时 200ms 的任务。
简单估算:
- 8 个线程,每秒最多处理约
8 / 0.2 = 40个任务 - 实际进入是每秒 300 个
- 净积压约每秒 260 个
积压几分钟后,会发生:
- 队列越来越长
- 每个请求等待线程池执行的时间越来越久
- 上游开始超时、重试
- 重试导致请求更多
- 队列继续膨胀
- 堆内存升高,GC 加剧
- 吞吐进一步下降
- 雪崩形成
这本质上是一个消费能力小于生产速度的问题,而无界队列把问题“藏起来了”。
3. 为什么内存会飙升
线程池队列里排的不只是一个“指针”。
实际排队任务可能会引用:
- 请求参数对象
- 用户上下文
- traceId / MDC 信息
- Lambda 闭包捕获变量
- 下游调用对象
- 大对象缓存片段
如果每个任务平均占几十 KB,看起来不大;但几十万任务一排,堆内存马上就上去了。
sequenceDiagram
participant Client as 上游请求
participant API as 接口线程
participant Pool as 业务线程池
participant Downstream as 下游服务
Client->>API: 请求进入
API->>Pool: submit(task)
Note over Pool: 下游变慢,任务处理不过来
Pool-->>API: 快速返回Future/等待结果
API->>Downstream: 实际调用延后
Client-->>API: 请求超时
Client->>API: 重试
API->>Pool: submit更多任务
Note over Pool: 队列堆积、内存上涨、GC变频繁
定位路径
线上排查我一般按这个顺序来,不容易漏。
1. 先看业务症状
先确认是不是线程池导致的,而不是下游单点故障:
- 接口 RT 是否持续上升而非瞬时抖动
- 超时比例是否逐步变高
- 线程数是否平稳但吞吐下降
- 内存曲线是否持续上涨
- GC 次数和耗时是否明显增加
如果这些同时出现,线程池积压的概率就很高。
2. 看 JVM 和线程池指标
建议至少暴露这些指标到监控系统:
activeCountpoolSizecorePoolSizemaximumPoolSizequeueSizecompletedTaskCounttaskCountrejectCount
如果你看到这样的组合,基本就能锁定问题:
activeCount接近corePoolSizepoolSize长期不上升queueSize持续增大completedTaskCount增速低rejectCount几乎为 0
这说明:任务在堆积,但没有形成有效背压。
3. 用线程栈和堆分析做交叉验证
线程栈看什么
使用:
jstack <pid>
重点看:
- 业务线程池线程是否大量处于
TIMED_WAITING - 是否卡在下游 HTTP / RPC / DB 调用
- 是否有大量线程都在等待同一个资源
堆分析看什么
使用:
jmap -histo:live <pid> | head -n 50
或者导出 heap dump 用 MAT 分析。
重点看:
LinkedBlockingQueue$NodeFutureTask- 业务 Runnable / Callable 实现类
- 大量请求 DTO、上下文对象是否被任务引用住
如果 LinkedBlockingQueue$Node 和 FutureTask 数量异常多,基本坐实队列堆积。
实战代码(可运行)
下面我给一个更完整的示例:先演示错误版本,再演示修复版本。
错误版本:无界队列 + 同步等待 Future
这个版本尤其危险,因为它看似“异步”,其实接口线程最后还是 get() 等结果,等于把请求线程和业务线程池一起拖死。
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.*;
public class ThreadPoolWrongUsageCase {
private static final ExecutorService EXECUTOR = new ThreadPoolExecutor(
4,
8,
60L,
TimeUnit.SECONDS,
new LinkedBlockingQueue<>(), // 无界队列,问题根源
new NamedThreadFactory("bad-pool"),
new ThreadPoolExecutor.AbortPolicy()
);
public static void main(String[] args) throws Exception {
List<Future<String>> futures = new ArrayList<>();
for (int i = 0; i < 20000; i++) {
final int id = i;
Future<String> future = EXECUTOR.submit(() -> slowRemoteCall(id));
futures.add(future);
}
for (Future<String> future : futures) {
try {
System.out.println(future.get(3, TimeUnit.SECONDS));
} catch (TimeoutException e) {
System.err.println("future timeout");
}
}
EXECUTOR.shutdown();
}
private static String slowRemoteCall(int id) throws InterruptedException {
Thread.sleep(200);
byte[] buf = new byte[1024 * 20];
return "ok-" + id + "-" + buf.length;
}
static class NamedThreadFactory implements ThreadFactory {
private final String prefix;
private int counter = 0;
NamedThreadFactory(String prefix) {
this.prefix = prefix;
}
@Override
public synchronized Thread newThread(Runnable r) {
Thread t = new Thread(r, prefix + "-" + (++counter));
t.setDaemon(false);
return t;
}
}
}
这个版本错在哪里
LinkedBlockingQueue无界,任务会无限积压- 下游慢时,线程池处理不过来
- 主线程仍然
future.get(),异步收益几乎为零 - 任务对象和结果对象长时间滞留,堆占用持续增加
修复版本:有界队列 + 超时 + 背压 + 明确降级
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicLong;
public class ThreadPoolFixedCase {
private static final AtomicLong REJECT_COUNT = new AtomicLong();
private static final ThreadPoolExecutor EXECUTOR = new ThreadPoolExecutor(
8,
16,
60L,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(200),
new NamedThreadFactory("biz-pool"),
new RejectedExecutionHandler() {
@Override
public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
REJECT_COUNT.incrementAndGet();
throw new RejectedExecutionException("thread pool overloaded");
}
}
);
public static void main(String[] args) throws InterruptedException {
int total = 5000;
int success = 0;
int rejected = 0;
for (int i = 0; i < total; i++) {
final int id = i;
try {
Future<String> future = EXECUTOR.submit(() -> guardedRemoteCall(id));
try {
String result = future.get(300, TimeUnit.MILLISECONDS);
if (result != null) {
success++;
}
} catch (TimeoutException e) {
future.cancel(true);
System.out.println("timeout for task " + id);
} catch (ExecutionException e) {
System.out.println("execution failed: " + e.getMessage());
}
} catch (RejectedExecutionException e) {
rejected++;
// 模拟降级返回
System.out.println("rejected task " + id + ", fallback");
}
if (i % 200 == 0) {
printStats();
}
}
EXECUTOR.shutdown();
EXECUTOR.awaitTermination(10, TimeUnit.SECONDS);
System.out.println("success=" + success);
System.out.println("rejected=" + rejected);
System.out.println("rejectCount=" + REJECT_COUNT.get());
}
private static String guardedRemoteCall(int id) throws InterruptedException {
int cost = ThreadLocalRandom.current().nextInt(50, 500);
Thread.sleep(cost);
return "result-" + id;
}
private static void printStats() {
System.out.println("poolSize=" + EXECUTOR.getPoolSize()
+ ", active=" + EXECUTOR.getActiveCount()
+ ", queue=" + EXECUTOR.getQueue().size()
+ ", completed=" + EXECUTOR.getCompletedTaskCount()
+ ", rejected=" + REJECT_COUNT.get());
}
static class NamedThreadFactory implements ThreadFactory {
private final String prefix;
private int counter = 0;
NamedThreadFactory(String prefix) {
this.prefix = prefix;
}
@Override
public synchronized Thread newThread(Runnable r) {
Thread t = new Thread(r, prefix + "-" + (++counter));
t.setDaemon(false);
return t;
}
}
}
这个版本的关键变化:
- 用
ArrayBlockingQueue做有界队列 - 设置明确的队列容量,避免无限吃内存
- 提交失败时直接拒绝,让上游感知压力
- 对
Future.get()加超时,避免无限等待 - 超时后
cancel(true),尽快释放资源 - 拒绝后走 fallback,而不是继续硬扛
核心原理的落地改造思路
如果把这次问题抽象一下,真正的修复不是“调大线程池”,而是建立完整的限流与背压机制。
flowchart LR
A[请求进入] --> B{线程池容量充足?}
B -- 是 --> C[执行任务]
B -- 否 --> D[快速失败/降级]
C --> E{下游是否超时?}
E -- 否 --> F[返回结果]
E -- 是 --> G[取消任务/熔断/降级]
D --> H[返回兜底结果]
G --> H
常见坑与排查
下面这些坑,我基本都见过,甚至有几个是我自己踩出来的。
坑 1:直接使用 Executors.newFixedThreadPool
很多人图省事直接写:
ExecutorService executor = Executors.newFixedThreadPool(20);
问题是它底层也是无界队列。这在低压场景没事,一到峰值流量就容易积压。
排查信号
- 线程数稳定
- 队列长度持续上涨
- 没有拒绝日志
- JVM 堆越来越高
建议
优先手动创建 ThreadPoolExecutor,显式指定:
- 核心线程数
- 最大线程数
- 队列类型与容量
- 拒绝策略
- 线程工厂
坑 2:线程池里执行阻塞 IO,却按 CPU 密集型配置
比如下游 HTTP、数据库、RPC 调用都是阻塞的,但线程池却只给了很小的线程数。
表现
- 活跃线程很快打满
- 队列积压
- 接口整体变慢
建议
线程池配置要基于任务类型:
- CPU 密集型:线程数接近 CPU 核数
- IO 密集型:线程数可以更高,但前提是有边界、有超时
不要一上来就“线程越多越好”。线程多了会增加上下文切换、连接竞争和内存开销。
坑 3:线程池异步化,但接口同步等待结果
这是最常见的“伪异步”:
Future<Result> future = executor.submit(this::callRemote);
Result result = future.get();
如果调用方马上 get(),那本质只是把等待从当前线程挪到了线程池线程,并没有减少整体阻塞。
建议
先问自己两个问题:
- 这个异步是否真的能和主流程并行?
- 如果最终必须同步等待,是否值得引入额外排队成本?
如果不能真正并行,很多时候直接同步调用反而更可控。
坑 4:没有超时,没有取消,没有降级
这三个缺一个,系统抗压能力都会差很多。
典型后果
- 下游卡住时,线程池线程被长期占用
- 队列积压越来越严重
- 上游超时重试继续放大流量
建议
至少做这三件事:
- 下游调用设置超时
Future.get()设置超时- 超时后取消任务,并返回降级结果
坑 5:拒绝策略选错
默认的 AbortPolicy 会直接抛异常,这没错,但你得接住并处理。
如果你用了 CallerRunsPolicy,要特别小心:高峰时任务可能回落到请求线程执行,导致接口线程被拖慢,进一步影响整体吞吐。
怎么选
- 对在线接口:通常更适合快速失败 + 降级
- 对后台任务:可考虑重试或延迟处理
- 不要让拒绝策略悄悄改变系统行为而没人知道
止血方案
如果线上已经开始雪崩,我建议按“先止血,再修复”的思路处理。
第一阶段:立刻止血
-
限流
- 在网关或接口层限制进入速率
- 防止更多请求压入线程池
-
熔断/降级
- 对非核心功能直接返回默认值
- 对慢下游临时熔断
-
缩短超时
- 包括 HTTP/RPC/DB 的调用超时
- 避免线程长时间挂死
-
清理积压
- 如果是可丢弃任务,考虑清队列
- 如果是在线核心请求,优先扩容下游或降级入口
-
必要时重启
- 这不是最优解,但在队列巨量积压、内存无法回落时,重启是有效止损手段
- 前提是你已经做好流量控制,否则重启后还会再炸一次
第二阶段:修复根因
- 改无界队列为有界队列
- 给线程池补齐监控指标
- 明确拒绝策略和降级逻辑
- 重新评估线程池大小与下游吞吐
- 区分不同业务线程池,避免互相污染
安全/性能最佳实践
这一部分我尽量说得“能拿去用”。
1. 不要混用业务类型不同的任务
不要把下面这些任务全丢到一个池子里:
- 用户接口请求
- 批处理任务
- 消息消费
- 慢 SQL 补偿任务
因为慢任务一旦积压,会拖垮快任务。
隔离永远比“共享一个大池子”更安全。
2. 线程池参数要按容量估算,而不是拍脑袋
一个简单估算方式:
- 假设目标吞吐
QPS = 200 - 平均任务耗时
RT = 100ms - 理论并发需求约
200 × 0.1 = 20
这只是起点,不是最终值。你还要考虑:
- 峰值流量
- 下游抖动
- 超时比例
- 机器 CPU / 内存
- 每个任务占用的上下文对象大小
线程池容量和队列容量一定要结合压测来定。
3. 监控必须覆盖“队列长度”
很多团队只看:
- CPU
- 内存
- RT
- 错误率
但线程池最关键的预警指标之一是:
- 队列长度
- 队列增长速度
- 拒绝次数
- 活跃线程占比
因为线程池问题往往在 CPU 打满前,就已经开始伤害 RT 了。
4. 给线程起有业务含义的名字
这是个小事,但排障时极有用。
new NamedThreadFactory("order-query-pool")
当你在 jstack 里看到:
order-query-pool-1order-query-pool-2
比看到一堆 pool-7-thread-3 好定位太多。
5. 尽量避免任务中持有大对象
比如:
- 超大请求体
- 大量查询结果列表
- 图片/二进制数据
- 整个 Spring 上下文对象链路引用
任务一旦排队,这些大对象也会被一起“锁”在堆里,导致内存无法回收。
6. 警惕 ThreadLocal 和上下文泄漏
线程池线程会被复用,所以如果任务里用了 ThreadLocal,一定要 remove()。
public class ThreadLocalDemo {
private static final ThreadLocal<String> TRACE_ID = new ThreadLocal<>();
public static void execute() {
try {
TRACE_ID.set("trace-123");
// 业务逻辑
} finally {
TRACE_ID.remove();
}
}
}
否则一个线程长期复用,残留上下文不仅会串数据,还可能形成隐蔽内存问题。
一个更稳的排查清单
如果你怀疑是线程池问题,可以按下面这个顺序执行:
stateDiagram-v2
[*] --> 观察现象
观察现象 --> 查看线程池监控
查看线程池监控 --> 分析队列长度
分析队列长度 --> 抓线程栈
抓线程栈 --> 看下游阻塞点
看下游阻塞点 --> 导出堆信息
导出堆信息 --> 确认任务积压对象
确认任务积压对象 --> 临时止血
临时止血 --> 调整线程池参数
调整线程池参数 --> 压测验证
压测验证 --> [*]
这套路径的好处是:
从外部症状到内部证据逐层收敛,不会一上来就怀疑错方向。
总结
这次线程池踩坑,根因其实很朴素:
- 任务处理能力不足
- 却用了无界队列把压力硬吞下去
- 再叠加同步等待、下游变慢、缺少超时和降级
- 最终从“接口变慢”演变成“接口雪崩 + 内存飙升”
你可以记住这几个最实用的结论:
- 线上接口线程池尽量不用无界队列
- 线程池不是缓存,更不是流量黑洞
- 异步不是目的,吞吐闭环和背压才是关键
- 要有超时、拒绝、降级、监控四件套
- 先止血,再修参数,最后靠压测验证
如果你现在的项目里还有下面这种代码:
Executors.newFixedThreadPool(20)
或者:
new LinkedBlockingQueue<>()
建议尽快回头看一眼。
平时它可能安安静静,一到流量峰值,就很可能把你最核心的接口一起带崩。