背景与问题
线上服务最怕的,不是“慢一点”,而是突然全线变慢,然后连锁超时,最后看起来像整个系统一起掉进坑里。
我第一次遇到这类问题时,表面现象特别像数据库抖动:
- 接口 RT 从几十毫秒飙到几秒
- Tomcat 工作线程堆满
- 下游调用超时数量暴增
- CPU 不一定高,甚至看起来“还没打满”
- 日志里开始出现大量
RejectedExecutionException,或者更隐蔽一点:请求一直排队不返回
最后排查下来,根因并不复杂:线程池参数配置错了。
这类问题之所以容易踩坑,是因为很多 Java 项目里线程池是“顺手一配”:
- 核心线程数拍脑袋写个 20
- 最大线程数写个 200,觉得“留点冗余”
- 队列开成
LinkedBlockingQueue默认无界,感觉“先别拒绝,稳一点” - 拒绝策略用默认
AbortPolicy,上线后才发现直接抛异常 - 甚至
@Async、业务线程池、HTTP 客户端线程池、MQ 消费线程池混着用,互相影响
结果就是:请求高峰一来,任务积压、线程争抢、超时扩散、重试放大,最后演变成服务雪崩。
本文我从 troubleshooting 的角度,带你完整走一遍:
- 线程池参数为什么会放大问题
- 如何复现“误配导致雪崩”
- 如何定位到底是队列、线程数还是拒绝策略的问题
- 如何做止血和长期优化
现象复现
先说一个典型错误配置:
corePoolSize = 8maximumPoolSize = 200workQueue = new LinkedBlockingQueue<>()RejectedExecutionHandler = AbortPolicy
很多人以为这个配置的意思是:
先用 8 个核心线程,忙不过来再扩到 200。
其实不是。
如果你用的是无界队列 LinkedBlockingQueue,线程池的行为通常是:
- 先创建核心线程
- 核心线程满了以后,任务直接进入队列
- 因为队列几乎装不满,所以很难触发创建更多非核心线程
maximumPoolSize基本形同虚设
于是你看到的不是“线程池扩容扛住流量”,而是:
- 只有少量线程在干活
- 海量任务在队列里排队
- 请求端不断超时
- 调用方重试后,队列更长
- 延迟越积越多,最后整体崩掉
下面这张图可以直观看到这个过程。
flowchart TD
A[流量上涨] --> B[核心线程很快打满]
B --> C[新任务进入无界队列]
C --> D[队列持续堆积]
D --> E[请求等待时间拉长]
E --> F[上游超时与重试]
F --> G[更多任务涌入]
G --> H[服务雪崩]
核心原理
理解线程池,关键不是背参数,而是搞清楚 ThreadPoolExecutor 的任务接收顺序。
1. 线程池接收任务的决策流程
ThreadPoolExecutor 大致会按下面顺序处理任务:
- 当前运行线程数
< corePoolSize:创建核心线程执行 - 否则尝试把任务放入队列
- 如果队列满了,且当前线程数
< maximumPoolSize:创建非核心线程执行 - 如果队列也满了,线程也到上限:执行拒绝策略
flowchart LR
A[提交任务] --> B{运行线程数 < corePoolSize?}
B -- 是 --> C[创建核心线程处理]
B -- 否 --> D{队列可入队?}
D -- 是 --> E[任务进入队列等待]
D -- 否 --> F{运行线程数 < maximumPoolSize?}
F -- 是 --> G[创建非核心线程处理]
F -- 否 --> H[触发拒绝策略]
2. 为什么无界队列很危险
很多项目默认就用:
new LinkedBlockingQueue<>()
它的问题不是“不能用”,而是你必须知道它的后果:
- 队列几乎不会满
- 所以
maximumPoolSize形同虚设 - 流量高峰时,系统不是快速失败,而是无限排队
- 排队带来的不是平滑,而是延迟堆积
对于 I/O 型任务,排队时间一长,业务超时阈值一到,请求虽然还在队列里,但用户侧已经认为失败了。
此时这些“无效任务”仍然消耗线程池资源,进一步拖慢真正有价值的请求。
3. 为什么大线程数也不一定是解法
另一个常见误区是:既然线程不够,那我把 maximumPoolSize 调大不就好了?
不一定。
线程数过大可能引起:
- 上下文切换增加
- 数据库连接池被打穿
- 下游服务被并发冲垮
- 堆内存占用增加
- GC 压力升高
所以线程池调优不是“越大越好”,而是要和以下资源一起看:
- CPU 核数
- 任务类型(CPU 密集 / I/O 密集)
- 平均执行时间
- 下游连接池容量
- 接口超时阈值
- 可接受的排队时间
4. 服务雪崩是怎么被放大的
线程池配置错误往往不是单点故障,而是链路放大器。
sequenceDiagram
participant U as 用户请求
participant S as 应用服务
participant TP as 业务线程池
participant D as 下游服务/数据库
U->>S: 发起请求
S->>TP: 提交异步/并发任务
TP-->>S: 任务排队等待
S->>D: 部分请求超时重试
D-->>S: 响应变慢
S-->>U: 整体RT升高/超时
U->>S: 重试或更多请求进入
S->>TP: 线程池继续堆积
你会发现,真正可怕的不是线程池本身,而是:
- 线程池排队
- 接口超时
- 调用方重试
- 下游变慢
- 更多任务积压
这几个因素一旦形成闭环,故障会迅速蔓延。
定位路径
出了问题以后,不要上来就改参数。先按顺序查。
1. 先看表象指标
建议先确认这几个指标:
activeCount:活跃线程数poolSize:当前线程数queue.size():队列长度taskCount/completedTaskCount- 拒绝次数
- 请求 RT、超时数、错误率
- 下游调用耗时
- 数据库连接池使用率
如果你看到:
- 活跃线程接近核心线程数
- 队列很长
- 最大线程数却始终上不去
那大概率就是无界队列把扩容路径堵死了。
2. 再看线程栈
通过 jstack 或 Arthas 看线程状态,重点关注:
- 大量业务线程卡在 I/O 等待
- HTTP/RPC 调用超时
- 数据库查询等待连接
- 队列消费者线程处理速度明显慢于生产速度
如果线程多数都在 TIMED_WAITING 或网络调用上,不代表系统没问题,反而说明:
线程没在算业务,而是在等下游。
3. 查代码里线程池怎么创建的
排查时经常能看到这些写法:
Executors.newFixedThreadPool(20)
Executors.newCachedThreadPool()
Executors.newSingleThreadExecutor()
这些工厂方法不是完全不能用,而是线上场景里容易埋坑:
newFixedThreadPool:底层常用无界队列,可能无限排队newCachedThreadPool:线程数可无限增长,可能把机器打爆newSingleThreadExecutor:单线程串行,极易积压
线上建议直接显式使用 ThreadPoolExecutor,把参数写清楚。
实战代码(可运行)
下面我写一个可运行的示例,先复现错误配置,再给出改进版本。
1. 错误示例:无界队列导致任务堆积
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,
50,
60,
TimeUnit.SECONDS,
new LinkedBlockingQueue<>(), // 无界队列:典型坑点
new NamedThreadFactory("bad-pool"),
new ThreadPoolExecutor.AbortPolicy()
);
// 模拟监控线程
ScheduledExecutorService monitor = Executors.newSingleThreadScheduledExecutor();
monitor.scheduleAtFixedRate(() -> {
System.out.println(String.format(
"[MONITOR] poolSize=%d, active=%d, queue=%d, completed=%d",
executor.getPoolSize(),
executor.getActiveCount(),
executor.getQueue().size(),
executor.getCompletedTaskCount()
));
}, 0, 1, TimeUnit.SECONDS);
// 模拟突发流量:快速提交大量慢任务
for (int i = 0; i < 2000; i++) {
final int taskId = i;
executor.submit(() -> {
try {
// 模拟下游调用慢
Thread.sleep(2000);
System.out.println(Thread.currentThread().getName() + " processed task " + taskId);
} 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快速增长到很大maximumPoolSize=50基本没意义- 任务执行越来越慢,因为大多数时间花在等待排队
这就是很多线上事故的缩影。
2. 改进示例:有界队列 + 明确拒绝策略 + 可观测性
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
public class BetterThreadPoolDemo {
public static void main(String[] args) throws InterruptedException {
AtomicLong rejectedCounter = new AtomicLong(0);
ThreadPoolExecutor executor = new ThreadPoolExecutor(
8,
16,
60,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(100),
new NamedThreadFactory("biz-pool"),
(r, ex) -> {
rejectedCounter.incrementAndGet();
throw new RejectedExecutionException("Task rejected. queue=" + ex.getQueue().size());
}
);
ScheduledExecutorService monitor = Executors.newSingleThreadScheduledExecutor();
monitor.scheduleAtFixedRate(() -> {
System.out.println(String.format(
"[MONITOR] poolSize=%d, active=%d, queue=%d, completed=%d, rejected=%d",
executor.getPoolSize(),
executor.getActiveCount(),
executor.getQueue().size(),
executor.getCompletedTaskCount(),
rejectedCounter.get()
));
}, 0, 1, TimeUnit.SECONDS);
for (int i = 0; i < 500; i++) {
final int taskId = i;
try {
executor.submit(() -> {
try {
Thread.sleep(800); // 模拟慢任务
System.out.println(Thread.currentThread().getName() + " processed task " + taskId);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
} catch (RejectedExecutionException e) {
System.out.println("reject taskId=" + taskId + ", reason=" + e.getMessage());
}
}
Thread.sleep(10000);
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());
}
}
}
这个版本的关键变化有三点:
- 队列改成有界
- 最大线程数有机会生效
- 拒绝可观测,不再悄悄积压
很多时候,明确拒绝比无限排队更健康。因为它能迫使你尽早暴露容量问题,而不是拖到整条链路一起崩。
3. 业务里更实用的线程池封装
下面给一个更贴近生产的封装方式。
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
public class ThreadPoolHolder {
public static final ThreadPoolExecutor ORDER_EXECUTOR =
new ThreadPoolExecutor(
16,
32,
60,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(500),
new NamedThreadFactory("order-worker"),
new ThreadPoolExecutor.CallerRunsPolicy()
);
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);
t.setUncaughtExceptionHandler((thread, ex) ->
System.err.println("Thread " + thread.getName() + " error: " + ex.getMessage()));
return t;
}
}
}
这里用了 CallerRunsPolicy,它不是万能解,但在某些场景特别有效:
- 任务提交速度过快时,让提交方自己执行
- 形成自然背压
- 避免任务无限积压
但它也有边界条件:
如果提交线程是 Tomcat/Netty 的请求线程,使用不当会把请求线程拖慢,所以一定要结合业务链路判断。
常见坑与排查
坑 1:以为 maximumPoolSize 一定会生效
这是最经典的误解。
如果队列是无界的,线程池往往优先排队,不会轻易扩到 maximumPoolSize。
所以你配置了 max=200,可能线上实际只跑着 core=8。
怎么确认
看这些数据:
poolSize是否长期不增长queue.size()是否持续增大activeCount是否稳定在核心线程附近
如果是,先看队列类型。
坑 2:拒绝策略只会“少量报错”,不会影响系统
错。
如果拒绝发生在核心业务路径,影响可能非常直接:
- 下单失败
- 支付回调丢失
- 消息消费中断
- 异步任务未执行但没有补偿
排查建议
- 给拒绝策略埋点
- 统计每分钟拒绝数
- 记录任务来源、业务类型、调用链路
- 区分“可丢弃任务”和“不可丢弃任务”
对于不可丢弃任务,不要只靠线程池硬扛,应该配合:
- 本地落盘
- MQ 削峰
- 补偿重试
- 幂等处理
坑 3:一个线程池跑所有任务
这也是线上非常常见的问题。
比如:
- 短平快查询任务
- 慢 SQL 导出任务
- 第三方接口调用
- 异步通知重试任务
全塞进一个池子里。
结果往往是:慢任务拖垮快任务。
建议
按任务特征拆分线程池:
- 用户请求关键路径线程池
- 外部依赖调用线程池
- 批处理/离线任务线程池
- 重试任务线程池
隔离比调参更重要。
坑 4:只看 CPU,不看等待资源
线程池问题很多时候不是 CPU 打满,而是线程都卡在外部资源上:
- 数据库连接池不足
- Redis 超时
- 第三方 HTTP 接口慢
- 磁盘 I/O 抖动
排查思路
如果线程池积压,继续往下看:
- 数据库连接池是否耗尽
- 下游接口 RT 是否升高
- 是否发生超时重试
- 是否存在锁竞争
- 是否存在单批次超大任务
不要把所有锅都甩给线程池,线程池只是最先“表现出来”。
止血方案
线上出了雪崩,不要一上来就做“大手术”。先止血。
1. 快速限流
如果入口流量已经明显超过处理能力,优先做:
- 网关限流
- 接口降级
- 熔断慢下游
- 关闭非核心功能
目标很明确:先让请求进入速度低于服务处理速度。
2. 缩短超时时间
如果下游已经慢了,不要让线程长时间挂死。
可以临时调小:
- HTTP 调用超时
- RPC 超时
- 数据库查询超时
原则是:
尽早失败,释放线程,比长时间等待更有价值。
3. 临时调线程池,但别盲调
可以根据机器资源和任务类型小幅调整:
- 增加核心线程数
- 缩小队列长度,减少无意义排队
- 切换更合适的拒绝策略
但我建议不要在不看下游容量的情况下暴力加线程,否则很容易把问题从应用层转移到数据库层。
安全/性能最佳实践
这里把我认为最实用的建议收敛成一组清单。
1. 线程池必须显式创建
不要在线上核心服务里直接使用 Executors 默认工厂方法,推荐显式写:
new ThreadPoolExecutor(core, max, keepAlive, unit, queue, factory, handler)
这样参数和意图都透明。
2. 队列优先选择有界
有界队列的价值在于:
- 防止无限堆积
- 帮助尽早暴露容量不足
- 让系统具备可控退化能力
常见可选项:
ArrayBlockingQueue:固定容量,简单直接LinkedBlockingQueue(capacity):指定容量也可以,但要明确上限SynchronousQueue:适合直接移交,不存储任务,适用场景更特殊
3. 根据任务类型估算线程数
一个经验原则:
- CPU 密集型:线程数接近 CPU 核数
- I/O 密集型:可适当高于 CPU 核数,但要结合等待时间和下游容量
粗略思路不是背公式,而是问清楚:
- 平均任务耗时多少
- 其中真正占 CPU 的时间有多少
- 下游连接池最多支持多少并发
- 业务允许排队多久
如果下游数据库连接池只有 30 个,你线程池开 200 个线程,通常不是优化,而是制造拥堵。
4. 给线程池做监控
至少监控这些指标:
- 当前线程数
- 活跃线程数
- 队列长度
- 完成任务数
- 拒绝次数
- 平均任务耗时
- 最大任务耗时
并且要设置告警,例如:
- 队列使用率超过 80%
- 拒绝数持续增加
- 活跃线程长期满载
- 任务平均耗时持续上升
5. 为关键任务设计背压和降级
线程池不是无限缓冲区。
对于关键链路,要在设计层面考虑:
- 调用方限流
- 熔断
- 超时控制
- 重试次数限制
- 幂等
- 异步化削峰
特别是“重试”,一定要谨慎。
没有节制的重试,是把局部慢故障放大成全局故障的常见元凶。
6. 线程池隔离
不同类型任务分池处理,避免相互污染:
classDiagram
class RequestPool {
用户核心请求
低延迟
}
class RemoteCallPool {
外部依赖调用
易受下游影响
}
class RetryPool {
重试/补偿任务
可降级
}
class BatchPool {
批量/导出任务
长耗时
}
7. 关注优雅关闭
服务下线时,如果线程池直接被强停,可能导致:
- 任务中断
- 数据不一致
- 部分订单/消息处理到一半
建议在服务停止时:
- 先停止接收新任务
- 等待已有任务执行完成
- 超时后再强制关闭
- 对中断任务做补偿记录
一个更实用的排查清单
当你怀疑“线程池把服务拖垮了”,可以直接按这个顺序走:
- 看接口 RT、超时率、错误率是否同步上涨
- 看线程池
activeCount / queueSize / rejected - 看
maximumPoolSize是否实际生效 - 看线程栈是否卡在数据库、HTTP、锁等待
- 看数据库连接池、下游接口 RT 是否异常
- 看是否存在重试风暴
- 看线程池是否混跑了不同类型任务
- 最后再决定改线程数、队列长度还是拒绝策略
这个顺序的目的,是避免“见线程池就调线程池”。
总结
线程池问题最容易让人误判的地方在于:
它经常不是根因,却总是最早暴露问题的地方。
这篇文章的核心结论,我建议你记住三点:
-
无界队列要慎用
它可能让maximumPoolSize失效,把问题从“并发不够”变成“无限排队”。 -
线程池参数必须结合下游容量设计
线程数、队列长度、超时时间、数据库连接池、外部依赖并发能力,本来就是一组联动参数。 -
比调参更重要的是隔离、限流和可观测性
如果没有监控、没有拒绝统计、没有分池隔离,线程池迟早会在流量高峰时给你“上强度”。
最后给一个落地建议,适合多数中级 Java 开发同学直接执行:
- 不用
Executors默认工厂方法上生产 - 核心线程池显式配置
- 队列使用有界容量
- 拒绝策略必须可观测
- 不同业务类型分池
- 配套接口超时、限流、熔断、重试上限
- 用压测验证线程池是否符合预期,而不是靠想象
如果你线上已经出现“请求变慢、线程不高、队列暴涨、重试增多”的组合症状,优先怀疑线程池参数误配,通常不会错。