springboot 每个 web 请求是一个线程吗?
Spring Boot 服务挂了,但 CPU 利用率只有 3%。内存没满,磁盘没满,网络也通。什么资源都没耗尽,请求就是进不来。
这种场景在用 Spring Boot 的团队里不算罕见。200 个工作线程全部卡在等 IO——等数据库、等第三方接口、等 Redis——没有一个线程空出来接新请求。CPU 闲着是因为线程什么计算都没做,纯粹在等。
搞清楚 Spring Boot 底下的线程模型到底是怎么回事,这种问题就不用排查半天了。
一个请求的完整旅程
客户端发了一个 HTTP 请求,到达服务器的网卡。操作系统把这个连接放进 TCP 的 backlog 队列。然后 Tomcat 的三层线程模型开始接手。
Acceptor 线程负责从操作系统的队列里把连接拿出来。这个线程默认只有 1 个,它干的活很轻——就是不停地调 ServerSocketChannel.accept(),把新连接捞上来,然后交给下一层。
Poller 线程拿到连接之后,用 Java NIO 的 Selector 监听这个连接上有没有数据可读。如果客户端还没把请求体发完,Poller 就等着,不占用工作线程。等数据到齐了,Poller 把这个连接包装成一个任务,丢进工作线程池。
Worker 线程才是真正干活的。它从线程池里被唤醒,开始处理这个请求:解析 HTTP 报文、走 Filter 链、进 DispatcherServlet、路由到你的 Controller 方法、执行业务逻辑、写 response。从你的 Controller 代码的角度看,整个过程就是在一个 Worker 线程里同步执行的。
server.tomcat.threads.max=200 控制的就是 Worker 线程池的大小。这 200 个线程用完了,新请求就得在队列里排着,队列也满了(accept-count 默认 100),直接拒绝连接。
所以准确地说:不是"一个请求一个线程",而是"一个请求在处理阶段占用一个 Worker 线程"。Acceptor 和 Poller 是共享的,不会被单个请求独占。
线程被占住的时候在干什么
理解了 Worker 线程是请求独占的,很多性能问题就好理解了。
你的 Controller 里调了一次数据库查询,耗时 50ms。在这 50ms 里,Worker 线程在干什么?什么都没干。它在等 JDBC 驱动把数据从 MySQL 那头取回来。等数据回来了,线程继续往下跑。
如果这个查询没走索引,耗时 3 秒。这个 Worker 线程就被白白占用了 3 秒。在这 3 秒里,线程池少了一个可用线程。200 个线程里如果有 50 个都卡在慢 SQL 上,剩下 150 个线程要处理所有其他请求。
再极端一点:你的接口调了一个第三方支付接口,对方要 5 秒才返回。这个 Worker 线程傻等 5 秒,什么有意义的计算都没做,纯粹在等网络 IO。如果并发来了 200 个这种请求,200 个线程全部挂在等支付接口上,服务彻底卡死——新请求一个都处理不了,但 CPU 利用率可能只有 5%。
这就是传统线程模型最大的问题:线程在等 IO 的时候是闲着的,但它占着茅坑不拉屎。 线程池的上限不是 CPU 的上限,而是"同时能有多少个请求在等 IO"的上限。
用 jstack 导出线程快照看一眼就很直观。200 个 Worker 线程,可能大半都卡在 socket read、等数据库返回、等 HTTP 响应这些地方。CPU 几乎空转,但线程池已经满了。
@Async 用的不是请求线程
有一个容易搞混的地方。你在 Controller 里调了一个 @Async 方法:
@GetMapping("/order")
public Result createOrder() {
orderService.createOrder(); // 同步,用的是 Tomcat Worker 线程
notifyService.sendNotification(); // @Async,用的是另一个线程池的线程
return Result.success();
}
sendNotification() 标了 @Async,Spring 会把它提交到另一个线程池里执行。这个线程池跟 Tomcat 的 Worker 线程池是两回事——它是 Spring 自己管理的 TaskExecutor。
所以 Controller 方法返回 response 之后,Tomcat 的 Worker 线程就释放了,可以去接下一个请求。但 sendNotification() 还在另一个线程里跑着。
Spring Boot 2.1 之后默认会自动配一个 ThreadPoolTaskExecutor,核心线程 8 个。听起来没问题,但有个隐藏的坑:默认队列容量是 Integer.MAX_VALUE。ThreadPoolExecutor 的扩容逻辑是"队列满了才创建新线程",队列无限大就意味着永远不会满,永远只有 8 个核心线程在干活。异步任务量一大,任务全堆在队列里,8 个线程慢慢消化,队列越堆越长,内存越吃越多。生产环境一定要自己配 queueCapacity 和 maxPoolSize,给线程池一个明确的边界。
虚拟线程:不再占着茅坑
Java 21 引入了虚拟线程,Spring Boot 3.2+ 一行配置就能开启:
spring:
threads:
virtual:
enabled: true
开了之后,Tomcat 的 Worker 线程从操作系统线程换成了虚拟线程。区别在哪?
传统的操作系统线程,一个请求占一个,等 IO 的时候线程挂起但不释放——操作系统不知道你在等 IO,它只知道这个线程还活着。虚拟线程不一样:它等 IO 的时候会主动让出底层的操作系统线程。JVM 把虚拟线程的栈保存起来,底层的操作系统线程去跑别的虚拟线程。等 IO 回来了,JVM 再把栈恢复,找一个操作系统线程继续跑。
打个比方:传统模型是每个客户配一个专属服务员,客户说"我要等朋友到了再点菜",服务员就站在旁边干等着。虚拟线程模型是客户说"等朋友",服务员先去服务其他桌,朋友到了再回来。
实际效果:同样的硬件,传统线程池 200 个线程可能只能扛 200 个并发 IO 请求。换成虚拟线程,几万个请求同时等 IO 也没问题——因为底层可能只用了几十个操作系统线程在轮转。
但虚拟线程不是万能的。你的接口要是在做 CPU 密集的计算(加解密、图片处理、复杂的业务规则引擎),线程等不等 IO 无所谓,CPU 本身就是瓶颈。这种场景下虚拟线程跟传统线程没区别。
还有一个坑:synchronized 块里的虚拟线程会"钉"在操作系统线程上(pinning),让出不了。代码里 synchronized 用得多的话,要么换成 ReentrantLock,要么等 JDK 后续版本解决这个问题。
其他语言是什么模型
题主还问了 PHP、Python、Go。
PHP 传统上是一个请求一个进程。Apache Prefork MPM + mod_php 模式下,Apache 预先 fork 好一批子进程等着接请求,一个进程同一时刻只处理一个请求。进程比线程重得多——独立的内存空间、独立的文件描述符,开销大。好处是隔离性极好,一个请求里 PHP 代码段错误了,其他请求完全不受影响。PHP-FPM 也是进程池模型,本质还是进程级隔离。
Python 的 WSGI 服务器(Gunicorn)也是类似的多进程模型。而且 Python 有 GIL(全局解释器锁),多线程在 CPU 密集场景下跟单线程差不多,所以 Python Web 框架倾向于用多进程而不是多线程。异步方向走的是 asyncio + ASGI(比如 uvicorn),从根本上换了一套并发模型——事件循环,单线程处理多个请求,遇到 IO 就切换。
Go 是一个请求一个 goroutine。goroutine 是 Go runtime 自己调度的轻量级协程,初始栈只有几 KB,创建成本极低。你可以同时跑几十万个 goroutine,Go runtime 会把它们调度到少量的操作系统线程上。goroutine 等 IO 的时候自动让出,跟 Java 虚拟线程的思路一样——区别是 Go 从 1.0 版本就有这个能力,Java 等到了 21 版本。
从演进方向看,各个语言都在朝同一个方向走:用轻量级的调度单元替代重量级的操作系统线程/进程,让 IO 等待不再浪费系统资源。 Java 的虚拟线程、Go 的 goroutine、Kotlin 的协程、Rust 的 async/await,解决的都是同一个问题。
Spring Boot 的"一个请求一个线程"模型在 Java 21 之前是没什么好办法绕开的(WebFlux 除外,但写法完全不同)。Java 21 之后,虚拟线程让你用同步的写法享受异步的性能——代码不用改,加一行配置,线程模型就从"200 个线程各占一个坑"变成了"几万个虚拟线程轮流用几十个坑"。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐


所有评论(0)