
上周三凌晨两点,运维电话把我从床上薅起来。告警系统显示订单服务的 P99 延迟从 200ms 飙到了 3 秒,CPU 利用率却只有 35%。
没有 OOM,没有死锁,GC 日志一片平静。
查了整整两天,最后定位到线程池——不是线程池挂了,是参数从一开始就设错了。核心线程数 10,最大线程数 200,队列用的是无界的 LinkedBlockingQueue。
问题出在哪?先看结论:那个队列是万恶之源。无界队列让"最大线程数"彻底成了摆设,线程池永远不会创建超过核心线程数的线程。流量一上来,全堆在队列里排队,延迟线性增长。
这个坑踩完后我把团队所有线程池配置翻了一遍,发现没一个设对的。今天把排查过程和背后的原理写出来,你对照着检查一下自己的配置。
任务提交后到底发生了什么
大多数人只记参数名字,不记执行顺序。但顺序比数值重要一百倍。
一段标准配置:
ThreadPoolExecutor executor = new ThreadPoolExecutor(
10, // corePoolSize:核心线程数
50, // maximumPoolSize:最大线程数
60L, TimeUnit.SECONDS, // keepAliveTime:非核心线程空闲存活时间
new LinkedBlockingQueue<>(), // workQueue:任务队列,**无界**
new ThreadPoolExecutor.AbortPolicy() // 拒绝策略
);
当一个任务通过 execute() 提交进来,ThreadPoolExecutor 的判断链路是这样的(源码在
java.util.concurrent.ThreadPoolExecutor.execute):
public void execute(Runnable command) {
int c = ctl.get();
// 1. 当前工作线程数 < 核心线程数:直接创建新线程(核心线程)
if (workerCountOf(c) < corePoolSize class="hljs-keyword">if (addWorker(command, true)) // true表示创建核心线程
return;
c = ctl.get();
}
// 2. 线程池正在运行,且任务成功入队
if (isRunning(c) && workQueue.offer(command)) {
int recheck = ctl.get();
// 双重检查:入队后线程池万一关了,回滚并拒绝
if (!isRunning(recheck) && remove(command))
reject(command);
// 入队后万一所有线程都死了(极端情况),补一个线程
else if (workerCountOf(recheck) == 0)
addWorker(null, false);
}
// 3. 队列满了,尝试创建非核心线程
else if (!addWorker(command, false)) // false表示创建非核心线程
// 4. 队列也满了,线程也到上限了 → 拒绝
reject(command);
}
关键在第二步和第三步的顺序:先入队,后扩容。这意味着只要队列没满,线程池就不会创建新线程。
这就是无界队列的致命问题。new LinkedBlockingQueue<>() 默认容量是 Integer.MAX_VALUE,约 21 亿。队列永远不会满,第三步和第四步永远不会执行。你设的 maximumPoolSize=200 从头到尾就是个摆设。
你的参数到底该怎么算
参数的核心矛盾就一个:任务到底在等什么。
等 CPU 还是等 IO,决定了线程数。等不等得起,决定了队列长度。丢了要不要紧,决定了拒绝策略。
核心线程数
CPU 密集型(加解密、压缩、图像处理):线程几乎全程占着 CPU,多开只会增加上下文切换开销。公式是 CPU 核心数 + 1,多出来的一个应对偶发的页缺失中断。
int corePoolSize = Runtime.getRuntime().availableProcessors() + 1; // CPU密集型
IO 密集型(RPC 调用、数据库查询、文件读写):线程大部分时间在等 IO 返回,CPU 闲着。这时候可以多开线程,让 CPU 在等待期间去处理别的任务。经典的阻塞系数公式:
// 阻塞系数法:期望线程数 = CPU核心数 / (1 - 阻塞系数)
// 假设任务平均等待 IO 时间占总时间的 80%
double blockingCoefficient = 0.8; // IO等待占比
int optimalThreads = (int) (cpuCores / (1 - blockingCoefficient));
// 8核CPU → 8 / 0.2 = 40个线程
这个公式的前提是你能准确测出阻塞系数。测不出来怎么办?先用 CPU 核心数 × 2 起跑,压测时观察 CPU 利用率,稳定在 70%-80% 之间就是甜点区。低于 60% 就加线程,高于 90% 就减。
队列:有界优先,容量要可推演
无界队列是把问题藏起来。任务越积越多,延迟飙升,最终 OOM——但监控上看一切正常,这才是最可怕的。
正确做法是永远用有界队列,容量按峰值 QPS 计算:
// 队列容量 = 峰值QPS × 单任务平均耗时(秒) × 安全系数
// 假设峰值为1000 QPS,任务平均处理时间50ms,乘1.5倍安全系数
int queueCapacity = (int) (1000 * 0.05 * 1.5); // ≈ 75
BlockingQueue workQueue = new LinkedBlockingQueue<>(queueCapacity);
这个公式的逻辑是:队列应该能缓冲一个"处理窗口"内的突发流量。如果队列经常满,说明线程数不够或者下游扛不住,这是你需要立刻知道的信号。
最大线程数
最大线程数是帮你扛突发峰值的,不是日常用的。
int maximumPoolSize = corePoolSize * 2; // IO密集型可到3-4倍,但不超过CPU核数×8
上限卡在 CPU 核心数 × 8 是有道理的。线程数超过这个值后,操作系统调度本身的开销会吃掉新增线程带来的收益。
keepAliveTime
long keepAliveTime = 60L; // 非核心线程空闲60秒后回收
60 秒是个经验值,足够度过短时流量尖峰,又不会让线程池长时间臃肿。
拒绝策略
new ThreadPoolExecutor.CallerRunsPolicy() // 推荐,自带背压效果
AbortPolicy 直接抛异常,用户看到的是 500。DiscardPolicy 静默丢弃,出了事日志里一个字没有。DiscardOldestPolicy 丢最老的,可能把关键任务丢了。
CallerRunsPolicy 是最务实的选择:让提交任务的线程(通常是 Tomcat 的工作线程)自己执行这个任务。这会减慢任务提交速度,形成天然的背压,保护下游。
一套可落地的配置模板
完整配置:
int cpuCores = Runtime.getRuntime().availableProcessors();
// IO密集型:核心线程 = CPU核数 × 2,最大线程 = 核心 × 3
int corePoolSize = cpuCores * 2;
int maxPoolSize = cpuCores * 4;
long keepAliveTime = 60L;
// 有界队列,容量基于压测数据
int queueCapacity = 200; // 按峰值QPS×平均耗时×1.5得出
BlockingQueue workQueue = new LinkedBlockingQueue<>(queueCapacity);
ThreadPoolExecutor executor = new ThreadPoolExecutor(
corePoolSize,
maxPoolSize,
keepAliveTime,
TimeUnit.SECONDS,
workQueue,
new ThreadFactory() {
private final AtomicInteger counter = new AtomicInteger(1);
@Override
public Thread newThread(Runnable r) {
// 自定义线程名,出问题时一眼能看出是哪个线程池
Thread t = new Thread(r, "order-pool-" + counter.getAndIncrement());
t.setDaemon(false); // 业务线程池不要用守护线程,JVM退出时不等待它们
return t;
}
},
new ThreadPoolExecutor.CallerRunsPolicy() // 队列满时让调用线程执行,天然背压
);
// 允许核心线程超时回收,适合流量波动大的服务
executor.allowCoreThreadTimeOut(true);
线上怎么知道自己设没设对
配置永远不是一次算对的。线上观察这几个指标:
// 建议每30秒打印一次,接入监控系统
int activeCount = executor.getActiveCount(); // 当前干活线程数
int poolSize = executor.getPoolSize(); // 当前池中线程总数
long completedTasks = executor.getCompletedTaskCount(); // 累计完成任务数
int queueSize = executor.getQueue().size(); // 队列积压任务数
// 判断逻辑
if (activeCount >= executor.getMaximumPoolSize() * 0.8) {
// 线程池接近饱和 → 考虑扩容或加机器
log.warn("线程池接近饱和: active={}, max={}", activeCount, executor.getMaximumPoolSize());
}
if (queueSize > queueCapacity * 0.5) {
// 队列积压过半 → 线程数可能不够
log.warn("队列积压: queue={}, capacity={}", queueSize, queueCapacity);
}
几个典型的信号和对应的动作:
- 队列经常满 + 最大线程数没到上限:队列容量设太小了,或者应该把队列设小一点让线程池更积极地扩容
- 最大线程数打满 + CPU 不到 60%:下游 IO 太慢,优化下游,不要继续堆线程
- CPU 持续 90%+:任务从 IO 密集型变成了 CPU 密集型,减线程数
- activeCount 长期等于 corePoolSize,队列空空:核心线程数可能设多了,浪费资源
结尾
线程池的参数不是一次性算出来的,是压出来的。
大多数人配线程池就三步:网上搜一个公式 → 填进 YAML → 再也没改过。但你的流量会变,下游的响应时间会变,机器的配置也会变。参数不跟着变,迟早出事。
把这篇文章里的监控代码贴到你的项目里,跑一周看看日志。大概率你会发现:自己以为设对了的东西,实际根本没按预期跑。
发表评论 取消回复