ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

为什么 Spring Boot 3 的虚拟线程没让接口变快:JDK 21 pinning 排查记录

为什么 Spring Boot 3 的虚拟线程没让接口变快:JDK 21 pinning 排查记录

本文摘要:启用虚拟线程后接口吞吐不升反降,根因是请求路径上的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.jar

JDK 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:true

Controller 包含两个端点,分别触发和规避 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.jar
foriin$(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
返回列表