虚拟线程上线后吞吐反而掉了:Spring Boot 3 里 synchronized 锁住载体线程的定位与修复

本文摘要 :开启虚拟线程后吞吐不升反降,5000 并发反而输给固定线程池基线。根因是 synchronized 钉住载体线程,改用 ReentrantLock 即可解开。

一、问题与结论

现象(Spring Boot 3.2.x + JDK 21.0.x,spring.threads.virtual.enabled=true):接口内部经过一个带 synchronized 的内存限流器。JMeter 固定并发 500 时,虚拟线程版本吞吐高于平台线程基线(server.tomcat.threads.max=200 固定池);并发提到 5000,吞吐反而跌到基线之下,P99 拉长一个数量级。

结论:这不是虚拟线程"不划算",而是 synchronized 的钉住(pinning)。JDK 21 中虚拟线程在等 monitor 或持 monitor 阻塞时无法与载体线程解绑;载体来自 ForkJoinPool,并行度约等于 CPU 核数,被占满后后续请求全部排队。把锁换成 ReentrantLock 后,同一压测下钉住消失、吞吐回升(数字见第四节记录表,需在你环境复跑)。

二、排查与选择依据

步骤如下,每步先说目的再给动作。

  1. 开启钉住跟踪:JVM 参数加 -Djdk.tracePinnedThreads=full(或 any),发生钉住时把堆栈打到 stderr。
  2. 压测打满后抓两份快照:
bash 复制代码
jcmd <pid> Thread.print -l -e > snap-5000.txt
jstack -e <pid> > jstack-5000.txt

-e 用于打印虚拟线程;不加它,快照里只剩载体线程,极易误判成"压测没生效"。

  1. 在快照里认两种形态(简化示意,字段名随小版本略有差异):
text 复制代码
"VirtualThread-per-WorkerPool-1" #71 (virtual) waiting to lock <0x0000000770aa10> (a java.lang.Object)
    com.example.demo.RateLimiter.acquire(RateLimiter.java:18)
    com.example.demo.OrderController.order(OrderController.java:31)

"ForkJoinPool-1-worker-4" #32
    com.example.demo.RateLimiter.acquire(RateLimiter.java:18)     ← 载体线程跑到虚拟线程的栈帧上
    com.example.demo.OrderController.order(OrderController.java:31)

判断依据:同一把锁同时出现在成百条虚拟线程栈上,而载体线程数长期稳定在 CPU 核数附近不再增长,即为钉住。

替代方案与取舍

方案 选择条件 代价 何时不用
synchronized → ReentrantLock(本文) 锁在自有代码、锁内有 sleep/IO/嵌套等待 需 finally 手动 unlock,可中断获取改用 lockInterruptibly 锁在无法修改的第三方库
升级 JDK 24(JEP 491) 基线允许升 LTS、代码改不动 依赖兼容与整体回归成本 公司基线锁定 JDK 21
回退平台线程 + 固定池 并发低、阻塞少、需严格限制资源 池耗尽即排队,吞吐上限受池大小约束 高并发长阻塞场景
缩小锁粒度 / 分段 高争用、临界区极短 重构数据结构,正确性更难验证 锁语义复杂、跨字段一致

三、关键原理

JEP 444 的规则:虚拟线程进入 synchronized 块时若 monitor 被占,就钉住当前载体线程;持锁期间的 Thread.sleep、阻塞 IO、park 同样独占载体。ReentrantLock/Condition 基于 LockSupport,等待时虚拟线程可整体解绑(unmount),把载体还给池。所以同样"等 1 ms",synchronized 版本烧掉一个核的载体,ReentrantLock 版本几乎零成本。

两个易错点:Object.wait() 期间释放 monitor,不钉住,但 wait() 返回后重新进入 synchronized 块时仍可能钉住;钉住有"等锁"与"持锁执行阻塞操作"两条路径。JDK 21 另提供 jdk.VirtualThreadPinning JFR 事件,可抽样统计钉住次数。

四、可运行示例

环境:JDK 21.0.x、Spring Boot 3.2.x、spring-boot-starter-web。

java 复制代码
import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

// 问题版本:锁内执行 sleep,钉住载体线程
class RateLimiter {
    private final long intervalNanos = TimeUnit.MILLISECONDS.toNanos(1);
    private long nextAllowedAt;

    public synchronized void acquire() throws InterruptedException {
        long now = System.nanoTime();
        if (now < nextAllowedAt) {
            TimeUnit.NANOSECONDS.sleep(nextAllowedAt - now);
        }
        nextAllowedAt = System.nanoTime() + intervalNanos;
    }
}

@RestController
@RequestMapping("/api")
public class OrderController {
    private final RateLimiter limiter = new RateLimiter();

    @GetMapping("/order")
    public Map<String, Object> order() throws InterruptedException {
        limiter.acquire();
        return Map.of("ok", true, "ts", System.currentTimeMillis());
    }
}

配置分两组,压测时分别启动:

yaml 复制代码
# 虚拟线程组
spring:
  threads:
    virtual:
      enabled: true

# 平台线程基线组(另跑一份)
# spring.threads.virtual.enabled=false
# server.tomcat.threads.max=200

操作步骤:

bash 复制代码
mvn spring-boot:run -Dspring-boot.run.jvmArguments="-Djdk.tracePinnedThreads=full"
# JMeter:HTTP 请求打 /api/order;并发 500、Ramp-Up 10s 跑一轮,再跑 5000
jcmd <pid> Thread.print -l -e > snap-5000.txt

预期输出:500 并发下虚拟线程版吞吐高于平台基线;5000 并发下快照出现同一 java.lang.Object 被成百虚拟线程争用,stderr 出现钉住警告。

实际输出:吞吐数字须用本机 JMeter 报告填入下表(未验证,此处只提供记录结构):

并发 平台线程(池=200)req/s 虚拟线程(修复前)req/s 虚拟线程(ReentrantLock)req/s
500 待填 待填 待填
5000 待填 待填 待填

修复版本只改锁,其余不变:

java 复制代码
private final ReentrantLock lock = new ReentrantLock();

public void acquire() throws InterruptedException {
    lock.lockInterruptibly();
    try {
        long now = System.nanoTime();
        if (now < nextAllowedAt) {
            TimeUnit.NANOSECONDS.sleep(nextAllowedAt - now);
        }
        nextAllowedAt = System.nanoTime() + intervalNanos;
    } finally {
        lock.unlock();
    }
}

常见失败:jstack <pid> 里看不到任何请求线程,误判成"压测没生效"。原因是默认不打印虚拟线程;改用 jstack -e <pid> 或 jcmd <pid> Thread.print -l -e,或直接依赖 -Djdk.tracePinnedThreads 的警告输出。

五、验证结果与边界

  • 修复有效判据:5000 并发快照中不再出现同一 monitor 被成百虚拟线程争用;吞吐回升到平台基线之上(数值需复跑验证)。
  • 边界一:钉住也可能来自第三方库(旧版 JDBC 驱动、日志组件里的 synchronized)。换掉自有锁后现象依旧,就回到第二节继续抓快照。
  • 边界二:高争用、极短临界区场景,ReentrantLock 不一定优于无锁结构,要按数据结构选型。
  • 边界三:不要因为"能跑"就无条件开虚拟线程;JVM 层根除 monitor 钉住要到 JDK 24(JEP 491)。

思考

  1. 锁在无法修改的第三方库时,升级 JDK 24 与在外层做限流降级,如何按回归风险取舍?
  2. ReentrantLock 的公平模式在高争用虚拟线程场景下,吞吐损失能否换来更稳的尾延迟?

参考资料

相关推荐
mister_guo1 小时前
Spring Boot 配置文件读取的几种方式:用法、原理与常见误区
java·spring boot
要开心吖ZSH1 小时前
DDD(领域驱动设计)篇三:实战对照——同一个订单业务,两种架构怎么写
java·架构·领域模型·ddd·领域驱动设计·设计思想
独泪了无痕9 小时前
Spring Data Redis 快速上手
spring boot·redis·后端
wangchunyu11411 小时前
JAVA CompletableFuture异步编程实战
java·开发语言·python
paopaokaka_luck12 小时前
流浪动物救助与收养系统(DeepSeek AI对话、 ECharts多维分析、救助流程衔接、领养与回访衔接、 捐赠确认与公示、志愿者协作)
java·前端·javascript·人工智能·mysql·echarts·推荐算法
Thomas.Sir12 小时前
第9课:SpringBoot4.x Web开发基础:WebMvc自动配置、静态资源、请求参数接收
java·spring boot
程序 代码狂人12 小时前
切片表与分区表的区别(数据开发面试版)
java·大数据·开发语言·数据仓库·算法
警醒与鞭策13 小时前
Codex桌面版与Claude桌面版使用设置
android·unity·ai·性能优化
2501_9445215913 小时前
Deepmerge 跨端三方库仓颉适配全流程
java·python·eclipse·github