WEBKT

Spring Boot 3 性能调优:手把手教你排查与解决虚拟线程 Pinning(线程固定)难题

4 0 0 0

在 Spring Boot 3 中,只需一行配置 spring.threads.virtual.enabled=true,就能轻松开启 Java 21 的虚拟线程(Virtual Threads)。这种“高并发神器”声称能用极低的资源消耗换取百万量级的并发吞吐。

然而,不少开发者在兴高采烈地将其推上线后,却发现系统吞吐量不升反降,甚至出现了大面积的请求超时和线程饥饿。

这背后的罪魁祸首,往往就是虚拟线程被“固定”(Pinning)到了平台线程上。其中,最常见的诱因就是代码中(或第三方依赖库中)大量存在的 synchronized 关键字。


什么是虚拟线程的 Pinning(固定)?

要理解 Pinning,我们需要先看看虚拟线程的调度模型。

虚拟线程(Virtual Thread)是用户态的轻量级线程,它不能直接在操作系统上运行,必须挂载到传统的平台线程(Platform Thread,也叫 Carrier Thread 载体线程)上才能执行。

正常情况下,当虚拟线程遇到阻塞操作(比如 I/O 读写、Thread.sleep())时,它会主动**让出(Unmount)**载体线程,让载体线程去运行其他的虚拟线程。

但是,如果虚拟线程在执行一个 synchronized 块或 synchronized 方法,并且在其中触发了阻塞操作,此时虚拟线程就会被“固定”(Pinned)在载体线程上。它无法被卸载,载体线程也会被同步阻塞。

如果底层 ForkJoinPool 的载体线程都被占满且处于 Pinning 状态,整个应用的虚拟线程调度就会陷入瘫痪。


场景复现:一个典型的 Pinning 接口

在 Spring Boot 3 项目中,我们写一个简单的控制器来复现这个问题:

@RestController
@RequestMapping("/api")
public class DemoController {

    private final Object lock = new Object();

    @GetMapping("/data")
    public String fetchData() {
        // 使用 synchronized 锁模拟互斥访问
        synchronized (lock) {
            try {
                // 模拟耗时的 I/O 操作(这里会触发虚拟线程阻塞)
                Thread.sleep(100); 
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }
        return "success";
    }
}

在高并发请求下,上面的 synchronized (lock) 加上 Thread.sleep 会直接导致承载虚拟线程的 ForkJoinPool 线程被锁死,性能出现断崖式下跌。


如何排查?三招揪出隐藏的 Pinning 隐患

排查虚拟线程 Pinning 主要有三种武器:JVM 诊断参数JFR(JDK Flight Recorder)性能分析,以及静态代码分析工具

第一招:利用 JVM 参数实时打印 Pinning 堆栈(推荐开发环境使用)

这是最直接、最痛快的方法。JDK 21 提供了一个系统属性 jdk.tracePinnedThreads,用于在发生 Pinning 时向控制台输出堆栈信息。

你可以在 Spring Boot 启动时加入以下 JVM 参数:

java -Djdk.tracePinnedThreads=full -jar app.jar

该参数支持两个值:

  • -Djdk.tracePinnedThreads=short:打印简短的冲突堆栈(仅单行)。
  • -Djdk.tracePinnedThreads=full:打印完整的调用栈,精准定位到是哪一行代码、哪一个 synchronized 块导致的固定。

控制台输出效果:

当有高并发请求打入我们的测试接口时,控制台会立刻刷出如下警告:

Thread[#37,ForkJoinPool-1-worker-1,5,CarrierThreads]
  java.base/java.lang.VirtualThread$State.PINNED
    at java.base/java.lang.VirtualThread.park(VirtualThread.java:585)
    at java.base/java.lang.System$2.park(System.java:2833)
    at java.base/java.util.concurrent.locks.LockSupport.parkNanos(LockSupport.java:275)
    ...
    at com.example.demo.DemoController.fetchData(DemoController.java:16) <== 罪魁祸首在这里!
    at java.base/java.lang.VirtualThread.run(VirtualThread.java:311)

看到 PINNEDcom.example.demo.DemoController.fetchData,你就可以直接定位到代码的具体行数进行重构了。


第二招:使用 JDK Flight Recorder (JFR) 生产诊断

在生产环境中,开启 tracePinnedThreads 会产生大量的控制台 I/O 损耗,不建议长期开启。此时,JDK Flight Recorder (JFR) 是更好的选择。

在 JVM 启动参数中开启 JFR 监控:

java -XX:StartFlightRecording=filename=pinned_check.jfr,settings=default -jar app.jar

或者使用 jcmd 工具在运行时动态开启录制:

jcmd <PID> JFR.start name=pinned_trace filename=pinned.jfr settings=profile

拿到 pinned.jfr 文件后,使用 JDK Mission Control (JMC) 打开,或者直接在 IntelliJ IDEA 的 Profiler 工具中导入。

在事件浏览器(Event Browser)中,搜索 "Virtual Thread Pinned" 事件。JFR 会为你清晰地记录:

  1. 发生了多少次 Pinning 动作;
  2. 每次 Pinning 持续了多长时间(Duration);
  3. 导致 Pinning 的具体线程堆栈。

第三招:集成 SonarQube / ErrorProne 静态检查

如果想在编译期就预防这种悲剧,可以在 CI/CD 流程中引入静态代码扫描。

例如,利用 ErrorProne 插件,或者在编写代码时,注意 IDE 给出的警告。现代 IDE(如 IntelliJ IDEA 2023.3 及以上版本)在检测到虚拟线程项目中使用 synchronized 且内部包含阻塞调用时,通常会给出弱警告提示。


彻底根治:如何消除 Pinning 问题?

定位到具体的 synchronized 锁代码后,我们该如何改造?

方案一:使用 ReentrantLock 替代 synchronized

这是官方最推荐的做法。虚拟线程在遇到 java.util.concurrent 包下的锁(如 ReentrantLockReentrantReadWriteLockSemaphore)时,并不会触发 Pinning。虚拟线程可以正常卸载(Unmount)并让出载体线程。

重构前:

public synchronized String getCacheData(String key) {
    // 假设这里有阻塞的 Redis 或数据库查询
    return dbService.query(key);
}

重构后:

import java.util.concurrent.locks.ReentrantLock;

private final ReentrantLock lock = new ReentrantLock();

public String getCacheData(String key) {
    lock.lock();
    try {
        // 阻塞的 I/O 查询
        return dbService.query(key);
    } finally {
        lock.unlock();
    }
}

虽然代码行数变多了,但由于 ReentrantLock 内部使用的是 AQS 机制,当虚拟线程尝试获取锁失败而挂起时,它会被优雅地卸载(Unmount),把承载它的平台线程留给别人。


方案二:升级第三方依赖库

有时候,synchronized 并不是写在我们的业务代码里,而是藏在第三方的 JDBC 驱动、HttpClient、Redis 客户端或者 Orm 框架中。

例如:

  • MySQL Connector/J 的老版本中存在大量的 synchronized 方法。在高并发虚拟线程场景下,会导致所有数据库操作线程被 Pin 住。
    • 解决方法:升级到 MySQL Connector/J 8.0.33+,官方已经针对虚拟线程对内部的 synchronized 进行了大面积的重构和重写。
  • Jedis 在某些老版本中也存在此问题。
    • 解决方法:升级到最新版,或者切换到支持虚拟线程更加友好的驱动,或者限制连接池的大小。

排查建议: 如果在使用 tracePinnedThreads 时,发现堆栈来自第三方 Jar 包,去 GitHub 上搜一下对应的 Issue,通常只要升级到支持 JDK 21 的最新版本即可解决。


方案三:真的所有 synchronized 都需要被替换吗?

答案是:不需要

如果你的 synchronized 块内没有 I/O 操作,没有 Thread.sleep(),也没有任何会引起线程阻塞的行为(例如只是简单的纯内存操作、短暂的成员变量赋值或 Map 读写),那么它执行的时间极短(微秒级)。

在这种极短的时间内发生 Pinning,其性能损耗是可以忽略不计的。

只有那些包裹了网络请求、数据库查询、磁盘读写、复杂加解密或者 Thread.sleepsynchronized,才是我们必须重构的死角。


总结

在 Spring Boot 3 中拥抱虚拟线程时,开发者需要从“物理线程思维”转变到“虚拟线程思维”:

  1. 监控先行:在新服务上线前,务必在测试环境通过 -Djdk.tracePinnedThreads=full 进行压测,揪出潜在的 Pinned 堆栈。
  2. 拥抱 JUC:在需要加锁的 I/O 密集型业务中,全面用 ReentrantLock 代替 synchronized
  3. 保持依赖更新:确保你的常用组件(Spring Boot, MySQL Driver, Redis Client)都处于兼容 JDK 21 的最新活跃版本。
码农老九 虚拟线程JVM调优

评论点评