背景与问题
线程池是 Java 服务里最常见、也最容易“看起来没问题,实际上很危险”的组件之一。
我自己踩过一个很典型的坑:业务高峰期,接口 RT 突然飙升,应用机器 CPU 不高,Full GC 也不明显,但请求就是越来越慢,最后部分接口直接超时。第一眼看监控,Tomcat 工作线程还活着,数据库连接池也没打满,缓存命中率正常,于是大家开始怀疑下游服务抖动。
结果最后定位下来,根因竟然是:线程池参数误配,导致任务大量堆积,队列拖垮了整个服务的响应链路。
这个问题难就难在它很“隐身”:
- CPU 不一定高
- JVM 不一定 OOM
- 日志里不一定直接报错
- 服务表面“还能用”,但延迟已经劣化
- 真正的故障点可能出现在异步化代码里,和超时报错的位置隔了好几层
这篇文章就按一次真实排障的思路来讲,重点回答几个问题:
- 线程池参数到底是怎么相互作用的?
- 为什么“线程数大一点、队列大一点”反而可能更危险?
- 出事故后该怎么快速止血?
- 后续怎么做治理,避免同类问题反复发生?
现象复现
先看一个非常常见的误配方式:
- 核心线程数:
4 - 最大线程数:
8 - 队列容量:
10000 - 拒绝策略:默认
AbortPolicy或者被业务吞掉异常 - 每个任务执行时间:
200ms ~ 2s
如果此时瞬时流量上来,任务提交速度远大于任务处理速度,那么会发生什么?
答案是:线程池不会优先扩容到最大线程数,而是会优先把任务往队列里塞。
如果队列又很大,就会造成任务海量堆积,排队时间远大于实际执行时间,最终接口超时、调用链雪崩。
一个简化的事故链路
flowchart TD
A[流量突增] --> B[业务线程提交异步任务]
B --> C[线程池核心线程很快占满]
C --> D[大容量阻塞队列持续堆积]
D --> E[任务等待时间急剧增加]
E --> F[上游请求超时]
F --> G[重试/补偿再次提交任务]
G --> D
D --> H[服务整体RT恶化]
核心原理
很多线上事故,本质上不是不会用线程池,而是对 ThreadPoolExecutor 的工作机制理解得不够细。
ThreadPoolExecutor 的关键参数
new ThreadPoolExecutor(
corePoolSize,
maximumPoolSize,
keepAliveTime,
unit,
workQueue,
threadFactory,
handler
)
几个最关键的参数:
corePoolSize:核心线程数maximumPoolSize:最大线程数workQueue:阻塞队列handler:拒绝策略
提交任务时的执行规则
线程池接收任务时,大致遵循下面这个顺序:
- 如果运行中的线程数
< corePoolSize,创建新线程执行任务 - 否则尝试把任务放进队列
- 如果队列已满,且运行中的线程数
< maximumPoolSize,继续创建线程 - 如果队列满了、线程也到上限了,执行拒绝策略
这个顺序非常关键。
也就是说,只要队列没满,线程池通常不会扩容到 maximumPoolSize。
这就是很多人误解的地方:
他们以为“我最大线程数配了 64,高峰来了会自动顶上去”。
实际上如果你配了一个巨大的 LinkedBlockingQueue,线程池可能永远只跑在 corePoolSize 附近,剩下的任务全部在队列里慢慢排队。
用图看更直观
flowchart LR
A[提交任务] --> B{worker < corePoolSize?}
B -- 是 --> C[创建核心线程执行]
B -- 否 --> D{队列可入队?}
D -- 是 --> E[任务进入阻塞队列]
D -- 否 --> F{worker < maximumPoolSize?}
F -- 是 --> G[创建非核心线程执行]
F -- 否 --> H[触发拒绝策略]
为什么大队列危险
大队列的问题不只是“占内存”,更严重的是它会掩盖系统过载信号:
- 请求本该快速失败,却变成长时间等待
- 问题本该在入口被限流,却被延迟放大到整个调用链
- 任务上下文对象长期堆积,增加 GC 压力
- 超时后上游重试,进一步放大任务量
换句话说:
大队列不是缓冲一切的保险箱,而是把系统过载从“明显失败”变成“缓慢死亡”。
定位路径
线上排查时,我一般按下面这条路径走,效率会比较高。
1. 先判断是“执行慢”还是“排队久”
这是最重要的一步。
如果任务本身执行慢,方向应该去看:
- SQL 慢查询
- 下游 RPC 抖动
- 锁竞争
- I/O 阻塞
如果任务执行本身不慢,但排队久,那就大概率是线程池参数、流量模型或任务提交策略的问题。
建议打的监控指标
至少要有:
activeCountpoolSizecorePoolSizemaximumPoolSizequeueSizecompletedTaskCounttaskCount- 任务平均执行时长
- 任务平均排队时长
- 拒绝次数
很多团队只监控“线程池活跃线程数”,这远远不够。
没有队列长度和等待时长,你几乎看不到真正的问题。
2. 通过 jstack 看线程状态
如果线程池线程都在干活,可以从线程栈里看到:
- 卡在
socketRead - 卡在数据库驱动
- 卡在某个锁上
- 卡在
Future.get()
如果线程数不多、但队列巨大,那线程栈里反而看不到特别明显的“卡死”现象,因为很多任务还没轮到执行。
3. 看线程池配置和队列实现
这里最容易踩坑的几个组合:
corePoolSize很小 +LinkedBlockingQueue很大- 业务线程池和公共线程池混用
CallerRunsPolicy用在核心链路却没评估回压后果- 线程池里执行的任务又同步等待另一个线程池结果,形成“池中池”阻塞
4. 结合流量变化和超时重试看放大效应
很多事故不是第一次流量突增就挂,而是这个过程:
- 突增流量进入
- 线程池排队
- 上游超时
- 上游重试
- 任务量继续上升
- 队列进一步堆积
这类问题如果不从“系统整体吞吐”去看,很容易误判成单点抖动。
实战代码(可运行)
下面用一个可运行的小例子,模拟“线程池大队列导致排队堆积”的问题。
错误示例:大队列掩盖过载
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
public class BadThreadPoolDemo {
public static void main(String[] args) throws InterruptedException {
ThreadPoolExecutor executor = new ThreadPoolExecutor(
4,
8,
60,
TimeUnit.SECONDS,
new LinkedBlockingQueue<>(10000),
new NamedThreadFactory("bad-pool"),
new ThreadPoolExecutor.AbortPolicy()
);
ScheduledExecutorService monitor = Executors.newSingleThreadScheduledExecutor();
monitor.scheduleAtFixedRate(() -> {
System.out.printf(
"[MONITOR] poolSize=%d, active=%d, queue=%d, completed=%d, task=%d%n",
executor.getPoolSize(),
executor.getActiveCount(),
executor.getQueue().size(),
executor.getCompletedTaskCount(),
executor.getTaskCount()
);
}, 0, 1, TimeUnit.SECONDS);
// 模拟突发提交 2000 个慢任务
for (int i = 0; i < 2000; i++) {
final int taskId = i;
executor.submit(() -> {
long start = System.currentTimeMillis();
try {
// 模拟业务执行耗时
Thread.sleep(500);
System.out.printf("task-%d done by %s cost=%dms%n",
taskId,
Thread.currentThread().getName(),
System.currentTimeMillis() - start);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
Thread.sleep(15000);
executor.shutdown();
monitor.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) {
return new Thread(r, prefix + "-" + counter.getAndIncrement());
}
}
}
这个示例会暴露什么问题
你会看到:
poolSize很可能长期只有4queue快速增长到很大- 任务完成速率很低
- 整体处理时间远超预期
这就是典型的:最大线程数看起来配了 8,但实际上大多数时候根本没机会扩容。
改进示例:小队列 + 明确拒绝 + 回压思路
对于很多 I/O 型但不能无限排队的业务,更合理的做法通常是:
- 队列不要太大
- 要有明确拒绝策略
- 上游感知失败并降级/限流
- 监控拒绝次数和等待时间
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
public class BetterThreadPoolDemo {
public static void main(String[] args) throws InterruptedException {
ThreadPoolExecutor executor = new ThreadPoolExecutor(
8,
16,
60,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(100),
new NamedThreadFactory("better-pool"),
new LoggingRejectHandler()
);
ScheduledExecutorService monitor = Executors.newSingleThreadScheduledExecutor();
monitor.scheduleAtFixedRate(() -> {
System.out.printf(
"[MONITOR] poolSize=%d, active=%d, queue=%d, completed=%d, task=%d%n",
executor.getPoolSize(),
executor.getActiveCount(),
executor.getQueue().size(),
executor.getCompletedTaskCount(),
executor.getTaskCount()
);
}, 0, 1, TimeUnit.SECONDS);
for (int i = 0; i < 1000; i++) {
final int taskId = i;
try {
executor.execute(() -> {
try {
Thread.sleep(300);
System.out.printf("task-%d handled by %s%n",
taskId,
Thread.currentThread().getName());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
} catch (RejectedExecutionException e) {
System.out.printf("task-%d rejected, do fallback%n", taskId);
}
}
Thread.sleep(10000);
executor.shutdown();
monitor.shutdown();
}
static class LoggingRejectHandler implements RejectedExecutionHandler {
@Override
public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
throw new RejectedExecutionException(
"Task rejected. poolSize=" + executor.getPoolSize()
+ ", 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) {
return new Thread(r, prefix + "-" + counter.getAndIncrement());
}
}
}
改进点说明
这个版本不是“绝不丢任务”,而是强调:
- 系统容量有限时,要尽快暴露过载
- 拒绝要可观测
- 失败要有业务兜底
这比把任务全塞进队列里等死要健康得多。
常见坑与排查
下面这些坑,基本都是线上高频事故源。
坑 1:误以为 maximumPoolSize 一定会生效
这是最常见的认知偏差。
如果你用的是大容量 LinkedBlockingQueue,那线程池往往更愿意排队,而不是扩容。
所以 maximumPoolSize 可能长期只是一个“心理安慰值”。
排查方式
- 看
poolSize是否接近corePoolSize - 看
queueSize是否持续上涨 - 看高峰期是否几乎从未扩到
maximumPoolSize
坑 2:线程池任务里嵌套等待另一个线程池
比如:
- A 线程池执行任务
- 任务里调用
future.get() - 结果依赖 B 线程池
- B 线程池又被别的任务占满
或者更糟,A 池里的任务又依赖 A 池自身的其他任务。
这类问题特别像“假死”。
sequenceDiagram
participant Req as 请求线程
participant PoolA as 线程池A
participant PoolB as 线程池B
Req->>PoolA: 提交任务A1
PoolA->>PoolB: 提交子任务B1
PoolA->>PoolB: Future.get()等待结果
Note over PoolB: PoolB已满或排队严重
PoolB-->>PoolA: 结果迟迟不返回
PoolA-->>Req: 请求超时
排查方式
- 搜索
Future.get()、join()、CountDownLatch.await() - 看是否发生在线程池工作线程中
- 检查依赖线程池之间是否有循环等待
坑 3:使用无界队列
像 Executors.newFixedThreadPool() 默认就可能让人掉坑里。
它底层使用的是无界 LinkedBlockingQueue。
这意味着:
- 任务堆积几乎没有硬上限
- 内存压力会越来越大
- 风险从“拒绝”转成“积压 + OOM + 超时”
排查方式
- 不要只看工厂方法名
- 一定要展开看底层构造参数
- 明确队列是否有界
坑 4:拒绝策略选错位置
CallerRunsPolicy 经常被说成“优雅降级”,但它不是银弹。
它的效果是:提交任务的线程自己去执行任务。
如果提交者是业务请求线程,那就意味着:
- 请求线程被拖慢
- 入口吞吐下降
- 形成天然回压
这在某些场景是好事,但如果你的请求线程非常宝贵,或者任务很慢,就可能把入口也一起拖死。
建议
- 核心链路要谨慎使用
- 一定结合压测评估
- 搭配超时、限流、熔断一起看
坑 5:把 CPU 密集型和 I/O 密集型任务放在同一个池
这会导致资源争抢和调优失焦。
比如:
- 图片压缩、加密、规则计算属于 CPU 密集型
- RPC、DB、HTTP 调用属于 I/O 密集型
两类任务混放后:
- 线程数很难统一设置
- 监控指标失真
- 故障影响面扩大
建议
按任务类型拆池,至少做到:
- 核心业务池
- I/O 异步池
- 批处理/低优先级池
止血方案
如果线上已经出现问题,不要一上来就“拍脑袋调大线程数”。
我更建议按下面顺序止血。
1. 优先减少流量放大
先做这些:
- 关闭非必要重试
- 下调消息拉取速率
- 暂时关闭低优先级异步任务
- 对高风险接口做限流
原因很简单:
如果任务生产速度不降,再怎么调线程池都只是延后崩盘。
2. 缩短任务执行时间
检查任务里有没有:
- 不必要的串行调用
- 没有超时设置的 RPC/HTTP
- 大对象构造和复制
- 长事务
- 锁粒度过大
3. 适度调整线程池参数
这里有边界条件:
- CPU 密集型任务不能盲目加线程,否则上下文切换更重
- I/O 密集型任务可以适度增线程,但前提是下游扛得住
- 队列应该小而可控,不宜无限放大
4. 显式失败,不要静默积压
如果业务允许,宁可:
- 快速拒绝
- 快速降级
- 返回“稍后重试”
也不要让请求卡 30 秒后再失败。
安全/性能最佳实践
这部分我更倾向于给“能落地”的建议,而不是一句“根据业务调整”。
1. 永远手动创建线程池,不用 Executors 默认工厂
推荐显式配置:
- 线程数
- 有界队列
- 线程名
- 拒绝策略
- 监控埋点
推荐模板
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
public class ThreadPoolFactory {
public static ThreadPoolExecutor createIoPool(String poolName) {
return new ThreadPoolExecutor(
16,
32,
60L,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(200),
new NamedThreadFactory(poolName),
new ThreadPoolExecutor.AbortPolicy()
);
}
static class NamedThreadFactory implements ThreadFactory {
private final String prefix;
private final AtomicInteger index = new AtomicInteger(1);
NamedThreadFactory(String prefix) {
this.prefix = prefix;
}
@Override
public Thread newThread(Runnable r) {
Thread t = new Thread(r, prefix + "-" + index.getAndIncrement());
t.setDaemon(false);
t.setUncaughtExceptionHandler((thread, ex) ->
System.err.println("uncaught exception in " + thread.getName() + ": " + ex.getMessage()));
return t;
}
}
}
2. 给任务设置“排队时长”监控
很多系统只监控执行时长,这是不够的。
建议任务对象里记录提交时间:
import java.util.concurrent.*;
public class QueueTimeDemo {
static class TimedTask implements Runnable {
private final long submitTime = System.currentTimeMillis();
private final Runnable delegate;
TimedTask(Runnable delegate) {
this.delegate = delegate;
}
@Override
public void run() {
long queueCost = System.currentTimeMillis() - submitTime;
System.out.println("queue wait = " + queueCost + " ms");
delegate.run();
}
}
public static void main(String[] args) {
ThreadPoolExecutor executor = new ThreadPoolExecutor(
2, 4, 60, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(10)
);
executor.execute(new TimedTask(() -> {
try {
Thread.sleep(200);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}));
executor.shutdown();
}
}
当你发现:
- 执行时长还行
- 但排队时长暴涨
那问题方向就非常明确了。
3. 线程池参数要和业务模型绑定
线程池不是独立调优项,它必须结合下面这些量一起看:
- 峰值 QPS
- 单任务平均耗时 / P99 耗时
- 下游超时阈值
- 可接受排队时间
- 是否允许丢弃
- 是否允许重试
一个粗略思路:
线程数决定并发处理能力,队列容量决定可接受瞬时缓冲,拒绝策略决定过载时系统表现。
如果这三者和业务 SLA 没绑定,参数基本就是“拍出来的”。
4. 为不同池配置不同治理策略
建议至少区分:
| 线程池类型 | 典型任务 | 建议 |
|---|---|---|
| 请求核心池 | 直接影响用户响应 | 小队列、强监控、快速失败 |
| I/O 异步池 | RPC/HTTP/DB 辅助任务 | 可适度增线程,严格超时 |
| 批处理池 | 导出、补偿、对账 | 独立资源隔离,限速执行 |
| 定时任务池 | 周期任务 | 避免与在线请求共池 |
5. 做线程池“可观测化”
我现在看一个服务是否健康,线程池除了配置,还会看它有没有这些能力:
- 指标上报:活跃线程、队列长度、拒绝次数、排队时长
- 日志打点:拒绝时打印池状态
- 告警规则:队列持续增长、拒绝突增、排队时间超阈值
- 动态配置:部分参数支持热更新,但要谨慎验证
一次典型排障流程模板
如果你要把它变成团队 SOP,可以参考下面这张图。
flowchart TD
A[接口RT飙升/超时告警] --> B{线程池监控是否异常?}
B -- 是 --> C[看activeCount poolSize queueSize rejectCount]
C --> D{执行慢还是排队久?}
D -- 执行慢 --> E[查SQL RPC 锁 I/O 超时]
D -- 排队久 --> F[查core/max/queue/reject配置]
F --> G[判断是否大队列掩盖过载]
G --> H[限流 降级 关闭重试]
H --> I[调整线程池参数并压测验证]
B -- 否 --> J[排查GC 网络 下游服务]
总结
这次线程池事故给我的一个很深的教训是:
线程池配置不是“性能优化细节”,而是系统稳定性的第一道闸门。
最后给几个可以直接执行的建议:
- 不要使用默认
Executors工厂创建业务线程池 - 优先使用有界队列,不要迷信大队列抗流量
- 核心监控一定补齐:活跃线程、队列长度、拒绝次数、排队时长
- 区分任务类型,避免 CPU 与 I/O 任务混池
- 过载时优先限流、降级、快速失败,不要静默堆积
- 压测时不仅看吞吐,也看线程池队列和等待时间
- 凡是在线程池里
get()/join()/await()的代码,都值得重点审查
如果你现在的服务里还有“核心线程 8、最大线程 64、队列 10000”这种配置,建议尽快重新评估。
它不一定今天出事,但一旦在高峰或下游抖动时碰上,往往就是那种最难排、最容易误判的线上事故。