背景与问题
线上系统一旦出现“请求越来越慢、CPU 不高、机器也没打满,但接口还是超时”的情况,我第一反应往往不是去看数据库,而是先看线程池。
原因很简单:很多 Java 服务把异步任务、批量处理、RPC 回调、MQ 消费都塞进线程池。一旦参数配置不合适,或者任务本身阻塞,线程池就会变成一个“吞任务但吐不出来”的黑洞。表面看只是慢,实质上是任务堆积、响应放大、上下游雪崩。
这类问题很常见,典型现象包括:
- 接口 RT 持续升高
- 日志里出现大量超时
- 线程池队列长度不断上涨
- 活跃线程数打满,但吞吐没有增长
- Full GC 变频繁,甚至 OOM
- 调用链上游开始重试,导致问题进一步恶化
很多同学调线程池时喜欢“先把核心线程数调大”。这有时有效,但也很容易把问题从“慢”调成“更慢”。因为线程池不是越大越好,它本质上是在平衡三件事:
- 并发度
- 排队长度
- 任务处理时间
如果不理解这些关系,调参基本靠运气。
本文我会从原理、复现、定位路径、止血方案、参数调优思路几个角度,把线程池任务堆积这件事讲清楚,并给出一套可运行代码,方便你自己验证。
核心原理
先看 ThreadPoolExecutor 的几个关键参数:
public ThreadPoolExecutor(
int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler
)
它们的作用并不只是“字面意思”,更重要的是组合行为。
1. 线程池的任务接收流程
线程池接到任务时,执行顺序大致如下:
- 如果运行线程数
< corePoolSize,直接创建核心线程执行 - 否则尝试进入阻塞队列
workQueue - 如果队列满了,且线程数
< maximumPoolSize,再创建非核心线程执行 - 如果队列也满、线程也到上限,触发拒绝策略
这四步决定了“线程池会优先扩线程,还是优先排队”。
flowchart TD
A[提交任务] --> B{当前线程数 < corePoolSize?}
B -- 是 --> C[创建核心线程执行]
B -- 否 --> D{队列可入队?}
D -- 是 --> E[任务进入队列等待]
D -- 否 --> F{当前线程数 < maximumPoolSize?}
F -- 是 --> G[创建非核心线程执行]
F -- 否 --> H[执行拒绝策略]
2. 为什么很多线程池“看起来配了 maximumPoolSize,却根本没生效”?
这是最常见的坑之一。
比如你用了:
corePoolSize = 10maximumPoolSize = 100workQueue = new LinkedBlockingQueue<>()
注意这里的 LinkedBlockingQueue 如果不指定容量,默认是近似无界队列。结果是什么?
- 核心线程满了以后,任务几乎都会进队列
- 队列很难满
maximumPoolSize基本没有机会发挥作用
也就是说,你以为自己配的是“10 到 100 的弹性线程池”,实际上可能是:
- 永远只有 10 个线程在跑
- 剩下几万任务在队列里排队
这就是很多“线程池没打满但任务堆积严重”的根源。
3. 任务堆积到底意味着什么?
线程池堆积,本质上是这个不等式长期成立:
任务到达速度 > 任务处理速度
更具体一点:
- 提交速率:每秒进来多少任务
- 单任务耗时:平均处理时间 / TP99 处理时间
- 实际并发处理能力:线程数 × 单线程吞吐
只要生产速度持续高于消费速度,队列就会涨。
这和 MQ 消费积压一个道理,只不过这里的“队列”是 JVM 内存里的阻塞队列。
4. 线程池参数与任务类型的关系
不是所有任务都适合同一套参数。
CPU 密集型任务
比如:
- JSON 序列化
- 加密解密
- 图像处理
- 复杂计算
特点:
- 线程大部分时间都在使用 CPU
- 线程数过多会造成频繁上下文切换
一般建议:
- 线程数接近
CPU 核数或CPU 核数 + 1
IO 密集型任务
比如:
- 数据库查询
- HTTP 调用
- 文件读写
- Redis 网络等待
特点:
- 线程经常阻塞等待 IO
- 可以适当提高线程数来覆盖等待时间
一般建议:
- 线程数可以高于 CPU 核数很多,但必须结合实际阻塞比例验证
一个常用的粗略估算公式:
最优线程数 ≈ CPU 核数 × (1 + 等待时间 / 计算时间)
这不是金科玉律,但做初始估算很有用。
现象复现
先写一个最小可运行示例,模拟“线程池配置不当导致任务堆积”。
这个例子里我们故意做几件危险的事:
- 核心线程数小
- 队列容量大
- 任务处理慢
- 提交速率快
示例 1:容易堆积的线程池
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
public class ThreadPoolBacklogDemo {
public static void main(String[] args) throws Exception {
ThreadPoolExecutor executor = new ThreadPoolExecutor(
2,
8,
60,
TimeUnit.SECONDS,
new LinkedBlockingQueue<>(1000),
new NamedThreadFactory("demo-pool"),
new ThreadPoolExecutor.AbortPolicy()
);
// 每 100ms 提交 20 个任务,每个任务执行 1s
ScheduledExecutorService producer = Executors.newSingleThreadScheduledExecutor();
producer.scheduleAtFixedRate(() -> {
for (int i = 0; i < 20; i++) {
try {
executor.execute(() -> {
try {
Thread.sleep(1000); // 模拟慢任务
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
} catch (RejectedExecutionException e) {
System.out.println("任务被拒绝: " + e.getMessage());
}
}
}, 0, 100, TimeUnit.MILLISECONDS);
// 每秒打印一次线程池状态
ScheduledExecutorService monitor = Executors.newSingleThreadScheduledExecutor();
monitor.scheduleAtFixedRate(() -> {
System.out.printf(
"poolSize=%d, active=%d, queue=%d, completed=%d%n",
executor.getPoolSize(),
executor.getActiveCount(),
executor.getQueue().size(),
executor.getCompletedTaskCount()
);
}, 0, 1, TimeUnit.SECONDS);
Thread.sleep(20000);
producer.shutdownNow();
monitor.shutdownNow();
executor.shutdownNow();
}
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;
}
}
}
你会看到什么?
这个配置下,大概率会看到:
active长期接近核心线程数附近queue持续上涨- 完成数增长很慢
- 最后队列满了,开始拒绝任务
这说明问题不是“线程池没收到任务”,而是“处理不过来”。
定位路径
线上排查时,我通常不建议一上来就改参数。先确认:是线程池配置问题,还是任务本身变慢了?
下面是一条实用的排查路径。
1. 先看线程池运行指标
最少要关注这些指标:
poolSize:当前线程数activeCount:活跃线程数queueSize:队列长度completedTaskCount:累计完成任务数taskCount:累计提交任务数largestPoolSize:历史峰值线程数rejectCount:拒绝次数,自行埋点统计
如果没有监控,最差也要临时打印或暴露到日志。
指标判断经验
| 现象 | 可能原因 |
|---|---|
| 活跃线程不高,队列却很长 | 队列过大、核心线程太少、maximumPoolSize失效 |
| 活跃线程打满,队列持续增长 | 任务处理速度不足 |
| 线程数很多,CPU 却不高 | 大量线程阻塞在 IO / 锁等待 |
| 队列不长,但大量拒绝 | 队列太小或突发流量过高 |
| 完成数突然变慢 | 下游依赖变慢、锁竞争、GC、外部资源抖动 |
2. 再看线程栈,判断线程在干什么
线程池堆积时,jstack 非常重要。重点看业务线程在什么状态:
RUNNABLEWAITINGTIMED_WAITINGBLOCKED
常见信号
大量线程卡在网络调用
java.net.SocketInputStream.socketRead0
sun.nio.ch.SocketChannelImpl.read
okhttp3.internal.connection.RealCall
通常说明:
- 下游接口慢
- 连接池不足
- 网络抖动
大量线程卡在锁竞争
java.lang.Object.wait
java.util.concurrent.locks.ReentrantLock$NonfairSync
通常说明:
- 共享资源争用严重
- 任务内部串行化过多
大量线程卡在数据库
com.mysql.cj.jdbc.ClientPreparedStatement.execute
oracle.jdbc.driver.T4CPreparedStatement.executeForRows
通常说明:
- SQL 慢
- 连接池不够
- 数据库本身有瓶颈
3. 观察任务耗时分布,而不是只看平均值
很多系统平均耗时不高,但 TP99 很高。线程池最怕这种长尾任务。
例如:
- 90% 任务耗时 50ms
- 10% 任务耗时 5s
如果长尾任务一多,线程就会被拖住,队列迅速堆积。
所以排查时请至少拿到:
- 平均耗时
- TP95 / TP99
- 超时比例
- 成功率
4. 区分“持续堆积”和“瞬时尖峰”
这个区分很关键。
- 瞬时尖峰:短时间流量高,队列升高后能回落
- 持续堆积:队列只涨不掉,说明长期产能不足
前者主要靠削峰和缓冲; 后者必须解决容量或慢任务问题。
sequenceDiagram
participant Client as 上游请求
participant Pool as 线程池
participant Queue as 阻塞队列
participant Worker as 工作线程
participant Downstream as 下游服务/DB
Client->>Pool: 提交任务
alt 核心线程未满
Pool->>Worker: 创建线程立即执行
else 核心线程已满
Pool->>Queue: 任务入队
end
Worker->>Downstream: 发起调用/执行任务
Downstream-->>Worker: 慢响应/阻塞
Worker-->>Pool: 线程长期占用
Queue-->>Pool: 等待任务越来越多
实战代码(可运行)
下面给一个更贴近线上使用的线程池包装类,包含:
- 有界队列
- 自定义线程工厂
- 拒绝统计
- 简单监控输出
- 提交超时任务示例
示例 2:一个更可控的线程池封装
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicInteger;
public class TunedThreadPoolExample {
public static void main(String[] args) throws InterruptedException {
AtomicLong rejectCounter = new AtomicLong();
ThreadPoolExecutor executor = new ThreadPoolExecutor(
8,
16,
30,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(200),
new NamedThreadFactory("biz-worker"),
(r, ex) -> {
rejectCounter.incrementAndGet();
throw new RejectedExecutionException("线程池已满,任务被拒绝");
}
);
// 允许核心线程超时可按场景开启,这里默认不开
// executor.allowCoreThreadTimeOut(true);
ScheduledExecutorService monitor = Executors.newSingleThreadScheduledExecutor(
new NamedThreadFactory("monitor")
);
monitor.scheduleAtFixedRate(() -> {
System.out.printf(
"[MONITOR] poolSize=%d, active=%d, core=%d, max=%d, queue=%d, completed=%d, task=%d, reject=%d%n",
executor.getPoolSize(),
executor.getActiveCount(),
executor.getCorePoolSize(),
executor.getMaximumPoolSize(),
executor.getQueue().size(),
executor.getCompletedTaskCount(),
executor.getTaskCount(),
rejectCounter.get()
);
}, 0, 1, TimeUnit.SECONDS);
// 模拟提交任务
for (int i = 0; i < 500; i++) {
final int taskId = i;
try {
executor.submit(() -> {
long start = System.currentTimeMillis();
try {
// 模拟混合型任务:部分任务快,部分任务慢
if (taskId % 10 == 0) {
Thread.sleep(2000);
} else {
Thread.sleep(200);
}
System.out.printf("task-%d done, cost=%dms, thread=%s%n",
taskId,
System.currentTimeMillis() - start,
Thread.currentThread().getName());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
} catch (RejectedExecutionException e) {
System.out.println("submit failed: " + e.getMessage());
}
}
Thread.sleep(15000);
monitor.shutdown();
executor.shutdown();
executor.awaitTermination(10, TimeUnit.SECONDS);
}
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.setUncaughtExceptionHandler((thread, ex) ->
System.err.println("线程异常: " + thread.getName() + ", " + ex.getMessage()));
return t;
}
}
}
这个版本有几个点值得注意:
ArrayBlockingQueue是有界队列,避免无限堆积把内存吃光- 拒绝策略里做了显式统计,便于监控告警
- 通过线程命名,
jstack时能快速找到业务线程 - 快慢任务混合,便于观察长尾任务对线程池的影响
参数调优方法:不要拍脑袋
线程池调优最怕一句话:“先把核心线程改成 200 试试。”
更稳妥的方法是按下面顺序来。
1. 先识别任务类型
先问自己几个问题:
- 任务是 CPU 密集还是 IO 密集?
- 是否依赖数据库、Redis、HTTP、MQ?
- 是否存在锁竞争?
- 单任务平均耗时和 TP99 分别是多少?
如果这一步没搞清楚,后面的参数几乎都是盲调。
2. 再决定队列策略
有界队列优先
绝大多数业务系统,建议优先用有界队列:
ArrayBlockingQueueLinkedBlockingQueue(capacity)
原因:
- 可以控制内存风险
- 可以体现背压
- 可以尽早暴露处理能力问题
无界队列谨慎使用
无界队列适合:
- 明确知道峰值很小
- 任务非常轻量
- 可容忍排队延迟
- 已有其他限流机制
否则容易出现:
- 任务越积越多
- 内存上涨
- GC 压力变大
- 故障恢复时间变长
3. 核心线程与最大线程怎么定?
CPU 密集型
可以从下面起步:
corePoolSize = CPU核数
maximumPoolSize = CPU核数 + 1
IO 密集型
可按阻塞比例估算,再结合压测修正:
corePoolSize = CPU核数 * 2 ~ 4
maximumPoolSize = corePoolSize * 2
但这里有边界条件:
- 下游服务有限流时,线程数过大只会放大超时
- 数据库连接池小于线程池时,线程再多也没用
- 容器 CPU 配额不足时,按宿主机核数计算会失真
4. 拒绝策略不要随便选
JDK 内置拒绝策略有四个:
AbortPolicy:直接抛异常CallerRunsPolicy:调用线程自己执行DiscardPolicy:静默丢弃DiscardOldestPolicy:丢弃最旧任务
怎么选?
AbortPolicy
适合:
- 需要明确失败
- 希望快速发现系统超载
这是我最常用的默认值。
CallerRunsPolicy
适合:
- 调用方线程可接受被拖慢
- 希望形成自然背压
但注意:如果提交线程是 Web 请求线程,可能把接口 RT 一起拖爆。
DiscardPolicy
除非任务天然可丢,比如某些低价值埋点,否则慎用。
DiscardOldestPolicy
对顺序敏感或时序敏感任务通常不合适,也要谨慎。
常见坑与排查
这一节我尽量写得“接地气”一点,很多都是线上真会踩到的。
坑 1:线程池用了无界队列,maximumPoolSize 形同虚设
表现
- 线程数一直上不去
- 队列越积越长
- 内存持续上涨
排查
看线程池构造:
new LinkedBlockingQueue<>()
如果没写容量,先警惕。
解决
改为有界队列,并重新评估:
corePoolSizemaximumPoolSize- 拒绝策略
- 调用方限流
坑 2:任务里存在同步等待,线程池自己把自己卡死
比如在线程池任务中又提交子任务到同一个线程池,然后 get() 等待结果。
Future<String> future = executor.submit(() -> "sub-task");
String result = future.get();
如果线程池线程已经被占满,而子任务又排在队列里,就可能形成“线程等线程”的死锁式阻塞。
解决
- 避免在同一线程池中递归提交并同步等待
- 拆分线程池
- 改成异步编排
- 给
get()设置超时
坑 3:任务内部做了超长阻塞,但没有超时控制
常见于:
- HTTP 调用没设超时
- 数据库查询缺超时
- 外部接口偶发卡死
表现
- 线程数满了
- CPU 低
- 线程栈大量
TIMED_WAITING/ 网络读阻塞
解决
必须给外部依赖设置:
- 连接超时
- 读取超时
- 总超时
并在业务上设计超时后的降级策略。
坑 4:多个业务共用同一个线程池
这是我非常不建议的做法。
比如:
- 发邮件
- 刷缓存
- 下游回调
- 批处理
全混在一个线程池里。结果某一类慢任务一堆积,其他业务全部被拖死。
解决
按业务隔离线程池,至少分成:
- 核心链路线程池
- 非核心异步线程池
- 慢 IO 专用线程池
classDiagram
class CoreRequestPool {
+处理核心请求
+低延迟
}
class AsyncNotifyPool {
+处理通知/回调
+可降级
}
class SlowIOPool {
+处理慢IO任务
+隔离阻塞风险
}
坑 5:只盯线程池,不看下游容量
线程池经常只是“症状放大器”,真正的问题在下游:
- 数据库连接池只有 20,你线程池开 200
- 下游接口 QPS 上限 100,你本地线程池并发 500
- Redis 连接数不够,任务都阻塞在取连接上
解决
调线程池之前,先看这些资源上限:
- DB 连接池大小
- HTTP 连接池大小
- 下游限流阈值
- MQ 消费速率
- 容器 CPU / 内存限额
止血方案
当线上已经出现堆积,不一定能马上根治,这时先考虑“止血”。
1. 临时限流
最有效的止血手段之一,就是让任务进入速度先降下来。
适用场景:
- 接口高峰突增
- 下游依赖变慢
- 拒绝数持续增加
可做法:
- 网关限流
- 业务入口令牌桶
- MQ 消费速率控制
- 降低批量任务并发
2. 任务降级或拆级
把任务分为:
- 必须执行
- 可延迟执行
- 可丢弃执行
当线程池压力过高时:
- 核心任务保留
- 非核心任务转异步补偿
- 低价值任务直接丢弃
3. 缩短任务执行时间
如果能快速做这些事,收益通常很高:
- 降低单次批量处理大小
- 给外部调用加超时
- 减少不必要的串行逻辑
- 去掉任务中的大对象构造与频繁日志
4. 临时扩容,但要有边界
扩容线程池不是不能做,而是要看:
- CPU 是否还有余量
- 下游是否扛得住
- 队列是否已经过大
- 是否会引发更多上下文切换
我的建议是:
- 先小步增大
- 配合监控观察
- 一次只改少量参数
- 不能把线程池调参当万能药
stateDiagram-v2
[*] --> 正常
正常 --> 轻度堆积: 队列持续增长
轻度堆积 --> 中度堆积: 活跃线程打满
中度堆积 --> 严重堆积: 拒绝/超时增加
严重堆积 --> 止血中: 限流/降级/扩容
止血中 --> 恢复观察: 队列回落
恢复观察 --> 正常
安全/性能最佳实践
这里的“安全”更多指系统稳定性安全,而不只是传统安全漏洞。
1. 永远给队列设置上限
这是防止 JVM 内存被任务队列拖垮的底线。
推荐:
new ArrayBlockingQueue<>(N)
或者
new LinkedBlockingQueue<>(N)
其中 N 不是越大越好,而是要结合:
- 可接受排队时长
- 单任务内存占用
- 峰值流量
- 故障恢复时间
2. 给任务加超时意识
线程池只负责调度,不会自动帮你“杀死慢任务”。
所以要在任务内控制:
- RPC 超时
- DB 超时
- Future 超时
- 外部命令执行超时
3. 为线程池做监控与告警
至少监控:
- 活跃线程数
- 队列长度
- 拒绝次数
- 任务耗时分位值
- 完成速率
- 提交速率
告警最好不是只看单点值,而是看趋势,比如:
- 队列长度连续 5 分钟增长
- 拒绝次数一分钟内超过阈值
- 完成速率持续低于提交速率
4. 线程池要命名,便于排障
线程名是线上排查的“路标”。
建议命名包含:
- 业务域
- 线程池用途
- 实例编号
例如:
order-notify-pool-1
risk-check-pool-3
5. 区分任务优先级
高优任务和低优任务不要混跑。
否则一个导出报表之类的慢任务,很容易把核心交易逻辑拖垮。实践中宁可多建几个线程池,也不要盲目大一统。
6. 在线程池外做背压,而不是只靠拒绝策略
更稳定的方式通常是:
- 入口限流
- 熔断降级
- MQ 削峰
- 批量合并
- 舱壁隔离
线程池拒绝策略是最后一道防线,不应成为第一道治理手段。
一份实用排查清单
如果你现在就在线上排一个线程池堆积问题,可以按这个顺序走:
第一步:看指标
- 队列是否持续增长?
- 活跃线程是否打满?
- 完成速率是否低于提交速率?
- 是否出现拒绝?
第二步:看配置
- 队列是否无界?
corePoolSize是否过小?maximumPoolSize是否根本触发不到?- 拒绝策略是否合理?
第三步:看线程栈
- 卡在网络?
- 卡在数据库?
- 卡在锁?
- 卡在 Future 等待?
第四步:看下游资源
- 数据库连接池够吗?
- HTTP 连接池够吗?
- 下游限流了吗?
- Redis / MQ 是否抖动?
第五步:止血
- 临时限流
- 非核心任务降级
- 缩短超时
- 小步扩容
第六步:复盘与固化
- 补监控
- 补线程命名
- 拆分线程池
- 压测验证新参数
总结
线程池调优这件事,核心不是把数字调大,而是搞清楚这三个问题:
- 任务是什么类型,耗时结构怎样
- 线程池是优先排队还是优先扩线程
- 真正的瓶颈在线程池本身,还是下游依赖
如果你只记住几条最重要的建议,我会给这几条:
- 优先使用有界队列
- 不要迷信
maximumPoolSize,先看队列类型 - 线程池问题排查一定要结合 监控 + jstack + 下游容量
- 慢任务、长尾任务、阻塞任务要优先治理
- 不同业务隔离线程池,别让慢任务拖垮核心链路
- 拒绝策略、超时控制、入口限流要配套设计
最后给一个很现实的边界条件:
如果你的任务处理速度长期小于任务进入速度,再完美的线程池参数也救不了系统。那时候该做的不是继续调线程数,而是:
- 优化任务本身
- 降低流量
- 扩容服务
- 重构处理链路
线程池是并发治理工具,不是性能奇迹发生器。理解这一点,很多排查会清晰很多。