ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

等用虚拟,算用池:Java线程池与虚拟线程分工实战指南

2026/9/9 1:57:06 拓冰建站 浏览量
等用虚拟,算用池:Java线程池与虚拟线程分工实战指南 “等的时候用虚拟线程算的时候用池线程池。”这句话是我最近在调一个接口性能问题时反复咀嚼的一句话。以前很多团队一提到并发就无脑上线程池结果IO密集场景线程池被长时间占着等响应吞吐量上不去JDK 21正式引入虚拟线程之后又有人走向另一个极端什么东西都往虚拟线程里塞。其实这两者根本不是替代关系而是分工关系。这篇文章就围绕这句话把虚拟线程和线程池的适用场景、线程池参数合理配置、阻塞队列选择、拒绝策略、多设备并发实战一次讲透适合正在调线程池参数、或者准备把项目迁到虚拟线程的Java开发者。1. 核心思路拆解“等的时候用虚拟算的时候用池”到底在说什么1.1 虚拟线程和线程池解决的是两种不同问题先说线程池。线程池里的线程是平台线程Platform Thread也就是操作系统线程。平台线程从创建到销毁都很昂贵创建要分配栈内存、要经过系统调用数量一多还容易把机器搞挂。所以Java早期才搞出线程池目的是复用线程减少创建销毁的开销同时把并发数限制在一个可控范围内。虚拟线程不同。虚拟线程是JVM层面的调度单元它不直接对应操作系统线程而是挂载到少量的平台线程称为carrier线程上执行。当虚拟线程遇到IO阻塞时JVM会自动把它从carrier上摘下来让carrier去执行其他虚拟线程。等IO就绪了再找一个新的carrier挂上去继续跑。这个动作非常便宜所以创建十几万个虚拟线程也没问题。我见过很多人在分析性能问题时只看线程数够不够、队列深度够不够却忽略了任务到底在“等”还是在“算”。一个任务通常包含两类时间等待时间等网络响应、等数据库返回、等锁和计算时间CPU计算、序列化、正则匹配。线程池擅长的是把计算时间控制住让CPU核心被充分、稳定地利用虚拟线程擅长的是把等待时间里的线程占用成本打下来让同一个carrier在别人等待时继续干活。1.2 为什么CPU密集型任务不应该用虚拟线程如果任务本身没有等待纯粹是CPU计算比如图像处理、加密、压缩、大数组排序那么再多线程也提升不了吞吐量因为CPU核心就那么多。此时用平台线程池把线程数设为CPU核心数左右就是最经济、最可控的做法。虚拟线程在这种场景下反而有额外开销。它的调度由JVM管理执行完一小段代码后可能要检查是否需要yield这种上下文切换虽然比操作系统线程轻量但也是成本。实测下来纯计算任务用虚拟线程和用固定线程池相比没有优势偶尔还会略慢。所以我一直强调虚拟线程解决的是“等”的成本不是“算”的成本。1.3 一句话判断你的任务该用虚拟线程还是线程池我给自己定了一个粗糙但很好用的判断标准任务里如果等待时间占比很高比如超过50%而且任务是短生命周期的比如一次HTTP调用、一个SQL查询、一次adb命令执行优先用虚拟线程如果任务几乎都在算或者任务会长时间持有稀缺资源比如数据库连接、文件句柄那就用线程池并且把线程数压到资源可控的范围内。现实业务里几乎没有纯IO或纯CPU的任务所以更常见的做法是两者配合入口用虚拟线程承接海量并发请求请求内部遇到真正需要限制并发度的资源访问时再通过信号量或专用线程池去兜底。这也是“等的时候用虚拟算的时候用池”这句话的完整含义。2. Java线程池参数合理配置先给任务做一次“等待计算比”体检2.1 线程数计算公式与任务画像我在给团队做线程池配置时第一件事不是打开配置文件填数字而是先给任务做一次画像这个任务平均耗时多少毫秒其中花了多少毫秒在等待IO多少毫秒在计算有一个流传很久的估算公式线程数 CPU核心数 × (1 等待时间 / 计算时间)如果一个任务整体耗时100ms其中80ms在等数据库返回20ms在计算那么等待时间/计算时间 4。8核机器上线程数可以配到 8 × (1 4) 40。注意这个公式给出的是线程池线程数的合理量级不是精确答案但它能避免两个极端线程太少导致IO等待时CPU闲着线程太多导致频繁上下文切换。纯计算任务的话等待时间约等于0公式变成“线程数 CPU核心数”。这也是为什么网上很多文章说计算密集型线程池配N1或2N其实都源自这个逻辑。2.2 corePoolSize、maximumPoolSize、keepAliveTime如何取值ThreadPoolExecutor有7个构造参数其中最常被搞混的是corePoolSize、maximumPoolSize和队列的关系。很多人以为任务多了就先扩线程到maximumPoolSize再入队。真实执行顺序是任务进来先看当前线程数有没有到corePoolSize没到就创建线程执行到了任务进队列队列满了才会继续创建线程直到maximumPoolSize连maximumPoolSize都达到且队列也满了才触发拒绝策略。所以配置时要一起看三个值。我一般这样取corePoolSize 设为 2 × CPU核心数适合大多数混合型业务接口maximumPoolSize设为 4 × CPU核心数左右给突发流量留余量keepAliveTime设60秒就行让高峰过去后非核心线程及时回收。但如果你明确知道任务是IO密集型就不要套这个经验值。我之前接过一个调用外部平台的接口平均等外部响应要700ms计算只有30ms8核机器按公式算下来线程数应该在 8 × (1 700/30) ≈ 194。当时直接配了60结果持续压测时CPU只有20%一大半请求排队。后来把core调成150才稳住。2.3 配好参数之后还要做的两件事第一线程池要预热。默认情况下core线程不会预先创建而是等第一个任务进来才创建高峰期第一个请求会额外承担线程创建开销。用executor.prestartAllCoreThreads()在应用启动时把核心线程建好能避免这种毛刺。第二线程池参数要压测验证。我对团队的要求是改完参数至少做一轮半小时以上的混合场景压测看四个指标CPU利用率是否稳定、平均响应时间是否达标、任务队列深度是否趋稳、有没有触发拒绝策略。参数不是为了填得好看而是为了业务指标服务的。3. 线程池阻塞队列选择与拒绝策略决定线程池上限的两把钥匙3.1 三种队列选型对比LinkedBlockingQueue、ArrayBlockingQueue、SynchronousQueue队列这块我必须先说一个反直觉的事实很多线上事故不是线程不够导致的而是队列太深导致的。无界队列用得最多因为它写着方便但也最容易在流量尖峰时积压几十万条任务内存涨上去下游被瞬间打爆问题还会往后端蔓延。我在真实项目里的选型参考是这样的队列类型特点适合场景风险点LinkedBlockingQueue无界任务可以无限堆积任务量稳定、对延迟不敏感的内部异步处理流量尖峰时内存暴涨问题后移ArrayBlockingQueue有界队列容量固定满了触发拒绝绝大多数外部接口调用必须配合合理的拒绝策略SynchronousQueue不存任务直接交接给线程任务处理极快、消费者充足的场景几乎等同于“线程数优先”模式我的默认选择是有界队列。ArrayBlockingQueue虽然不像LinkedBlockingQueue那样可以无界增长但它把问题挡在入口让你在拒绝策略里有一个明确的兜底手段。LinkedBlockingQueue也可以传capacity参数变成有界队列所以“无界”这个词不是某个类独有的坑而是配置不当的结果。3.2 拒绝策略怎么选Abort、CallerRuns、Discard阻塞队列满且线程数达到maximumPoolSize之后新任务会交给RejectedExecutionHandler处理。JDK自带了四种策略AbortPolicy默认策略直接抛RejectedExecutionException。适合明确接受不了丢任务的场景至少报警了。CallerRunsPolicy由提交任务的线程自己执行。相当于把压力反向传递给调用方在线程池满时天然削峰。DiscardPolicy静默丢弃任务。基本不建议用出了问题都不知道丢在哪。DiscardOldestPolicy丢弃队列里最老的任务再尝试提交新任务。适合能容忍丢旧任务、必须处理最新任务的场景。我在实际中用的最多的是CallerRunsPolicy。不过有个坑要提醒如果调用方是Tomcat的工作线程一旦线程池满调用线程会直接执行任务可能导致这些工作线程也被长任务卡住进而影响其他请求。所以用它之前要评估调用方的线程资源是否充足。如果调用线程本身就很宝贵我倾向于AbortPolicy加告警宁可先失败也不要拖垮调用方。3.3一个真实接口的线程池配置全过程我拿一个真实的B端查询接口举例这个接口要去三个下游服务拿数据再做合并排序单次平均耗时150ms其中等待下游约120ms计算约30ms。服务器是8核16G接口QPS峰值为300。配置过程如下先算线程数8 × (1 120/30) 40所以corePoolSize设为40瞬时流量偶尔到500maximumPoolSize保守点给到80队列用ArrayBlockingQueue容量设为1000因为QPS峰值300时1000意味着约3秒的缓冲够下游抖动恢复拒绝策略用CallerRunsPolicy因为调用方是网关线程池还有一定冗余。压测下来CPU稳定在75%左右P99响应时间从原来的620ms降到180ms没有触发一次拒绝。这套配置后来一直沿用只在业务高峰期前临时调大了队列容量。这个例子想说明的是线程池参数不是拍脑袋定的从任务画像出发每个数字都有依据后面维护才不慌。4. JDK 21虚拟线程实战从newFixedThreadPool迁到虚拟线程的完整示例4.1 两种创建虚拟线程的方式JDK 21正式支持虚拟线程JEP 444使用起来非常简单。第一种方式是用Thread.ofVirtual()Thread thread Thread.ofVirtual() .name(io-task-, 0) .start(() - { // 执行IO密集型任务 }); thread.join();第二种方式是使用ExecutorService这也是最贴近现有代码习惯的迁移路径try (var executor Executors.newVirtualThreadPerTaskExecutor()) { ListFuture? futures tasks.stream() .map(executor::submit) .toList(); for (var future : futures) { future.get(); } }注意newVirtualThreadPerTaskExecutor返回的ExecutorService实现了AutoCloseable用try-with-resources可以像关普通资源一样关闭它。关闭时会等待已提交的任务全部完成不会中断正在跑的任务。对比一下传统写法ExecutorService executor Executors.newFixedThreadPool(40);如果任务本身是短IO型newFixedThreadPool(40)意味着最多只能40个任务同时等响应其余都在队列里排队。而newVirtualThreadPerTaskExecutor没有这个瓶颈每一个任务都独占一个虚拟线程数量不受限制。代码层面只是换了个工厂方法行为上却从“共享线程池”变成了“一人一线程”收益巨大。4.2 用Semaphore限制虚拟线程的并发数刚才说虚拟线程不受数量限制那是不是可以无限创建当然不是。虚拟线程本身便宜但业务底层往往依赖一些昂贵资源比如数据库连接池、HTTP连接池。如果每个虚拟线程都同时拿一个连接而且连接池只有50个连接那么没拿到连接的虚拟线程依然会在等待中只不过这次等的是连接而不是任务调度。正确做法是给资源访问加信号量限流。比如限制最多同时有200个虚拟线程执行外部调用Semaphore semaphore new Semaphore(200); try (var executor Executors.newVirtualThreadPerTaskExecutor()) { tasks.forEach(task - executor.submit(() - { semaphore.acquire(); try { // 这里访问受限资源 callRemoteService(task); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { semaphore.release(); } })); }这种模式下虚拟线程负责承载高并发任务“有限资源”交给Semaphore控制二者配合既保留了虚拟线程的轻量又没有把资源耗尽的风险。4.3 迁移虚拟线程时的三个坑第一不要池化虚拟线程。市面上有人把虚拟线程也放进池子里复用这是完全错误的。虚拟线程的优势就是创建便宜一旦池化你既没得到调度上的好处又增加了池的管理复杂度。我看到这种写法都会直接让同事改掉。第二synchronized里不要做阻塞IO。虚拟线程在synchronized块里发生阻塞时会“钉住”pin底层carrier线程导致carrier无法被其他虚拟线程复用副作用就是吞吐量下降。JDK 24中这个问题有所修复但对存量代码我建议用ReentrantLock替代synchronized或者避免在同步块里做长时间IO。第三ThreadLocal要慎用。虚拟线程数量可能非常庞大ThreadLocal的数据跟着虚拟线程走如果每个虚拟线程都往ThreadLocal里塞大对象内存会显著膨胀。JDK为此推出了ScopedValue但它还在孵化阶段目前我的建议是能用方法参数传递的数据就不要放ThreadLocal实在要放任务结束后务必清理。5. 多设备并发场景实战从adb命令看虚拟线程的用武之地5.1 qml adb多设备并发到底卡在哪前段时间我帮同事优化一个基于qml的Android设备管理工具场景是一次性对几十台设备执行adb命令比如批量安装APK、批量抓日志、批量查设备型号。adb命令的特点是典型的IO密集型命令发出去之后要等设备返回一台设备慢的时候能拖好几秒但CPU几乎不参与计算。原来的实现是用固定线程池设备数40台就开40个线程命令一多线程都被adb进程的等待占住再想并发跑其他任务就得排队。更麻烦的是qml主线程还要刷新UI一旦线程池满了操作响应也会变慢。这其实是“等”的问题用虚拟线程再合适不过。5.2 用虚拟线程重写多设备并发脚本核心思路是把每台设备的每个adb命令执行任务交给一个虚拟线程。代码如下ListString deviceIds getOnlineDevices(); // 获取设备列表 try (var executor Executors.newVirtualThreadPerTaskExecutor()) { ListFutureString futures deviceIds.stream() .map(deviceId - executor.submit(() - execAdb(deviceId, getprop ro.product.model))) .toList(); for (int i 0; i futures.size(); i) { String model futures.get(i).get(); updateDeviceModel(deviceIds.get(i), model); // 回调更新UI } }execAdb内部就是ProcessBuilder启动adb进程读取输出流然后返回结果。因为虚拟线程在等待进程输出时会让出carrier所以即使同时有上百台设备执行adb命令底层占用的平台线程也就十几个CPU几乎不涨。同事一开始还担心ProcessBuilder会不会和虚拟线程冲突实测下来完全没问题Java 21的虚拟线程对普通阻塞IO适配得很好不需要额外改动。5.3 混合使用调度用虚拟线程计算用线程池在这个adb场景里还有一个计算点有些命令返回的结果要做XML解析或正则匹配把几十KB的日志变成结构化数据。如果每条日志都交给虚拟线程去解析虽然也能跑但如果解析很重反而会拖累整个工具。我的处理方式是虚拟线程只负责执行adb命令和读取原始输出拿到输出后把解析任务提交给一个专用的线程池ExecutorService parsePool Executors.newFixedThreadPool( Runtime.getRuntime().availableProcessors() * 2 ); try (var executor Executors.newVirtualThreadPerTaskExecutor()) { executor.submit(() - { String raw execAdb(deviceId, dumpsys); parsePool.submit(() - parseDump(raw)); }); }这样一来等待设备响应时用的是虚拟线程的高并发能力解析CPU密集型内容时用的是线程池的稳定计算能力两边各干各的互不干扰。这个模式我会推荐给所有做设备管理、任务编排类工具的人它和“等虚拟、算池”的原则完全互相对应。6. 常见问题与排查技巧实录6.1 五个高频问题速查表问题现象可能原因排查与解法线程池满但CPU利用率很低任务在等IO线程被阻塞占用计算等待/计算比调大线程数或改用虚拟线程队列任务积压内存持续上涨用了无界队列流量尖峰时队列无限增长换有界队列并配置明确拒绝策略线程池参数改了没生效用了Executors静态工厂或Spring默认配置直接用new ThreadPoolExecutor构造别用Executors虚拟线程数量很多但吞吐量上不去synchronized里做了阻塞IO钉住carrier换ReentrantLock避免同步块内做长IO虚拟线程任务抛异常后无日志提交方式不对异常被Future吞了用execute提交并在任务内catch或用Future.get显式检查6.2 排查思路与避坑清单排查线程池问题我习惯先看四张图线程数曲线、活跃线程数、队列深度、拒绝计数。这四张图能快速定位瓶颈在哪一环。如果队列一直有深度但线程没有跑满大概率是线程数不够而且任务有IO等待如果线程数持续逼近maximumPoolSize同时队列又是空的说明任务计算量大该看CPU是不是已经打满如果拒绝计数开始涨说明容量设计跟不上流量要么扩容要么削峰。还有一个容易被忽视的点不要用Executors.newFixedThreadPool()和Executors.newCachedThreadPool()去创建生产环境的线程池。前者默认用无界队列后者最大线程数是Integer.MAX_VALUE。这两个都是事故温床。我在代码评审里看到这两个方法基本都会要求改成显式的ThreadPoolExecutor构造。关于CallerRunsPolicy还有一个补充它的“调用者执行”会让提交任务的线程直接参与业务执行因此要格外小心线程复用导致上下文污染。比如一个线程在处理A请求时执行了B任务如果任务内部用了ThreadLocal存储请求IDB任务可能会读到A的数据。我在一个网关项目里就踩过这个坑后来在CallerRuns执行前强制清理ThreadLocal才解决。最后压测时不要只测平均响应时间。虚拟线程场景下低并发时性能和线程池差不多优势要到高并发、长等待时才明显。我见过有人在只有10个并发的情况下测虚拟线程得出结论“没区别”这是压测场景没设计对。要让虚拟线程发挥价值就把并发数提到500、1000并且让任务里有真实的网络等待或磁盘等待这种时候差距才拉得开。我个人在实际操作中的体会是没有哪种并发方案是银弹线程池和虚拟线程解决的是不同层面的问题核心还是先搞清楚任务是在等还是在算。等的时间多虚拟线程是首选算的量大线程池反而是更稳的选择。把两者合理地组合起来才是真正理解了并发编程的取舍。