1. Spring Boot 请求处理全链路
当一个 HTTP 请求到达 Spring Boot 应用时,它经历的完整路径如下:
-
连接器监听 (Connector): 嵌入式 Tomcat(或其他容器)的 Acceptor 线程监听到 TCP 连接。
-
线程池分配 (Executor): Tomcat 维护着一个工作线程池(Work Threads)。它会从池中取出一个空闲线程来处理该请求。
-
过滤器链 (Filter Chain): 请求经过一系列 Servlet Filter(如 Spring Security, CharacterEncodingFilter)。
-
核心调度 (DispatcherServlet): 请求到达 Spring MVC 的心脏。它负责找到对应的 Handler(Controller)。
-
拦截器 (Interceptor): 执行
preHandle方法。(这里通常是ThreadLocal.set()的最佳时机)。 -
业务处理 (Controller/Service/DAO): 业务逻辑执行。由于处于同一线程,可以跨层通过
ThreadLocal共享数据。 -
响应返回: 执行拦截器的
afterCompletion。(这里是ThreadLocal.remove()的黄金时机)。 -
线程归还: 线程完成任务,并不会被销毁,而是带着它的
ThreadLocalMap回到线程池。
2. 线程模型的关键参数
Spring Boot 默认使用 Tomcat 作为 Web 容器,其线程池配置直接影响并发能力:
-
server.tomcat.threads.max: 最大工作线程数(默认 200)。这决定了应用能同时处理多少个并发请求。 -
server.tomcat.threads.min-spare: 最小空闲线程数(默认 10)。
核心冲突点:
线程是有限且复用 的。如果请求 1 在线程 A 中存了
ThreadLocal没清理,请求 2 复用线程 A 时,就像是"穿了别人的脏衣服"。
3. Spring Boot 中的对象生命周期
在可达性分析视角下,Spring Boot 请求模型中的对象关系如下:
| 对象类型 | 生命周期 | 存储位置 |
|---|---|---|
| Request/Response | 仅限单次请求 | 局部变量/栈 |
| Spring Bean (Singleton) | 应用全生命周期 | 堆 (由 ApplicationContext 引用) |
| ThreadLocal 数据 | 跟随工作线程 | 线程内部的 ThreadLocalMap |
4. 异步模型:Spring WebFlux (响应式)
如果你使用的是 Spring WebFlux(底层通常是 Netty),请求模型会发生巨变:
-
非阻塞模型: 少量线程处理海量请求。一个线程在等待 I/O 时会去处理其他请求。
-
ThreadLocal 失效 : 在 WebFlux 中,绝对不能使用 ThreadLocal。因为一个请求可能会由多个不同的线程交替处理,或者一个线程在同一时间交替处理多个请求。
-
替代方案 : 使用 Reactor 提供的
Context对象。
5. 最佳实践总结
在 Spring Boot 的标准模型中,你可以放心使用 ThreadLocal 来优化代码架构,但必须遵循以下模式:
Java
// 使用拦截器形成完整的生命周期闭环
@Component
public class ContextInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(...) {
// 入口:初始化环境
BaseContext.setUserId(extractId(request));
return true;
}
@Override
public void afterCompletion(...) {
// 出口:必须清理,防止线程池污染
BaseContext.remove();
}
}
追问1:Tomcat 线程池模型
Gemini said
Tomcat 的线程池模型是其高性能的核心,它在标准 Java 线程池(ThreadPoolExecutor)的基础上进行了"魔改",以更适应 Web 场景下 I/O 密集型任务的特点。
1. 核心差异:Tomcat 的"逆天"逻辑
标准的 Java ThreadPoolExecutor 的逻辑是:
-
核心线程满了 → 放进队列。
-
队列满了 → 再开启最大线程。
Tomcat 认为这不符合 Web 特性: 如果有大量请求进来,先排队会导致响应变慢。Tomcat 希望尽快开启 maxThreads 来处理请求,而不是先让请求在队列里发霉。
Tomcat 的逻辑:
-
核心线程(
minSpareThreads)满了 → 依然尝试开启新线程 ,直到达到maxThreads。 -
达到
maxThreads之后 → 才放进队列排队。 -
队列也满了 → 抛出拒绝策略。
2. 核心组件:Executor 与 TaskQueue
Tomcat 实现这种逻辑的关键在于定制了 TaskQueue。
-
TaskQueue (任务队列): 它重写了
offer()方法。当线程池尝试把任务放入队列时,队列会先判断:"现在线程数达到最大了吗?"如果没有,它会返回false(强迫线程池创建新线程),而不是老老实实地接收任务。 -
LimitLatch (限流锁): 用来控制最大连接数。如果连接数超过
maxConnections,Tomcat 会阻塞新的连接,不让它进入线程池。
3. 关键参数设置
在 Spring Boot 的 application.yml 中,你可以直接调整这些参数来优化你那台 2 核设备:
YAML
server:
tomcat:
threads:
# 核心线程数(最小空闲线程)
min-spare: 10
# 最大线程数(处理并发请求的主力)
max: 200
# 最大连接数(能同时接待的客人总量,包含排队的)
max-connections: 8192
# 等待队列长度
accept-count: 100
4. 线程模型与 ThreadLocal 的"羁绊"
由于 Tomcat 线程池采用的是 "长寿线程复用" 机制,这对 ThreadLocal 产生了两个直接影响:
-
数据的"幽灵效应": 一个工作线程在处理完请求后,并不会被销毁。如果你在处理过程中通过
ThreadLocal.set()存了值但没remove(),这个值会随着线程回到池子。当下一个倒霉的请求复用这个线程时,它能直接通过get()拿到上一个请求的数据。 -
内存累积: 即使并发量下来了,Tomcat 也会维持
min-spare数量的线程。这意味着至少有这么多份ThreadLocalMap会永久驻留在堆内存中。
5. 2 核设备下的性能建议
对于 2 核 100 并发请求的场景,Tomcat 的模型会这样运作:
-
CPU 调度: 尽管有 100 个线程在跑,但 2 个物理核心会在这些线程间快速切换。
-
瓶颈在于: 如果你的业务逻辑涉及大量数据库查询(I/O),100-200 个线程是合理的;如果业务逻辑是纯数学计算,200 个线程会导致严重的上下文切换损耗,反而不如减少线程数。
总结
Tomcat 的线程池是一个"激进"的池子,它优先创建线程而非优先排队。这提高了 Web 响应速度,但也意味着对 ThreadLocal 的清理要求达到了"强迫症"级别,因为任何一次遗忘都会导致线程池中的某个线程被"污染"。
追问2:如何通过 Spring Boot 的 Actuator 端点实时查看当前 Tomcat 线程池的活跃线程数和队列状态
要实时监控 Spring Boot 内部 Tomcat 线程池的状态,最直接的方法是利用 Spring Boot Actuator 提供的 metrics 端点。
通过这些数据,你可以清晰地看到在那台 2 核设备上,100 个请求进来时,线程池是如何震荡的。
1. 开启监控配置
首先,确保你的 pom.xml 引入了依赖:
XML
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
然后在 application.yml 中暴露监控端点:
YAML
management:
endpoints:
web:
exposure:
include: "metrics,prometheus" # 暴露指标端点
endpoint:
metrics:
enabled: true
2. 关键监控指标查询
服务启动后,你可以通过访问以下 URL(GET 请求)来获取 Tomcat 线程池的实时数值。
① 查看当前活跃线程数 (Active Threads)
URL: /actuator/metrics/tomcat.threads.active
-
含义: 当前正在处理请求的线程数量。
-
分析: 如果这个值长时间等于
maxThreads,说明你的 2 核服务器已经满载,后续请求开始排队。
② 查看当前已创建的线程总数 (Current Threads)
URL: /actuator/metrics/tomcat.threads.config.max (最大值) URL: /actuator/metrics/tomcat.threads.current (当前存活值)
- 分析: Tomcat 会根据流量动态增减线程。通过这个值可以观察线程池的扩容情况。
③ 查看等待队列状态 (Task Queue)
虽然 Actuator 原生对 Tomcat 队列深度的直接 Metric 较少,但你可以通过以下指标间接判断: URL: /actuator/metrics/tomcat.connections.current
- 含义: 当前所有的连接数(包括正在处理的和正在排队的)。
3. 更直观的方案:Prometheus + Grafana
直接看 JSON 数据很累,在生产环境中,通常将 Actuator 的数据接入 Prometheus ,然后在 Grafana 中配置看板。
在 Grafana 中,你可以搜索编号为 11074 或 12900 的经典仪表盘模板,它们能直接展示出:
-
Threads Busy (活跃线程)
-
Threads Total (总线程)
-
Connection Count (连接数)
4. 进阶:如何监控 ThreadLocal 导致的内存增长?
既然我们之前聊了 ThreadLocal 内存泄漏,你可以配合监控老年代内存占用 : URL: /actuator/metrics/jvm.memory.used?tag=area:heap&tag=id:G1 Old Gen (以 G1 回收器为例)
观察技巧: 如果在请求峰值过后,活跃线程数降下来了,但 Old Gen(老年代) 的内存占用却没有回落,这通常就是
ThreadLocal里的Value没有被remove()掉,随着线程回到了池子,并最终晋升到了老年代。
总结
通过 Actuator,你可以实时监控到 2 核设备在高并发下的真实压力。如果你发现 tomcat.threads.active 频繁触顶且响应变慢,那就说明需要增加核心数或者优化 ThreadLocal 的使用策略了。
追问3:设置的核心线程数大于机器物理线程数会怎么样,会创建这么多线程吗
简短的回答是:会创建这么多线程。 JVM 和操作系统并不限制你创建比 CPU 核心数更多的线程,但"能创建"和"跑得快"是两码事。
1. 线程真的会创建出来吗?
是的。 当你设置核心线程数为 100,而机器只有 2 核时,JVM 会向操作系统申请创建 100 个线程。
-
操作系统视角: 只要内存足够(每个线程默认分配约 1MB 栈空间),操作系统就会分配这些线程。
-
状态: 这 100 个线程可以同时处于
RUNNABLE(就绪/运行)状态。
2. 物理核心如何处理这些线程?
虽然你创建了 100 个线程,但 2 个物理核心在同一瞬间只能真正执行 2 个线程。
为了让这 100 个线程看起来都在"同时运行",操作系统使用了时间片轮转(Time Slicing)。
-
CPU 给线程 A 跑 10 毫秒,然后强行暂停,把线程 A 的状态保存起来。
-
CPU 切换到线程 B 跑 10 毫秒。
-
这个切换过程就叫 上下文切换(Context Switch)。
3. 设置过大的后果是什么?
① 性能下降(计算密集型任务)
如果你的任务是纯计算(如:视频转码、复杂算法、加解密):
-
后果: 设置核心线程数远超 CPU 核心数是负优化。
-
原因: CPU 本来就在全力奔跑,频繁的上下文切换(保存寄存器、刷新缓存等)会白白浪费大量的 CPU 资源。就像一个厨师切 100 个菜,如果切一刀就换一个菜板,效率会极低。
② 吞吐量提升(I/O 密集型任务)
如果你的任务是 I/O 密集型(如:查询数据库、调用远程 API、读写文件):
-
后果: 设置核心线程数大于 CPU 核心数是非常有必要的。
-
原因: 当线程 A 在等待数据库返回数据时,它是阻塞的,不占用 CPU。此时 CPU 可以切换到线程 B 去处理业务。
-
公式参考: 常见的经验公式是 线程数=CPU核心数×(1+线程等待时间/线程计算时间)。
③ 内存压力与 OOM 风险
每个线程都需要独立的**虚拟机栈(Stack)**空间(默认 1M)。
-
100 个线程 = 100MB 内存。
-
1000 个线程 = 1GB 内存。
-
如果线程数过多,可能会直接触发
java.lang.OutOfMemoryError: unable to create new native thread。
4. 与 ThreadLocal 的关系
回到我们之前的讨论,如果 2 核机器你开了 100 个核心线程:
-
内存中会长期存在 100 个
ThreadLocalMap。 -
即使 CPU 核心只有 2 个,这 100 个 Map 也会一直占用堆内存。
-
如果没有
remove(),这 100 个长寿线程会一直拽着它们各自的 Value,导致内存持续阴跌。
5. 总结:该怎么设?
| 场景 | 推荐线程数 | 理由 |
|---|---|---|
| 计算密集型 | CPU核心数+1 | 减少切换开销,保持 CPU 高效率。 |
| I/O 密集型 | CPU核心数×2 到 CPU核心数×N | 利用等待时间处理更多请求。 |
| Spring Boot 默认 | 200 | 考虑到大多数 Web 请求都是 I/O 密集型的。 |