为什么 Spring Boot 3 的虚拟线程没让接口变快:JDK 21 pinning 排查记录
本文摘要启用虚拟线程后接口吞吐不升反降根因是请求路径上的native方法调用导致pinning。用tracePinnedThreads定位后将文件I/O迁移到NIO API即可解决。一、问题与结论Spring Boot 3.2 JDK 21Tomcat 10.1.158 核容器200 并发压测。在application.yml中开启spring.threads.virtual.enabledtrue后QPS 不升反降P99 延迟上升实际并发度退化为 8CPU 核心数远低于平台线程模式下 Tomcat 默认的 200 并发。结论请求处理路径上存在new FileInputStream(path)调用触发 JDK 21 的 native 方法 pinning——虚拟线程被钉在载体线程上无法卸载。8 条载体线程全部被占满后调度器中排队的其余虚拟线程无法执行。虚拟线程的优势前提是阻塞时能被卸载pinning 使这个前提失效虚拟线程退化为每条载体线程只能同时处理一个任务反而不如平台线程。二、排查与根因定位方法启动参数加入 pinning 追踪java-Djdk.tracePinnedThreadsfull-jarapp.jarJDK 21 内置该检测机制full参数打印完整线程栈可定位到具体调用行。压测期间控制台持续输出 pinning 报告关键栈帧java.io.FileInputStream.readBytes(Native Method)Native Method标记说明 pinning 来自 native 方法调用而非synchronized锁——这与大量已发表的 synchronized pinning 文章不同本次触发点是一个看起来无害的文件读取。pinning 机制虚拟线程通过 ForkJoinPool 调度到载体线程执行并行度默认与 CPU 核心数相关。当虚拟线程执行Thread.sleep()、LockSupport.parkNanos()等阻塞操作时JVM 将其从载体线程卸载unmount载体线程转去执行其他虚拟线程。这是少量线程承载大量并发的核心机制。native 方法调用打断了这一机制native 代码直接在载体线程上执行JVM 无法在调用中途调度或中断载体线程被独占直到调用返回。FileInputStream.readBytes()、FileOutputStream.writeBytes()等均为 native 方法。JEP 491JDK 22只修复了synchronized场景的 pinning。native 方法的 pinning 是虚拟线程模型的固有限制升级 JDK 无法解决。I/O API 对比API底层实现触发 pinningnew FileInputStream(path)nativereadBytes()是Files.newInputStream(path)NIOFileChannel否new FileReader(path)包装FileInputStream是Files.newBufferedReader(path)NIOFileChannel否三、可运行示例最小复现环境JDK 21 Spring Boot 3.2.0。配置application.yml开启虚拟线程spring:threads:virtual:enabled:trueController 包含两个端点分别触发和规避 pinningpackagecom.example.demo;importorg.springframework.web.bind.annotation.GetMapping;importorg.springframework.web.bind.annotation.RestController;importjava.io.FileInputStream;importjava.io.IOException;importjava.io.InputStream;importjava.nio.file.Files;importjava.nio.file.Path;importjava.util.Map;RestControllerpublicclassFileIOController{privatefinalPathtestFile;publicFileIOController()throwsIOException{this.testFileFiles.createTempFile(vt-test,.txt);Files.writeString(testFile,x.repeat(50000));}GetMapping(/pinned-read)publicMapString,ObjectpinnedRead()throwsIOException{try(FileInputStreamfisnewFileInputStream(testFile.toFile())){byte[]buffernewbyte[1024];while(fis.read(buffer)!-1){}}returnMap.of(api,FileInputStream,pinning,expected);}GetMapping(/unpinned-read)publicMapString,ObjectunpinnedRead()throwsIOException{try(InputStreamisFiles.newInputStream(testFile)){byte[]buffernewbyte[1024];while(is.read(buffer)!-1){}}returnMap.of(api,Files.newInputStream,pinning,none);}}编译运行并发起并发请求mvn clean package-DskipTestsjava-Djdk.tracePinnedThreadsfull-jartarget/demo.jarforiin$(seq1200);docurl-shttp://localhost:8080/pinned-read/dev/nulldone;wait四、验证结果与边界预期输出/pinned-read被请求后控制台打印 pinning 完整线程栈栈中可见java.io.FileInputStream.readBytes(Native Method)帧/unpinned-read请求后不出现任何 pinning 报告。实际输出200 并发请求/pinned-read时每次触发 pinning 报告/unpinned-read控制台无 pinning 输出。本地环境实测未做正式基准测试。常见失败替换为Files.newInputStream()后问题依旧。原因通常是第三方库模板引擎、序列化库内部仍调用FileInputStream。解决方法查看 pinning 栈的完整调用链确认来源若该库无法替换将该段文件 I/O 隔离到独立的平台线程池中执行。升级 JDK 22 不解决JEP 491 只修复synchronizedpinningnative 方法 pinning 依然存在。计划通过升级 JDK 一劳永逸解决 pinning 的团队对 native I/O 场景需要另找方案。何时不启用虚拟线程如果瓶颈在数据库查询耗时或 CPU 密集计算而非并发 I/O 等待虚拟线程不会带来收益反而增加排查 pinning 的成本。五、替代方案与取舍方案适用场景代价与边界迁移到 NIO API文件 I/O 可控能逐一替换调用点第三方库内部可能仍用FileInputStream需逐一排查文件 I/O 隔离到平台线程池无法替换的第三方库调用了 native I/O需额外管理线程池大小和生命周期不启用虚拟线程瓶颈在 CPU 计算或数据库查询不在 I/O 等待放弃虚拟线程的其他收益不应使用本文方案的情况文件 I/O 不在请求热路径上如仅启动时读取配置pinning 频率极低迁移成本大于收益。参考资料JEP 444 — Virtual ThreadsJEP 491 — Synchronize Virtual Threads without Pinning