背景与问题
线上接口偶发超时,一开始大家通常会怀疑:
- 是不是数据库慢了?
- 是不是某个下游接口抖动?
- 是不是 GC 太频繁?
- 是不是发布后引入了死锁?
这些方向都没错,但我自己踩过一个特别“隐蔽”的坑:线程池误用。
表面现象看起来像性能问题,实际根因却是:
- 线程池参数配置不合理
- 使用了无界队列
- 任务里嵌套异步,又共用同一个线程池
- 异常吞掉导致任务堆积
- 请求量上来后,队列不断膨胀,最终引发:
- 接口响应越来越慢
- JVM 堆内存持续上涨
- Full GC 频繁
- 某些实例直接 OOM
这类问题最麻烦的地方在于:它不是立刻炸,而是慢慢拖垮系统。
本文我从一个常见故障场景切入,带你按“现象复现 → 定位路径 → 止血方案 → 根因修复”的方式走一遍。
现象复现
先看一个很典型、也很容易在业务里出现的错误写法。
错误示例:无界队列 + 慢任务 + 高并发提交
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.*;
public class BadThreadPoolDemo {
// 典型踩坑:固定线程数 + 无界阻塞队列
private static final ExecutorService EXECUTOR =
new ThreadPoolExecutor(
8,
8,
0L,
TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<>()
);
public static void main(String[] args) throws Exception {
List<Future<String>> futures = new ArrayList<>();
// 模拟短时间提交大量请求
for (int i = 0; i < 20000; i++) {
int taskId = i;
Future<String> future = EXECUTOR.submit(() -> {
// 模拟慢任务,比如调用下游接口/复杂计算
Thread.sleep(500);
return "task-" + taskId;
});
futures.add(future);
}
int success = 0;
for (Future<String> future : futures) {
try {
future.get(200, TimeUnit.MILLISECONDS); // 故意设置较短超时
success++;
} catch (TimeoutException e) {
System.out.println("调用超时");
}
}
System.out.println("success = " + success);
EXECUTOR.shutdown();
}
}
这个例子为什么危险?
因为这个线程池:
- 核心线程数 = 8
- 最大线程数 = 8
- 队列 =
LinkedBlockingQueue<>(),默认几乎等于无界
结果是:
- 请求一下子来了 20000 个
- 只有 8 个线程真正执行
- 其余任务全部进入队列等待
- 队列对象越来越多,占用堆内存
- 等待时间越来越长,接口超时开始出现
- 如果每个任务还带有上下文对象、请求参数、缓存引用,内存增长更明显
这类场景在线上很常见:业务方以为用了线程池就“异步提速”了,实际上只是把压力从调用线程挪到了内存队列里。
核心原理
要修好这类问题,必须先把线程池调度逻辑搞明白。
ThreadPoolExecutor 的基本工作流程
当一个新任务提交时,线程池处理顺序大致是:
- 如果运行中的线程数 <
corePoolSize,创建核心线程执行任务 - 否则尝试把任务放入队列
- 如果队列满了,且线程数 <
maximumPoolSize,继续创建非核心线程 - 如果队列也满,线程数也到上限,触发拒绝策略
flowchart TD
A[提交任务] --> B{运行线程数 < corePoolSize?}
B -- 是 --> C[创建核心线程执行]
B -- 否 --> D{队列能放下?}
D -- 是 --> E[任务入队等待]
D -- 否 --> F{线程数 < maximumPoolSize?}
F -- 是 --> G[创建非核心线程执行]
F -- 否 --> H[触发拒绝策略]
为什么无界队列容易掩盖问题?
因为当你用了无界队列:
- 第 2 步几乎总能成功入队
- 第 3 步“扩容线程”的机会基本不会发生
maximumPoolSize这个参数形同虚设- 线程池从“并发执行工具”,退化成“任务堆积容器”
也就是说,很多人以为自己配了:
corePoolSize = 8
maximumPoolSize = 64
能在高峰期自动扩到 64 个线程。
实际上如果队列是无界的,线程数可能永远只维持在 8。
接口为什么会超时?
因为请求处理链路被“排队时间”拖长了。
接口耗时 = 排队等待时间 + 实际执行时间
当任务处理速度 < 任务提交速度时:
- 队列长度持续增长
- 后来的请求等待更久
- 即便单个任务执行只需 200ms,排队后总耗时可能变成数秒
sequenceDiagram
participant Client as 客户端
participant API as 接口线程
participant Pool as 业务线程池
participant Worker as 工作线程
Client->>API: 发起请求
API->>Pool: submit(task)
Pool-->>API: 快速返回 Future
Note over Pool: 队列已堆积大量任务
Worker->>Pool: 逐个拉取任务
Worker->>Worker: 实际执行慢任务
API->>Pool: 等待结果/超时
API-->>Client: 接口超时
内存为什么会飙升?
因为排队的不只是“一个 Runnable 引用”。
真实业务里,一个任务对象往往会间接持有:
- 请求 DTO
- 用户上下文
- 日志 Trace 信息
- 大对象参数
- 下游调用结果占位对象
- Lambda 捕获的外部变量
如果队列里积压几万、几十万个任务,堆内存自然就上去了。
定位路径
线上排查这类问题,我一般会按下面这个顺序来。
1. 先看监控表现
重点关注这些指标:
- 接口 RT/P99 是否持续升高
- 超时比例是否和流量高峰同步
- JVM 堆使用率是否持续走高
- Full GC 次数是否增加
- 线程池活跃线程数是否打满
- 线程池队列长度是否持续增长
- 拒绝任务数是否出现
如果你们系统没有线程池监控,这本身就是个坑。至少要补:
activeCountpoolSizequeueSizecompletedTaskCounttaskCountrejectCount
2. 用 jstack 看线程状态
如果是线程池拥堵,常见现象包括:
- 大量业务线程卡在
Future.get() - 工作线程都在执行慢任务
- 没有明显死锁,但吞吐上不去
例如你可能看到:
WAITING:调用方在等异步结果TIMED_WAITING:任务内 sleep、IO 等待、网络超时- 少量
RUNNABLE:真正忙碌中的线程
3. 用 jmap / MAT 看堆对象
如果内存上涨明显,可以重点看:
LinkedBlockingQueue$Node- 大量
FutureTask - 大量业务 Task 对象
- 被线程池队列强引用的请求上下文对象
这类内存图通常会非常直观:
不是某个缓存爆了,而是线程池队列积压了太多任务。
4. 回代码找线程池定义
这是关键一步。重点排查:
- 是否用了
Executors.newFixedThreadPool() - 是否用了
Executors.newSingleThreadExecutor() - 是否用了默认无界队列
- 是否多个业务共用同一个线程池
- 是否任务内部又 submit 到同一个线程池
- 是否存在同步等待异步结果的“伪异步”
实战代码(可运行)
下面给一个更贴近线上问题的完整示例:先演示错误写法,再给出修复版本。
错误版:请求线程同步等待异步结果,线程池排队严重
import java.util.concurrent.*;
public class ThreadPoolTimeoutBadCase {
private static final ExecutorService BIZ_POOL = new ThreadPoolExecutor(
4,
4,
0L,
TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<>(),
new ThreadPoolExecutor.AbortPolicy()
);
public static String handleRequest(int reqId) {
Future<String> future = BIZ_POOL.submit(() -> {
// 模拟下游调用耗时
Thread.sleep(300);
return "result-" + reqId;
});
try {
// 接口线程同步等待,容易造成整体 RT 升高
return future.get(200, TimeUnit.MILLISECONDS);
} catch (TimeoutException e) {
return "timeout-" + reqId;
} catch (Exception e) {
return "error-" + reqId;
}
}
public static void main(String[] args) throws InterruptedException {
int timeoutCount = 0;
for (int i = 0; i < 200; i++) {
String result = handleRequest(i);
if (result.startsWith("timeout")) {
timeoutCount++;
}
if (i % 20 == 0) {
ThreadPoolExecutor executor = (ThreadPoolExecutor) BIZ_POOL;
System.out.printf("i=%d, active=%d, queue=%d, completed=%d%n",
i,
executor.getActiveCount(),
executor.getQueue().size(),
executor.getCompletedTaskCount());
}
}
System.out.println("timeoutCount=" + timeoutCount);
BIZ_POOL.shutdown();
}
}
这个错误版的问题
- 线程池太小
- 队列无界
- 任务执行时间比等待超时时间更长
- 调用方同步
get(),并没有真正提升吞吐 - 队列持续增长时,结果越来越容易超时
修复版:有界队列 + 显式拒绝策略 + 降级处理 + 独立线程池
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
public class ThreadPoolTimeoutFixedCase {
private static final ThreadPoolExecutor BIZ_POOL = new ThreadPoolExecutor(
8,
16,
60L,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(100),
new NamedThreadFactory("biz-pool"),
new ThreadPoolExecutor.CallerRunsPolicy()
);
public static String handleRequest(int reqId) {
CompletableFuture<String> future;
try {
future = CompletableFuture.supplyAsync(() -> {
try {
// 模拟下游调用
Thread.sleep(100);
return "result-" + reqId;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("interrupted", e);
}
}, BIZ_POOL);
} catch (RejectedExecutionException e) {
// 止血:线程池满时快速降级,避免继续堆积
return "degraded-" + reqId;
}
try {
return future.get(200, TimeUnit.MILLISECONDS);
} catch (TimeoutException e) {
return "timeout-" + reqId;
} catch (Exception e) {
return "error-" + reqId;
}
}
public static void main(String[] args) throws InterruptedException {
int degraded = 0;
int timeout = 0;
for (int i = 0; i < 500; i++) {
String result = handleRequest(i);
if (result.startsWith("degraded")) {
degraded++;
}
if (result.startsWith("timeout")) {
timeout++;
}
if (i % 50 == 0) {
System.out.printf("i=%d, poolSize=%d, active=%d, queue=%d, completed=%d%n",
i,
BIZ_POOL.getPoolSize(),
BIZ_POOL.getActiveCount(),
BIZ_POOL.getQueue().size(),
BIZ_POOL.getCompletedTaskCount());
}
}
System.out.println("degraded=" + degraded + ", timeout=" + timeout);
BIZ_POOL.shutdown();
}
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;
}
}
}
修复点解释
这个版本并不追求“绝不超时”,而是追求系统可控:
- 使用
ArrayBlockingQueue限制排队长度 - 允许线程池在高峰期扩容到
maximumPoolSize - 使用
CallerRunsPolicy施加反压 - 提交失败时快速降级,避免内存无限增长
- 自定义线程工厂,方便排查线程问题
如果你的业务不能接受调用线程执行任务,也可以换成:
AbortPolicy:直接拒绝,调用方兜底- 自定义
RejectedExecutionHandler:记录日志、埋点、返回业务降级
常见坑与排查
下面这些坑,我建议一个一个对照自己项目看。
坑 1:迷信 Executors 工厂方法
很多项目喜欢这么写:
ExecutorService executor = Executors.newFixedThreadPool(20);
看着简单,实际风险很高。newFixedThreadPool 底层就是无界队列,容易在高峰流量下把任务全堆到内存里。
建议:优先直接使用 ThreadPoolExecutor 显式配置。
坑 2:任务里再提交子任务到同一个线程池
比如:
Future<String> f1 = pool.submit(() -> {
Future<String> inner = pool.submit(() -> "inner");
return inner.get();
});
如果线程池线程数很小,这种写法很容易形成“线程互等”:
- 外层任务占着线程
- 内层任务排队等线程
- 外层又在等内层结果
最终表现像死锁,实质是线程池饥饿。
flowchart LR
A[外层任务占用线程1] --> B[提交内层任务]
B --> C[内层任务进入队列]
C --> D[外层任务等待 inner.get]
D --> E[线程无法释放]
E --> F[内层任务迟迟得不到执行]
坑 3:把 CPU 密集任务和 IO 密集任务放同一个池
这也是线上常见误用。
- CPU 密集型:计算、加解密、规则匹配
- IO 密集型:HTTP 调用、数据库访问、文件操作
如果混在一起:
- IO 任务容易长时间占住线程
- CPU 任务得不到及时调度
- 整体吞吐波动明显
建议按任务类型拆池。
坑 4:异常被吞掉,任务失败但没人知道
比如:
executor.submit(() -> {
throw new RuntimeException("fail");
});
如果你既不 get(),也不统一记录日志,任务其实已经失败了,但系统表面上“风平浪静”。
建议:
- 统一包装任务,记录异常
- 对关键任务补充监控和告警
- 使用
CompletableFuture.whenComplete()处理异常链路
坑 5:超时时间拍脑袋设置
有的接口外层超时 200ms,结果内部异步任务自己就要跑 500ms。
这时你会看到“线程池很忙、接口一直超时”,但根因不是线程池本身,而是超时预算设计不合理。
建议把超时拆成:
- 网关超时
- 接口总超时
- 下游调用超时
- 异步任务超时
要有明确预算,而不是每层都随手写一个数字。
止血方案
线上故障已经发生时,先别急着“优雅重构”,先止血。
1. 限流
如果任务堆积速度太快,第一步通常是限制进入系统的请求量:
- 网关限流
- 业务接口降级
- 对大客户/高频请求做熔断保护
2. 临时缩短队列长度
如果当前队列无界,建议尽快改为有界。
宁可让部分请求快速失败,也不要让整个实例被拖死。
3. 快速降级
对非核心功能:
- 返回缓存数据
- 返回默认值
- 返回“稍后再试”
- 关闭可选增强逻辑
4. 拆线程池
如果多个模块共用一个池,先把最容易阻塞的那类任务拆出去。
这通常能明显改善“相互拖累”的问题。
5. 重启不是修复,但有时是必要操作
如果实例已经:
- 队列堆积严重
- Full GC 频繁
- RT 持续恶化
重启能暂时释放堆积对象,恢复服务能力。
但这只是争取时间,根因不改,下一波流量还会复发。
安全/性能最佳实践
这一部分给一些可直接落地的建议。
1. 线程池参数要基于业务类型设计
CPU 密集型
一般建议线程数接近 CPU 核数,例如:
NcpuNcpu + 1
IO 密集型
可以适当放大,但不要无限放大。
最终还是要根据:
- 下游 RT
- 机器核数
- 内存大小
- 峰值并发
- 压测结果
综合调整。
2. 一定使用有界队列
常见可选项:
ArrayBlockingQueue- 指定容量的
LinkedBlockingQueue
这样至少能保证:
- 内存上限更可控
- 能触发拒绝策略
- 能尽早暴露系统容量问题
3. 拒绝策略要和业务语义匹配
几种常见策略:
AbortPolicy:抛异常,适合调用方能明确兜底CallerRunsPolicy:让提交方执行,适合施加反压DiscardPolicy:直接丢弃,不建议用于关键业务DiscardOldestPolicy:丢最旧任务,需谨慎
我的经验是:
- 核心链路:优先显式拒绝 + 降级
- 非核心链路:可考虑限量丢弃
- 高吞吐服务:配合监控和告警,不要静默失败
4. 给线程池加监控埋点
至少暴露这些指标:
poolSize
activeCount
corePoolSize
maximumPoolSize
queueSize
remainingCapacity
taskCount
completedTaskCount
rejectCount
如果能接 Prometheus / Micrometer,会更容易发现趋势问题。
5. 不要“为了异步而异步”
很多接口写成:
- 主线程 submit 一个任务
- 然后立刻
future.get()
这种模式本质上并没有减少等待,只是多了一层线程切换和队列成本。
如果没有解耦价值,直接同步调用往往更简单、更稳。
6. 任务对象要尽量轻量
避免在任务里持有:
- 巨大的请求体
- 不必要的缓存引用
- 完整上下文对象
- 可延迟获取的大对象
因为一旦排队,这些对象都会跟着被“挂”在内存里。
7. 为异步任务设置边界
包括但不限于:
- 最大并发数
- 最大队列长度
- 最大等待时间
- 最大重试次数
- 明确取消策略
没有边界的异步,最终几乎都会演变成资源失控。
一个推荐的线程池配置模板
下面给一个更实用的线程池创建方法,可以在项目里做统一封装。
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
public class ThreadPoolFactory {
public static ThreadPoolExecutor newBizPool(
String poolName,
int coreSize,
int maxSize,
int queueCapacity) {
return new ThreadPoolExecutor(
coreSize,
maxSize,
60L,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(queueCapacity),
new NamedThreadFactory(poolName),
new LogAndAbortPolicy(poolName)
);
}
static class NamedThreadFactory implements ThreadFactory {
private final String poolName;
private final AtomicInteger counter = new AtomicInteger(1);
NamedThreadFactory(String poolName) {
this.poolName = poolName;
}
@Override
public Thread newThread(Runnable r) {
return new Thread(r, poolName + "-" + counter.getAndIncrement());
}
}
static class LogAndAbortPolicy implements RejectedExecutionHandler {
private final String poolName;
LogAndAbortPolicy(String poolName) {
this.poolName = poolName;
}
@Override
public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
System.err.printf(
"ThreadPool rejected, pool=%s, active=%d, queue=%d, taskCount=%d%n",
poolName,
executor.getActiveCount(),
executor.getQueue().size(),
executor.getTaskCount()
);
throw new RejectedExecutionException("Thread pool is full: " + poolName);
}
}
}
使用示例:
import java.util.concurrent.ThreadPoolExecutor;
public class ThreadPoolFactoryDemo {
public static void main(String[] args) {
ThreadPoolExecutor executor = ThreadPoolFactory.newBizPool(
"order-query",
8,
16,
200
);
executor.execute(() -> System.out.println(Thread.currentThread().getName() + " running"));
executor.shutdown();
}
}
排查清单
如果你已经怀疑是线程池误用,可以直接按这个清单过一遍:
1. 线程池是不是无界队列?
2. maximumPoolSize 是否实际上从未生效?
3. 接口线程是否 submit 后立刻 future.get()?
4. 队列长度是否持续增长?
5. 活跃线程数是否长期接近上限?
6. 是否多个不同业务共享同一个线程池?
7. 是否任务内嵌套提交同一线程池?
8. 是否有拒绝策略监控?
9. 是否存在大量 FutureTask / 队列节点对象?
10. 超时预算是否明显短于任务执行耗时?
总结
这类故障的核心不是“线程池不好用”,而是线程池非常容易被误用。
你可以记住这几个关键结论:
- 无界队列是高风险配置,容易把吞吐问题拖成内存问题
- 接口超时不一定是执行慢,很多时候是排队太久
- maximumPoolSize 不是总会生效,队列策略决定了扩容时机
- 异步如果马上 get,本质上可能只是更复杂的同步
- 线上治理重点是:有界、可观测、可拒绝、可降级
如果你现在就想做点实事,我建议按优先级执行:
- 先排查所有
Executors.newFixedThreadPool()的使用点 - 把核心线程池改成
ThreadPoolExecutor + 有界队列 - 给线程池补齐监控指标
- 为关键接口设计清晰的超时和降级策略
- 压测验证线程池容量,而不是靠经验值拍参数
很多线程池问题,开发阶段感觉不到;一到流量高峰,就会以“接口超时 + 内存飙升”的形式一起爆出来。
早点把边界收住,系统会稳很多。