背景与问题
线上接口偶发超时、RT 飙高、机器内存持续上涨,这类问题在 Java 服务里并不少见。真正麻烦的是:它往往不是“代码报错”这么直接,而是服务还能跑,但越跑越慢,最后把自己拖死。
我之前就踩过一个很典型的坑:
- 某接口为了加快响应,把多个下游调用改成并发执行
- 开发时直接用了
Executors.newFixedThreadPool(...) - 压测初期看起来没问题,QPS 一上来后:
- 接口超时明显增多
- Full GC 开始频繁
- Old 区占用持续升高
- 最终出现 OOM 风险,实例被摘流量
表面上看是“下游变慢导致超时”,但根因其实是:线程池配置与业务负载不匹配,且使用方式存在误区,导致任务堆积、请求阻塞、内存被工作队列吃光。
这篇文章我就按一次完整排障过程来讲,带你从现象、原理、复现、定位到修复,走一遍完整 troubleshooting 流程。
现象复现
先把典型症状列清楚。线程池误用导致的问题,通常不是单点表现,而是一串连锁反应:
- 接口线程把任务提交到线程池
- 线程池处理不过来,任务进入队列
- 队列越积越多,占用越来越多堆内存
- 调用方还在
Future.get()或CompletableFuture.join()等结果 - 接口线程被卡住,请求数继续堆积
- RT 上升、超时增多、内存飙升、GC 频繁
可以用下面这张图理解:
flowchart TD
A[请求进入接口] --> B[提交异步任务到线程池]
B --> C{线程池有空闲线程?}
C -- 有 --> D[立即执行任务]
C -- 没有 --> E[进入阻塞队列]
E --> F[队列持续堆积]
F --> G[堆内存上涨]
D --> H[下游慢/任务执行久]
H --> I[调用方等待Future结果]
I --> J[接口RT升高]
F --> J
J --> K[超时/GC频繁/实例不稳定]
很多团队会误以为“用了线程池就一定更快”,实际上如果没有容量控制,它只是把同步阻塞换成了可排队的阻塞,并且这个排队还会吞内存。
定位路径
排查这类问题时,我建议不要一上来就盯代码,而是按下面的路径逐层收缩范围。
1. 先看监控现象是否一致
重点看这几类指标:
- 接口 RT、超时率、错误率
- JVM 堆内存、Old 区、Full GC 次数
- 线程数、活跃线程数
- 线程池:
- activeCount
- poolSize
- queueSize
- taskCount
- completedTaskCount
- rejectCount
如果出现下面这种组合,线程池基本就值得重点怀疑了:
- activeCount 长时间接近 core/max
- queueSize 持续增长且不回落
- completedTaskCount 增速明显慢于 taskCount
- 接口线程数也在上涨
- 堆内存随着 queueSize 一起上涨
2. 用线程栈确认“谁在等谁”
执行:
jstack <pid>
经常能看到业务线程卡在这些位置:
FutureTask.getCompletableFuture.joinCountDownLatch.await- HTTP/RPC 下游调用超时等待
而线程池工作线程则可能卡在:
- 下游 IO 慢调用
- 数据库慢查询
- 三方接口阻塞
- 锁竞争
这说明问题不是“线程池没执行”,而是执行速度远小于提交速度。
3. 用堆分析确认内存被谁占了
执行:
jmap -histo:live <pid> | head -50
或者导出 dump 用 MAT 分析。常见表现:
LinkedBlockingQueue$Node数量巨大FutureTask、业务Runnable/Callable对象堆积- 请求上下文、参数对象被任务引用,无法回收
这时基本就能确认:不是单纯内存泄漏,而是任务积压造成的“逻辑性内存膨胀”。
核心原理
要修这个坑,得先搞清楚 Java 线程池的几个关键行为。
1. Executors.newFixedThreadPool 为什么危险
很多人喜欢这样写:
ExecutorService executor = Executors.newFixedThreadPool(20);
看起来很正常,但内部其实等价于:
- 核心线程数 = 20
- 最大线程数 = 20
- 队列 =
LinkedBlockingQueue,容量近似无界
这意味着什么?
- 20 个线程忙满后,新任务不会创建更多线程
- 任务会源源不断进入队列
- 如果任务处理慢,队列就会无限增长
- 队列中的每个任务都占内存
也就是说,它不是帮你“削峰”,而是在没有上限地“存债”。
2. 线程池参数不是越大越好
ThreadPoolExecutor 的关键参数:
corePoolSize:核心线程数maximumPoolSize:最大线程数keepAliveTime:非核心线程空闲回收时间workQueue:任务队列RejectedExecutionHandler:拒绝策略
执行逻辑可以简化为:
flowchart LR
A[提交任务] --> B{运行线程数 < core?}
B -- 是 --> C[创建核心线程执行]
B -- 否 --> D{队列未满?}
D -- 是 --> E[任务入队]
D -- 否 --> F{运行线程数 < max?}
F -- 是 --> G[创建非核心线程执行]
F -- 否 --> H[触发拒绝策略]
这里最容易被误解的是:
- 队列太大:容易积压,延迟和内存上升
- 队列太小:容易触发拒绝,但这是可控失败
- max 太大:线程切换和下游压力暴涨
- 完全无界队列:是最危险的默认坑之一
3. 接口“异步化”不等于性能更高
如果你的接口代码是这样:
Future<Result> f1 = pool.submit(task1);
Future<Result> f2 = pool.submit(task2);
Result r1 = f1.get();
Result r2 = f2.get();
那它只是把两个慢操作并发执行了,但当前请求线程依然在同步等待结果。
如果线程池容量不足或者下游慢:
- 请求线程会被
get()卡住 - 上游请求越来越多
- Tomcat/Netty/业务线程继续堆积
- 服务整体吞吐反而更差
所以判断是否值得并发,不是看“用了线程池没有”,而是看:
- 下游是否真能并发获益
- 线程池容量是否可控
- 超时、限流、熔断是否完善
- 失败是否能快速返回
实战代码(可运行)
下面我给一个可运行示例,先复现问题,再给出修复方案。
1. 错误示例:无界队列 + 同步等待
这个例子会模拟接口不断提交慢任务。线程池处理不过来时,队列会持续积压。
```java
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.*;
public class BadThreadPoolDemo {
// 典型误用:fixed thread pool 底层是无界 LinkedBlockingQueue
private static final ExecutorService EXECUTOR = Executors.newFixedThreadPool(8);
public static void main(String[] args) throws Exception {
// 模拟持续请求
for (int round = 1; round <= 200; round++) {
handleRequest(round);
if (round % 10 == 0) {
printStats(round);
}
// 请求来得比任务处理速度快
Thread.sleep(50);
}
EXECUTOR.shutdown();
}
private static void handleRequest(int requestId) {
List<Future<String>> futures = new ArrayList<>();
for (int i = 0; i < 20; i++) {
int taskId = i;
futures.add(EXECUTOR.submit(() -> {
// 模拟慢下游调用
Thread.sleep(3000);
return "req=" + requestId + ", task=" + taskId;
}));
}
// 模拟接口线程等待结果
for (Future<String> future : futures) {
try {
future.get(5, TimeUnit.SECONDS);
} catch (Exception e) {
System.out.println("request " + requestId + " timeout/error: " + e.getClass().getSimpleName());
}
}
}
private static void printStats(int round) {
if (EXECUTOR instanceof ThreadPoolExecutor tpe) {
System.out.printf(
"[round=%d] poolSize=%d, active=%d, queue=%d, completed=%d, task=%d%n",
round,
tpe.getPoolSize(),
tpe.getActiveCount(),
tpe.getQueue().size(),
tpe.getCompletedTaskCount(),
tpe.getTaskCount()
);
}
}
}
你会看到典型特征:
- `active` 很快打满
- `queue` 持续上升
- 大量请求超时
- 如果任务对象很大,堆内存会明显上涨
> 注意:这里为了演示问题,代码刻意简单化。真实线上场景往往还会带上请求参数、上下文对象、日志 MDC、用户信息等,这会让单个队列任务更“重”。
---
## 2. 正确示例:显式 `ThreadPoolExecutor` + 有界队列 + 拒绝策略 + 超时控制
修复思路不是一句“把线程数调大”,而是建立**边界**。
```java
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
public class GoodThreadPoolDemo {
private static final AtomicInteger REJECT_COUNT = new AtomicInteger();
private static final ThreadPoolExecutor EXECUTOR = new ThreadPoolExecutor(
8,
16,
60L,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(100),
new NamedThreadFactory("biz-pool"),
new ThreadPoolExecutor.AbortPolicy()
);
public static void main(String[] args) throws Exception {
for (int round = 1; round <= 200; round++) {
try {
handleRequest(round);
} catch (RejectedExecutionException e) {
REJECT_COUNT.incrementAndGet();
System.out.println("request " + round + " rejected fast");
}
if (round % 10 == 0) {
printStats(round);
}
Thread.sleep(50);
}
EXECUTOR.shutdown();
}
private static void handleRequest(int requestId) {
List<CompletableFuture<String>> futures = new ArrayList<>();
for (int i = 0; i < 20; i++) {
int taskId = i;
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
try {
Thread.sleep(3000);
return "req=" + requestId + ", task=" + taskId;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException(e);
}
}, EXECUTOR).orTimeout(2, TimeUnit.SECONDS)
.exceptionally(ex -> "fallback-" + taskId);
futures.add(future);
}
List<String> results = futures.stream().map(CompletableFuture::join).toList();
System.out.println("request " + requestId + " result size = " + results.size());
}
private static void printStats(int round) {
System.out.printf(
"[round=%d] poolSize=%d, active=%d, queue=%d, completed=%d, task=%d, reject=%d%n",
round,
EXECUTOR.getPoolSize(),
EXECUTOR.getActiveCount(),
EXECUTOR.getQueue().size(),
EXECUTOR.getCompletedTaskCount(),
EXECUTOR.getTaskCount(),
REJECT_COUNT.get()
);
}
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;
}
}
}
这版修复做了几件关键事:
- 不再使用
Executors - 队列改为有界
- 明确配置核心线程数和最大线程数
- 拒绝策略采用快速失败
- 单任务增加超时
- 失败提供降级结果,避免接口无限等
一次完整排障思路
下面用流程图把一次线上问题定位串起来。
sequenceDiagram
participant U as 用户请求
participant A as 接口线程
participant P as 业务线程池
participant D as 下游服务
participant M as JVM/监控
U->>A: 发起请求
A->>P: 提交多个异步任务
alt 线程池有空闲
P->>D: 并发调用下游
else 线程池繁忙
P-->>A: 任务进入队列等待
end
D-->>P: 响应变慢
P-->>A: Future迟迟未完成
A-->>U: 接口超时
P->>M: 队列增长/活跃线程打满
M->>M: 堆内存上涨/GC频繁
我的经验是,碰到这类问题要避免两个误区:
- 只看接口日志,不看线程池监控
- 只看 CPU,不看队列和堆对象
因为很多时候 CPU 并不高,但服务已经被“等待”和“排队”拖垮了。
常见坑与排查
坑 1:直接使用 Executors 工厂方法
最常见的有:
Executors.newFixedThreadPoolExecutors.newSingleThreadExecutor
问题不在“不能用”,而在于它们默认队列通常不符合线上高并发场景。尤其是 newFixedThreadPool 的无界队列,特别容易把问题积压成大问题。
建议:
统一改为显式 ThreadPoolExecutor。
坑 2:线程池按“感觉”配置
比如:
- CPU 4 核,线程池开到 200
- 下游 IO 慢,结果还把 max 开得更大
- 完全没有根据接口超时、下游 RT、QPS 做容量估算
粗略经验
- CPU 密集型:线程数接近
CPU核数或CPU核数 + 1 - IO 密集型:可适当放大,但必须压测验证
- 如果任务是调用慢下游,不要只靠加线程数硬顶
更本质的问题是:下游能不能承受你放大的并发。
坑 3:任务里带了大量上下文对象
例如把这些内容一起捕获进 Runnable/Callable:
- 大请求对象
- 大响应对象
- 文件流/字节数组
- 用户上下文
- Trace/MDC 透传对象
如果这些任务排队,就会导致对象长时间存活,Old 区占用增长非常快。
排查方法:
- 看 dump 中
Runnable/Callable/FutureTask持有链 - 看是否有大对象被闭包引用
- 尽量只传必要字段,而不是整个 DTO
坑 4:异步任务没有超时,也没有取消
如果下游挂住,任务可能长时间占着线程不释放。
接口层即使超时返回了,线程池里的任务可能还在继续跑,这会形成“幽灵任务”。
建议:
- RPC/HTTP 客户端必须设置连接超时、读超时
CompletableFuture层面设置orTimeout- 必要时支持取消和中断传播
坑 5:拒绝策略选错
默认常见策略:
AbortPolicy:直接抛异常CallerRunsPolicy:提交线程自己执行DiscardPolicy:静默丢弃DiscardOldestPolicy:丢最老任务
线上最常踩的坑是:误用 CallerRunsPolicy。
表面上“不丢任务”,实际上高峰时会让接口线程自己干活,导致接口线程被拖住,RT 更差。
拒绝策略怎么选?
- 对强实时接口:优先快速失败 + 降级
- 对可丢任务场景:允许丢弃,但必须有监控
- 对核心链路:不要静默丢弃
坑 6:一个线程池干所有事
把这些任务全塞到一个池子里:
- 查询数据库
- 调下游 RPC
- 发消息
- 刷缓存
- 导出报表
结果一个慢操作就把所有任务拖死,形成线程池“污染”。
建议:按业务隔离线程池,至少区分:
- 接口短任务池
- 慢 IO 池
- 定时任务池
- 批处理池
止血方案
线上已经超时、内存上涨时,先别急着大改,优先止血。
短期止血
-
降低入口流量
- 限流
- 熔断部分功能
- 灰度摘流量
-
快速收紧线程池边界
- 改无界队列为有界队列
- 增加拒绝监控
- 超时快速返回
-
缩短下游超时
- 防止线程长期挂死
-
临时降级
- 非关键字段不查
- 并发子任务改串行或减少数量
中期修复
- 拆分线程池
- 完善线程池指标埋点
- 对接口并发模型重新评估
- 压测验证不同参数下的拐点
长期治理
- 建立线程池使用规范
- 禁止直接用
Executors创建线上线程池 - 每个线程池都要求:
- 容量说明
- 拒绝策略说明
- 监控说明
- 压测报告
安全/性能最佳实践
1. 线程池必须“有边界”
这是最重要的一条:
- 线程数有上限
- 队列有上限
- 等待时间有上限
- 失败有兜底
没有边界的系统,本质上就是把风险延后爆发。
2. 给线程池打全监控
至少监控这些指标:
poolSizeactiveCountcorePoolSizemaximumPoolSizequeueSizeremainingCapacitytaskCountcompletedTaskCountrejectCount
如果你用 Spring,可以把这些指标接到 Micrometer + Prometheus + Grafana。
3. 任务只传必要数据
不要把整个请求上下文塞进异步任务。
尤其是大对象、文件、列表、响应报文,能裁剪就裁剪。
4. 每个异步任务都要有超时
推荐做到三层超时控制:
- 下游客户端超时
- Future/CompletableFuture 超时
- 接口整体超时
这样才能避免某一层“永远等”。
5. 拒绝要可观测,失败要可恢复
线程池拒绝不是坏事,不可见的拒绝才是坏事。
建议:
- 记录拒绝计数
- 打关键日志
- 触发告警
- 给调用方明确降级结果
6. 做容量估算,不靠拍脑袋
一个简单思路:
- 单请求平均提交任务数:
n - 峰值 QPS:
q - 单任务平均执行时间:
t - 理论并发需求大致接近:
n * q * t
当然这只是粗估,真实情况还要考虑:
- 下游波动
- P99 延迟
- 请求突刺
- 重试流量
- 任务是否可取消
最终还是要靠压测。
一个推荐的线程池配置模板
下面给一个更适合线上落地的模板。
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
public class ThreadPoolTemplate {
public static ThreadPoolExecutor newBizExecutor(
String name,
int core,
int max,
int queueSize
) {
return new ThreadPoolExecutor(
core,
max,
60L,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(queueSize),
new NamedThreadFactory(name),
(r, executor) -> {
// 这里可接监控/日志/告警
throw new RejectedExecutionException(
"Task rejected. pool=" + name +
", active=" + executor.getActiveCount() +
", queue=" + executor.getQueue().size()
);
}
);
}
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.setUncaughtExceptionHandler((thread, ex) ->
System.err.println("Thread error: " + thread.getName() + ", ex=" + ex.getMessage()));
return t;
}
}
}
这个模板不神奇,但它至少解决了几个基础问题:
- 明确命名线程,方便
jstack - 有界队列,避免无限积压
- 拒绝可见
- 异常可定位
总结
这次踩坑的核心结论可以浓缩成一句话:
线程池不是性能优化按钮,而是并发资源管理工具。用错了,它会把慢问题放大成稳定性问题。
回到本文这个场景,接口超时和内存飙升的根因并不神秘:
- 使用了不合适的线程池创建方式
- 队列无界,导致任务堆积
- 接口线程同步等待异步结果
- 下游变慢时没有超时、拒绝、降级等保护机制
如果你要在项目里落地修复,我建议按这个顺序做:
- 禁止线上直接使用
Executors.newFixedThreadPool - 统一改用显式
ThreadPoolExecutor - 队列必须有界
- 每个任务必须有超时
- 拒绝策略要明确且可观测
- 线程池按业务隔离
- 上线前必须压测并看队列、拒绝、RT、GC 走势
最后补一句边界条件:
如果你的任务本质上是慢 IO,而且接口线程又必须等待结果返回,那么线程池只能缓解局部并发,不能从根上消灭慢调用问题。真正的优化点,很多时候还是在:
- 下游接口性能
- 缓存设计
- 批量化调用
- 降级与数据裁剪
- 服务容量规划
把这些基础工作做好,线程池才会是助力,而不是事故放大器。