背景与问题
线上接口突然开始变慢,最开始只是偶发超时,后来直接演变成:
- 接口 RT 持续升高
- Tomcat 工作线程大量阻塞
- 进程内存不断上涨
- Full GC 频率明显增加
- 机器 CPU 不一定高,但服务就是“越来越卡”
这类问题我踩过不止一次,最容易误判成“数据库慢了”或者“下游接口抖动”。但真正可怕的是:业务层为了提升吞吐引入了线程池,结果线程池配置和使用方式不对,反而把系统拖垮了。
这篇文章就从一个典型事故场景出发,带你完整走一遍:
- 怎么复现线程池误用
- 为什么会导致接口超时和内存飙升
- 如何定位是线程池而不是别的组件
- 怎样修复,并且避免二次踩坑
现象复现
先说一个非常常见的错误用法:使用 Executors.newFixedThreadPool() 处理高并发接口异步任务。
它看起来很安全,固定线程数、代码也简洁,但内部其实用了无界队列。请求量一旦超过消费速度,任务就会无限堆积,最终表现为:
- 新请求排队越来越久,接口超时
- 队列中的任务对象、上下文对象堆积,内存上涨
- GC 回收不掉,因为任务还在队列里“活着”
一个常见的错误场景
假设接口里要并发处理多个远程调用,开发者为了“加速”,在接口内提交任务到公共线程池:
ExecutorService executor = Executors.newFixedThreadPool(20);
public String query() throws Exception {
Future<String> future = executor.submit(() -> {
Thread.sleep(2000); // 模拟慢调用
return "ok";
});
return future.get(3, TimeUnit.SECONDS);
}
如果瞬时流量上来,比如每秒几百个请求,而线程池只有 20 个线程,每个任务平均耗时 2 秒,那么多出来的任务会不断进入队列排队。
问题不在“线程少”本身,而在“队列无上限 + 提交速度持续高于消费速度”。
核心原理
线程池是怎么工作的
线程池的处理逻辑可以简化为:
- 核心线程没满,创建新线程执行
- 核心线程满了,任务进入阻塞队列
- 队列满了,如果线程数还没到最大线程数,则继续扩容线程
- 如果队列也满、线程也到上限,则执行拒绝策略
但 Executors.newFixedThreadPool(n) 的内部等价于:
- corePoolSize = n
- maximumPoolSize = n
- workQueue =
LinkedBlockingQueue(默认近似无界) - 拒绝策略几乎永远触发不到,因为队列太大了
也就是说,它的行为其实是:
线程数固定,剩余任务全部进队列排队。
这对于短任务、低峰值、可控流量还勉强能用;但只要是接口流量型场景,就很容易出事。
为什么接口会超时
因为请求线程虽然很快把任务提交进线程池,但业务结果还要等待异步任务返回:
future.get(3, TimeUnit.SECONDS)
如果线程池里的任务已经排了很长队,即使单个任务执行时间只有 2 秒,也可能因为前面排了几十秒,最终导致:
future.get()超时- 请求线程阻塞等待
- 容器线程被耗尽
- 整体雪崩
为什么内存会飙升
队列里的每个任务都不是一个“空壳”:
- Runnable/Callable 对象本身占内存
- 可能捕获了请求参数、用户信息、上下文对象
- 可能包含大对象引用,比如 DTO、列表、缓存数据
- FutureTask 还会持有状态和结果引用
当队列积压几十万条任务时,内存上涨是必然的。
flowchart TD
A[请求到达接口] --> B[提交任务到线程池]
B --> C{核心线程是否空闲}
C -- 是 --> D[立即执行任务]
C -- 否 --> E[进入阻塞队列]
E --> F[队列持续堆积]
F --> G[future.get等待变长]
G --> H[接口超时]
F --> I[任务对象堆积]
I --> J[堆内存上涨/频繁GC]
线程池误用的本质
这类问题本质上是一个生产速度 > 消费速度,却没有背压机制的问题。
线程池不是“性能加速器”,它只是一个资源调度器。如果没有:
- 有界队列
- 拒绝策略
- 超时控制
- 限流/降级
- 监控报警
那么线程池只是在帮你把故障延迟暴露。
sequenceDiagram
participant Client as 调用方
participant API as 接口线程
participant Pool as 线程池
participant Worker as 工作线程
Client->>API: 发起请求
API->>Pool: submit(task)
alt 工作线程空闲
Pool->>Worker: 立即执行
Worker-->>API: 返回结果
API-->>Client: 正常响应
else 工作线程繁忙
Pool-->>API: 任务入队
API->>API: future.get等待
Note over Pool: 队列越来越长
API-->>Client: 超时/失败
end
定位路径
线上排查时,我一般不会一上来就改代码,而是按下面的路径缩小范围。
1. 先确认是不是“慢在排队”
如果接口日志里有这些特征,就要警惕线程池排队问题:
- 业务方法执行日志不慢,但总耗时很长
- 下游调用耗时和接口总耗时对不上
- 超时请求集中出现在高峰流量时段
- 线程池提交量持续高于完成量
建议在业务里补三个时间点:
- 请求进入时间
- 任务提交时间
- 任务开始执行时间
如果“提交到开始执行”的间隔越来越长,基本就坐实是排队。
2. 看线程池运行指标
重点关注:
poolSizeactiveCountqueue.size()completedTaskCounttaskCount
如果你看到:
activeCount长时间等于线程池大小queue.size()持续增长completedTaskCount增长缓慢
那就是标准积压。
3. 看 JVM 内存与 GC
常见现象:
- Old 区占用持续升高
- Full GC 后下降不明显
- 堆 dump 里大量
FutureTask、LinkedBlockingQueue$Node、业务 Runnable 对象
这说明不是普通对象泄漏,而是任务队列滞留。
4. 看线程栈
通过 jstack 往往能看到两类线程:
- 工作线程忙于执行慢任务
- 请求线程阻塞在
FutureTask.get()或CompletableFuture.join()
这时不要只盯着数据库线程,问题可能就在应用内部。
实战代码(可运行)
下面用一个简化可运行示例,复现“错误线程池配置导致排队和内存上涨”。
错误示例:无界队列导致任务堆积
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.*;
public class BadThreadPoolDemo {
// 典型误用:固定线程池 + 无界队列
private static final ExecutorService EXECUTOR = Executors.newFixedThreadPool(10);
public static void main(String[] args) throws Exception {
ScheduledExecutorService monitor = Executors.newSingleThreadScheduledExecutor();
monitor.scheduleAtFixedRate(() -> {
if (EXECUTOR instanceof ThreadPoolExecutor) {
ThreadPoolExecutor tpe = (ThreadPoolExecutor) EXECUTOR;
System.out.println(String.format(
"[MONITOR] poolSize=%d, active=%d, queue=%d, taskCount=%d, completed=%d",
tpe.getPoolSize(),
tpe.getActiveCount(),
tpe.getQueue().size(),
tpe.getTaskCount(),
tpe.getCompletedTaskCount()
));
}
}, 0, 1, TimeUnit.SECONDS);
// 模拟高并发请求不断提交慢任务
for (int i = 0; i < 100000; i++) {
final int requestId = i;
EXECUTOR.submit(() -> {
try {
// 模拟慢IO
Thread.sleep(2000);
// 模拟任务持有一定上下文数据
byte[] payload = new byte[1024 * 50];
payload[0] = 1;
return "request-" + requestId;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return "interrupted";
}
});
}
}
}
运行后你会看到什么
active很快打满到 10queue持续增长- 程序内存逐渐上升
- 执行速度非常慢
这就是典型的积压。
正确示例:显式构造线程池 + 有界队列 + 拒绝策略
import java.util.concurrent.*;
public class GoodThreadPoolDemo {
private static final ThreadPoolExecutor EXECUTOR = new ThreadPoolExecutor(
10, // corePoolSize
20, // maximumPoolSize
60L, TimeUnit.SECONDS, // keepAliveTime
new ArrayBlockingQueue<>(100), // 有界队列
new NamedThreadFactory("biz-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, taskCount=%d, completed=%d",
EXECUTOR.getPoolSize(),
EXECUTOR.getActiveCount(),
EXECUTOR.getQueue().size(),
EXECUTOR.getTaskCount(),
EXECUTOR.getCompletedTaskCount()
));
}, 0, 1, TimeUnit.SECONDS);
for (int i = 0; i < 1000; i++) {
final int requestId = i;
try {
EXECUTOR.submit(() -> {
try {
Thread.sleep(300);
return "request-" + requestId;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return "interrupted";
}
});
} catch (RejectedExecutionException e) {
System.out.println("task rejected: " + requestId);
}
}
Thread.sleep(10000);
EXECUTOR.shutdown();
monitor.shutdown();
}
static class NamedThreadFactory implements ThreadFactory {
private final String prefix;
private int index = 0;
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;
}
}
}
这个版本的几个关键点:
- 用
ThreadPoolExecutor显式配置,不偷懒 - 队列有界,防止任务无限堆积
- 使用
CallerRunsPolicy,在高压时让提交方“变慢”,形成背压 - 自定义线程名,便于排查
接口场景修复示例
如果你在 Web 接口中等待异步任务,一定要控制提交和等待边界:
import java.util.concurrent.*;
public class ApiService {
private final ThreadPoolExecutor executor = new ThreadPoolExecutor(
8,
16,
60L,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(200),
new ThreadPoolExecutor.AbortPolicy()
);
public String query() {
Future<String> future;
try {
future = executor.submit(() -> {
// 模拟下游调用
Thread.sleep(500);
return "success";
});
} catch (RejectedExecutionException e) {
return "系统繁忙,请稍后重试";
}
try {
return future.get(800, TimeUnit.MILLISECONDS);
} catch (TimeoutException e) {
future.cancel(true);
return "处理超时";
} catch (Exception e) {
return "系统异常";
}
}
}
这里的重点不是“返回什么文案”,而是:
- 队列满时要明确拒绝
- 等待超时后要取消任务
- 不要让请求线程无限等
常见坑与排查
坑 1:以为 fixedThreadPool 很稳
这是最常见误区。
很多人看到“固定线程数”,会觉得资源可控。实际上它只是控制了线程数,没有控制队列大小。接口类应用更怕的是排队,而不是线程创建本身。
排查办法
检查线程池初始化代码:
Executors.newFixedThreadPool(...)
Executors.newSingleThreadExecutor(...)
这两个都要重点审视,因为都可能带无界队列。
坑 2:接口里异步,最后又同步等待
很多代码看起来“用了异步”,其实只是把等待位置换了个地方:
Future<Result> future = executor.submit(task);
return future.get();
如果最终还是当前请求线程等结果,那它不是解耦,只是多引入了一层排队。
什么时候值得用线程池
- 需要隔离慢任务和主线程资源
- 有明确的超时、拒绝、降级方案
- 可以接受部分失败或异步返回
什么时候不值得
- 只是为了“看起来并发”
- 最终仍必须同步等待所有结果
- 下游本身已经很慢,线程池只会堆积更多请求
坑 3:线程池开太大
另一个方向的误用是:发现慢,就把线程池从 20 调到 200、500。
这通常只会:
- 加剧上下文切换
- 压垮数据库或下游服务
- 放大超时和失败面
线程池大小不是越大越好,要看任务类型:
- CPU 密集型:接近 CPU 核数
- IO 密集型:可以适当更大,但必须结合吞吐、下游能力、超时来压测
坑 4:没有监控,出事才看日志
线程池如果没有指标暴露,等接口超时了再看日志,其实已经比较晚了。
至少要监控这些指标
- 活跃线程数
- 队列长度
- 拒绝次数
- 任务执行耗时
- 任务排队耗时
- 超时次数
- 取消次数
坑 5:ThreadLocal 和上下文泄漏叠加问题
线程池线程会复用,如果任务里用了 ThreadLocal 但没有清理,问题会更隐蔽:
- 单次请求看不出来
- 长时间运行后内存异常
- 某些脏数据串到别的请求
建议
try {
// 设置ThreadLocal
} finally {
// 必须remove
}
线程池导致的内存上涨,不一定全是队列,也可能是队列积压 + ThreadLocal 泄漏一起发生。
stateDiagram-v2
[*] --> 正常
正常 --> 积压: 提交速率 > 消费速率
积压 --> 超时增多: 排队时间变长
超时增多 --> 容器线程阻塞
容器线程阻塞 --> 服务雪崩
积压 --> 内存上涨
内存上涨 --> 频繁GC
频繁GC --> 服务雪崩
止血方案
线上已经出故障时,不要一上来大改架构,先止血。
短期止血
-
限制入口流量
- 网关限流
- 降级部分非核心功能
- 拒绝低优先级请求
-
减少任务堆积
- 临时缩短超时时间
- 队列改为有界
- 快速失败而不是无限排队
-
隔离高风险任务
- 不同业务用不同线程池
- 不要一个公共线程池承接所有异步任务
-
必要时重启,但要知道只是缓解
- 重启能清掉积压队列
- 但如果配置不改,流量一上来还会复发
中期修复
- 按业务类型拆线程池
- 每个线程池单独评估容量
- 补监控和报警
- 给下游调用设置超时、重试上限
- 拒绝策略与业务降级联动
安全/性能最佳实践
1. 永远优先显式创建线程池
不要直接依赖 Executors 的默认快捷工厂,尤其在线上服务里。
推荐写法:
ThreadPoolExecutor executor = new ThreadPoolExecutor(
corePoolSize,
maximumPoolSize,
keepAliveTime,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(queueSize),
threadFactory,
new ThreadPoolExecutor.AbortPolicy()
);
2. 线程池参数要和业务匹配
至少明确这几个问题:
- 任务是 CPU 密集还是 IO 密集?
- 平均耗时和 P99 耗时是多少?
- 峰值 QPS 是多少?
- 下游能承受多少并发?
- 拒绝后业务是否允许降级?
一个简单估算思路
如果某类任务:
- 峰值每秒 100 请求
- 平均耗时 200ms
那么粗略并发需求约为:
100 * 0.2 = 20
这只是起点,不是最终值。还需要考虑:
- 抖动
- 长尾耗时
- 下游限流
- 容器总线程预算
3. 队列必须有界
无界队列适合非常有限的离线处理场景,不适合承接线上接口洪峰。
建议:
- 优先
ArrayBlockingQueue - 容量根据压测结果设置
- 让积压有上限,故障可控
4. 拒绝策略要可解释
常见策略:
AbortPolicy:直接抛异常,适合快速失败CallerRunsPolicy:提交方自己执行,形成自然背压DiscardPolicy:直接丢弃,不推荐,除非任务天然可丢DiscardOldestPolicy:丢最老任务,要谨慎
接口型业务通常更适合:
- 快速失败
- 明确告知系统繁忙
- 配合监控和降级
5. 给任务设置执行超时
线程池不是超时控制器,慢任务不会自动停止。
建议:
- 下游 HTTP/RPC/DB 都设置超时
Future.get设置等待超时- 超时后视情况
cancel(true) - 任务代码内部响应中断
6. 线程池要按职责隔离
不要把这些任务放到一个池子里:
- 用户请求链路任务
- 日志补偿任务
- 导出报表任务
- MQ 消费任务
- 定时任务
否则一个慢任务池就能拖垮全部业务。
7. 监控是线程池治理的一部分
建议暴露到监控系统的指标:
thread_pool_active_count
thread_pool_queue_size
thread_pool_rejected_count
thread_pool_completed_total
thread_pool_task_duration_ms
thread_pool_wait_duration_ms
报警阈值可以从这些入手:
- 队列长度持续超过容量 70%
- 拒绝次数连续增长
- 等待耗时大于执行耗时
- 活跃线程持续满载
一个实用排查清单
当你怀疑线程池导致接口超时时,可以按这个顺序看:
- 接口总耗时是否主要卡在等待异步结果
- 线程池是否用了无界队列
- 队列长度是否持续增长
- 活跃线程是否长期打满
- 堆内存中是否有大量任务对象/FutureTask
- 请求线程是否阻塞在
get/join - 下游调用是否本身就慢,导致池内任务出不来
- 是否存在公共线程池被多个业务争抢
- 是否缺少拒绝策略和降级
- 是否存在
ThreadLocal未清理问题
这个顺序的好处是:先判断是不是线程池积压,再追原因,不会一开始就陷入局部细节。
总结
这次问题的关键结论其实很朴素:
- 线程池不是用了就能提速
newFixedThreadPool在接口场景下很容易埋雷- 真正危险的是无界队列 + 慢任务 + 同步等待
- 超时和内存飙升,很多时候是同一个根因的两个表象
如果你只记住三件事,我建议是:
- 线上服务不要偷懒用默认线程池工厂
- 线程池一定要有界、有拒绝、有监控
- 如果请求线程最终还要等结果,就必须评估排队成本
最后给一个比较务实的边界条件:
如果你的任务执行时间不可预测、下游波动大、业务又要求强实时返回,那线程池不是万能药。这种场景更应该优先考虑:
- 限流
- 降级
- 结果缓存
- 异步化改造
- 链路资源隔离
把线程池当成“资源闸门”,而不是“性能外挂”,很多坑就能提前避开。