本文摘要:启用虚拟线程后接口吞吐不升反降,根因是请求路径上的native方法调用导致pinning。用tracePinnedThreads定位后将文件I/O迁移到NIO API即可解决。
一、问题与结论
Spring Boot 3.2 + JDK 21,Tomcat 10.1.15,8 核容器,200 并发压测。在application.yml中开启spring.threads.virtual.enabled=true后,QPS 不升反降,P99 延迟上升,实际并发度退化为 8(CPU 核心数),远低于平台线程模式下 Tomcat 默认的 200 并发。
结论:请求处理路径上存在new FileInputStream(path)调用,触发 JDK 21 的 native 方法 pinning——虚拟线程被钉在载体线程上无法卸载。8 条载体线程全部被占满后,调度器中排队的其余虚拟线程无法执行。虚拟线程的优势前提是"阻塞时能被卸载",pinning 使这个前提失效,虚拟线程退化为"每条载体线程只能同时处理一个任务",反而不如平台线程。
二、排查与根因
定位方法
启动参数加入 pinning 追踪:
java-Djdk.tracePinnedThreads=full-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 491(JDK 22)只修复了synchronized场景的 pinning。native 方法的 pinning 是虚拟线程模型的固有限制,升级 JDK 无法解决。
I/O API 对比
| API | 底层实现 | 触发 pinning |
|---|---|---|
new 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 包含两个端点,分别触发和规避 pinning:
packagecom.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.testFile=Files.createTempFile("vt-test",".txt");Files.writeString(testFile,"x".repeat(50000));}@GetMapping("/pinned-read")publicMap<String,Object>pinnedRead()throwsIOException{try(FileInputStreamfis=newFileInputStream(testFile.toFile())){byte[]buffer=newbyte[1024];while(fis.read(buffer)!=-1){}}returnMap.of("api","FileInputStream","pinning","expected");}@GetMapping("/unpinned-read")publicMap<String,Object>unpinnedRead()throwsIOException{try(InputStreamis=Files.newInputStream(testFile)){byte[]buffer=newbyte[1024];while(is.read(buffer)!=-1){}}returnMap.of("api","Files.newInputStream","pinning","none");}}编译运行并发起并发请求:
mvn clean package-DskipTestsjava-Djdk.tracePinnedThreads=full-jartarget/demo.jarforiin$(seq1200);docurl-shttp://localhost:8080/pinned-read>/dev/null&done;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 只修复synchronizedpinning,native 方法 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 Threads
- JEP 491 — Synchronize Virtual Threads without Pinning