背景与问题
线上接口偶发超时,最开始看起来像是“下游慢了”。但继续观察会发现几个更危险的信号:
- 接口 RT 持续升高,超时比例上升
- JVM 堆内存一路爬升,Full GC 变频繁
- 机器 CPU 不一定高,但服务吞吐明显下降
- 日志里开始出现大量排队、重试、请求堆积
- 线程数并不夸张,可任务队列长度越来越大
这类问题,我自己踩过一次之后印象特别深:不是线程不够,而是线程池用错了。
很多项目里会出现类似代码:
ExecutorService executor = Executors.newFixedThreadPool(20);
或者:
ExecutorService executor = Executors.newSingleThreadExecutor();
表面上看没问题,实际上这里埋着一个很常见的坑:默认使用无界队列。
当请求速度大于线程池处理速度时,任务不会被拒绝,而是不断进入队列。结果就是:
- 队列越来越长
- 每个任务对象、参数、上下文都留在内存里
- 内存上涨,GC 压力变大
- 等待时间变长,最终接口超时
- 上游重试后,堆积进一步恶化
这篇文章就从“现象复现 → 定位路径 → 止血方案 → 修复方案”的角度,完整走一遍。
现象复现
先把故障模型说清楚:
假设接口收到请求后,把一个耗时任务扔给线程池执行,比如调用第三方服务、生成报表、发消息等。线程池处理速度跟不上入口流量时,如果队列是无界的,问题就会出现。
一个典型错误示例
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class WrongThreadPoolDemo {
private static final ExecutorService EXECUTOR = Executors.newFixedThreadPool(4);
public static void main(String[] args) {
for (int i = 0; i < 1000000; i++) {
final int taskId = i;
EXECUTOR.submit(() -> {
try {
Thread.sleep(200);
System.out.println("task " + taskId + " done");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
}
}
这个例子的问题不在于线程数小,而在于:
newFixedThreadPool(4)底层是LinkedBlockingQueue- 默认容量几乎等于无界
- 任务生产速度远大于消费速度时,队列无限增长
故障链路图
flowchart TD
A[请求进入接口] --> B[任务提交到线程池]
B --> C{线程池忙吗}
C -- 否 --> D[立即执行]
C -- 是 --> E[进入无界队列]
E --> F[队列持续增长]
F --> G[堆内存上升/GC频繁]
G --> H[任务等待时间变长]
H --> I[接口超时]
I --> J[上游重试]
J --> F
这张图基本就是线上事故的典型闭环:慢 → 堆积 → 更慢 → 超时 → 重试 → 更严重堆积。
核心原理
要解决这个坑,先得明白线程池几个关键参数到底在干什么。
ThreadPoolExecutor 的执行规则
ThreadPoolExecutor 的核心构造参数:
corePoolSize:核心线程数maximumPoolSize:最大线程数keepAliveTime:非核心线程空闲保活时间workQueue:任务队列threadFactory:线程工厂RejectedExecutionHandler:拒绝策略
提交任务后的行为大致如下:
- 当前运行线程数
< corePoolSize,创建新线程执行 - 否则任务进入队列
- 如果队列满了,且运行线程数
< maximumPoolSize,继续创建线程 - 如果线程数也到上限,则触发拒绝策略
为什么 Executors 容易埋坑
很多人以为:
fixedThreadPool = 固定线程数 + 安全稳定
其实不是。它的问题在于队列默认过大甚至无界。
几个常见工厂方法的隐藏行为:
Executors.newFixedThreadPool(n)
使用LinkedBlockingQueue,队列近似无界Executors.newSingleThreadExecutor()
也是无界队列Executors.newCachedThreadPool()
队列不存任务,线程数可快速膨胀到非常大Executors.newScheduledThreadPool()
定时任务也可能因为任务执行时间过长而堆积
所以很多线上规范会直接要求:不要直接使用 Executors 创建线程池,而是显式使用 ThreadPoolExecutor。
为什么会导致接口超时和内存飙升
因为任务从“提交成功”到“真正执行”之间,可能已经在队列里排了很久。
比如:
- 线程池 10 个线程
- 每个任务处理要 500ms
- 每秒进来 200 个任务
那线程池每秒最多处理约 20 个任务,剩下的 180 个会进队列。
如果队列无界,这些任务对象会一直堆着,堆内存自然上涨。
线程池状态视角
stateDiagram-v2
[*] --> 提交任务
提交任务 --> 直接执行: 线程数未到core
提交任务 --> 入队等待: 线程数已到core且队列未满
入队等待 --> 执行中: 工作线程取走任务
提交任务 --> 扩容线程: 队列已满且线程数未到max
扩容线程 --> 执行中
提交任务 --> 拒绝执行: 队列已满且线程数已到max
执行中 --> 完成
完成 --> [*]
定位路径
遇到“超时 + 内存涨 + 任务堆积”时,我通常按下面这个顺序看,比较快。
1. 先确认是否是线程池问题
重点指标:
- 活跃线程数
activeCount - 池中线程数
poolSize - 队列长度
queue.size() - 已完成任务数
completedTaskCount - 总任务数
taskCount - 拒绝任务数
- 接口 RT / 超时率
- Full GC 次数和耗时
如果看到:
activeCount接近核心线程数或最大线程数queue.size()持续上涨completedTaskCount增长很慢
那基本就能判断:消费不过来,线程池已经在排队。
2. 用 jstack 看线程在干什么
如果线程池工作线程大多卡在:
- 下游 HTTP 调用
- 数据库慢 SQL
- 锁等待
- IO 阻塞
说明根因可能不是线程池本身,而是任务执行时间过长。
线程池只是放大器。
3. 用 jmap / MAT 看内存里是什么
如果堆里大量对象来自:
Runnable/FutureTask- 请求参数对象
- 日志上下文、Trace 上下文
- 大对象缓存到任务闭包里
说明队列积压已经把任务对象“挂”在内存里了。
4. 看业务层是否有重试放大
很多系统的雪崩不是慢本身,而是:
- 接口超时
- 上游重试
- 每次重试又丢到同一个线程池
- 堆积越来越严重
这时就不是单纯调大线程数能解决的。
实战代码(可运行)
下面给一个完整示例,包含:
- 一个错误线程池实现
- 一个修复后的线程池实现
- 指标输出,便于直观看到堆积变化
错误版本:无界队列导致任务堆积
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
public class BadPoolCase {
private static final ExecutorService EXECUTOR = Executors.newFixedThreadPool(4);
private static final AtomicInteger SUCCESS = new AtomicInteger();
public static void main(String[] args) throws Exception {
for (int i = 0; i < 50000; i++) {
final int taskId = i;
EXECUTOR.submit(() -> {
try {
Thread.sleep(300);
SUCCESS.incrementAndGet();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
if (i % 1000 == 0) {
System.out.println("submitted: " + i + ", success: " + SUCCESS.get());
}
}
Thread.sleep(10000);
EXECUTOR.shutdown();
}
}
这个程序很容易制造“提交很快,执行很慢”的局面。
虽然它不一定立刻 OOM,但已经具备线上故障的典型特征。
修复版本:显式有界队列 + 拒绝策略 + 监控
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicLong;
public class GoodPoolCase {
private static final AtomicLong REJECT_COUNT = new AtomicLong();
private static final ThreadPoolExecutor EXECUTOR = new ThreadPoolExecutor(
4, // corePoolSize
8, // maximumPoolSize
60, TimeUnit.SECONDS, // keepAliveTime
new ArrayBlockingQueue<>(200), // 有界队列
new NamedThreadFactory("biz-worker"),
new ThreadPoolExecutor.CallerRunsPolicy() // 背压
);
public static void main(String[] args) throws Exception {
ScheduledExecutorService monitor = Executors.newSingleThreadScheduledExecutor(
new NamedThreadFactory("monitor")
);
monitor.scheduleAtFixedRate(() -> {
System.out.println(String.format(
"poolSize=%d, active=%d, queue=%d, completed=%d, task=%d, reject=%d",
EXECUTOR.getPoolSize(),
EXECUTOR.getActiveCount(),
EXECUTOR.getQueue().size(),
EXECUTOR.getCompletedTaskCount(),
EXECUTOR.getTaskCount(),
REJECT_COUNT.get()
));
}, 0, 1, TimeUnit.SECONDS);
for (int i = 0; i < 5000; i++) {
try {
final int taskId = i;
EXECUTOR.execute(() -> {
try {
Thread.sleep(300);
if (taskId % 1000 == 0) {
System.out.println(Thread.currentThread().getName() + " processed " + taskId);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
} catch (RejectedExecutionException e) {
REJECT_COUNT.incrementAndGet();
System.err.println("task rejected: " + i);
}
}
Thread.sleep(15000);
monitor.shutdown();
EXECUTOR.shutdown();
}
static class NamedThreadFactory implements ThreadFactory {
private final String prefix;
private int index = 1;
NamedThreadFactory(String prefix) {
this.prefix = prefix;
}
@Override
public synchronized Thread newThread(Runnable r) {
Thread t = new Thread(r, prefix + "-" + index++);
t.setDaemon(false);
return t;
}
}
}
这个版本的关键改动:
- 使用
ArrayBlockingQueue限制队列容量 - 设置
maximumPoolSize - 使用
CallerRunsPolicy做简单背压 - 加了线程池运行指标输出
这样做的核心价值不是“永不出错”,而是:
- 问题暴露得更早
- 系统不会悄悄把内存吃光
- 请求高峰时会有可控退化,而不是整体拖死
推荐封装:统一线程池工厂
项目里最好不要每个业务自己手写一套。可以统一封装:
import java.util.concurrent.*;
public class ExecutorFactory {
public static ThreadPoolExecutor newBizExecutor(
String poolName,
int core,
int max,
int queueSize
) {
return new ThreadPoolExecutor(
core,
max,
60L,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(queueSize),
new NamedThreadFactory(poolName),
new ThreadPoolExecutor.AbortPolicy()
);
}
static class NamedThreadFactory implements ThreadFactory {
private final String prefix;
private int index = 1;
NamedThreadFactory(String prefix) {
this.prefix = prefix;
}
@Override
public synchronized Thread newThread(Runnable r) {
return new Thread(r, prefix + "-" + index++);
}
}
}
使用时:
ThreadPoolExecutor orderExecutor = ExecutorFactory.newBizExecutor("order-pool", 8, 16, 500);
统一工厂的好处是:
- 命名规范统一
- 参数来源统一
- 拒绝策略统一
- 便于接监控
- 减少“顺手写个 Executors”的概率
常见坑与排查
坑 1:以为线程越多越快
不是。线程不是免费资源。
如果任务是 CPU 密集型,线程过多只会增加上下文切换; 如果任务是 IO 密集型,虽然可以适当多一些线程,但也不能无限加。
排查建议:
- CPU 打满时看是否线程过多
top -H、jstack结合看线程真实状态- 如果线程都在阻塞 IO,优先排查下游耗时
坑 2:把所有业务共用一个线程池
这也是事故高发点。
例如:
- 发短信
- 导出报表
- 订单异步处理
- 用户画像计算
全都放一个线程池里。
结果一个慢任务堆积,就把整个线程池拖死,其他业务全部受影响。
建议:按业务隔离线程池。
坑 3:只看线程数,不看队列长度
很多人观察线程池时只看:
- 当前多少线程
- CPU 高不高
但真正致命的往往是队列。
因为线程数固定,问题会被隐藏在排队里。
建议重点监控:
queue.size()taskCount - completedTaskCount- 单任务等待时长
坑 4:任务里塞了大对象
比如这样:
executor.submit(() -> process(bigList, hugeMap, requestBody));
如果任务排队,这些大对象会一直被引用,GC 无法释放。
尤其是把整段请求体、大批量结果集、图片字节数组塞进任务时,很容易放大内存问题。
建议:
- 任务参数尽量轻量
- 传 ID,不传整对象
- 大对象尽量落盘、缓存或分片处理
坑 5:Future 提交了但没人 get,也没人兜底异常
用 submit() 时,异常会被吞进 Future。
如果没人 get(),问题可能长期隐藏。
executor.submit(() -> {
throw new RuntimeException("boom");
});
这段代码可能不会像你想象中那样直接打印异常。
建议:
- 不关心返回值时,优先使用
execute() - 必须用
submit()时,做好Future结果处理 - 给线程池任务加统一异常日志
坑 6:拒绝策略选错
常见拒绝策略:
AbortPolicy:直接抛异常CallerRunsPolicy:由调用线程执行DiscardPolicy:直接丢弃DiscardOldestPolicy:丢弃队列最老任务
没有“通用最优解”。
什么时候适合 CallerRunsPolicy
适合希望给上游施加背压的场景,比如同步入口接口。
线程池满了,调用方自己执行,入口自然变慢,从而减缓继续提交。
什么时候不能随便用
如果调用线程本身就是:
- Netty IO 线程
- Servlet 请求线程
- MQ 消费主线程
那可能把关键线程拖住,产生更大连锁反应。
止血方案
线上故障时,先别急着“优雅重构”,先止血。
短期止血 1:限流
如果入口流量超过系统处理能力,第一件事是限流。
否则无论怎么调线程池,只是在延后崩溃时间。
可用手段:
- 网关限流
- 接口级熔断降级
- 非核心功能直接拒绝
- 按租户/用户做配额控制
短期止血 2:暂停或拆分重任务
如果某类任务特别慢,比如报表导出、批量同步、补偿任务,优先暂停或切走。
先保核心链路。
短期止血 3:把无界队列改成有界队列
这是最关键的一步。
它不能立刻提升吞吐,但能防止内存继续被任务堆满。
短期止血 4:缩短超时,减少无意义等待
如果下游本来 3 秒拿不到结果就没意义,那就别等 30 秒。
长超时会让线程长期挂住,加剧池内阻塞。
安全/性能最佳实践
这一节给可直接落地的建议。
1. 永远显式声明线程池参数
不要偷懒用 Executors 默认工厂。
推荐写法:
ThreadPoolExecutor executor = new ThreadPoolExecutor(
8,
16,
60L,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(1000),
new ThreadFactory() {
private int i = 1;
@Override
public synchronized Thread newThread(Runnable r) {
return new Thread(r, "biz-pool-" + i++);
}
},
new ThreadPoolExecutor.AbortPolicy()
);
2. 按任务类型选择线程数
CPU 密集型
比如加密、压缩、复杂计算。
一般建议线程数接近 CPU 核数或 CPU 核数 + 1。
IO 密集型
比如 HTTP、DB、文件 IO。
线程数可适当高一些,但前提是下游能承受,而且队列必须有界。
3. 做好监控,不要等超时了才看
最少要监控这些指标:
- 核心线程数、最大线程数、当前线程数
- 活跃线程数
- 队列长度
- 已完成任务数
- 拒绝次数
- 任务平均耗时、P95、P99
- 任务等待时长
- GC 次数和停顿时间
4. 业务隔离
建议至少按下面维度拆池:
- 核心链路 / 非核心链路
- 快任务 / 慢任务
- 用户请求任务 / 后台批处理任务
5. 不要在线程池里做无限重试
错误示例:
executor.execute(() -> {
while (true) {
try {
callRemote();
break;
} catch (Exception e) {
// 一直重试
}
}
});
这种代码会把工作线程永远占住。
正确做法是:
- 有限次数重试
- 指数退避
- 失败入补偿队列
- 与主线程池解耦
6. 注意 ThreadLocal 泄漏
线程池线程会复用,如果任务里用了 ThreadLocal 但不清理,可能造成脏数据或内存泄漏。
try {
contextHolder.set("traceId-123");
// do work
} finally {
contextHolder.remove();
}
线程池排障流程图
下面这张图适合做日常排查 checklist。
flowchart TD
A[接口超时/内存上涨] --> B[看线程池监控]
B --> C{队列是否持续增长}
C -- 否 --> D[优先排查下游耗时/锁竞争/GC]
C -- 是 --> E[检查是否无界队列]
E --> F[确认活跃线程和完成任务数]
F --> G[用jstack看线程阻塞点]
G --> H[是否存在重试放大]
H --> I[先限流/降级止血]
I --> J[改为有界队列+合理拒绝策略]
J --> K[拆分业务线程池并补齐监控]
一个更贴近线上问题的调用时序
sequenceDiagram
participant Client as 上游调用方
participant API as 接口服务
participant Pool as 线程池
participant Downstream as 下游服务
Client->>API: 发起请求
API->>Pool: 提交异步任务
alt 线程池可处理
Pool->>Downstream: 调用下游
Downstream-->>Pool: 返回结果
Pool-->>API: 任务完成
API-->>Client: 正常返回
else 线程池拥堵
Pool-->>API: 任务排队
API-->>Client: 超时/等待过长
Client->>API: 重试请求
API->>Pool: 再次提交任务
end
这张图说明了一件很现实的事:
如果没有背压和拒绝,重试会把线程池压得更快。
总结
这类故障最容易误判成“机器不够”或者“下游太慢”,但很多时候,真正的触发点是:
- 用了
Executors.newFixedThreadPool()之类的默认工厂 - 队列无界
- 任务执行慢
- 缺少监控
- 叠加上游重试,最终把系统拖入堆积
可以直接记住这几个结论:
- 线上线程池尽量不要直接用
Executors默认工厂 - 队列一定要有界
- 线程池参数要和任务类型匹配,不是越大越好
- 业务要隔离,慢任务不要污染核心链路
- 监控至少覆盖活跃线程、队列长度、拒绝次数、任务耗时
- 出现堆积时,先限流止血,再修参数和架构
如果你现在维护的服务里还有这样的代码:
Executors.newFixedThreadPool(20)
建议第一时间看一眼它承载的任务类型、流量峰值和队列情况。
很多坑平时“看起来没事”,只是因为流量还没把它打出来而已。