ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Java 25虚拟线程生产实践:高并发IO密集场景下的线程池替代方案

Java 25虚拟线程生产实践:高并发IO密集场景下的线程池替代方案 上个季度我把一个核心网关服务从固定线程池迁到了虚拟线程线上 QPS 从原先的 1.8 万直接干到 5.6 万P99 从 780ms 降到 210ms机器一台都没加。这结果比我预想的好但过程远没有网上教程说的那么丝滑。说实话虚拟线程这个东西原理看着简单真正落到生产环境坑比想象的多而且很多坑是 JDK 21 上不存在、到了 JDK 25 才彻底解决的。这篇就围绕 Java 25 这一代 LTS 版本把虚拟线程从底层机制、选型判断、迁移步骤到上线排障完整梳理一遍重点是我实际踩过的 pinning、ThreadLocal、限流这些问题以及结构化并发在高并发应用里怎么用才不出乱子。如果你是做 Java 后端的手上有高并发、IO 密集、线程池吃不消这类业务这篇内容可以直接当参考清单用如果只是刚接触虚拟线程前半部分把机制讲透了后边的实操和坑也能让你少走几个月的弯路。1. 线程池为什么在高并发下会“卡死”一次事故复盘1.1 阻塞 I/O 才是真正的性能杀手我接手那个网关服务的时候线上已经出过一次事故。业务方反馈接口越来越慢看监控 CPU 才 30% 左右但 Tomcat 线程池被打满请求排队时间往上飙。当时第一反应是加线程从 200 加到 500结果更糟P99 不降反升。这个事得从线程模型说起。我们平时写的同步代码一次请求里经常要调三四个下游查缓存、查数据库、调第三方接口。这些操作都是阻塞 I/O线程发完请求之后只能挂在那边等网络返回。真正干活的时间可能只有 5%剩下 95% 的线程生命周期都在socketRead里空转。一个平台线程默认栈大小 1MB200 个线程就是 200MB 的内存再加上线程调度、上下文切换的开销线程数越往上加系统花在线程管理上的资源反而越多。这就是为什么线程池调大之后接口更慢了——大量 CPU 时间都消耗在切换上下文上了而不是在处理业务。1.2 线程不是越开越多就越快资源维度平台线程虚拟线程默认栈大小约 1MB可调几十 KB动态伸缩创建成本毫秒级到秒级微秒级几乎无成本调度方式由操作系统调度JVM 内部调度可同时存在的数量受内存和系统限制通常几千可达几十万、上百万这里的核心问题不是“线程数量”而是“阻塞期间线程被白白占用”。你用线程池本质上是把“同时处理的请求数”和“线程数”绑定了。下游接口响应慢线程就占着不放后面所有请求要么排队要么拒绝。线程池又不是无限大的加了线程数也只是延缓爆掉的时间点。1.3 JDK 25 的虚拟线程把问题换了个解法虚拟线程的做法很直接不再让一个线程独占一个操作系统级资源而是让成百上千的虚拟线程共享少量的平台线程carrier thread。阻塞的时候虚拟线程把 CPU 让出来等数据到了再继续跑。对你来说代码还是同步写法但底层已经变成了“阻塞即让出”的调度。这里的背景要说明白虚拟线程在 JDK 21 里转正JDK 24 解决了 synchronized 导致线程钉扎pinning的问题JDK 25 是 LTS 版本等于把前面这些补丁全部沉淀下来了。所以现在聊“Java 25 虚拟线程生产实践”和我们前两年拿 JDK 21 上生产已经是两回事了很多 JDK 21 的坑在 25 上其实不存在了但网上大量资料还没更新到这一步。2. 虚拟线程的调度机制到底是怎么工作的2.1 从重量级线程到轻量级任务1:N 的关系虚拟线程和平台线程的关系可以理解成酒店房间和客人的关系。酒店的房间数量有限但客人可以非常多。客人要睡觉了阻塞把房间腾出来给别人住睡醒了I/O 完成再找一间空房继续活动。这里的关键点是“客人”不用自己买床只需要一个能临时落脚的地方。虚拟线程的栈不是像平台线程那样一次性分配 1MB 的固定空间而是按需增长、按需释放挂在堆上。所以创建几十万个虚拟线程不会把内存吃完这在平台线程时代是不可想象的。JVM 内部通过一个 ForkJoinPool 来调度虚拟线程这个池的大小一般和 CPU 核心数一致。虚拟线程在自己的生命周期里会被多次挂到 carrier thread 上执行这个切换动作叫mount从载体线程上卸下来叫unmount。切换代价极小因为这是在 JVM 用户态完成的不经过操作系统内核。2.2 阻塞点自动让出 CPU对业务代码透明虚拟线程最舒服的地方是你写的代码和原来一模一样Thread.sleep()、socket.read()、ResultSet.next()这些阻塞操作JVM 会在字节码/运行时层面拦截在阻塞前自动执行 unmount数据就绪后再 mount 到某个 carrier thread 上继续执行。业务代码不用改也不存在回调地狱。你可以把虚拟线程理解成“帮你写好了异步逻辑的同步代码”。这事对工程效率的意义非常大因为异步编程最大的成本不是机器性能而是代码复杂度和心智负担。虚拟线程让普通后端开发也能用同步的思维写出高并发的服务。2.3 什么情况下虚拟线程会“重新变重”机制再漂亮也有例外。虚拟线程的核心依赖“阻塞操作能被 JVM 感知并让出”但有两种情况它做不到在synchronized块或方法里执行阻塞操作JDK 24 之前会 pinning24 之后修复执行进入 native 方法JNI的代码比如某些文件系统操作、加密库调用JVM 无法在 native 帧里做 stack walk只能等整个 native 调用返回这两种情况如果发生虚拟线程会钉在 carrier thread 上把平台线程占死。你以为自己在用虚拟线程实际上背地里把 carrier thread 当成了普通线程在堵。后面我会专门讲怎么定位和避免。3. 生产选型别急着全量替换先判断业务是不是 IO 密集3.1 判断 IO 密集的三个信号虚拟线程不是银弹它解决的是“阻塞等待型”并发。判断你的业务适不适合迁我总结了三个信号满足两个以上就可以动手了。信号一线程池经常处于打满状态但 CPU 使用率却不高。这是最典型的表现。如果你某台 8 核机器上 Tomcat 200 个线程全被占住但 CPU 只有不到 40%大概率不是计算不够而是大家都在等 I/O。信号二线程转储里大量线程阻塞在 I/O 调用上。打一份 jstack 出来如果绝大多数线程的栈顶是java.net.SocketInputStream.socketRead0、sun.nio.ch.FileDispatcherImpl.read0这一类方法说明线程都被 I/O 卡着。信号三下游接口耗时不短而且并发请求量很高。你的系统如果大量依赖调用第三方 API、数据库、Redis、消息队列单次 RTT 在几十毫秒以上这就是天然适合虚拟线程的场子。我那个网关服务三个信号全中。当时线程池 200因为下游接口 P99 就有 150msQPS 到 18000 就上不去了纯粹是被线程数锁死了。3.2 不建议用虚拟线程的场景有些场景迁过去没有收益甚至会倒退CPU 密集型任务。虚拟线程本质是把阻塞期腾出来给别人的“时间分片术”如果你的任务本来就在满载跑计算没有等待时间可以腾引入虚拟线程只会增加调度开销。锁竞争激烈的代码。虚拟线程数量一大如果大家在争抢同一个锁阻塞在锁上的虚拟线程数量会非常恐怖。虽然 JDK 24 起 synchronized 不再 pin 了但锁等待毕竟是真等待锁并不是 CPU 资源你把它让出去也没意义反而会让锁竞争更严重。依赖大量 JNI 类库的场景。只要 native 调用存在就有钉扎风险承载大量虚拟线程的载体被钉住问题往往在压测时才暴露。判断标准就一句话你的瓶颈到底在“等”还是在“算”。等就换算别动。3.3 与 CompletableFuture、Reactor、Kotlin 协程的选择方案代码风格学习成本生态侵入适合场景虚拟线程同步阻塞式低低传统同步栈改造、IO 密集CompletableFuture回调链式中低单条异步链路编排Reactor/RxJava响应式流高高全链路由 Mono/Flux 包裹数据流处理、背压场景Kotlin 协程同步挂起中依赖 Kotlin 生态新项目、Kotlin 栈虚拟线程最大的价值在于“赛道不变就能提速”而响应式编程要求全链路改造从 Controller 到 DAO 全部换成非阻塞 API很多中间件客户端压根不提供。如果你是个老项目线上线程池又不够用虚拟线程几乎是侵入性最小的优化方案。4. 迁移落地从固定线程池换到虚拟线程的完整手术流程4.1 最小改动只换执行器如果你在代码里用ExecutorService管理线程池迁移第一步简单到让你怀疑是不是有诈// 改造前 ExecutorService executor Executors.newFixedThreadPool(200); // 改造后每个任务一个虚拟线程 ExecutorService executor Executors.newVirtualThreadPerTaskExecutor();newVirtualThreadPerTaskExecutor()创建的执行器每次提交任务都会新建一个虚拟线程任务结束线程也跟着结束。你没看错它对线程数没有上限因为虚拟线程足够廉价不需要池化复用。这块有个容易被忽略的点你可能在多个地方创建了线程池迁移时最好统一收敛到一个公共的工具类或配置类里后面如果要加信号量限流、加监控埋点只改一处就够了。4.2 Spring Boot 3.x 里开启虚拟线程的正确姿势如果你用的是 Spring Boot连上面这行代码都不用写在application.yml加一个开关就行spring: threads: virtual: enabled: true这个开关从 Spring Boot 3.2 开始支持开启后 Tomcat 的请求处理线程会切换为虚拟线程。注意几个前提Spring Boot 版本别太老。3.2 刚出的时候集成还不算完善我建议生产环境至少上 3.4 或更新的补丁版本对 JDK 25 的兼容适配更到位。内置 Tomcat 没问题但外置容器要注意。有些外置 Web 容器是拿系统线程处理请求的开关不一定生效迁移前先确认你的部署形态。异步 Servlet 和 WebFlux 走的是另一套模型。如果你已经在用 WebFlux就没必要再开虚拟线程了两套异步机制叠加反而增加排查成本。另外如果项目里有自己定义的TaskExecutor、Async异步线程池也建议一并换成虚拟线程执行器不然请求层是虚拟线程异步任务还是老线程池瓶颈会被藏到你看不到的地方。4.3 依赖库兼容性排查清单虚拟线程对大多数同步库是透明的但有几个点必须单独验证。检查项风险点怎么验证数据库连接池HikariCP/Druid连接获取是否阻塞在池上查看获取连接的等待时间监控Redis 客户端是否支持阻塞式同步调用压测环境下观察是否出现钉扎HTTP 客户端OkHttp/Apache HttpClient连接池、超时策略压测请求混合外部调用synchronized内部做 IO 的旧库JDK 24 前必然钉扎JDK 25 上再跑一遍验证ThreadLocal 缓存类库虚拟线程数量大时内存爆炸观察堆内存和 GC 压力我在迁移时专门开过一个 JVM 参数来暴露钉扎问题-Djdk.tracePinnedThreadsfull这个参数会在虚拟线程发生钉扎时打印完整栈跑一轮压测扫日志里有没有Pinned标记有就针对性地改。JDK 25 上 synchronized 导致的钉扎已经修复但 native 调用导致的钉扎还在jdk.tracePinnedThreads依然是你排查此类问题的最快路径。4.4 压测验证吞吐、P99 和连接池的联动调整迁移完别急着全量上线先压测。我有几点实际经验吞吐量看拐点。压测时逐步加并发观察 QPS 曲线。平台线程时代 QPS 到一定值就横盘甚至下滑虚拟线程时代曲线会更陡峭地爬升而且拐点出现得更晚。P99 比平均值更能说明问题。虚拟线程大幅降低了排队等待时间P99 的改善往往比平均值更明显如果你的 P99 没降大概率还有别的阻塞点。数据库连接池大小要重新算。这是最容易遗漏的。原来 200 个线程数据库连接池配 50 就够用现在并发请求数从 200 变成几万数据库连接池反而成了瓶颈。连接池设置多大是个权衡题设太大数据库可能扛不住设太小虚拟线程全堵在拿连接上。稳妥做法是先用压测找出数据库能承受的连接上限再给连接池设一个明确的上限配合信号量限制同时访问数据库的任务数。我当时压测就发现服务线程不堵了HikariCP 的connectionTimeout开始频繁触发。原因是大量虚拟线程同时去拿数据库连接连接池空了。最后是把连接池上限从 50 调到 80再给数据库访问加了一层信号量限流才把曲线拉平。5. 上线后的真实战场pinning、ThreadLocal 和限流旧账5.1 synchronized 造成的 pinning 是怎么被 JDK 24 修掉的先复述一下 pinning 到底是什么虚拟线程想在阻塞点让出 carrier thread但 JVM 需要完整遍历栈帧才能挂起恢复如果栈上有一个synchronized修饰的临界区而临界区里的某个点发生了阻塞JVM 不敢在这个临界区里把虚拟线程卸下来因为离开临界区时需要释放监视器锁这个操作要求线程上下文必须连续。JDK 24 的 JEP 491 修掉了这个问题做法是改进了阻塞时的处理让虚拟线程在 synchronized 块内也能安全让出JDK 25 作为 LTS 天然带上了这个修复。所以现在你不需要把所有synchronized改成ReentrantLock了。但还有一块没完全解决native 方法调用。如果一段代码进入 JNI 调用JVM 无法在 native 栈帧里做挂起操作这类调用依旧会钉扎。实践中这类情况少很多主要集中在文件 IO 的某些底层 API、加密解密库上。遇到也别慌加压测、开jdk.tracePinnedThreadsfull扫描出钉扎点后把那一小段代码改成非 synchronized 或用 JUC 的锁替代即可。只改少数热路径就行不用全项目排查。5.2 ThreadLocal 在百万线程面前站不住脚我团队里有人踩过这个坑一个老项目把用户上下文放在 ThreadLocal 里还顺手做了个几 KB 的缓存对象。原来 200 个平台线程最多 200 份无感换成虚拟线程之后每请求一个虚拟线程ThreadLocal 就跟着请求一起创建瞬时并发几万的时候几 KB 的缓存对象被复制了上万份堆内存直接报警。这个问题的本质是 ThreadLocal 的使用前提已经变了。平台线程是稀缺资源线程池复用同一个线程时ThreadLocal 相当于一次初始化、多次复用虚拟线程则是“用完即弃”ThreadLocal 的“复用”优势完全没了只剩下重复创建的浪费。我的建议按优先级处理能用方法参数传的别用 ThreadLocal 存值对象尽量不可变、共享而不是每个线程拷贝一份如果是做链路追踪 ID、用户上下文的透传而项目刚好在比较新的 JDK 上可以关注一下 ScopedValue预览中它就是官方为替代 ThreadLocal 场景设计的但目前还不建议生产环境大规模依赖预览特性5.3 用信号量替代“靠线程池限流”老代码里经常有人这么干线程池设个最大值线程满了请求就被拒绝用线程数同步限流。这套玩法和虚拟线程天然冲突——虚拟线程没有上限你拿它限不了流。正确做法是把“限流”从线程模型里拿出来单独用Semaphore控制private final Semaphore dbSemaphore new Semaphore(80); try { dbSemaphore.acquire(); // 执行数据库操作 return dao.query(...); } finally { dbSemaphore.release(); }这样既保留虚拟线程的高并发能力又能限制打到数据库或其他下游的最大并发数避免把数据库或第三方接口打爆。信号量的值要根据下游的真实承载力来配压测得出来。5.4 线程转储和 JFR排障方式发生了哪些变化虚拟线程上线后排障手段也要升级。传统的jstack能看到平台线程但虚拟线程数量一多传统转储方式就不够用了。JDK 21 之后推荐用jcmd直接导出线程转储jcmd pid Thread.dump_to_file -formattext thread_dump.txt这份转储里会同时包含平台线程carrier threads和虚拟线程。虚拟线程栈信息非常全能直接看到它阻塞在哪个业务代码上这对定位问题帮助极大。另外强烈建议配合 JFR 使用开启jdk.VirtualThreadStart、jdk.VirtualThreadEnd、jdk.VirtualThreadPinned这类事件钉扎问题、虚拟线程创建速率都能在可视化工具里直观看到。我后来在监控面板里专门加上了“活跃虚拟线程数”这个指标高峰期能到 8 万多看着吓人但系统很稳这就是虚拟线程该有的样子。6. 结构化并发让高并发代码从“放养”变成“可控”6.1 并发任务不管控的后果虚拟线程让“起一个线程”变得太容易了容易到随手就executor.submit()一把梭。但线程开出去之后呢谁来保证失败时必须取消其他任务谁来确保没有任务泄漏如果某个子任务永远阻塞整个请求是不是要一直等它我见过一个事故代码里向三个下游并发发请求用的是CompletableFuture.allOf其中一个下游超时挂起但因为没有人调用cancel那个虚拟线程一直挂着请求的资源释放不了最后把系统拖垮。这就是并发任务“放养”的代价。6.2 StructuredTaskScope 的两个内置策略怎么用StructuredTaskScope的核心理念是并发任务的生命周期必须和代码块的生命周期绑定。用try-with-resources包住代码块结束所有子任务要么完成、要么被取消不存在泄漏的线程。它提供了两个开箱即用的策略ShutdownOnSuccess第一个子任务成功就关闭整个 scope适合“多路请求谁先返回用谁”的场景ShutdownOnFailure任一子任务失败就关闭整个 scope适合“多个请求必须全部成功否则整体失败”的场景看个实际例子一个用户详情聚合接口用户信息和用户订单并行查try (var scope new StructuredTaskScope.ShutdownOnFailure()) { FutureUserInfo userFuture scope.fork(() - loadUserInfo(userId)); FutureListOrder orderFuture scope.fork(() - loadOrders(userId)); scope.join(); // 等待全部子任务结束或失败 scope.throwIfFailed(); // 有失败抛出第一个异常 UserInfo user userFuture.resultNow(); ListOrder orders orderFuture.resultNow(); return buildResponse(user, orders); }resultNow()只允许在任务已完成时调用语法上就杜绝了你拿一个还没完成的结果。这和Future.get()的阻塞等待完全不同天然规避了“先调用后等待”的顺序错误。6.3 超时、取消和错误传播的实践组合高并发生产环境必须考虑超时。StructuredTaskScope支持带超时的 jointry (var scope new StructuredTaskScope.ShutdownOnFailure()) { scope.fork(() - loadUserInfo(userId)); scope.fork(() - loadOrders(userId)); scope.join(Duration.ofSeconds(3)); // 最多等 3 秒 scope.throwIfFailed(); }这里有个细节join(3秒)返回后如果某些子任务没完成close()会去中断它们。但中断只是设置了线程的中断标志如果你的业务代码不响应中断、还在继续跑虚拟线程不会立刻停下来。所以子任务里的 I/O 操作仍然要自己传超时参数比如 HTTP 客户端的connectTimeout、readTimeout数据库查询的queryTimeout两层超时配合才不会挂死。另外错误传播也值得一提。传统ExecutorService的submit会把异常吞进Future你忘了get()就完全感知不到StructuredTaskScope则把子任务异常汇拢throwIfFailed()会把第一个异常原样抛给外层。配合请求级别的日志链路失败后能精准定位到是哪个下游调用挂了而不是看一堆孤立的超时日志猜来猜去。最后再分享一个实操体会虚拟线程不是“开得越多越好”它放开了并发数的天花板但下游系统的承载力没变。真正的生产调优其实是围绕“虚拟线程 信号量 超时控制 结构化取消”这四件套来做的。把这四项配合好你的高并发应用才算真正把虚拟线程用稳了而不只是换个执行器图个心理安慰。我这边跑了大半年最大的感受是以前调线程池参数像是在悬崖边试探现在可以放心大胆地把并发打上去只要下游扛得住系统层面几乎不用再操心线程资源了。
返回列表