背景与问题
线上接口“偶发超时”这类问题,很多人第一反应是:是不是下游慢了、数据库抖了、网络波动了。
但我自己排过几次之后,越来越确定一件事:如果接口耗时突然变长,同时 JVM 内存一路往上走,十有八九要看看线程池是不是被用歪了。
这篇文章讲一个典型场景:
- 某接口为了“提升性能”,把部分业务逻辑改成异步执行
- 使用了
Executors.newFixedThreadPool(...) - 请求高峰期开始出现:
- 接口 RT 飙升
- 超时增多
- Young GC/Full GC 变频繁
- 堆内存持续上涨
- 最终服务几乎不可用
乍一看,线程池不是为了提升并发吗?为什么反而把服务拖垮了?
根因往往不是“用了线程池”,而是线程池参数、任务模型、拒绝策略、超时控制、上下游容量没有一起设计。
尤其是 Executors 的默认实现,很容易把问题藏起来,平时没事,一压测就炸。
先看一个典型错误用法
很多项目里都能看到这种代码:
ExecutorService executor = Executors.newFixedThreadPool(20);
public String query() throws Exception {
Future<String> future = executor.submit(() -> {
// 模拟远程调用或复杂计算
Thread.sleep(2000);
return "ok";
});
// 同步等待结果
return future.get();
}
表面上:
- 有线程池
- 有异步提交
- 代码还挺“高级”
但实际上这里踩了两个坑:
- 异步转同步:提交后立刻
get(),当前请求线程还是被阻塞了 - 默认队列无界:
newFixedThreadPool底层是无界LinkedBlockingQueue
结果就是:
- 请求线程在等
- 线程池工作线程在干活
- 新请求继续往队列堆
- 队列中的任务对象越来越多
- 内存开始涨
- 排队等待时间越来越长
- 最终接口超时
这类问题最坑的地方是:平时流量低时完全看不出来。
现象复现
下面用一段可运行示例,复现“接口超时 + 内存飙升”的趋势。
错误示例:无界队列 + 慢任务 + 同步等待
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.*;
public class BadThreadPoolDemo {
// 典型误用:FixedThreadPool 底层使用无界队列
private static final ExecutorService EXECUTOR = Executors.newFixedThreadPool(10);
public static void main(String[] args) throws InterruptedException {
int requestCount = 2000;
CountDownLatch latch = new CountDownLatch(requestCount);
List<Thread> callers = new ArrayList<>();
long start = System.currentTimeMillis();
for (int i = 0; i < requestCount; i++) {
Thread t = new Thread(() -> {
try {
String result = mockApiCall();
if (!"ok".equals(result)) {
System.out.println("unexpected result");
}
} catch (Exception e) {
System.out.println("request failed: " + e.getMessage());
} finally {
latch.countDown();
}
});
callers.add(t);
t.start();
}
latch.await();
long cost = System.currentTimeMillis() - start;
System.out.println("all requests done, cost(ms)=" + cost);
EXECUTOR.shutdown();
}
public static String mockApiCall() throws Exception {
Future<String> future = EXECUTOR.submit(() -> {
// 模拟慢任务
Thread.sleep(3000);
return "ok";
});
// 看似异步,实际上同步阻塞等待
return future.get(5, TimeUnit.SECONDS);
}
}
这个程序会发生什么
当并发请求远大于线程池处理能力时:
- 线程池只有 10 个工作线程
- 每个任务执行 3 秒
- 新任务会被不断塞进无界队列
- 调用方线程在
future.get(5s)上阻塞 - 很多请求在排队阶段就已经接近超时
- 队列任务对象不断堆积,内存上涨
定位路径
真实线上排查时,我通常不会一上来就改代码,而是按下面这条路径走,先确认是不是线程池问题。
1. 先看外部现象
典型监控信号:
- 接口 P99/P999 延迟突然升高
- 超时比例升高
- CPU 不一定高,但堆内存明显上涨
- GC 次数增多
- Tomcat/Jetty/Netty 业务线程被大量占住
这时候要警惕:不是纯 CPU 打满,而是大量线程在等待。
2. 看线程栈
通过 jstack 看线程状态,往往能看到很多业务线程卡在:
java.util.concurrent.FutureTask.get
java.util.concurrent.ThreadPoolExecutor.getTask
java.util.concurrent.locks.AbstractQueuedSynchronizer
或者请求线程卡在:
java.util.concurrent.FutureTask.get
说明请求线程在同步等待异步任务结果。
3. 看堆内存和对象分布
如果使用 jmap -histo 或 MAT 分析堆转储,可能会看到:
LinkedBlockingQueue$Node数量很多FutureTask数量很多- 业务请求对象、上下文对象被任务引用住,无法及时释放
这类迹象很明确:任务在队列里堆积了。
4. 看线程池运行时指标
线上最好暴露这些指标:
poolSizeactiveCountqueueSizecompletedTaskCounttaskCount
如果你发现:
activeCount接近核心线程数上限queueSize持续增长completedTaskCount增速跟不上请求量
那基本就能坐实:线程池处理不过来,而且在“排队吃内存”。
核心原理
要修这个问题,必须先理解 ThreadPoolExecutor 的工作机制。很多坑都出在“以为自己懂线程池”。
线程池任务进入流程
flowchart TD
A[提交任务] --> B{当前线程数 < corePoolSize?}
B -- 是 --> C[创建核心线程执行]
B -- 否 --> D{队列是否可入队?}
D -- 是 --> E[任务进入阻塞队列]
D -- 否 --> F{当前线程数 < maximumPoolSize?}
F -- 是 --> G[创建非核心线程执行]
F -- 否 --> H[触发拒绝策略]
关键点只有一句话:
线程池不是无限吞吐器,处理不过来的任务,不是排队,就是拒绝。
而问题常常出在这里:
- 队列设得过大甚至无界:任务不拒绝,只会无限堆积
- 任务执行时间长:吞吐上不去
- 提交方没有超时/降级:请求线程被拖死
- 业务把线程池当“削峰神器”:结果只是把洪峰搬到了内存里
为什么 newFixedThreadPool 容易踩坑
Executors.newFixedThreadPool(n) 底层等价于:
corePoolSize = nmaximumPoolSize = nworkQueue = new LinkedBlockingQueue<>(),无界队列
这意味着:
- 线程数永远固定
- 队列几乎无限增长
- 高峰时不会扩线程,只会不断排队
所以它的风险不是“线程过多”,而是:
队列无限积压,导致延迟不断放大,内存持续上涨。
为什么“异步后立刻 get”没意义
如果你这样写:
Future<Result> future = executor.submit(task);
return future.get();
那本质上只是把工作从 A 线程挪到 B 线程,然后 A 线程继续等 B 线程。
这不会减少等待,只会额外引入:
- 线程切换开销
- 队列排队开销
- Future 对象开销
- 更复杂的异常链路
如果调用链最终还是同步返回,那你至少要问一句:
这段异步化,到底优化了什么?
问题链路图
sequenceDiagram
participant Client as 客户端
participant Biz as 接口线程
participant Pool as 业务线程池
participant Queue as 队列
participant Downstream as 慢任务/下游服务
Client->>Biz: 发起请求
Biz->>Pool: submit(task)
Pool-->>Queue: 任务排队
Biz->>Pool: future.get(timeout)
Queue->>Pool: 被工作线程取出
Pool->>Downstream: 执行慢调用
Downstream-->>Pool: 返回结果
Pool-->>Biz: future完成
Biz-->>Client: 返回响应
Note over Queue,Biz: 高峰期队列积压,Biz等待时间变长,最终超时
实战代码:从误用到修复
下面给一个更合理的线程池写法,核心思路是:
- 不用无界队列
- 设置合理拒绝策略
- 对任务执行和等待都做超时控制
- 在无法处理时快速失败或降级
- 暴露监控指标
修复版示例
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
public class GoodThreadPoolDemo {
private static final ThreadPoolExecutor EXECUTOR = new ThreadPoolExecutor(
8, // corePoolSize
16, // maximumPoolSize
60L, TimeUnit.SECONDS, // keepAliveTime
new ArrayBlockingQueue<>(100), // 有界队列,避免无限堆积
new NamedThreadFactory("biz-pool"),
new ThreadPoolExecutor.AbortPolicy() // 明确拒绝,避免静默堆积
);
public static void main(String[] args) {
printStats();
for (int i = 0; i < 300; i++) {
try {
String result = queryWithTimeout();
System.out.println("result=" + result);
} catch (RejectedExecutionException e) {
System.out.println("request rejected: 系统繁忙,请稍后再试");
} catch (TimeoutException e) {
System.out.println("request timeout: 执行超时,触发降级");
} catch (Exception e) {
System.out.println("request failed: " + e.getMessage());
}
}
EXECUTOR.shutdown();
}
public static String queryWithTimeout() throws Exception {
Future<String> future = null;
try {
future = EXECUTOR.submit(() -> {
// 模拟业务处理
Thread.sleep(500);
return "ok";
});
// 对等待时间做限制
return future.get(800, TimeUnit.MILLISECONDS);
} catch (TimeoutException e) {
if (future != null) {
future.cancel(true);
}
throw e;
}
}
private static void printStats() {
ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
scheduler.scheduleAtFixedRate(() -> {
System.out.println(String.format(
"poolSize=%d, active=%d, queue=%d, completed=%d",
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 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;
}
}
}
这版修了什么
1. 把无界队列改成有界队列
new ArrayBlockingQueue<>(100)
这非常关键。
有界队列的意义不是“提高性能”,而是给系统一个明确容量边界。
系统满了怎么办?
要么扩容,要么拒绝,要么降级。
总之不能让它无限吃内存。
2. 拒绝策略明确化
new ThreadPoolExecutor.AbortPolicy()
AbortPolicy 会直接抛异常,提醒调用方:系统满了。
在接口场景里,这通常比“继续排队直到超时”更友好,因为至少你能快速失败。
3. 超时后取消任务
future.cancel(true);
注意边界条件:
- 这不是“强杀线程”
- 只有任务代码对中断敏感,才能尽快停止
- 如果内部调用不响应中断,任务仍可能继续执行
所以这一步是必要但不充分,真正关键的是:任务本身也要支持超时与中断。
线程池参数怎么理解才不容易踩坑
很多人看到线程池参数就头大,我这里用最实战的方式解释。
参数关系图
classDiagram
class ThreadPoolExecutor {
int corePoolSize
int maximumPoolSize
BlockingQueue workQueue
ThreadFactory threadFactory
RejectedExecutionHandler handler
long keepAliveTime
}
class BlockingQueue {
<<interface>>
}
class ArrayBlockingQueue
class LinkedBlockingQueue
class SynchronousQueue
BlockingQueue <|-- ArrayBlockingQueue
BlockingQueue <|-- LinkedBlockingQueue
BlockingQueue <|-- SynchronousQueue
ThreadPoolExecutor --> BlockingQueue
1. corePoolSize
核心线程数。
可以理解为“常驻工人数量”。
适合:
- 稳定负载
- 常态并发
不适合简单拍脑袋设很大。
线程多不代表吞吐一定高,尤其 I/O、锁竞争、上下文切换很多时,盲目加线程只会更乱。
2. maximumPoolSize
最大线程数。
当队列满了之后,线程池是否还能临时扩线程。
但有个前提:队列不能是无界队列。
无界队列下,任务一直入队,maximumPoolSize 几乎形同虚设。
3. workQueue
最容易出事故的就是这里。
LinkedBlockingQueue:默认可很大甚至无界,容易堆积ArrayBlockingQueue:固定容量,适合做边界控制SynchronousQueue:不存储任务,适合直接移交,常用于高响应场景
对于接口类业务,如果你没有特别清晰的容量模型,优先考虑有界队列。
4. RejectedExecutionHandler
系统满了时怎么处理:
AbortPolicy:抛异常,推荐用于需要快速失败的接口CallerRunsPolicy:调用方线程自己执行DiscardPolicy:直接丢弃DiscardOldestPolicy:丢最旧任务再尝试提交
为什么 CallerRunsPolicy 也可能踩坑
它看起来很温和,但在 Web 接口里要小心:
- 如果请求线程自己执行慢任务
- 那请求线程池就可能被拖住
- 上游流量一来,整个服务入口会被反向压死
所以它更适合某些离线或内部可控场景,不一定适合在线接口。
常见坑与排查
坑 1:把线程池当成“万能提速工具”
很多异步化改造只是心理安慰:
- 本来单线程执行 200ms
- 改成提交线程池再等待,还是 200ms+
- 还多了调度成本
判断方法
如果最终必须等待结果才能返回,那就要确认:
- 这段逻辑是否真的能并行
- 是否能与其他步骤重叠执行
- 是否有多个独立 I/O 可以并发
如果都没有,线程池可能只是增加复杂度。
坑 2:线程池共用,互相拖垮
比如:
- 接口 A 用这个线程池
- 接口 B 也用这个线程池
- 定时任务也用这个线程池
- 消息消费还用这个线程池
结果就是某个慢任务一多,整个池子一起堵死。
建议
按业务隔离线程池,至少区分:
- 核心接口线程池
- 非核心异步任务线程池
- 定时任务线程池
- 下游调用专用线程池
坑 3:只配线程数,不配超时
即使线程池参数合理,如果下游调用本身没有超时:
- HTTP 调用卡住
- RPC 调用卡住
- 数据库查询卡住
线程还是会一直占着不放。
排查思路
检查所有外部依赖是否配置:
- 连接超时
- 读超时
- 调用总超时
线程池只是容器,慢调用本身不止血,线程池迟早也扛不住。
坑 4:以为取消任务就一定能停掉
future.cancel(true);
很多人以为这句能立刻停止任务。
实际上,它只是发出中断信号。
如果任务里是这种代码:
while (true) {
// 不检查中断
}
那根本停不下来。
正确写法示例
public class InterruptibleTask implements Callable<String> {
@Override
public String call() throws Exception {
while (!Thread.currentThread().isInterrupted()) {
// 执行业务逻辑
Thread.sleep(100);
}
throw new InterruptedException("task interrupted");
}
}
坑 5:只看 CPU,不看排队
线程池问题有时候 CPU 不会特别高,因为大家都在:
- 等锁
- 等 I/O
- 等 Future
- 等队列调度
所以别看到 CPU 不高就误判“服务不忙”。
更应关注的指标
- 接口等待时间
- 线程池队列长度
- 活跃线程数
- 拒绝次数
- 下游平均耗时/P99
- GC 停顿时间
止血方案
如果线上已经在抖,优先做“止血”,再做彻底治理。
短期止血
-
立刻限制流量
- 网关限流
- 热点接口降级
- 非核心功能熔断
-
缩短超时
- 避免请求长时间挂起
- 快速释放线程和连接资源
-
临时扩容
- 增加实例数
- 分摊排队压力
-
关闭高成本异步逻辑
- 某些非核心计算、回写、统计可以先降级
中期修复
- 把
Executors.newFixedThreadPool/newCachedThreadPool改为显式ThreadPoolExecutor - 使用有界队列
- 设计拒绝策略和兜底响应
- 为下游依赖配置超时
- 暴露线程池指标并接入监控告警
长期治理
- 做容量评估:接口峰值 QPS、单任务平均耗时、可接受排队时间
- 线程池按业务隔离
- 引入限流、熔断、降级
- 对高耗时链路做异步解耦,而不是“异步后同步等待”
安全/性能最佳实践
这一节我尽量写得能直接落地。
1. 不要直接用 Executors 快速创建线程池
更推荐显式构造:
ThreadPoolExecutor executor = new ThreadPoolExecutor(
corePoolSize,
maximumPoolSize,
keepAliveTime,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(queueSize),
new NamedThreadFactory("biz"),
new ThreadPoolExecutor.AbortPolicy()
);
原因很简单:
线程数、队列、拒绝策略都得自己心里有数。
2. 线程池大小要结合任务类型
一个粗略经验:
- CPU 密集型:线程数接近 CPU 核数
- I/O 密集型:线程数可适当大一些,但要结合外部依赖容量
不要只看本机 CPU,也要看:
- 下游服务并发承受能力
- 数据库连接池大小
- HTTP 连接池大小
- 单机内存上限
3. 队列大小不是越大越稳
很多人会说:“队列调大点,不就不拒绝了?”
这通常只是把问题延后:
- 拒绝少了
- 但排队时间更长了
- 用户超时更多了
- 内存压力更大了
好的系统设计不是“尽量不报错”,而是:
在超出容量时,尽快、可控地失败。
4. 给线程池加监控
至少监控这些指标:
- 当前线程数
- 活跃线程数
- 队列长度
- 完成任务数
- 拒绝次数
- 任务平均耗时/超时次数
可以定时上报到日志、Prometheus、Micrometer 等。
示例:
public void printExecutorStats(ThreadPoolExecutor executor) {
System.out.printf(
"pool=%d, active=%d, queued=%d, completed=%d%n",
executor.getPoolSize(),
executor.getActiveCount(),
executor.getQueue().size(),
executor.getCompletedTaskCount()
);
}
5. 任务要可中断、可超时、可降级
这是很多“线程池治理”最后没落地的原因:
只改池子,不改任务。
任务代码应该做到:
- 能响应中断
- 有明确超时
- 失败后有默认值或降级路径
例如:
public String fallbackQuery() {
return "system busy";
}
6. 注意上下文对象泄漏风险
任务排队时,如果 Runnable/Callable 持有大对象,比如:
- 请求报文
- 用户上下文
- 大集合
- 缓存结果
这些对象会跟着任务一起滞留在队列中,放大内存问题。
建议
- 只传必要参数
- 避免把整个请求上下文塞进任务
- 大对象用完尽早释放引用
一个更实用的排查清单
如果你怀疑是线程池误用,我建议按这个顺序排:
flowchart TD
A[接口超时告警] --> B[看RT、超时率、GC、内存]
B --> C[看线程池指标: active/queue/reject]
C --> D{队列持续增长?}
D -- 是 --> E[检查是否无界队列]
E --> F[检查任务耗时和下游超时]
F --> G[检查是否submit后立刻get]
G --> H[评估是否需要有界队列+拒绝策略+降级]
D -- 否 --> I[继续看锁竞争/数据库/网络]
这张图我自己排障时也经常照着走,能少掉很多无效怀疑。
总结
这次踩坑背后的核心,不是“线程池不好用”,而是:
- 无界队列会隐藏容量问题
- 异步后立刻
get(),很容易把问题复杂化 - 线程池参数必须和任务耗时、下游容量、超时策略一起设计
- 系统满了要快速失败,而不是无限排队
- 监控、限流、超时、降级,缺一项都可能在线上吃亏
如果你只记住一条,我建议记这个:
对接口型业务,优先使用显式
ThreadPoolExecutor + 有界队列 + 明确拒绝策略 + 超时控制,不要把线程池当成无限缓冲区。
最后给几个可执行建议:
- 新项目里,尽量避免直接使用
Executors.newFixedThreadPool() - 所有业务线程池都要可观测
- 所有下游调用都要设超时
- 如果异步结果必须立即等待,先质疑这段异步是否真的必要
- 队列长度和拒绝次数,比“线程数配多少”更值得关注
线程池本来是拿来稳住系统的,别让它变成问题放大器。