背景与问题
线上服务“突然变慢”,很多时候并不是数据库先出问题,也不是 GC 先背锅,而是线程池配置和使用方式不对。
我自己就踩过一个很典型的坑:Spring Boot 项目里为了“提升并发”,把一些耗时逻辑丢进线程池异步执行。上线初期看着没问题,请求量一上来,监控开始出现这些症状:
- 接口 RT 持续升高,从几百毫秒涨到十几秒
- Tomcat 工作线程占满,请求开始排队
- JVM 堆内存快速上涨,Old 区迟迟不回落
- Full GC 次数增多,但吞吐并没有恢复
- 应用没立刻挂,只是“越来越慢”,属于最难受的那种故障
最后排查发现,根因不是“线程太少”,恰恰是线程池误用:
- 用了
Executors.newFixedThreadPool(),默认队列几乎无界 - 每个请求都往线程池塞大量任务
- 任务里还有远程调用、数据库操作、日志拼接等慢动作
- 拒绝策略没有设计,实际上变成“无限堆积”
- 异步任务吃光内存后,反过来拖垮主请求线程
这类问题非常隐蔽,因为它往往不是瞬时崩,而是逐渐堆积,直到把 RT、吞吐、内存一起拉垮。
现象复现
先看一个很容易在项目里出现的错误写法。它不一定和你线上代码一模一样,但味道通常差不多。
错误示例:无界队列 + 慢任务
package com.example.demo.bad;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
@RestController
public class BadController {
// 典型误区:固定线程数 + 默认无界 LinkedBlockingQueue
private final ExecutorService executorService = Executors.newFixedThreadPool(8);
@GetMapping("/bad")
public String bad() {
for (int i = 0; i < 200; i++) {
executorService.submit(() -> {
try {
// 模拟慢任务:远程调用 / IO / 大对象处理
byte[] payload = new byte[1024 * 256]; // 256 KB
Thread.sleep(2000);
payload[0] = 1;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
return "submitted";
}
}
这个接口每请求一次,就往线程池里丢 200 个任务。线程池只有 8 个线程,但队列几乎不受控:
- 来得及执行的任务:只有 8 个工作线程在跑
- 来不及执行的任务:全都堆到队列里
- 队列里排队的任务对象、参数对象、闭包引用,都会占堆内存
- 请求一多,内存就会越来越高
复现思路
可以用压测工具简单打一下:
ab -n 2000 -c 50 http://127.0.0.1:8080/bad
或者:
wrk -t4 -c50 -d30s http://127.0.0.1:8080/bad
你通常会看到:
- 接口本身返回很快,因为只是“提交任务”
- 但后台任务永远处理不过来
- JVM 内存持续增长
- 队列长度越来越夸张
- 最后 GC 频繁,甚至 OOM
核心原理
这个坑要真正修好,关键不是背几个线程池参数,而是理解线程池的工作机制。
ThreadPoolExecutor 的任务接收顺序
线程池接收任务时,大致遵循下面这个流程:
flowchart TD
A[提交任务] --> B{当前线程数 < corePoolSize?}
B -- 是 --> C[创建核心线程执行]
B -- 否 --> D{队列未满?}
D -- 是 --> E[进入阻塞队列等待]
D -- 否 --> F{当前线程数 < maximumPoolSize?}
F -- 是 --> G[创建非核心线程执行]
F -- 否 --> H[触发拒绝策略]
这里最容易被忽略的一点是:
如果队列是无界的,那么任务几乎总是先进队列,
maximumPoolSize基本失效。
也就是说,很多人以为自己配了:
- corePoolSize = 8
- maximumPoolSize = 64
就能顶住高峰。
但如果队列是 LinkedBlockingQueue 默认无界,实际往往只会稳定在 8 个线程,然后无限排队。
为什么会导致内存飙升
因为任务不是抽象概念,它们都是对象:
Runnable/Callable本身是对象- Lambda 可能持有上下文引用
- 任务参数可能引用请求体、DTO、缓存对象
- 如果里面构造了大对象,或者闭包捕获了大对象,排队任务就像一节一节货车往堆里塞
可以把它理解成:
- 线程池不是“加速器”
- 它更像“收费站”
- 通道有限,后面车无限排队,就会把高速路堵死
Spring Boot 中更隐蔽的误用
在 Spring Boot 里,常见误用还有这几类:
-
@Async没有显式线程池- 会走默认执行器
- 行为不符合预期,容量不可控
-
业务线程池和请求线程池互相等待
- 主线程
future.get()等异步结果 - 异步任务又依赖其他受限资源
- 很容易造成“伪异步”
- 主线程
-
线程池里跑阻塞 IO
- 远程接口慢、数据库慢、消息积压
- CPU 看着不高,但线程长期占住
-
把线程池当削峰缓冲区
- 没有队列上限
- 没有超时
- 没有降级
- 本质上是把流量压力从入口搬到内存里
定位路径
线上碰到这类问题时,我一般不是一上来改参数,而是先按下面路径收敛问题。
1. 看接口与线程池指标是否同步恶化
重点观察:
- 请求 QPS
- 接口 RT、P99
- Tomcat
maxThreads使用率 - 业务线程池的:
activeCountpoolSizequeueSizecompletedTaskCount
- JVM 堆使用量、GC 次数、Full GC 时间
如果你发现:
- RT 越来越高
- 线程池活跃线程数接近上限
- 队列持续增长不回落
- 堆内存同步上涨
那基本就可以锁定到“异步任务堆积”。
2. 用线程 dump 看线程在干什么
jstack <pid> > thread_dump.txt
重点看:
- 大量业务线程是否都卡在
sleep/socketRead/http client/jdbc - Tomcat 线程是否在等待业务返回
- 是否有线程池名字能对应到你的自定义执行器
3. 看堆快照里谁在占内存
jmap -dump:live,format=b,file=heap.hprof <pid>
用 MAT 或 JProfiler 打开,重点看:
LinkedBlockingQueue$Node- 大量
FutureTask - 大量业务
Runnable - 某些 DTO / byte[] / 日志字符串是否被任务引用链挂住
如果 Runnable、FutureTask、队列节点很多,基本就是任务积压。
4. 检查代码里线程池创建方式
尤其搜索这些关键词:
Executors.newFixedThreadPool
Executors.newCachedThreadPool
Executors.newSingleThreadExecutor
@Async
CompletableFuture.supplyAsync
很多问题的根因不是“线程池不存在”,而是偷偷用了默认线程池。
实战代码(可运行)
下面给一个可运行、可观测、可止血的 Spring Boot 示例。重点是:
- 不使用
Executors快捷工厂 - 显式定义有界队列
- 设置合理拒绝策略
- 暴露线程池状态用于排查
- 给异步任务加超时与异常处理
1. 线程池配置
package com.example.demo.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.concurrent.*;
@Configuration
public class ExecutorConfig {
@Bean("bizExecutor")
public ThreadPoolExecutor bizExecutor() {
int corePoolSize = 8;
int maximumPoolSize = 16;
long keepAliveTime = 60L;
int queueCapacity = 200;
return new ThreadPoolExecutor(
corePoolSize,
maximumPoolSize,
keepAliveTime,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(queueCapacity),
new NamedThreadFactory("biz-exec-"),
new ThreadPoolExecutor.CallerRunsPolicy()
);
}
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;
}
}
}
为什么这里这么配
ArrayBlockingQueue<>(200):有界队列,避免无限堆积maximumPoolSize = 16:高峰期允许一定扩容CallerRunsPolicy:队列满时让提交方自己执行,形成自然背压- 自定义线程名:方便
jstack排查
2. 模拟业务服务
package com.example.demo.service;
import org.springframework.stereotype.Service;
@Service
public class SlowBusinessService {
public String process(String input) {
try {
Thread.sleep(500); // 模拟慢 IO
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return "interrupted";
}
return "done:" + input;
}
}
3. 正确提交异步任务
package com.example.demo.controller;
import com.example.demo.service.SlowBusinessService;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
@RestController
public class GoodController {
private final ThreadPoolExecutor bizExecutor;
private final SlowBusinessService slowBusinessService;
public GoodController(@Qualifier("bizExecutor") ThreadPoolExecutor bizExecutor,
SlowBusinessService slowBusinessService) {
this.bizExecutor = bizExecutor;
this.slowBusinessService = slowBusinessService;
}
@GetMapping("/good")
public String good(@RequestParam(defaultValue = "demo") String input) {
try {
return CompletableFuture
.supplyAsync(() -> slowBusinessService.process(input), bizExecutor)
.orTimeout(1, TimeUnit.SECONDS)
.exceptionally(ex -> "fallback")
.join();
} catch (Exception e) {
return "error";
}
}
@GetMapping("/pool-stats")
public String poolStats() {
return String.format(
"poolSize=%d, active=%d, core=%d, max=%d, queue=%d, completed=%d",
bizExecutor.getPoolSize(),
bizExecutor.getActiveCount(),
bizExecutor.getCorePoolSize(),
bizExecutor.getMaximumPoolSize(),
bizExecutor.getQueue().size(),
bizExecutor.getCompletedTaskCount()
);
}
}
这个版本虽然不是“绝对最强”,但已经比无界队列安全很多。至少高峰来了以后,不会无止境把任务和内存一起堆爆。
4. 启动类
package com.example.demo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}
5. Maven 依赖
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>demo</artifactId>
<version>0.0.1-SNAPSHOT</version>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.5</version>
</parent>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
</dependencies>
</project>
核心原理补充:为什么 CallerRunsPolicy 能止血
很多同学第一次看到 CallerRunsPolicy 会疑惑:
“拒绝就拒绝,为什么要让调用方自己跑?”
因为它能形成背压。
sequenceDiagram
participant C as Client
participant T as Tomcat线程
participant P as 业务线程池
participant B as 业务任务
C->>T: 发起HTTP请求
T->>P: 提交异步任务
alt 队列未满
P->>B: 工作线程执行
B-->>T: 返回结果
T-->>C: 响应
else 队列已满
T->>B: 调用方线程自己执行
B-->>T: 返回结果
T-->>C: 响应变慢
end
它的效果是:
- 正常时:线程池消化任务
- 拥堵时:提交方线程被迫承担执行成本
- 提交速度自然下降
- 系统不会无限制堆积内存
当然,它也有边界:
- 如果主请求线程不能被拖慢,就不能直接这么用
- 如果接口必须秒回,可能更适合快速失败或降级
所以拒绝策略没有银弹,只有适合场景的权衡。
常见坑与排查
这一部分我尽量写成“看到现象就能对号入座”的形式。
坑 1:用了 Executors.newFixedThreadPool() 觉得很安全
ExecutorService pool = Executors.newFixedThreadPool(16);
问题不是 fixed,而是它底层常配无界队列。
结果就是:
- 线程数固定住了
- 队列无限长
- 内存风险被隐藏了
建议
始终优先手动 new ThreadPoolExecutor,把这几个参数写清楚:
- corePoolSize
- maximumPoolSize
- queueCapacity
- threadFactory
- rejectedExecutionHandler
坑 2:maximumPoolSize 配了,但从来没生效
这几乎都和队列类型有关。
如果是无界队列,线程池通常在核心线程满后直接入队,不会继续扩到 maximumPoolSize。
排查办法
看队列实现:
new LinkedBlockingQueue<>()
如果没传容量,通常就要警觉。
坑 3:每个请求批量提交子任务
例如一个接口进来,循环 100 次提交任务。
在低并发时没问题,一旦请求并发上来,任务总量是乘法增长:
50 个并发请求 × 每个请求 100 个任务 = 5000 个待处理任务
建议
- 能串行就别盲目拆分并发
- 批量任务优先考虑限流、分批、消息队列削峰
- 不要把请求线程池直接变成“任务投递器”
坑 4:异步了,但马上 join() / get()
CompletableFuture<String> future = CompletableFuture.supplyAsync(task, bizExecutor);
return future.join();
这属于很常见的“伪异步”:
- 你把任务扔给线程池
- 当前请求线程又在原地等结果
- 多了一次线程切换
- 如果线程池拥堵,请求线程一起被拖死
什么时候还能这么用?
- 确实需要隔离执行资源
- 需要超时控制和拒绝保护
- 需要把某类慢任务从 Tomcat 线程中剥离出来
否则,盲目异步只会让问题更复杂。
坑 5:线程池里执行的任务没有超时
远程调用最怕这个:
- 下游慢
- 连接没及时断
- 线程一直卡住
- 池子很快耗尽
建议
超时要成套配置:
- HTTP connect timeout
- HTTP read timeout
- 数据库超时
- Future 超时
- 熔断 / 降级
坑 6:没有监控队列长度
如果没有线程池指标,线上只能靠猜。
最少也要采集:
- 活跃线程数
- 当前线程数
- 队列长度
- 拒绝次数
- 完成任务数
如果用了 Micrometer,可以把这些指标接到 Prometheus。
止血方案
线上已经出现请求堆积和内存上涨时,不建议一上来就“多加线程”。我更推荐下面这个顺序。
1. 先限制堆积规模
最优先做的是把无界队列改成有界队列。
new ArrayBlockingQueue<>(200)
这样至少能把风险控制在一个可预期范围内。
2. 再选择拒绝策略
常见选择:
AbortPolicy:直接拒绝,快速失败CallerRunsPolicy:调用方执行,形成背压- 自定义策略:记录日志、打点、降级返回
如果你的接口必须保障主链路 RT,优先考虑:
new ThreadPoolExecutor.AbortPolicy()
然后在业务层捕获拒绝异常,返回“系统繁忙,请稍后重试”。
3. 给慢任务做超时和降级
不要让任务无限等下游。
4. 从源头减少任务量
例如:
- 合并重复任务
- 避免每次请求都异步刷日志/查库/调下游
- 非核心操作改消息队列异步化
- 对热点接口做限流
安全/性能最佳实践
这一部分给一些真正能落地的建议。
1. 不要混用不同性质的任务
CPU 密集和 IO 密集任务,不建议共用一个线程池。
- CPU 密集:线程数接近 CPU 核数
- IO 密集:线程数可以适当更高,但必须严格有界
如果混在一起,IO 阻塞很容易把 CPU 任务也拖慢。
2. 线程池容量要结合业务时间算
一个非常实用的思路:
可承受并发任务数 ≈ 线程数 + 队列容量
比如:
- 最大线程数 16
- 队列容量 200
那最多只允许 216 个任务处于“执行中 + 等待中”。
如果入口流量远大于这个值,就必须:
- 限流
- 拒绝
- 降级
- 削峰到 MQ
而不是继续往内存里塞。
3. 线程名一定要自定义
这件事看起来小,但线上排查时价值极大。
错误示例:
pool-1-thread-1
pool-1-thread-2
正确示例:
biz-exec-1
biz-exec-2
order-async-1
report-export-1
你在 jstack 里一眼就知道谁在堵。
4. 给线程池加监控和报警阈值
至少建议对下面指标报警:
- 队列使用率超过 70%
- 活跃线程接近最大线程数
- 拒绝次数持续增加
- 平均任务耗时突然拉长
很多事故其实在“快出问题”时已经有信号,只是没被看见。
5. 明确异步任务是否必须和请求强绑定
这是设计上的关键问题。
如果任务结果必须立刻返回给用户,那它本质还是同步链路的一部分。
这时线程池更多是资源隔离工具,不是提升吞吐的万能钥匙。
如果任务不要求立即完成,比如:
- 发通知
- 刷统计
- 生成报表
- 同步搜索索引
更适合:
- 消息队列
- 延迟任务
- 后台作业系统
不要把 HTTP 请求入口当成批处理调度器。
6. 注意上下文传播和对象引用
异步任务里如果顺手把这些对象带进去:
HttpServletRequest- 大型 DTO
- 用户上下文
- 大对象缓存引用
就可能加重内存占用,甚至引发线程安全问题。
建议
只传递必要参数,避免把整个请求上下文塞进任务闭包。
一张图看完整排查思路
flowchart LR
A[接口RT上升] --> B[检查线程池指标]
B --> C{队列持续增长?}
C -- 是 --> D[检查是否无界队列]
D --> E[查看任务是否慢IO/批量提交]
E --> F[抓线程Dump和HeapDump]
F --> G[改为有界队列+拒绝策略+超时]
C -- 否 --> H[排查数据库/下游依赖/锁竞争]
总结
这类问题的本质,不是“线程池参数没调好”这么简单,而是:
把线程池当成无限缓冲区,最终把流量压力转移成了请求堆积和内存压力。
如果你只记住 5 条,我建议记这几个:
- 不要滥用
Executors默认工厂方法 - 线程池队列必须有界
- 拒绝策略要结合业务设计,不能默认不管
- 慢任务必须有超时、降级和监控
- 异步不是性能优化的同义词,很多时候只是延迟暴露问题
最后给一个可执行的排查顺序:
- 看 RT、GC、线程池队列是否同步恶化
- 用
jstack看线程都卡在哪 - 用堆快照确认是不是任务对象堆积
- 检查是否用了无界队列或默认线程池
- 改成有界队列 + 合理拒绝策略 + 超时控制
- 必要时做限流、降级、MQ 削峰
如果你的服务已经在高峰期出现“慢但不挂、GC 很忙、内存持续涨”的症状,优先怀疑线程池堆积,往往不会错。