背景与问题
线上接口“偶发超时”这件事,很多人第一反应会怀疑数据库、Redis、网络抖动,甚至怀疑 JVM 参数不合理。
但我自己踩过几次坑之后,越来越确定一件事:线程池用错了,问题会伪装成很多别的故障。
这篇文章讲一个很典型、也很隐蔽的场景:
- 某个接口为了提升吞吐,把多个子任务并发执行
- 使用了
Executors.newFixedThreadPool()或者自定义线程池 - 提交任务时没有设置合理的队列、拒绝策略、超时控制
- 结果在流量上来时:
- 接口 RT 持续升高
- Tomcat/Jetty 工作线程被拖住
- 堆内存不断上涨
- Full GC 变频繁
- 最终出现超时雪崩,严重时甚至 OOM
这种问题最难受的地方在于:线程池本来是为了解决并发问题的,结果却成了放大器。
本文我会按“复现现象 → 理清原理 → 排查路径 → 修复代码 → 最佳实践”的顺序,带你完整走一遍。
背景与问题
先看一个简化后的业务模型。
一个聚合接口 /queryUserProfile,内部要并发查 3 个下游:
- 用户基础信息
- 订单统计
- 优惠券信息
于是很多同学会这样写:
ExecutorService executor = Executors.newFixedThreadPool(20);
public UserProfile queryUserProfile(Long userId) throws Exception {
Future<String> userFuture = executor.submit(() -> queryUser(userId));
Future<String> orderFuture = executor.submit(() -> queryOrder(userId));
Future<String> couponFuture = executor.submit(() -> queryCoupon(userId));
String user = userFuture.get();
String order = orderFuture.get();
String coupon = couponFuture.get();
return new UserProfile(user, order, coupon);
}
本地压测时看起来挺好,QPS 低的时候也没问题。
但线上一旦碰到下游变慢、偶发超时、流量高峰,这段代码就会暴露出几个连锁问题:
- 线程池队列持续堆积
- 请求线程阻塞在
Future.get() - 任务对象、上下文对象无法及时释放
- 堆内存上涨,GC 压力激增
- 接口整体超时,形成反压失败
一句话概括:线程池没有帮你削峰,反而把问题缓存进了内存。
现象复现
先把常见线上症状列出来,方便你对号入座。
典型症状
- 应用 CPU 不一定很高,但 RT 明显升高
- 服务线程数不断增加,业务线程大量 WAITING/TIMED_WAITING
- 堆内存持续上涨,Old 区占用回不去
- Full GC 次数明显增多
- 接口日志里出现大量超时
- 线程池监控显示:
activeCount接近核心线程数/最大线程数queueSize持续增长completedTaskCount增速很慢
一个高频误用点
很多人以为:
Executors.newFixedThreadPool(20)
就只是“20 个线程,挺安全”。
实际上它底层等价于:
new ThreadPoolExecutor(
20,
20,
0L,
TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>()
);
注意这里的 LinkedBlockingQueue 是无界队列。
这意味着:只要提交速度大于处理速度,任务就会无限堆积在队列里,最终变成内存问题。
核心原理
先把整个故障链路讲透,后面的排查和修复就顺了。
1. 无界队列会吞掉“背压”
线程池本来应该承担两件事:
- 限制并发
- 在超出承载时触发背压或拒绝
但如果你用了无界队列,那线程池的行为会变成:
- 核心线程满了
- 新任务不扩容,而是继续入队
- 队列无限增长
- 内存持续被任务对象占用
这相当于把“系统处理不过来”的问题,变成“先存在内存里,晚点再处理”。
听起来温和,实际上是把延迟和风险向后推,最后一起爆。
2. Future.get() 会把上层请求线程也拖住
如果接口线程提交 3 个子任务,然后直接 get() 等结果:
- 下游快时,问题不明显
- 下游慢时,请求线程会同步阻塞
- 请求线程本身又是有限资源
- 更多请求进入后,容器线程也被耗尽
于是就从“子任务线程池拥堵”演变成“整个 Web 服务拥堵”。
3. 任务排队会放大对象生命周期
一个排队中的任务,往往不只是一个 Runnable 那么简单。
它背后可能还引用着:
- 请求参数
- 用户上下文
- traceId / MDC
- 大对象缓存
- Lambda 捕获的外部变量
- 结果回调对象
如果队列里堆了几万、几十万个任务,这些对象就很难被 GC 回收。
所以你看到的“内存飙升”,很多时候不是传统意义上的“内存泄漏”,而是任务滞留导致的对象堆积。
4. 下游慢 + 无限排队 = 延迟雪崩
当下游接口从 50ms 变成 1s 时,线程池吞吐会断崖式下降。
如果上游还持续投递任务:
- 队列越来越长
- 单个任务等待时间越来越久
- 请求总 RT 越来越高
- 超时请求越来越多
- 但排队任务仍然在执行,继续消耗资源
这就是典型的超时不等于停止执行问题。
一图看懂故障链路
flowchart TD
A[请求进入接口] --> B[提交多个异步子任务]
B --> C[线程池核心线程被占满]
C --> D[新任务进入无界队列]
D --> E[队列持续积压]
E --> F[Future.get阻塞请求线程]
F --> G[接口RT升高/超时]
E --> H[任务对象堆积]
H --> I[堆内存上涨/GC频繁]
G --> J[流量高峰时雪崩]
I --> J
线程池误用与正确配置对比
classDiagram
class BadThreadPool {
+corePoolSize = 20
+maximumPoolSize = 20
+queue = LinkedBlockingQueue(unbounded)
+rejection = default
+risk = 内存堆积
}
class GoodThreadPool {
+corePoolSize = N
+maximumPoolSize = M
+queue = ArrayBlockingQueue(bounded)
+rejection = CallerRuns/Abort
+timeout = get(timeout)
+monitor = metrics/log
}
定位路径
真到线上出问题时,不要上来就改线程数。
先按这个顺序排查,效率最高。
第一步:看接口 RT 和超时比例
重点关注:
- 平均 RT
- P95 / P99
- 超时数
- 错误码分布
如果 RT 拉高和超时基本同时出现,通常说明不是偶发网络毛刺,而是系统性阻塞。
第二步:看线程池指标
如果你没有暴露线程池监控,建议尽快补上。至少要有:
poolSizeactiveCountqueueSizetaskCountcompletedTaskCountlargestPoolSize
一个非常危险的信号是:
activeCount长时间接近满值queueSize持续增长completedTaskCount增长变慢
这通常说明处理能力低于提交速度。
第三步:抓线程栈
使用:
jstack <pid> > thread.dump
重点看两类线程:
-
业务请求线程
- 是否大量阻塞在
FutureTask.get - 是否卡在下游网络调用
- 是否大量阻塞在
-
线程池工作线程
- 是否忙于执行慢 SQL / HTTP 调用
- 是否长时间 WAITING/TIMED_WAITING
你常会看到类似调用链:
java.util.concurrent.FutureTask.get
xxx.service.UserProfileService.queryUserProfile
xxx.controller.UserController.query
这说明主请求线程在等异步任务结果,但异步任务并没有及时完成。
第四步:看堆内存和对象分布
使用:
jmap -histo:live <pid> | head -50
或者直接 dump 堆,用 MAT 分析。
重点关注:
FutureTaskLinkedBlockingQueue$Node- 业务
Runnable/Callable - 大量 Lambda 生成对象
- 请求上下文对象
如果这些对象数量异常多,基本就能确定是任务积压。
第五步:结合下游耗时看根因
线程池出问题,常常不是“线程池本身太小”,而是:
- 下游接口突然变慢
- 外部依赖超时设置太长
- 重试逻辑放大请求量
- 批量任务没有限流
线程池只是最后承受压力的地方。
实战代码(可运行)
下面我给两个版本:
- 一个是错误示例:容易复现接口超时和内存上涨
- 一个是修复示例:带边界队列、超时、拒绝策略和降级
错误示例:无界队列 + 无超时等待
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
import java.util.concurrent.*;
public class BadThreadPoolDemo {
private static final ExecutorService EXECUTOR = Executors.newFixedThreadPool(8);
private static final Random RANDOM = new Random();
public static void main(String[] args) throws Exception {
for (int i = 0; i < 50000; i++) {
final int requestId = i;
new Thread(() -> {
try {
String result = handleRequest(requestId);
if (requestId % 1000 == 0) {
System.out.println("request=" + requestId + ", result=" + result);
}
} catch (Exception e) {
System.err.println("request=" + requestId + " error: " + e.getMessage());
}
}).start();
}
}
public static String handleRequest(int requestId) throws Exception {
Future<String> f1 = EXECUTOR.submit(() -> slowDependency("user-" + requestId));
Future<String> f2 = EXECUTOR.submit(() -> slowDependency("order-" + requestId));
Future<String> f3 = EXECUTOR.submit(() -> slowDependency("coupon-" + requestId));
// 没有超时控制,主线程会一直等
return f1.get() + "|" + f2.get() + "|" + f3.get();
}
private static String slowDependency(String name) throws InterruptedException {
// 模拟下游偶发变慢
int sleep = RANDOM.nextInt(100) < 10 ? 3000 : 200;
Thread.sleep(sleep);
return name + "-ok";
}
}
这个例子虽然简单,但足以说明问题:
- 工作线程只有 8 个
- 每个请求却要提交 3 个任务
- 下游一旦慢,请求速度立刻超过处理速度
- 无界队列开始堆积
get()会让请求线程一直等
如果你给它较小堆内存运行,例如:
java -Xms256m -Xmx256m BadThreadPoolDemo
很容易观察到内存迅速上涨、吞吐下降。
修复示例:有界队列 + 超时 + 拒绝策略 + 降级
import java.util.Random;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
public class GoodThreadPoolDemo {
private static final Random RANDOM = new Random();
private static final ThreadPoolExecutor EXECUTOR = new ThreadPoolExecutor(
8,
16,
60L,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(200),
new NamedThreadFactory("biz-pool"),
new ThreadPoolExecutor.CallerRunsPolicy()
);
public static void main(String[] args) throws Exception {
ScheduledExecutorService monitor = Executors.newSingleThreadScheduledExecutor();
monitor.scheduleAtFixedRate(() -> {
System.out.println(String.format(
"poolSize=%d, active=%d, queue=%d, completed=%d",
EXECUTOR.getPoolSize(),
EXECUTOR.getActiveCount(),
EXECUTOR.getQueue().size(),
EXECUTOR.getCompletedTaskCount()
));
}, 1, 1, TimeUnit.SECONDS);
for (int i = 0; i < 2000; i++) {
final int requestId = i;
new Thread(() -> {
try {
String result = handleRequest(requestId);
if (requestId % 100 == 0) {
System.out.println("request=" + requestId + ", result=" + result);
}
} catch (Exception e) {
System.err.println("request=" + requestId + " error: " + e.getMessage());
}
}).start();
}
}
public static String handleRequest(int requestId) {
Future<String> f1 = submitSafe(() -> slowDependency("user-" + requestId));
Future<String> f2 = submitSafe(() -> slowDependency("order-" + requestId));
Future<String> f3 = submitSafe(() -> slowDependency("coupon-" + requestId));
String user = getOrDefault(f1, 800, "user-default");
String order = getOrDefault(f2, 800, "order-default");
String coupon = getOrDefault(f3, 800, "coupon-default");
return user + "|" + order + "|" + coupon;
}
private static Future<String> submitSafe(Callable<String> task) {
try {
return EXECUTOR.submit(task);
} catch (RejectedExecutionException e) {
return CompletableFuture.completedFuture("degraded");
}
}
private static String getOrDefault(Future<String> future, long timeoutMs, String defaultValue) {
try {
return future.get(timeoutMs, TimeUnit.MILLISECONDS);
} catch (TimeoutException e) {
future.cancel(true);
return defaultValue;
} catch (Exception e) {
return defaultValue;
}
}
private static String slowDependency(String name) throws InterruptedException {
int sleep = RANDOM.nextInt(100) < 10 ? 3000 : 200;
Thread.sleep(sleep);
return name + "-ok";
}
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);
t.setName(prefix + "-" + counter.getAndIncrement());
return t;
}
}
}
这个版本的关键点有 4 个:
-
有界队列
ArrayBlockingQueue<>(200)- 避免任务无限堆积
-
合理扩容
core=8, max=16- 允许在压力升高时短暂扩容
-
拒绝策略
CallerRunsPolicy- 让调用方感知压力,形成自然背压
- 当然,这个策略也有边界,后面会讲
-
结果等待超时
future.get(timeout)- 超时后取消任务并返回降级结果
修复前后时序对比
sequenceDiagram
participant C as 客户端
participant W as Web线程
participant P as 业务线程池
participant D as 下游服务
C->>W: 请求接口
W->>P: 提交3个任务
P->>D: 并发调用下游
alt 下游慢且线程池拥堵
W-->>W: Future.get阻塞
P-->>P: 队列持续堆积
W-->>C: 接口超时
else 修复后
W->>P: 有界提交
P->>D: 超时调用
W-->>C: 返回部分结果/降级结果
end
常见坑与排查
这一节我尽量讲“真坑”,不是只讲教科书。
坑 1:误用 Executors 工厂方法
很多项目里直接写:
Executors.newFixedThreadPool(20)
Executors.newCachedThreadPool()
Executors.newSingleThreadExecutor()
这些方法不是不能用,而是默认策略往往不适合线上业务场景。
newFixedThreadPool:无界队列,容易堆积任务newCachedThreadPool:线程数可无限增长,容易把机器打爆newSingleThreadExecutor:单线程串行,极易成为瓶颈
建议:线上统一显式使用 ThreadPoolExecutor。
坑 2:只设置线程数,不设置队列容量
很多人会仔细调整 corePoolSize 和 maximumPoolSize,但对队列随手一写。
实际上,队列容量往往比线程数更决定系统行为。
- 队列太大:延迟堆积、内存膨胀
- 队列太小:拒绝太频繁
- 正确做法:根据 SLA、下游耗时、峰值流量估算
坑 3:异步了,但马上 get()
这种写法我见得特别多:
Future<A> a = pool.submit(() -> taskA());
Future<B> b = pool.submit(() -> taskB());
return assemble(a.get(), b.get());
它不是完全没意义,但如果上层线程必须同步等待结果,那本质上你只是把工作切到了另一个线程池。
一旦任务慢,就会出现“双重线程占用”:
- 请求线程在等
- 工作线程在跑
如果接口链路很长,这个成本很高。
坑 4:超时只在上层做,子任务不取消
例如接口 1 秒超时,但你提交给线程池的任务还在继续跑 5 秒。
用户已经拿到超时响应了,但资源还在消耗。
所以要做两层控制:
- 接口总超时
- 子任务执行超时 / 下游调用超时 / 可取消
坑 5:拒绝策略随便选
常见拒绝策略:
AbortPolicy:直接抛异常CallerRunsPolicy:调用线程自己执行DiscardPolicy:直接丢弃DiscardOldestPolicy:丢最老任务
没有哪个绝对最好,要看业务。
什么时候适合 CallerRunsPolicy
适合:
- 任务执行时间较短
- 调用方线程可承受少量回退执行
- 希望自然限流
不适合:
- Web 请求线程很宝贵
- 任务执行很慢
- 调用链已经很长
如果你在接口线程里用了 CallerRunsPolicy,而任务本身又是慢 IO,那可能会把请求线程直接拖死。
这时更适合:
- 快速失败
- 返回降级
- 配合限流
坑 6:线程池共用,互相污染
比如:
- 查询接口
- 导出任务
- 消息消费
- 定时任务
全都共用一个线程池。
结果导出一波高峰上来,把接口线程池占满,接口 RT 一起炸。
建议按业务隔离线程池。
止血方案
如果你现在已经在线上碰到了这个问题,先不要追求“一步到位最优雅”,先止血。
立即可做的 5 件事
1. 给线程池加监控和日志
至少周期性打印:
System.out.printf("active=%d, queue=%d, completed=%d%n",
executor.getActiveCount(),
executor.getQueue().size(),
executor.getCompletedTaskCount());
2. 把无界队列改成有界队列
哪怕容量先保守一点,也比无限堆积强。
3. 给 Future.get() 加超时
没有超时的等待,线上迟早要还债。
4. 下游调用超时要比接口总超时更短
比如接口 SLA 1 秒:
- 下游调用超时:300~500ms
- 聚合处理预留:200ms
- 超时后快速降级
5. 对高成本任务单独隔离
导出、批量查询、报表这类任务,不要和在线接口混池。
安全/性能最佳实践
这里给一套我更推荐的落地原则,适合中级 Java 工程师在项目里直接使用。
1. 线上禁用默认 Executors 快捷工厂
统一显式配置:
ThreadPoolExecutor executor = new ThreadPoolExecutor(
corePoolSize,
maximumPoolSize,
keepAliveTime,
unit,
new ArrayBlockingQueue<>(queueSize),
threadFactory,
rejectionHandler
);
2. 线程池参数要按业务模型估算
简单经验法则:
- CPU 密集型:线程数接近 CPU 核数
- IO 密集型:线程数可适度大于 CPU 核数,但必须配合超时和有界队列
不要只凭感觉把线程数从 50 改到 200。
线程不是越多越快,过多线程会带来:
- 上下文切换
- 内存占用增加
- 调度开销变大
- 争用更严重
3. 队列容量要和 SLA 挂钩
这个思路很好用:
队列不是“缓存多少任务”,而是“允许系统积压多少延迟”。
例如:
- 平均处理能力:1000 task/s
- 你只能接受额外 200ms 排队
- 那队列容量大约应控制在 200 左右量级,而不是 10000
4. 必须有超时、取消和降级
最少要覆盖三层:
- 下游 RPC/HTTP/DB 超时
Future.get(timeout)- 超时后的默认返回值 / 降级逻辑
5. 线程池隔离
至少按以下维度隔离:
- 在线接口
- 批处理/导出
- 消息消费
- 定时任务
- 第三方依赖调用
6. 给线程起可读名称
线上排查时,线程名就是你的路标。
比如:
user-query-pool-1coupon-remote-pool-3export-worker-2
比看到一堆 pool-17-thread-6 强太多。
7. 监控一定要成体系
建议监控这些指标:
- 活跃线程数
- 队列长度
- 拒绝次数
- 任务执行耗时
- 任务等待耗时
- 超时次数
- 降级次数
如果只监控 RT,不监控线程池,很容易到故障后期才发现。
8. 不要忽略 MDC / ThreadLocal 污染
线程池复用线程,如果用了:
ThreadLocal- 日志 MDC
- 用户上下文
一定要在任务结束后清理。
否则不仅有内存风险,还可能出现上下文串数据。
示例:
try {
// 业务逻辑
} finally {
// ThreadLocal.remove();
}
一套更实用的排查清单
如果你线上再遇到类似问题,可以直接照着做。
快速判断
- 接口 RT 是否突然拉高?
- 线程池
queueSize是否持续增长? - 是否大量线程卡在
Future.get()? - 堆里是否有大量
FutureTask/LinkedBlockingQueue$Node?
快速止血
- 限流
- 降级
- 缩短下游超时
- 有界队列
- 快速失败替代无限排队
根因确认
- 下游变慢?
- 重试过多?
- 单线程池共享过多业务?
- 队列无界?
- 调用方同步等待异步结果?
持续治理
- 线程池参数标准化
- 监控告警补齐
- 链路超时统一治理
- 线程池按业务隔离
总结
这个坑的本质,不是“线程池不会用”,而是没有把线程池当成容量边界来设计。
请记住这几个关键结论:
newFixedThreadPool()最大的问题不是固定线程数,而是无界队列- 异步提交后立刻
get(),会把请求线程也拖进阻塞 - 接口超时不代表任务停止,超时后的后台执行一样会消耗资源
- 内存飙升很多时候不是泄漏,而是任务积压导致对象滞留
- 正确做法是:有界队列 + 明确拒绝策略 + 超时 + 取消 + 降级 + 隔离
如果你想把建议落到代码层,我推荐最低标准是这 6 条:
- 不用
Executors默认工厂方法 - 所有线程池使用有界队列
- 所有异步结果等待都带超时
- 下游调用必须设置超时
- 不同业务隔离线程池
- 线程池指标接入监控告警
最后给一个边界提醒:
如果你的接口本质上是强依赖多个慢下游、且必须全部成功才能返回,那线程池调得再漂亮,也只能缓解,不能根治。
这时候要回到架构层考虑:
- 是否能做缓存
- 是否能做预计算
- 是否能接受部分结果
- 是否能改成异步化流程
线程池是工具,不是万能补丁。
但只要把边界和背压设计对,它至少不会再成为事故放大器。