Java 21 虚拟线程实战:Spring Boot 并发性能、线程固定与排障指南

📅 2026/7/3 5:46:40
Java 21 虚拟线程实战:Spring Boot 并发性能、线程固定与排障指南
虚拟线程不是让单个请求执行得更快而是让大量阻塞式请求以更低的线程成本并发执行。本文通过一个可运行的 Spring Boot 示例说明虚拟线程适合什么场景、如何启用、怎样压测以及为什么synchronized、数据库连接池和错误的性能预期会让优化失效。本文示例基于Java 21 Spring Boot 3.5.x。一、虚拟线程解决的到底是什么问题传统 Spring MVC 通常采用“一个请求对应一个平台线程”的模型。请求等待数据库、Redis 或远程 HTTP 响应时线程虽然没有计算却仍占用操作系统线程资源。并发升高后线程数量、栈内存和上下文切换都会成为成本。虚拟线程仍采用易于理解的同步编程方式但由 JVM 调度大量虚拟线程可以复用少量平台线程HTTP 请求 → 虚拟线程 → 执行业务代码 ↓ 遇到可挂起的阻塞操作 JVM 卸载虚拟线程 ↓ 平台线程执行其他任务因此它更适合请求主要等待 JDBC、HTTP、文件等阻塞式 I/O并发量较高同时希望保留同步代码风格线程池已经成为吞吐量瓶颈。它不擅长纯 CPU 密集型任务。视频编码、复杂加密或大规模计算的上限仍由 CPU 核数决定。二、在 Spring Boot 中启用虚拟线程项目至少需要 Java 21propertiesjava.version21/java.version/propertiesdependenciesdependencygroupIdorg.springframework.boot/groupIdartifactIdspring-boot-starter-web/artifactId/dependencydependencygroupIdorg.springframework.boot/groupIdartifactIdspring-boot-starter-actuator/artifactId/dependency/dependencies在application.yml中开启spring:threads:virtual:enabled:truemain:keep-alive:truekeep-alive值得保留。虚拟线程是守护线程如果应用只剩守护线程JVM 可以直接退出这对依赖Scheduled维持运行的程序尤其重要。编写一个模拟下游 I/O 的接口RestControllerRequestMapping(/virtual-thread)publicclassVirtualThreadController{GetMapping(/io)publicMapString,Objectio()throwsInterruptedException{Thread.sleep(Duration.ofMillis(200));// 模拟数据库或 HTTP 等待ThreadthreadThread.currentThread();returnMap.of(thread,thread.toString(),virtual,thread.isVirtual());}}访问接口后virtual应为true。不要只根据线程名称判断Thread.isVirtual()更可靠。三、虚拟线程为什么能提升吞吐量假设每个请求只计算 5 毫秒却等待下游 195 毫秒。传统线程在 195 毫秒等待期内无法处理其他请求虚拟线程阻塞时通常会被卸载承载它的平台线程可以继续运行别的虚拟线程。可以用 JDK 自带 API 做一个简化实验publicclassThreadBenchmark{privatestaticfinalintTASKS10_000;publicstaticvoidmain(String[]args)throwsException{run(Executors.newFixedThreadPool(200),platform-200);run(Executors.newVirtualThreadPerTaskExecutor(),virtual);}privatestaticvoidrun(ExecutorServiceexecutor,Stringname)throwsException{longstartSystem.nanoTime();try(executor){ListFuture?futuresIntStream.range(0,TASKS).mapToObj(i-executor.submit(()-{try{Thread.sleep(200);}catch(InterruptedExceptione){Thread.currentThread().interrupt();}})).toList();for(Future?future:futures){future.get();}}longmillisDuration.ofNanos(System.nanoTime()-start).toMillis();System.out.printf(%s: %d ms%n,name,millis);}}这个实验只证明“等待型任务能容纳更多并发”不能代替业务压测。真实系统还受数据库连接数、下游限流、内存和网络带宽约束。四、不要用线程池限制虚拟线程数量平台线程昂贵因此过去常用固定线程池控制数量。虚拟线程本身很轻量再把它们放入固定大小的线程池往往等于主动取消其优势。真正需要限制的是稀缺资源。例如下游接口最多允许 100 个并发可以使用信号量ComponentpublicclassLimitedClient{privatefinalSemaphorepermitsnewSemaphore(100);publicStringcall()throwsInterruptedException{permits.acquire();try{returninvokeRemoteService();}finally{permits.release();}}privateStringinvokeRemoteService()throwsInterruptedException{Thread.sleep(100);returnok;}}这表达的是“保护下游资源”而不是“因为线程太贵而复用线程”。数据库同样受连接池约束即使创建十万个虚拟线程只有 50 个数据库连接时也只能同时执行约 50 个 SQL。五、最危险的陷阱Pinned Virtual Thread虚拟线程执行阻塞操作时理想情况是从载体线程上卸载。如果它在某些不能安全卸载的代码区域发生阻塞就可能固定在载体线程上。载体线程无法服务其他虚拟线程吞吐量会下降。典型反例是在synchronized代码块内执行慢 I/OpublicclassBadOrderService{privatefinalObjectlocknewObject();publicStringqueryOrder()throwsInterruptedException{synchronized(lock){Thread.sleep(500);// 用慢调用模拟远程 I/Oreturnorder;}}}改进原则是缩小锁的范围不要持锁执行网络或数据库操作publicStringqueryOrder()throwsInterruptedException{StringresultcallRemoteOrderService();synchronized(lock){updateLocalState(result);}returnresult;}如果确实需要在可中断等待期间加锁可以评估ReentrantLock。但不能机械地替换全部synchronized先通过监控找到真实热点。六、用 JFR 和 jcmd 定位线程固定生产排障时可以开启 Java Flight Recorderjava-XX:StartFlightRecordingfilenameapp.jfr,duration60s\-jarapp.jar也可以对运行中的 JVM 发起录制jcmdpidJFR.startnamevirtual-threadsettingsprofileduration60sfilenameapp.jfr使用 JDK Mission Control 打开文件重点查看虚拟线程固定事件、事件堆栈及持续时间。排查顺序建议如下找到持续时间最长、出现频率最高的固定事件沿堆栈定位业务锁、第三方驱动或本地方法判断阻塞是否发生在锁内部修改后用相同流量模型复测 P95、P99 和吞吐量。仅看到一次极短事件不代表系统存在问题。性能优化应以业务指标为判断依据。七、上线前必须回答的五个问题当前瓶颈真的是平台线程而不是数据库连接池吗请求以阻塞式 I/O 为主还是以 CPU 计算为主下游服务允许多大并发是否设置超时和限流依赖库在虚拟线程下是否存在长时间固定问题是否用相同机器、数据和流量模型完成前后对比建议至少记录吞吐量、P50/P95/P99、错误率、CPU、堆内存、数据库连接池等待时间和下游并发数。只比较平均响应时间很容易得出错误结论。八、总结虚拟线程的核心价值不是“神奇加速”而是降低阻塞式并发模型的线程成本。Spring Boot 中开启它只需要一项配置真正困难的是资源治理数据库连接依然有限下游仍需限流持锁阻塞仍可能固定载体线程。正确做法是先定位瓶颈再启用虚拟线程通过 JFR 排查固定问题并用真实压测验证收益。如果业务主要是高并发阻塞式 I/O又希望继续使用清晰的同步代码虚拟线程通常值得优先评估。