从Java 21到Java 25:虚拟线程如何追上并超越事件循环方案

这玩意儿居然能跑赢虚拟线程!

2026年9月,一个叫Jactl的JVM脚本语言,扔出了一份让Java圈炸锅的基准测试报告。

报告对比了两种并发模型:一种是Java 21正式引入的虚拟线程(Virtual Threads),另一种是Vert.x框架成名多年的事件循环(Event-Loop)。测试结果在Java 21上让人大跌眼镜——执行10次10毫秒的模拟阻塞操作,Jactl挂起/恢复机制搭配Vert.x的吞吐量,是虚拟线程方案的2.5倍。但在Java 25上,虚拟线程又反超了。

同一个脚本语言,同一个测试代码,同一个硬件环境,仅仅因为JDK版本不同,冠军就换了人。

这不只是一场性能竞赛。这背后是Java并发编程二十年来最深刻的一次底层逻辑碰撞:事件循环的“不阻塞”原则,和虚拟线程的“随便阻塞”哲学,到底谁更正确?

一个脚本,两种活法

先搞清楚Jactl是什么。

Jactl是专门为事件驱动、响应式Java应用设计的嵌入式脚本语言。它的语法混了Java、Groovy和Perl的血统,编译成JVM字节码执行。整个JAR包不到1MB,而Groovy光核心包就7.7MB。安全沙箱、零依赖、支持Java 8以上——这些特性让它很适合在多租户、需要动态脚本的场景里当“插件语言”用。

但这篇文章的主角不是Jactl本身,而是它处理阻塞操作的方式。

测试用的脚本长这样:


var totals    = [:]
var itemCount = 0
var grandTotal = 0.0
var topCategory = ''
var topAmount = -1.0
var slept = 0

def checkInventory(widget, count) {
  sleep(10) if slept++ < sleepCount
  return true
}

def processOrder(order) {
  var price    = order.price
  var qty      = order.quantity
  var category = order.category
  return unless checkInventory(order.category, order.quantity)

  var discount = 0.0
  if      (qty >= 100) { discount = 0.20 }
  else if (qty >=  50) { discount = 0.10 }
  else if (qty >=  20) { discount = 0.05 }

  var lineTotal = price * qty * (1.0 - discount)
  if (totals[category] == null) {
    totals[category] = 0.0
  }
  totals[category] = totals[category] + lineTotal
  grandTotal       = grandTotal + lineTotal
  itemCount        = itemCount + 1
  if (totals[category] > topAmount) {
    topAmount   = totals[category]
    topCategory = category
  }
}

for (order in orders) {
  processOrder(order)
}
'Processed ' + itemCount + ' orders. Grand total: ' + grandTotal + '. Top category: ' + topCategory

核心就一行:sleep(10) if slept++ < sleepCount。根据配置,这行代码会在脚本里被执行0次、1次、2次、5次或10次。每次sleep(10)代表一次10毫秒的阻塞操作——数据库查询、RPC调用、HTTP请求,随便你怎么代入。

测试跑了10万个事件,每个事件执行一遍这个脚本。然后用JMH做基准测试,比较每秒能处理多少个事件。

同样的脚本,在Jactl里可以用两种完全不同的底层机制跑。

第一种:关闭Jactl自己的异步机制,让sleep()老老实实阻塞线程。然后脚本跑在虚拟线程上——Java虚拟线程会接管阻塞,自动挂起和恢复。

第二种:打开Jactl的异步机制。sleep()被调用时,Jactl不会真的阻塞,而是通过抛异常的方式捕获整个脚本的执行状态(Continuation),立刻返回给事件循环线程。等10毫秒过后,再从上一次停下的地方精准恢复执行。

同一个sleep(10),在虚拟线程世界里是“线程被挂起”,在事件循环世界里是“函数被中断然后恢复”。 这是两种完全不同的并发哲学在争夺同一块领土。

事件循环的“黄金法则”,被虚拟线程一脚踹翻

Vert.x的事件循环模型有个铁律:事件处理器永远不能阻塞。因为事件循环线程就那么几个(默认是CPU核心数的2倍),任何一个线程被阻塞,它负责的所有连接都得排队等着。阻塞一多,整个系统的吞吐量就直接塌了。

所以Vert.x应用里,所有耗时的I/O操作都必须写成非阻塞的——用回调、用Future、用Promise,反正不能用同步代码。这种编程范式叫“响应式编程”,优点是吞吐量高、资源占用少,缺点是对开发者不友好——代码被切成碎片,调试困难,学习曲线陡峭。

虚拟线程的出现,就是要终结这种痛苦。

Java 21的虚拟线程让开发者可以用同步阻塞的写法,拿到异步非阻塞的性能。你在代码里写Thread.sleep(1000),JVM在底层帮你做挂起和恢复,载体线程不会被真正阻塞。开发者不用再学响应式编程那一套,写出来的代码就是最自然的、顺序执行的、一眼能看懂的。

听起来虚拟线程完胜,对吧?

但Jactl的基准测试在Java 21上给出了完全相反的结论。

Java 21:2.5倍差距,事件循环赢了

Java 21上的测试结果:

没有sleep()调用时,虚拟线程方案的吞吐量比Vert.x事件循环方案高出大约15%。这很好理解——纯调度效率,虚拟线程的执行器比Vert.x的事件循环快一点点。

但一旦引入哪怕一次sleep(10)调用,局面立刻反转。Jactl挂起/恢复加上Vert.x的组合,反超了虚拟线程方案。

随着脚本里sleep()调用次数增加,差距越拉越大。到10次sleep(10)的时候,Vert.x方案的吞吐量已经是虚拟线程方案的2.5倍。

10毫秒的阻塞,重复10次,总共100毫秒的“模拟I/O等待”。在这种场景下,事件循环方案的处理能力是虚拟线程的2.5倍。

这就怪了。

虚拟线程不是号称“让阻塞变得廉价”吗?为什么在Java 21上,廉价的阻塞反而比事件循环的显式中断更贵?

答案藏在虚拟线程的挂载/卸载机制里。

虚拟线程虽然轻量,但每次阻塞操作的挂起和恢复,都需要JVM做一系列工作:保存栈帧、释放载体线程、重新调度、恢复栈帧。这些操作有开销。而Jactl的Continuation机制,是通过抛异常来捕获执行状态——异常处理虽然也不便宜,但在Java 21这个版本上,它比虚拟线程的完整挂载/卸载流程要轻量。

换句话说:在Java 21上,虚拟线程为“通用性”付出的代价,高于Jactl为“专用性”付出的代价。

Java 25:虚拟线程的逆袭

把同样的测试搬到Java 25上,结果完全翻转。

没有sleep()时,虚拟线程依然领先,幅度和Java 21差不多。

但这次,所有带sleep()的场景,虚拟线程方案都赢了。随着sleep()次数增加,差距在缩小,但虚拟线程始终压着Vert.x方案一头。

Java 21到Java 25之间发生了什么?

Java 25的虚拟线程调度器引入了全新的ForkJoinPool-backed Mounting Scheduler,把传统的操作系统线程绑定式调度解耦为两级调度模型。载体线程的创建和销毁开销减少了,官方测试数据显示CPU使用率降低了约5%到10%。另外,Java 24还解决了synchronized代码块导致虚拟线程被“钉住”(pinning)的问题——这是虚拟线程早期版本的一个重大性能陷阱。

这些改进叠加起来,让虚拟线程在Java 25上的挂起/恢复开销大幅降低。低到足以击败专门为事件循环优化的Jactl Continuation机制。

但这只是开始。

有个细节值得注意:有OpenJDK邮件列表的报告指出,在某个特定场景下,JDK 25上虚拟线程的Thread.sleep(1)有时会耗时长达60秒。虽然这个问题据说在25.0.3中得到了修复,但它提醒我们——虚拟线程的性能还在快速演进中,远未定型。

一场还没打完的架

这场基准测试最有意思的地方,不是谁赢了,而是赢家会变。

Java 21上,Vert.x方案赢。Java 25上,虚拟线程赢。如果Java 26又出一个新优化呢?如果再换一个硬件平台呢?如果阻塞操作的时长从10毫秒变成1毫秒或者100毫秒呢?

Jactl的作者James Crawford在结论里说了几句很克制的话:

如果你用的是Java 21之前的版本,虚拟线程根本不存在,Jactl/Vert.x是你唯一的选择,而且性能足够好。

如果你在Java 21上已经有了一套Vert.x应用,别急着重构成虚拟线程——Jactl/Vert.x的性能更好,重构不划算。

如果你能升级到Java 25,虚拟线程的吞吐量更高,可以考虑切换。

但最后一句才是关键: 选择虚拟线程还是事件循环,更多取决于你的Java应用本身的架构。Jactl脚本本身不关心底层是事件循环还是虚拟线程——它两种都支持。真正需要做决定的是承载Jactl的Java应用:你愿意继续写响应式的回调代码,还是切换到同步阻塞的虚拟线程风格?

这不是性能问题,是编程范式问题。

一个没被回答的问题

基准测试里有个细节,原文没有展开讨论。

测试环境是一台18核的MacBook Pro M5 Max。18个物理核心,Vert.x默认会创建36个事件循环线程(2倍核心数)。10万个事件在这36个线程上排队执行,每个事件执行一次带10次sleep(10)的脚本。

Vert.x方案赢了。

但如果换成一个只有4核的服务器呢?如果并发数从10万提高到100万呢?如果阻塞操作不是10毫秒而是1毫秒呢?

这些变量会怎么影响结果?

虚拟线程的优势之一是可以创建数十万甚至上百万个线程而不会耗尽系统资源。事件循环的线程数是固定的,通常只有几十个。在极端高并发下,事件循环的队列会越积越长,延迟会上升。而虚拟线程可以“无限”扩展。

Jactl的基准测试只测了吞吐量(每秒事件数),没有测延迟(P99响应时间)。吞吐量高不一定意味着延迟低。 事件循环方案在高吞吐下,单个事件的排队延迟可能比虚拟线程更高——因为线程少,队列深。

这个问题,基准测试没有回答。

也许这就是James Crawford留下那句话的深意:“选择应该更多关于Java应用本身是如何架构的”。性能数据只是参考,真正的答案藏在你的业务场景里。