结合场景,线程可视化理解
场景:SpringBoot + Tomcat 默认
假设要根据userId查用户信息,通过调接口/getUser。前端在发起接口请求开始,到接口查到数据返回给前端这个过程中,线程是怎么执行的。
一个前端请求发起 → 进入后端接口 → 直到数据返回给前端,全程独占 1 个线程,中间不管是查库、等接口、等 Redis,这个线程都会一直被占着,啥也不干干等着,直到整个请求结束,线程才会被释放。
对比虚拟线程是怎么解决这个浪费问题的。
一、完整流程:前端请求 → 后端返回,线程到底怎么跑?
前提
- 后端:SpringBoot + Tomcat(默认同步模型)
- 接口:
/getUser?userId=1001
- 逻辑:Controller → Service → Mapper/MyBatis → MySQL 查询 → 返回数据
全程线程执行链路(一步不落)
1. 前端发起 HTTP 请求
浏览器发送请求到服务器,到达Tomcat 服务器。
2. Tomcat 分配线程(线程开始占用)
Tomcat 内部有一个工作线程池(默认核心线程 20,最大 200),请求进来后:
- Tomcat 从线程池里拿出一个空闲的平台线程
- 把这个请求全权交给这个线程负责
- 从这一刻起,这个线程就被当前请求独占了,直到请求结束
3. 线程执行 Controller 代码
@GetMapping("/getUser")
public User getUser(Long userId) {
// 线程执行到这里
return userService.getUserById(userId);
}
线程正常跑,无阻塞。
4. 线程执行 Service,走到 MyBatis 查 MySQL(关键阻塞点)
public User getUserById(Long userId) {
// 执行JDBC查询MySQL
return userMapper.selectById(userId);
}
**这里发生 IO 阻塞!**线程发起数据库查询后,必须等 MySQL 网络返回数据,这段等待时间:
- 线程不消耗 CPU
- 线程啥也不干,原地空等
- 但线程依然被当前请求独占,不释放
- Tomcat 线程池里的这个线程,被占死了,不能处理其他请求
5. MySQL 返回数据,线程继续执行
数据库查到数据返回,线程苏醒,继续封装 User 对象。
6. 线程把结果返回给 Tomcat,Tomcat 响应前端
线程将结果交给 Tomcat,Tomcat 把 HTTP 响应写回浏览器。
7. 请求结束,线程释放(占用结束)
直到这一刻,这个线程才被释放回 Tomcat 线程池,重新变成空闲状态,才能接收下一个前端请求。
二、最关键的问题:为什么说这种模式浪费资源?
举个直观的例子:
- 一次
/getUser请求总耗时:300ms
- 其中真正 CPU 计算耗时:5ms(封装参数、处理逻辑)
- 其中阻塞等待耗时:295ms(等 MySQL 返回)
=> 98% 的时间,线程都是空占着不干活!
如果此时有300 个并发请求:
- Tomcat 线程池最大 200 个线程
- 200 个线程全被占满,都在等数据库
- 剩下 100 个请求只能排队等待
- 接口响应变慢、甚至超时
这就是传统平台线程最大的痛点:IO 阻塞时,线程资源被白白浪费。
三、虚拟线程是怎么解决这个问题的?(对应你之前的疑问)
开启 SpringBoot 虚拟线程后:spring.threads.virtual.enabled=true
流程变了,但「请求绑定线程」的逻辑没变
- 前端请求进来,Tomcat 分配虚拟线程(而非平台线程)
- 虚拟线程依然全程绑定这个请求,直到返回
- 执行到查 MySQL 阻塞时:
- JVM 把这个虚拟线程挂起
- 立刻释放底层的平台线程
- 这个平台线程马上可以去处理其他虚拟线程的请求
- MySQL 返回数据后:
- JVM 唤醒虚拟线程
- 再找一个空闲平台线程继续执行
- 请求结束,虚拟线程销毁
核心区别
- 传统平台线程:阻塞时占着坑不拉屎,线程池很快满
- 虚拟线程:阻塞时让出平台线程,几个平台线程就能处理成千上万并发请求
四、一句话终极总结
- 同步 Web 接口下:1 个前端请求 = 独占 1 个线程,从请求进来一直到响应返回,线程才释放。
- 中间所有 IO 阻塞(查库、Redis、第三方接口),线程都在空等,资源浪费极大。
- 虚拟线程不改变「请求绑定线程」的模型,只是阻塞时自动释放平台线程,彻底解决线程池耗尽、吞吐量上不去的问题。
在实际项目场景中,通常会统计QPS,结合线程的逻辑,如何解释QPS。
一、先给 QPS 一个落地定义(项目里实际用的)
QPS = Queries Per Second,直译:每秒查询数放到 SpringBoot Web 项目里,就是:服务器每秒能成功处理完、并返回给前端的 /getUser 请求数量
简单说:1 秒内,有多少个查询用户的请求从头到尾跑完了,这个数字就是 QPS。
二、结合「请求独占线程」的逻辑,QPS 是怎么算出来的?
先回顾核心前提:1 个前端请求 = 独占 1 个线程,从请求进来 → 响应返回,线程才释放
QPS 的大小,直接由两个核心因素决定:
- 可用线程总数(Tomcat 线程池最大线程数)
- 单个请求的平均耗时(RT)
核心公式(项目压测必用)
理论最大 QPS ≈ 工作线程数 ÷ 单个请求平均耗时 (秒)
用/getUser接口举实例
假设:
- Tomcat传统平台线程池:最大线程 = 200
/getUser 平均耗时(RT)= 300ms = 0.3 秒(其中 295ms 在等 MySQL,纯阻塞)
代入公式:理论 QPS = 200 ÷ 0.3 ≈ 666
meaning:服务器极限情况下,每秒只能处理 666 次查询用户的请求。超过这个数,请求就会排队、响应变慢、甚至直接超时。
三、为什么 IO 阻塞会直接拉低 QPS?(核心痛点)
你的/getUser接口:
- 真正 CPU 干活时间:5ms(封装参数、组装对象)
- 线程空等 DB 时间:295ms(阻塞)
=> 线程 98% 的时间都在闲着,但又占着线程不释放这就导致:
- 200 个线程被占满
- 每秒只能处理 666 个请求
- CPU 利用率很低、线程资源被浪费,QPS 死活上不去
这就是IO 密集型接口(查库、调第三方、Redis) 的典型 QPS 瓶颈。
四、虚拟线程是怎么把 QPS 拉上去的?
开启虚拟线程后,线程模型变了:虚拟线程阻塞(等 DB)时,会立刻释放底层平台线程
假设:
- 平台线程只需要20 个(足够调度)
- 虚拟线程可以无限开(上万级别)
- 单个请求 RT 还是 300ms
此时:
- 线程不再是瓶颈
- QPS 不再受线程数限制
- 极限 QPS 逼近MySQL / 网卡 / Redis的硬件上限
同样的/getUser接口:QPS 能从 600+ 直接涨到 几千~上万
五、项目里真实的 QPS 场景(你一定会遇到)
场景 1:低并发
- 并发请求:50
- QPS:≈160
- 线程池空闲,接口响应飞快
场景 2:压测到极限
- 并发请求:300
- 线程池被占满(200 个)
- 100 个请求排队
- RT 从 300ms 涨到 1s+
- QPS 达到峰值 666 后,不再上涨,甚至下降
场景 3:开虚拟线程后
- 并发请求:10000
- 平台线程仅 20 个,被极致复用
- RT 稳定 300ms
- QPS 轻松破 5000+
六、一句话总结 QPS 和线程的关系
- QPS 是结果:代表接口每秒的处理能力
- 传统平台线程:请求独占线程 + 阻塞不释放 → 线程数是 QPS 的硬天花板
- 虚拟线程:阻塞时释放平台线程 → 打破线程瓶颈,QPS 逼近硬件极限
/getUser这类IO 密集型接口,QPS 低的根本原因就是:线程被白白浪费在等待上