背景与问题
线上接口突然开始变慢,最开始只是偶发超时,后来监控里连着几项指标一起报警:
- 接口 RT 从几十毫秒拉高到几秒
- Tomcat 工作线程堆积
- 堆内存持续上涨,Full GC 变频繁
- CPU 不一定很高,但系统“就是越来越卡”
这类问题我踩过不止一次,最后排下来,很多时候不是“业务逻辑突然变复杂了”,而是线程池用错了。
尤其是下面这几种写法,看起来很方便,实际上很容易把服务拖进坑里:
- 使用
Executors.newFixedThreadPool(),默认搭配无界队列 - 使用
Executors.newCachedThreadPool(),请求高峰时线程数失控 - 把耗时 IO、重试任务、异步回调全塞进同一个线程池
- 提交任务后不设超时、不做降级、不关注拒绝策略
- 每次请求临时创建线程池,导致线程和对象无法及时回收
本文我就按一次真实的排查思路来讲:怎么复现、怎么定位、为什么会超时和内存飙升、最后怎么改。
现象复现
先看一个非常典型的错误示例。它的问题不在“语法”,而在配置默认值。
错误示例:固定线程数 + 无界队列
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
public class WrongThreadPoolDemo {
private static final ExecutorService POOL = Executors.newFixedThreadPool(8);
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 100000; i++) {
final int taskId = i;
POOL.submit(() -> {
try {
// 模拟下游慢调用
TimeUnit.MILLISECONDS.sleep(500);
if (taskId % 10000 == 0) {
System.out.println("done: " + taskId);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
System.out.println("tasks submitted");
}
}
这段代码的问题是:
- 线程数只有 8
- 每个任务都要执行 500ms
- 外部一下子提交 10 万个任务
- 队列默认是
LinkedBlockingQueue,近似无界
结果就是:
- 前 8 个任务在跑
- 其余大量任务在队列里排队
- 队列对象不断占内存
- 请求侧继续等待,接口 RT 越来越高
- GC 开始频繁,最终表现为“超时 + 内存飙升”
定位路径
排查这类问题,我通常会按这条线走,比较稳。
1. 先确认是“业务慢”还是“排队慢”
如果你有链路追踪,重点看两段时间:
- 业务方法真正执行的耗时
- 任务从提交到开始执行的等待耗时
很多同学一看接口超时,就盯着 SQL、Redis、HTTP 下游。但线程池误用时,常见情况是:
- 真正执行只要 100ms
- 但任务在队列里先排了 3 秒
这时候慢的不是业务,而是调度。
2. 看线程池指标
重点看:
poolSizeactiveCountqueueSizecompletedTaskCounttaskCount
如果你发现:
activeCount长时间等于最大线程数queueSize持续上涨completedTaskCount增长缓慢
基本就能判断:线程池被打满了,而且消费速度赶不上生产速度。
3. 看 JVM 与线程栈
常用工具:
jstackjmapjcmd- Arthas
- VisualVM / MAT
你往往会看到:
- 大量业务线程卡在
LinkedBlockingQueue.take()或poll() - 工作线程卡在慢 IO、RPC、数据库调用上
- 堆中积压大量
Runnable、FutureTask、业务上下文对象
4. 结合流量高峰看队列变化
如果内存曲线和请求高峰一致,并且高峰后也回不来,十有八九是:
- 队列堆积太深
- 任务对象持有大字段
- 异步任务消费能力持续不足
flowchart TD
A[接口RT升高] --> B{业务执行慢还是排队慢?}
B -->|业务执行慢| C[排查SQL/缓存/RPC]
B -->|排队慢| D[检查线程池参数]
D --> E[activeCount是否打满]
E --> F[queueSize是否持续增长]
F --> G[检查任务类型: IO阻塞/重试/大对象]
G --> H[确认误用线程池导致堆积]
核心原理
线程池问题之所以容易“隐蔽”,是因为很多坑都藏在默认实现里。
1. Executors.newFixedThreadPool() 的隐患
它底层大致等价于:
new ThreadPoolExecutor(
nThreads,
nThreads,
0L,
TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>()
);
注意这个 LinkedBlockingQueue:
- 默认容量非常大,近似无界
- 线程池会优先把新任务放入队列
- 因为队列放得下,所以线程数不会继续扩容
这就导致一个反直觉现象:
你以为“固定线程池”很稳,实际上它可能在高峰时疯狂堆任务,把内存吃起来。
2. 为什么会引发接口超时
当请求线程把工作异步提交给线程池后,往往还会等待结果,比如:
Future.get()CompletableFuture.join()- 轮询异步结果
- 等待批量子任务汇总
如果线程池已堆满,那么任务无法及时执行,调用方就一直等,最终形成接口超时。
3. 为什么会引发内存飙升
排队的不是一个简单数字,而是一个个任务对象。每个任务可能还引用:
- 请求参数
- 用户上下文
- 大对象集合
- 序列化数据
- 重试状态
任务越多,引用链越长,GC 越难回收。
4. 混用线程池会放大问题
很多项目里一个公共线程池负责:
- 下游 HTTP 调用
- 发消息
- 写日志
- 数据聚合
- 定时补偿
这样一来,只要其中一个场景阻塞,整个线程池都会被拖慢,其他接口也跟着受影响。
sequenceDiagram
participant Client as 调用方
participant Web as 接口线程
participant Pool as 线程池
participant Downstream as 下游服务
Client->>Web: 发起请求
Web->>Pool: submit(task)
Note over Pool: 队列已堆积
Pool-->>Web: 任务等待排队
Web->>Pool: Future.get(timeout)
Pool->>Downstream: 迟迟未执行/执行很慢
Web-->>Client: 超时响应
实战代码(可运行)
下面给一个完整示例,分为两部分:
- 一个“错误实现”,展示无界队列如何导致堆积
- 一个“修复实现”,使用有界队列、命名线程工厂、拒绝策略、超时控制
错误实现
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
public class BadCase {
private static final ExecutorService EXECUTOR = Executors.newFixedThreadPool(4);
public static void main(String[] args) throws Exception {
long begin = System.currentTimeMillis();
for (int i = 0; i < 20000; i++) {
final int id = i;
EXECUTOR.submit(() -> {
try {
// 模拟慢IO
TimeUnit.MILLISECONDS.sleep(300);
if (id % 5000 == 0) {
System.out.println("processed " + id);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
long cost = System.currentTimeMillis() - begin;
System.out.println("submit cost(ms): " + cost);
System.out.println("大量任务已进入无界队列,风险开始累积");
}
}
这个程序的危险点在于:提交非常快,执行非常慢。
提交快不代表系统健康,很多事故就是这么来的。
修复实现
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
public class GoodCase {
private static final ThreadPoolExecutor EXECUTOR = new ThreadPoolExecutor(
8,
16,
60L,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(200),
new NamedThreadFactory("biz-worker"),
new ThreadPoolExecutor.CallerRunsPolicy()
);
public static void main(String[] args) throws Exception {
startMonitor();
for (int i = 0; i < 1000; i++) {
final int id = i;
try {
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> slowCall(id), EXECUTOR)
.orTimeout(800, TimeUnit.MILLISECONDS)
.exceptionally(ex -> "fallback-" + id);
String result = future.get(1000, TimeUnit.MILLISECONDS);
if (id % 100 == 0) {
System.out.println("result: " + result);
}
} catch (RejectedExecutionException e) {
System.out.println("task rejected: " + id);
} catch (TimeoutException e) {
System.out.println("task timeout: " + id);
}
}
EXECUTOR.shutdown();
EXECUTOR.awaitTermination(10, TimeUnit.SECONDS);
}
private static String slowCall(int id) {
try {
TimeUnit.MILLISECONDS.sleep(200);
return "ok-" + id;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return "interrupted-" + id;
}
}
private static void startMonitor() {
ScheduledExecutorService monitor = Executors.newSingleThreadScheduledExecutor(
new NamedThreadFactory("pool-monitor"));
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);
}
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);
return t;
}
}
}
这个版本做了几件关键的事:
- 有界队列:
ArrayBlockingQueue<>(200),避免无限堆积 - 合理扩容:核心线程 8,最大线程 16
- 拒绝策略:
CallerRunsPolicy让调用方感受到背压 - 超时保护:
orTimeout - 兜底降级:
exceptionally - 监控输出:实时看线程池状态
止血方案
线上已经出问题时,第一目标不是“优雅”,而是先止血。
1. 临时减小流量入口
如果能做限流,先做。因为线程池问题本质上是:
单位时间进来的任务,远大于单位时间能处理的任务。
不先控流,单纯改线程数,常常只是把事故推迟几分钟。
2. 快速切换有界队列
如果当前使用的是无界队列,优先改成有界队列,并配置明确拒绝策略。
常见选择:
AbortPolicy:快速失败,适合核心链路要显式报警CallerRunsPolicy:把压力回传给上游,适合削峰- 不建议默认“悄悄吞掉任务”
3. 给异步结果加超时
没有超时的异步,最后经常会变成“同步卡死”。
例如:
future.get(800, TimeUnit.MILLISECONDS);
或者:
completableFuture.orTimeout(800, TimeUnit.MILLISECONDS);
4. 分池隔离
至少把这些任务拆开:
- IO 密集型任务
- CPU 密集型任务
- 定时补偿任务
- 低优先级异步任务
不要所有任务共用一个池子。
常见坑与排查
坑 1:以为线程越多越快
不是。
如果是数据库、HTTP、Redis 这类慢 IO,线程加太多,反而会:
- 增加上下文切换
- 放大连接池竞争
- 放大下游压力
- 让故障更难恢复
建议:线程池大小要根据任务类型、下游容量、机器核数来定,不要凭感觉拍。
坑 2:只看 CPU,不看队列
线程池事故不一定 CPU 爆满。
很多时候 CPU 很正常,但队列已经堆到危险值了。
建议:线程池监控至少包含:
- 活跃线程数
- 队列长度
- 拒绝次数
- 平均执行耗时
- 最大等待时长
坑 3:任务里持有大对象
比如把整个请求 DTO、查询结果集、日志上下文都闭包进 Runnable 里。
队列一积压,内存马上放大。
建议:
- 任务只传必要字段
- 避免在异步任务里捕获大对象
- 提前做轻量化转换
坑 4:CompletableFuture 默认线程池误用
如果你不显式指定线程池,很多异步逻辑会落到公共线程池上。业务一复杂,就可能和别的模块互相影响。
CompletableFuture.supplyAsync(() -> doWork());
建议改为:
CompletableFuture.supplyAsync(() -> doWork(), customExecutor);
坑 5:拒绝策略选了也没处理
配了 AbortPolicy,但业务代码没捕获 RejectedExecutionException,结果直接 500。
这不叫保护,这叫把问题甩给用户。
建议:拒绝时要有明确降级路径。
stateDiagram-v2
[*] --> Healthy
Healthy --> Busy: 流量上升
Busy --> Queuing: 活跃线程打满
Queuing --> Timeout: 请求等待过久
Queuing --> OOMRisk: 无界队列持续堆积
Timeout --> Degrade: 启用超时/降级/限流
OOMRisk --> Degrade: 切换有界队列
Degrade --> Healthy: 流量恢复+参数修正
安全/性能最佳实践
这里给一套我比较推荐的线程池使用原则,偏实战。
1. 优先显式创建 ThreadPoolExecutor
不要图省事直接用 Executors 工厂方法。
推荐自己写清楚参数:
ThreadPoolExecutor executor = new ThreadPoolExecutor(
coreSize,
maxSize,
60L,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(queueCapacity),
threadFactory,
new ThreadPoolExecutor.AbortPolicy()
);
这样每个参数都“看得见”。
2. 一定要有界
无界队列在大部分线上业务里都很危险。
有界的意义不是“让任务少一点”,而是让系统在超载时有边界。
3. 做分池隔离
可以按下面拆:
queryExecutor:查询聚合rpcExecutor:下游远程调用retryExecutor:重试补偿asyncLogExecutor:低优先级日志
隔离后,一个模块堵住,不会把整个应用拖垮。
4. 配监控和报警
线程池要像数据库连接池一样看待,不是配完就完事。
至少要监控:
- 当前线程数
- 活跃线程数
- 队列使用率
- 拒绝次数
- 任务执行时间分位数
- 任务等待时间分位数
5. 超时、取消、降级要成套出现
只有超时,没有取消,任务可能还在后台继续跑。
只有拒绝,没有降级,用户体验仍然很差。
一套完整保护通常包括:
- 提交前限流
- 执行中超时
- 超时后取消
- 拒绝后降级
- 下游异常后熔断
6. 线程池参数要根据场景定
CPU 密集型
适合线程数接近 CPU 核数,避免过多切换。
IO 密集型
可以适当放大线程数,但前提是:
- 下游扛得住
- 连接池够
- 请求超时合理
- 队列容量可控
7. 不要每次请求新建线程池
这是另一个经典坑:
public void handle() {
ExecutorService executor = Executors.newFixedThreadPool(10);
executor.submit(() -> doWork());
}
这样会不断创建线程和队列,资源极其浪费。
线程池应该是复用型基础设施,不是一次性对象。
一份实用排查清单
出问题时可以按下面顺序检查:
- 接口耗时升高,是执行慢还是排队慢?
- 线程池活跃线程是否长期打满?
- 队列长度是否持续增长?
- 是否使用了无界队列?
- 是否把慢 IO 和普通任务混用一个池?
- 是否存在大量
Future.get()阻塞等待? - 是否没有配置超时与拒绝策略?
- 任务对象是否引用了大集合或大上下文?
- 是否有监控能看到拒绝次数和队列利用率?
- 下游服务是否已经成为瓶颈,导致线程长期不释放?
总结
这类故障最麻烦的地方在于:表面是接口超时,根因却是线程池配置不当。
如果再叠加无界队列、慢 IO、公共线程池混用,最后很容易演变成:
- 请求大量排队
- 内存不断上涨
- GC 频繁
- 接口雪崩
真正有效的修复,不是简单“把线程调大”,而是这几件事一起做:
- 使用
ThreadPoolExecutor显式配置参数 - 队列必须有界
- 根据任务类型分池隔离
- 设置超时、拒绝策略与降级逻辑
- 给线程池补齐监控与报警
- 在高峰时做限流和背压
如果你现在项目里还在大量使用 Executors.newFixedThreadPool(),建议尽快扫一遍。
很多坑平时看不出来,一到流量高峰就会原形毕露。早点改,真的能少背很多锅。