Spring Boot 请求处理链路

1. Spring Boot 请求处理全链路

当一个 HTTP 请求到达 Spring Boot 应用时,它经历的完整路径如下:

  1. 连接器监听 (Connector): 嵌入式 Tomcat(或其他容器)的 Acceptor 线程监听到 TCP 连接。

  2. 线程池分配 (Executor): Tomcat 维护着一个工作线程池(Work Threads)。它会从池中取出一个空闲线程来处理该请求。

  3. 过滤器链 (Filter Chain): 请求经过一系列 Servlet Filter(如 Spring Security, CharacterEncodingFilter)。

  4. 核心调度 (DispatcherServlet): 请求到达 Spring MVC 的心脏。它负责找到对应的 Handler(Controller)。

  5. 拦截器 (Interceptor): 执行 preHandle 方法。(这里通常是 ThreadLocal.set() 的最佳时机)

  6. 业务处理 (Controller/Service/DAO): 业务逻辑执行。由于处于同一线程,可以跨层通过 ThreadLocal 共享数据。

  7. 响应返回: 执行拦截器的 afterCompletion(这里是 ThreadLocal.remove() 的黄金时机)

  8. 线程归还: 线程完成任务,并不会被销毁,而是带着它的 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 的逻辑是:

  1. 核心线程满了 → 放进队列

  2. 队列满了 → 再开启最大线程

Tomcat 认为这不符合 Web 特性: 如果有大量请求进来,先排队会导致响应变慢。Tomcat 希望尽快开启 maxThreads 来处理请求,而不是先让请求在队列里发霉。

Tomcat 的逻辑:

  1. 核心线程(minSpareThreads)满了 → 依然尝试开启新线程 ,直到达到 maxThreads

  2. 达到 maxThreads 之后 → 才放进队列排队

  3. 队列也满了 → 抛出拒绝策略


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 产生了两个直接影响:

  1. 数据的"幽灵效应": 一个工作线程在处理完请求后,并不会被销毁。如果你在处理过程中通过 ThreadLocal.set() 存了值但没 remove(),这个值会随着线程回到池子。当下一个倒霉的请求复用这个线程时,它能直接通过 get() 拿到上一个请求的数据。

  2. 内存累积: 即使并发量下来了,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 中,你可以搜索编号为 1107412900 的经典仪表盘模板,它们能直接展示出:

  • 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 密集型的。
相关推荐
rabbitlzx1 小时前
《Async in C# 5.0》第十四章 深入探讨编译器对于async的转换
java·开发语言·c#·异步·asynchronous
LSL666_1 小时前
3 Redis 的 Java 客户端
java·数据库·redis
虫师c1 小时前
Spring Boot自动配置黑魔法:手写Starter实现原理深度解析
java·spring boot·后端·自动配置·starter
树獭叔叔1 小时前
大模型对齐终极指南:RLHF 与DPO
后端·openai
code袁2 小时前
基于Springboot+Vue的家教小程序的设计与实现
vue.js·spring boot·小程序·vue·家教小程序
范什么特西2 小时前
狂神---死锁
java·前端·javascript
code_YuJun2 小时前
Servlet
后端
小飞学编程...2 小时前
【Java相关八股文(二)】
android·java·开发语言
程序猿阿越2 小时前
Kafka4(一)KRaft下的Controller
java·后端·源码阅读