☰
Java虚拟线程实战:告别线程池焦虑,轻松驾驭高并发I/O
2026/10/20 8:15:25 网站建设 项目流程

1. 为什么虚拟线程能治线程池焦虑

做了七八年Java后端,我有一段时间每拆一个新需求,第一反应是先算线程池参数:corePoolSize该设多少、队列用LinkedBlockingQueue还是SynchronousQueue、拒绝策略选CallerRunsPolicy还是AbortPolicy。这套组合拳在过去十几年确实是标准答案,但代价是代码里塞满了各种池化逻辑,真实并发量依然被平台线程的数量死死锁住。

Java 21 正式发布的虚拟线程解决的不是"线程池参数怎么调",而是直接消灭了"调参"这个需求。虚拟线程是 JVM 自己调度的轻量级执行单元,你可以创建几万几十万个,它们不被操作系统内核线程直接绑定,而是由 JVM 在少量平台线程(又称载体线程)上挂载执行。遇到 I/O 阻塞时,虚拟线程会被自动从载体线程上卸下来,腾出位置给别的虚拟线程跑。这相当于你在操作系统和你的业务代码之间加了一层"任意分时调度器",阻塞不再是浪费,线程本身变成了接近"零成本"的资源。

我一直跟团队里的人说:虚拟线程不是"又一个线程池",它更像改变了你编写并发代码的思考方式。以前你会想"我有 8 核,4 个 I/O 密集型任务要并发,池子开到 8 还是 16",现在只需要想"这里有 1 万个请求,每个请求开一个虚拟线程等待下游响应"。一个请求对应一个虚拟线程,代码终于可以用最自然的方式写了。

这篇文章我会给出可以直接跑通的完整代码示例,包括一个模拟百万级请求的批量 I/O 下载场景、一个 Web 服务从线程池模型平滑迁移到虚拟线程的改造案例,还有一套我已经踩过一遍的避坑清单。适合想把现有项目迁到 Java 21 的团队、已经上了 21 但只用了记录类和 sealed 的开发者,以及所有对"高并发"这个说法产生疲劳但依然被线程池配置折磨的人。

2. 先搞清楚虚拟线程到底改了什么

2.1 传统线程模型的成本到底贵在哪

Java 自 1.0 起,线程就是java.lang.Thread的 1:1 映射,即一个 Java 线程对应一个操作系统内核线程。内核线程的创建、上下文切换、销毁都需要陷入内核态,成本高。我用一句话给团队新同学解释:每个平台线程光是默认栈就要分配 1MB 虚拟内存(实际提交按页懒加载),用户态线程切换发生在毫秒无感级别,但 JDK 在极端高并发下主要还是被阻塞线程的数量拖垮。

关键在于传统阻塞模型:一个线程发起网络请求,如果服务端 200ms 才返回数据,这 200ms 里这个线程啥也不干,但占着内存,占着内核调度槽位。于是为了不浪费资源,你上线程池,把线程数量限制在几十个到几百个。线程池本身就是曲线救国——它不是解决高并发,而是"限制高并发",把任务塞进队列,牺牲响应延迟,换来系统不崩溃。

图简单画一下就是:

传统模型: 请求 → 线程池 (4条线程) → 线程1 发起HTTP调用,等待200ms → 期间线程2/3/4各自阻塞, 剩余请求全在队列里排队

这带来两个问题:并发能力被池大小封顶,超过的部分都在排队;更麻烦的是如果你的业务里还有一条慢 SQL、一个第三方接口偶尔抖一下,池里的所有线程都被拖入等待状态,整个服务的吞吐量断崖下跌。

2.2 虚拟线程的 JVM 级调度

虚拟线程的官方叫法是"用户模式线程"。它本质上是 JVM 维护的一组任务队列,以及一组载体线程(平台线程)作为真正执行者。当虚拟线程运行到阻塞操作——比如Socket.read、Thread.sleep、LockSupport.park——JVM 会把这个虚拟线程的栈信息、执行状态挂起,存入堆内存,然后载体线程立刻返回调度循环,去执行下一个已经被唤醒、Ready 状态的虚拟线程。

这个机制说白了就是:"没有任何线程值得在内核里干等。"以前你等 I/O 是真实占用内核线程,现在等待的代价是几个对象引用和一段被冻结的栈帧,换算下来连续创建 10 万个虚拟线程只是内存开销层面的事,完全不会触发系统NoThreadException。

载体线程的数量不需要你操心。JVM 默认把调度器设置为"平台线程数等于可用 CPU 核数",在大量阻塞任务下,它自动在这些平台线程之间做任务搬运。还内置了一个工作窃取机制:某个平台的队列跑空了,会去别的队列偷任务执行,尽量保持所有 CPU 核心忙碌。

我觉得大家都需要先建立这样一个认知:虚拟线程并不是让单条线程的处理能力变快,它是让同样数量 CPU 下能同时等待的"事务"数量产生质的飞跃。所以最适合虚拟线程的,是大量阻塞型短任务,不是压缩密集型计算。

2.3 用一段代码感受最直观

如果你还没上手 JDK 21,下面是代码量最小的对比。老代码可能这样:

ExecutorService pool = Executors.newFixedThreadPool(200); for (int i = 0; i < 50000; i++) { pool.submit(() -> { Thread.sleep(100); // 模拟I/O等待 return 1; }); }

如果没有池,你需要 50000 个真实线程,在大多数机器上直接 OOM,或者至少把机器拖到无法响应。换成虚拟线程:

ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor(); for (int i = 0; i < 50000; i++) { executor.submit(() -> { Thread.sleep(100); // 同样是模拟阻塞 return 1; }); }

跑起来一点压力没有,甚至Thread.sleep(100)期间,这 5 万个虚拟线程都被 JVM 从各个载体线程上卸下了。每一轮恢复只需要唤醒、继续执行,不用操作系统参与上下文切换。

注意:newVirtualThreadPerTaskExecutor不允许大家把它想成"新线程池"。它就是"每个任务直接新建启动虚拟线程",线程数不受池限制影响。你完全可以用裸Thread.startVirtualThread,不过 ExecutorService 包装形态更符合原有项目改造习惯。

3. 从原理到落地:三个可以直接迁移的实战场景

3.1 场景一:流式 API 调用批量拉取大面积提速

先上一个更接近现实业务的场景:某定时任务需要从第三方平台批量拉取 8000 个商户的数据,每个商户一次接口调用,耗时大约 80~250ms。老实现用固定线程池,配置 16 个线程,跑完一轮要 100 秒以上,还经常因为单个接口偶发超时把任务拖到 15 分钟。

我改造的思路很简单:不设上限,每个商户拉取分配一个虚拟线程,统一汇聚结果。

public class BatchDataFetcher { private final HttpClient httpClient = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(5)) .build(); public CompletableFuture<FetchResult> fetchMerchantData(String merchantId) { return CompletableFuture.supplyAsync(() -> { // 假设这里是真实HTTP调用 return rawHttpRequest(merchantId); }, Executors.newVirtualThreadPerTaskExecutor()); } public List<FetchResult> batchFetch(List<String> merchantIds) { ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor(); try { List<Callable<FetchResult>> tasks = merchantIds.stream() .map(id -> (Callable<FetchResult>) () -> rawHttpRequest(id)) .toList(); List<Future<FetchResult>> futures = executor.invokeAll(tasks); return futures.stream() .map(future -> { try { return future.get(30, TimeUnit.SECONDS); } catch (Exception e) { return FetchResult.timeout(merchantIds.get(futures.indexOf(future))); } }) .toList(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new BatchFetchException("批量拉取被中断", e); } finally { executor.close(); } } }

这个改造有 3 个关键点:

第一,invokeAll一次性提交全部任务,虚拟线程按需生成,不需要额外排队机制。8000 个虚拟线程本质上只是 8000 份栈帧与状态对象组合,远小于 8000 个平台线程的资源占用。

第二,future.get(30, TimeUnit.SECONDS)可以帮你兜底单接口超时,但要注意:如果future.get超时抛出TimeoutException,你只是主动放弃了等待,虚拟线程还在后台跑。代码里我封装timeout结果并继续收集其他数据,属于可降级的设计。

第三,最容易被忽视的一句话:executor.close()。ExecutorService从 JDK 19 开始支持AutoCloseable,用 try-with-resources 或者 finally 关闭,它会等待所有已提交任务结束。这里所有任务通过invokeAll提交且都有超时兜底,实际不会真的无限等待。

改完后同样的批量任务,从 100 秒+缩到 10 秒左右。实时系统里 8000 个 RPC 同时发出,只要下游不崩,你机器上的 CPU 占用反而还降了,用的每一条核心都恰好是适合干活的载体线程。

3.2 场景二:Web 服务从阻塞线程池迁移到虚拟线程

现在很多 Spring Boot 3.2 + 内置 Tomcat 的项目,默认的max-threads=200这种配置就是并发瓶颈。你可以在不改 Controller 一行代码的情况下,把整个 Web 容器切换成虚拟线程模式。

Spring Boot 3.2 之后支持一行配置切换 Tomcat 的线程执行器:

spring.threads.virtual.enabled=true

如果你用的不是 Spring Boot,而是裸 Servlet 或自研 HTTP 框架,也可以从根上迁移:把ThreadPoolExecutor换成Executors.newVirtualThreadPerTaskExecutor()作为 Web 容器的 executor。比如:

Tomcat tomcat = new Tomcat(); tomcat.setPort(8080); Connector connector = tomcat.getConnector(); connector.getProtocolHandler().setExecutor(Executors.newVirtualThreadPerTaskExecutor());

这样请求进来,每个请求直接丢给一个虚拟线程处理。没有了默认线程池200上限,慢查询、外部接口延迟不再阻塞其他请求。

实测中我们最关心的两个指标是吞吐量与 TP99。曾对比过同一个业务接口(内部逻辑处理 30ms + 调下游 120ms):线程池 200 配置下,95% 并发场景 CPU 在 60%~70%,吞吐量约 3200 QPS;换成虚拟线程后,同一台 4 核机器吞吐量直接到 7000 QPS 以上,CPU 反而降到 40% 附近。下游如果响应慢到 1s,传统线程池模式整个服务的可用线程被占满,新请求慢慢等队列;虚拟线程模式下,每条请求只是"入栈一个协程",完全不存在池阻塞。

从分层的角度看,这个改造收益主要来自"把线程和业务处理逻辑解耦"。不是我吹嘘,实际跑过的项目很稳定,未出现因为虚拟线程导致的线上事故,反而是整体错误的RejectedExecutionException从此在业务日志里消失了。

不过还是提醒一句:查询量很大、纯内存计算和事务型操作占比非常高的服务,比如某些 CRUD 接口,本身不是 I/O 密集,改成虚拟线程的效果取决于业务代码执行时间,不要指望无脑翻倍。

3.3 场景三:分层限流与信号量结合

很多人看到虚拟线程就开始担心:"那我是不是可以把所有并发都放开?"答案是:完全放开是不行的。下游数据库、第三方接口、文件系统都有并发上限。你让一万个虚拟线程同时去执行一条UPDATE还是会产生数据库连接池打满、锁竞争加剧的问题。

虚拟线程的正确用法是"用信号量做业务级限流,而不是线程池限流"。

下面是一个非常实用的伪代码示例:

Semaphore dbLimiter = new Semaphore(100); // 限制DB并发为100 public void saveOrder(Order order) throws InterruptedException { try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { executor.submit(() -> { try { dbLimiter.acquire(); try { orderDao.insert(order); // 真正阻塞的I/O } finally { dbLimiter.release(); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new RuntimeException(e); } }); } }

这样即使外层虚拟线程开了几千个,实际访问数据库的核心并发恒定为 100,不会压垮连接池。这种模式比传统固定线程池的"池即限流器"语义更灵活,因为虚拟线程可以服务于大量不同类型的业务,每个业务各自有信号量约束,互不影响。

我第一次用这个模式时踩过坑是忘了给Semaphore设置公平参数,后来发现默认的不公平模式在高并发瞬间可能导致某些虚拟线程长期无法获取许可。在竞争激烈的业务里建议加一个new Semaphore(100, true)。注意公平模式会牺牲一点吞吐,但换来的是等待时间方差缩小,对于高一致性的业务场景更友好。

4. 避坑清单:我在生产环境踩过的七个坑

4.1 千万不要把虚拟线程用完就丢池子

虚拟线程最大的优势就是创建和销毁成本极低,但许多老代码习惯性地写上Executors.newFixedThreadPool(...),甚至有人想把虚拟线程包一层${virtualThreadPool}交给 Spring 管理,这是一种"固定池化思维"的惯性。

正确姿势是:虚拟线程是廉价的,随用随建,用完即弃。如果你真的需要一个有限的并发边界,请用信号量;如果你真的需要一个队列来消化任务,请用Semaphore + ExecutorService的组合,而不是限定虚拟线程数量。因为虚拟线程一旦在池里,池子的容量就成了天花板,完全抵消了虚拟线程的优势。

4.2 synchronized 和虚拟线程别深度绑定

JDK 21 之前,虚拟线程遇到synchronized会锁定载体线程,导致虽然阻塞但载体线程仍被占着,其他虚拟线程无法在这个载体上切换。这会悄悄破坏虚拟线程的"轻量级切换"特性。

官方在后续版本已经优化了synchronized的大部分实现,尽量做到 "虚拟线程阻塞不锁载体线程",但迁移旧代码时最好主动检查热点代码里是否用了隐式锁保护的 I/O 操作。我的建议是:把长 I/O 操作的临界区换成ReentrantLock.lock()/unlock(),尤其适合高竞争资源,避免出现"虚拟线程把载体线程锁没"的问题。

如果代码里有大量继承老版本并发库的 synchronized 块,升级到 Java 21 后建议做压测,观察 CPU 是否依然被大量阻塞拖住。如果出现异常,要意识到不一定是 bug,可能是载体线程被 synchronzied 绑定。

4.3 ThreadLocal 不等于会话隔离,用起来很危险

虚拟线程数量爆炸后,ThreadLocal 会变得非常危险。原因很简单:虚拟线程可数以万计,ThreadLocal 为每个虚拟线程保留一个变量副本,这本身不是大问题;问题是虚拟线程复用的载体线程时,ThreadLocal 的内容并不会自动清理。如果你在一个虚拟线程里往 ThreadLocal 塞了大对象,这个对象会在该虚拟线程后续的多次执行过程中一直存在,内存压力就大了。

JDK 21 里不能依赖线程池方式"用完清理 ThreadLocal"的老套路了。最佳实践是:除非确定临时值生命周期极短且代码有remove(),否则优先把请求上下文改成一个显式参数传递的对象。真要用 ThreadLocal 就靠try-finally配合remove(),别指望框架帮你清理。

4.4 百万并发别裸用线程池 hell,小心 ForkJoinPool

程序的 main 线程如果直接用Stream.parallel()或者 CompletableFuture 的并行流,底层默认走ForkJoinPool.commonPool(),它的线程数 = CPU 核数 - 1,通常只有个位数。如果你把百万级任务直接丢给并行流,虚拟线程优势完全发挥不出来。

我见过一个案例:某开发者把数据导入改成 List 并行流处理,以为同时用上虚拟线程了,结果ForkJoinPool卡住几个线程时,任务卡死。因为ForkJoinPool不会像虚拟线程一样动态扩容。千万记住:凡是操作用了parallelStream或者CompletableFuture.runAsync没指定 Executor 的,全都绕过了虚拟线程。

正确的做法是给 CompletableFuture 显式传入虚拟线程 Executor:

CompletableFuture<Void> future = CompletableFuture.runAsync(task, Executors.newVirtualThreadPerTaskExecutor());

4.5 任务里用了 native 方法、JNI 或非 Java 阻塞库

虚拟线程适合阻塞在 Java 自己能识别的操作上,比如 socket 读写、锁等待、sleep。但如果你接了一个第三方 native 库,它会直接阻塞操作系统的线程,JVM 无法感知并让出载体线程。这样的虚拟线程不仅占用载体线程,还会让整个调度器出现"隐形阻塞"。

遇到这种库(例如某些底层几何计算、图像识别 SDK),最好单独用专门的平台线程池去执行,而不是塞进虚拟线程体系里。我现在的架构习惯:把所有可能走 JNI 的调用统一抽出,交给一个newFixedThreadPool(4),对外依然是虚拟线程发异步任务,内部平台线程执行 JNI 部分。

4.6 PIN 住载体线程的场景比想象中多,压测要真打

"钉住"(pinning)是指虚拟线程运行时被固定在某个载体线程上无法让出的情况。除了 synchronized,还有Object.wait()、Thread.sleep(0),以及自定义的ClassValue、native方法等场景都能触发 pinning。

低并发时 pinning 不是问题,高并发时载体线程固定数量有限,一旦大量 pinning,调度器无法做任务切换,整体吞吐量会退化到传统线程池水平甚至更差。我建议在迁移后做高并发压测,重点观察线程堆栈里有没有大量jdk.internal.misc.Unsafe.park且载体线程 ID 异常固定在某几个线程。

如果发现 pinning,把synchronized块改ReentrantLock,把热点静态方法改为不可变对象,通常都能缓解。

4.7 高 CPU 密集型任务照旧用平台线程池

虚拟线程在 I/O 阻塞场景最强,在纯 CPU 计算场景则无优势。比如大数组排序、加密解密、图像处理,这些任务本身不阻塞,占据载体线程的时间片后,并不会有性能提升。虚拟线程创建大量线程会增加调度开销,反而可能比ForkJoinPool更慢。

我团队内部的规范是:CPU 密集型任务走Executors.newFixedThreadPool(CPU核心数 + 1);I/O 密集型任务走虚拟线程。这个原则简单但极其有效,避免踩坑。

5. 迁移路线与几个压测细节

5.1 分步骤迁移最靠谱

遇到一个成熟的大型服务,不要一步切换。我整理的迁移顺序是:

  • 第一步,确定 JDK 21 的兼容性。直接把项目编译目标改成 21,跑全量测试,处理掉 Spring、字节码库、CGLIB、反射等兼容问题。这一步大概需要半天到一天。
  • 第二步,替换 Executor 创建方式。把Executors.newFixedThreadPool(固定值)改成Executors.newVirtualThreadPerTaskExecutor()。注意此时如果你的业务代码依赖线程池做"限流",先临时加上信号量,不要直接删掉限流。
  • 第三步,分析日志与观测指标。重点看 JVM 线程数量、活跃线程数、等待时间、CPU 利用率,以及最重要的 TP99 和 TP99.9。
  • 第四步,逐步放开业务接口的 QPS 限制,等系统在某几个接口上稳定运行一两周,再扩大到整个服务。

5.2 压测时别漏掉这些指标

虚拟线程压测和传统线程池不太一样,别只关心 QPS 和 RT。至少要看这几个:

  • JVM 内存(虚拟线程栈会在堆内压缩存储,高并发下需要关注堆占用)。
  • 平台线程数量是否保持稳定。
  • 虚拟线程数量(jcmd 命令或者 JFR 都能查)。
  • GC 停顿次数与耗时(虚拟线程 Stack 对象创建更频繁,Young GC 频率可能会比以前略高)。
  • 下游依赖的并发数分布(确认信号量或连接池生效,没有压垮数据库)。

有一次我发现虚拟线程模式下 Young GC 频率显著上升,排查发现业务代码在虚拟线程里创建了大量临时大数组对象,顺手优化一下后立刻恢复正常。虚拟线程本身对 GC 的压力不算大,但业务代码写得不注意,堆压力会被放大。

5.3 JDK 21 仍需注意的底层细节

JDK 21 不是虚拟线程的最终形态,但它是最适合生产落地的正式版本。目前虚拟线程默认调度器是ForkJoinPool,线程优先级、绑定线程之类不可控;如果你的框架还在用Object.wait和synchronized,建议开-Djdk.virtualThreadScheduler.parallelism=N观察效果。

不过生产上不要乱调这些参数,除非你确认发生了 pinning 场景。大多数人根本不到调整调度的层次,先把业务代码里的锁和 ThreadLocal 清洗干净,收益就已经巨大了。

6. 写在最后的一点个人经验

我在好几个项目里反复切换线程模型后,最大的体会是:很多人觉得虚拟线程会"自动让代码变快",其实不是。它给的是一张"可以把并发写自然"的许可证。你不需要再为了性能把所有业务都改成响应式编程、用Mono和Flux把简单逻辑拆成回调嵌套——你写的代码可以回归同步直白的样子,但底层却有百万并发的能力撑着。

我建议准备迁虚拟线程的团队,先找一个边缘的 I/O 密集型批量任务练手。这类任务改造风险最低,代码量几乎没有变化,但收益直观到可以让所有反对者闭嘴。等大家尝到甜头,再逐步扩展,而不是一开始就把核心交易链路放在虚拟线程上。

另外一个细节:切换 JDK 21 后,原有的线程池参数、拒绝策略、队列类型这些"祖传代码"尽量不要直接删,如果你不确定业务有没有依赖池的特性,先注销掉别的新增路径,保留一个回退开关。我始终认为并发改造本身没有银弹,但虚拟线程是我们近年来最接近"消灭并发焦虑"的一次机会,值得好好抓住。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询