背景与问题
线上接口偶发抖动,最怕的不是“慢”,而是“忽快忽慢”。这类问题通常让人很难复现:压测时没问题,到了业务高峰就开始 P99 飙升,GC 次数增加,机器内存还一路往上爬。
我曾经遇到过一次非常典型的场景:
- 平时接口 RT 在 50ms 左右
- 高峰期 P95 到 800ms,P99 甚至到数秒
- 应用堆内存持续上涨,Full GC 开始频繁
- CPU 不一定打满,但系统明显“喘不过气”
- 重启后短暂恢复,过一段时间又复发
最后定位下来,根因不是 SQL,不是 Redis,不是网络,而是线程池误用。
更具体一点,是下面几类错误叠加在一起:
- 使用了无界队列,请求堆积时内存被任务对象吃光
- 核心线程数和最大线程数配置不合理,导致看起来有线程池,实际“只排队不扩容”
- 业务代码中提交了大量阻塞型任务
- 接口线程同步等待异步结果,线程池反而变成了“延迟放大器”
- 没有监控线程池运行状态,直到 GC 和 RT 告警才发现
这篇文章就按一次真实排障的节奏,带你从现象复现 -> 原理理解 -> 代码修复 -> 最佳实践走一遍。
现象复现
先说一个最常见的误用方式:开发时图省事,直接 new 一个线程池。
new ThreadPoolExecutor(
8,
32,
60L,
TimeUnit.SECONDS,
new LinkedBlockingQueue<>()
);
看起来没问题,实际上问题很大:
LinkedBlockingQueue<>()默认几乎等于无界队列- 有了无界队列后,线程池通常不会增长到 maximumPoolSize
- 请求一多,任务就不断进入队列
- 每个任务都可能持有请求参数、上下文对象、缓存引用
- 队列越堆越大,堆内存越涨越高
- 接口线程如果还在
future.get()等待,RT 就会一路抖动
可以先用一张图理解“抖动 + 内存飙升”的形成路径。
flowchart TD
A[流量突增] --> B[请求提交到线程池]
B --> C{队列是否有界?}
C -- 否 --> D[任务持续堆积]
D --> E[堆内存上涨]
D --> F[等待时间变长]
E --> G[GC频繁]
F --> H[接口RT抖动]
G --> H
H --> I[超时/失败率升高]
定位路径
排查这类问题,我一般不会上来就看代码,而是按下面的路径缩小范围。
1. 先看监控现象是不是“排队型故障”
几个指标很关键:
- 接口 RT:平均值、P95、P99
- JVM 堆使用率
- Young GC / Full GC 次数
- 线程总数、活跃线程数
- CPU 使用率
- 线程池队列长度
- 拒绝次数
如果你看到的是这种组合:
- RT 抖动明显
- 堆内存持续上涨
- GC 频繁
- CPU 不一定高
- 线程池 activeCount 接近核心线程数,但队列很长
那就很像是任务堆积,而不是纯 CPU 计算打满。
2. 再看线程栈
线程栈通常会给出明显信号:
- 大量业务线程卡在
FutureTask.get - 线程池工作线程卡在 I/O、远程调用、sleep 或锁等待
- 某个线程池名字反复出现
比如:
"http-nio-8080-exec-12" waiting on java.util.concurrent.FutureTask.get
"biz-worker-17" RUNNABLE
"biz-worker-18" TIMED_WAITING
这说明接口线程把请求转交给线程池后,自己又在同步等待结果。
如果线程池处理能力跟不上,请求线程就会成批阻塞。
3. 最后回到代码
常见的高危点:
Executors.newFixedThreadPool(...)Executors.newSingleThreadExecutor(...)LinkedBlockingQueue<>()未指定容量- 每次请求临时创建线程池
- 线程池里的任务又向同一个线程池提交子任务并等待
下面这张时序图能更直观看出“线程池把自己堵死”的过程。
sequenceDiagram
participant Client as 调用方
participant API as 接口线程
participant Pool as 业务线程池
participant Remote as 下游服务
Client->>API: 发起请求
API->>Pool: submit(task)
API->>Pool: future.get()
Pool->>Remote: 远程调用/阻塞操作
Note over Pool: 流量高时任务持续排队
Remote-->>Pool: 返回变慢
Pool-->>API: 结果延迟返回
API-->>Client: RT抖动/超时
核心原理
线程池看似简单,真正容易踩坑的是它的任务接收策略。
ThreadPoolExecutor 的基本执行逻辑
简化理解如下:
- 当前运行线程数 < corePoolSize:创建新线程执行
- 否则尝试入队
- 队列满了且运行线程数 < maximumPoolSize:继续扩容线程
- 还不行:执行拒绝策略
关键点在第 2 步:如果队列是无界的,通常永远不会满。
也就是说,线程池往往只会维持在 corePoolSize 附近,后面的任务全部排队。
这正是很多人误以为“我明明配了 maximumPoolSize,为什么没生效”的原因。
为什么会引发内存飙升
因为队列里的每个任务都不是“一个数字”那么简单。它可能包含:
- 请求参数对象
- traceId、用户上下文
- 大对象引用
- lambda 捕获的外部变量
- 重试状态、回调对象
一旦高峰期持续排队,这些对象就在堆里排起长队。
如果任务执行本身又慢,队列清不掉,内存就只会越积越多。
为什么会引发响应抖动
接口响应时间可以粗略拆成两段:
RT = 排队等待时间 + 任务实际执行时间
很多时候不是业务执行变慢,而是排队时间突然变长。
这就会表现为:
- 平均值还凑合
- 但 P95 / P99 特别难看
- 少数请求非常慢,形成“抖动感”
一个容易被忽视的死锁/饥饿问题
如果在线程池任务内部,又向同一个线程池提交子任务并等待结果,就可能出现线程饥饿。
flowchart LR
A[父任务占用线程池工作线程] --> B[父任务提交子任务到同一线程池]
B --> C[父任务等待子任务结果]
C --> D{线程池是否还有空闲线程?}
D -- 否 --> E[子任务无法执行]
E --> F[父任务一直等待]
F --> G[线程池饥饿/假死]
这个坑在“批量并发查询 + 汇总结果”的代码里特别常见。
实战代码(可运行)
下面我给两份代码:
- 错误示例:模拟无界队列导致的排队和内存上涨
- 修复示例:改成有界队列、显式拒绝策略和降级控制
错误示例:无界队列 + 阻塞等待
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
public class BadThreadPoolDemo {
private static final ThreadPoolExecutor EXECUTOR = new ThreadPoolExecutor(
4,
16,
60L,
TimeUnit.SECONDS,
new LinkedBlockingQueue<>(), // 无界队列:危险
new NamedThreadFactory("bad-pool")
);
public static void main(String[] args) throws Exception {
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 < 20000; i++) {
final int requestId = i;
EXECUTOR.submit(() -> simulateSlowTask(requestId));
}
Thread.sleep(30000);
monitor.shutdownNow();
EXECUTOR.shutdownNow();
}
private static void simulateSlowTask(int requestId) {
// 模拟任务持有较大的临时对象,放大内存问题
byte[] payload = new byte[256 * 1024]; // 256KB
try {
Thread.sleep(300); // 模拟阻塞IO或慢下游
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
if (requestId % 5000 == 0) {
System.out.println("done requestId=" + requestId + ", payload=" + payload.length);
}
}
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可能一直不大active接近 4queue快速膨胀- 内存占用明显增加
- 如果堆设置得小一点,甚至可能直接 OOM
比如这样运行:
java -Xms256m -Xmx256m BadThreadPoolDemo
这时问题会更明显。
修复示例:有界队列 + 背压 + 明确拒绝策略
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
public class GoodThreadPoolDemo {
private static final ThreadPoolExecutor EXECUTOR = new ThreadPoolExecutor(
4,
8,
60L,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(200),
new NamedThreadFactory("good-pool"),
new ThreadPoolExecutor.CallerRunsPolicy() // 背压:让提交方也参与执行
);
public static void main(String[] args) throws Exception {
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 < 5000; i++) {
final int requestId = i;
try {
EXECUTOR.execute(() -> simulateTask(requestId));
} catch (RejectedExecutionException e) {
System.err.println("task rejected, requestId=" + requestId);
}
}
Thread.sleep(20000);
monitor.shutdownNow();
EXECUTOR.shutdown();
}
private static void simulateTask(int requestId) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
if (requestId % 1000 == 0) {
System.out.println("done requestId=" + requestId);
}
}
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());
}
}
}
修复点解释
这段代码的改动不复杂,但很关键:
ArrayBlockingQueue<>(200):限制排队长度maximumPoolSize=8:队列满时允许适度扩容CallerRunsPolicy:当系统过载时,让提交方变慢,形成天然背压- 监控线程池指标:方便判断是否接近饱和
注意:
CallerRunsPolicy 不是万能的。如果提交线程就是 Tomcat / Netty 的请求线程,要评估是否会拖慢入口线程。
但从“防止无限堆积”这个角度,它比无界排队安全得多。
止血方案
线上出问题时,不要一上来就大改架构。先止血,再修复。
我一般会按这个优先级处理。
1. 临时缩短任务生命周期
如果线程池里是远程调用任务,可以先:
- 下调超时时间
- 减少重试次数
- 去掉不必要的串行 fallback
- 降低单次批量大小
目标是让任务更快完成,把队列先消下去。
2. 改成有界队列
如果当前是无界队列,优先改掉。
这是最有效的防炸堆措施。
new ArrayBlockingQueue<>(500)
容量不要拍脑袋,后面“最佳实践”里我会说怎么估。
3. 补上拒绝策略和降级逻辑
建议至少明确拒绝策略,不要依赖默认行为。
常见选项:
AbortPolicy:直接抛异常,适合强提醒CallerRunsPolicy:给调用方施加背压- 自定义拒绝策略:记录日志、埋点、降级返回
例如:
RejectedExecutionHandler handler = (r, executor) -> {
System.err.println("thread pool overload, queue=" + executor.getQueue().size());
throw new RejectedExecutionException("thread pool is overloaded");
};
4. 给接口加快速失败
如果下游已经明显过载,就别让更多请求进来排队了。
可以结合:
- 限流
- 熔断
- 超时控制
- 默认值降级
一句话:拒绝一部分请求,往往比拖死全部请求更划算。
常见坑与排查
坑 1:误用 Executors 工厂方法
很多教程喜欢这样写:
ExecutorService executor = Executors.newFixedThreadPool(8);
这个 API 用起来很方便,但内部队列通常是无界的。
在生产环境里,我更建议直接使用 ThreadPoolExecutor 显式配置每一项参数。
坑 2:maximumPoolSize 配了等于没配
如果用的是无界队列,maximumPoolSize 大概率不会发挥作用。
这是最常见的认知偏差之一。
坑 3:线程池里跑长时间阻塞任务
比如:
- 慢 SQL
- 远程 HTTP 调用
- 大文件读写
- 外部系统回调等待
阻塞型任务和 CPU 密集型任务,不适合混用同一个线程池。
否则一个池子里全是“慢活”,很容易把别的任务拖死。
坑 4:每个业务都共用一个大线程池
看似统一管理,实际容易互相影响:
- 导出任务高峰影响接口响应
- 异步消息消费影响查询接口
- 某个下游超时把整个池子拖住
经验上,至少按任务类型隔离:
- 接口异步处理池
- I/O 调用池
- 定时任务池
- 批处理池
坑 5:提交异步,结果却同步等待
很多代码表面上用了线程池,实际上没有提升吞吐:
Future<String> future = executor.submit(() -> remoteCall());
String result = future.get();
如果调用线程马上 get(),那就只是多了一次线程切换和排队成本。
除非你是为了隔离耗时操作,否则这种写法很可能得不偿失。
坑 6:线程池任务再提交子任务到同池并等待
这是“线程池饥饿”经典坑。
特别是在批量聚合接口中非常容易出现。
错误示意:
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.*;
public class StarvationDemo {
public static void main(String[] args) throws Exception {
ExecutorService pool = Executors.newFixedThreadPool(2);
Callable<String> parentTask = () -> {
Future<String> child = pool.submit(() -> {
Thread.sleep(1000);
return "child done";
});
return "parent wait -> " + child.get();
};
List<Future<String>> list = new ArrayList<>();
list.add(pool.submit(parentTask));
list.add(pool.submit(parentTask));
for (Future<String> f : list) {
System.out.println(f.get());
}
pool.shutdown();
}
}
这段代码很可能卡住。因为两个父任务已经占满了线程池,子任务没有线程可执行。
安全/性能最佳实践
这一节我尽量给“能直接拿去用”的建议。
1. 显式定义线程池,不要偷懒
推荐至少把这些参数写清楚:
- corePoolSize
- maximumPoolSize
- queueCapacity
- threadFactory
- rejectedExecutionHandler
示例模板:
ThreadPoolExecutor executor = new ThreadPoolExecutor(
8,
16,
60L,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(1000),
new ThreadFactory() {
private final AtomicInteger idx = new AtomicInteger(1);
@Override
public Thread newThread(Runnable r) {
Thread t = new Thread(r, "biz-executor-" + idx.getAndIncrement());
t.setDaemon(false);
return t;
}
},
new ThreadPoolExecutor.AbortPolicy()
);
2. 线程池大小按任务类型分开估
一个粗略经验:
- CPU 密集型:线程数接近 CPU 核数
- I/O 密集型:线程数可适当大一些,但一定要结合超时和队列长度
不要看到机器 16 核,就一口气给线程池配 200 个线程。
线程不是越多越快,线程切换、锁竞争、上下文切换都会增加成本。
3. 队列容量要和“最大可接受等待时间”挂钩
可以这样估一个起点:
queueCapacity ≈ 峰值每秒请求数 × 可接受排队秒数
例如:
- 峰值 200 req/s
- 允许最多排队 2 秒
则队列可先估到 400 左右。
再通过压测和监控修正。
4. 任务必须有超时
线程池只能管理“线程资源”,不能自动拯救慢任务。
如果任务内部没有超时,线程池再漂亮也会被拖垮。
比如 HTTP 调用、数据库查询、RPC,都必须明确超时。
5. 监控一定要补齐
至少要暴露这些指标:
- poolSize
- activeCount
- queueSize
- completedTaskCount
- rejectCount
- task execution time
- task wait time
如果你的监控只能看到“接口慢了”,却看不到“慢在排队还是执行”,排障会很被动。
6. 不要让任务持有大对象
避免在线程池任务里长期持有:
- 大数组
- 大 JSON 字符串
- 大集合
- 全量上下文对象
能传轻量参数就传轻量参数,能按需查询就别整包塞进任务。
7. 对入口流量做背压,而不是一味堆积
系统过载时只有三种选择:
- 排队
- 扩容
- 拒绝
无限排队往往是最差的。
因为它会把“局部慢”拖成“系统性雪崩”。
一份更稳妥的线程池封装示例
如果你想在业务项目里统一使用,可以封一层简单工厂。
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
public class ExecutorFactory {
public static ThreadPoolExecutor createBizExecutor(
String name,
int core,
int max,
int queueCapacity) {
return new ThreadPoolExecutor(
core,
max,
60L,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(queueCapacity),
new NamedThreadFactory(name),
new LogAndAbortPolicy(name)
);
}
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;
}
}
static class LogAndAbortPolicy implements RejectedExecutionHandler {
private final String poolName;
LogAndAbortPolicy(String poolName) {
this.poolName = poolName;
}
@Override
public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
System.err.println(String.format(
"pool=%s rejected, active=%d, queue=%d, taskCount=%d",
poolName,
executor.getActiveCount(),
executor.getQueue().size(),
executor.getTaskCount()
));
throw new RejectedExecutionException("pool " + poolName + " overloaded");
}
}
}
使用方式:
public class ExecutorUsageDemo {
public static void main(String[] args) {
ThreadPoolExecutor executor = ExecutorFactory.createBizExecutor(
"order-query-pool", 8, 16, 500
);
executor.execute(() -> {
System.out.println("do business...");
});
executor.shutdown();
}
}
排查清单
如果你现在正在线上查类似问题,可以按下面这份清单快速过一遍:
- 是否使用了
Executors.newFixedThreadPool/newSingleThreadExecutor - 队列是否是无界的
-
maximumPoolSize是否实际上不起作用 - 线程池任务是否包含阻塞调用
- 是否有任务提交后立刻
get()等待 - 是否存在父任务等待同池子任务的情况
- 队列长度、活跃线程数、拒绝次数是否可观测
- 每类业务是否使用独立线程池
- 下游调用是否设置了超时
- 高峰期是否具备限流/降级/熔断能力
总结
这类故障的本质,不是“线程池不好用”,而是线程池把系统真实处理能力暴露出来了。
如果配置和使用方式不对,它就会从“资源隔离工具”变成“问题放大器”。
你可以记住三个最关键的结论:
- 生产环境谨慎使用无界队列
- 线程池不是越大越好,排队也不是越长越安全
- 接口抖动很多时候不是执行慢,而是排队慢
如果你现在就想做改进,我建议按这个顺序落地:
- 把
Executors工厂方法替换为显式ThreadPoolExecutor - 改无界队列为有界队列
- 配置合理拒绝策略
- 区分 CPU / I/O / 定时 / 批处理线程池
- 补齐线程池监控和任务超时
- 给入口增加限流和降级
最后补一句边界条件:
如果你的任务本身就是超长耗时、强依赖外部系统、且峰值波动极大,那单纯调线程池参数只能缓解,不能根治。这个时候要进一步考虑异步解耦、消息队列削峰、缓存前置、读写拆分等架构手段。
但在很多真实线上故障里,先把线程池从“无界堆积”改成“有界可控”,就已经能解决 70% 的问题了。