Java 8 中无 Loom 的脚本语言虚拟线程
摘要
Jactl 在 Java 8 中不使用 Loom 为其脚本语言实现了虚拟线程和延续,使得在事件驱动应用中能够进行非阻塞操作。
暂无内容
查看缓存全文
缓存时间: 2026/09/04 03:02
# Jactl 在 Java 8 中的延续与虚拟线程
来源:https://jactl.io/blog/2026/08/28/jactl-virtual-threads
## 简介
(https://jactl.io/blog/2026/08/28/jactl-virtual-threads#introduction)
Jactl 是一个面向 Java 应用程序的安全、可嵌入的脚本语言。当我最初开始开发 Jactl 时,我希望拥有一种能编译为字节码以实现最佳性能的脚本语言,它必须是安全的,以便应用程序可以精确控制脚本能做什么和不能做什么,最重要的是,在执行长时间运行的阻塞操作时不应阻塞执行线程。
开始开发 Jactl 时,Java 21 和虚拟线程尚未出现,事件驱动、响应式应用(例如基于 Vert.x (https://vertx.io/) 的应用)是编写高吞吐量 Java 应用程序的主流方式。同时,我还需要一种能在仍然停留在 Java 8 或 Java 11 上运行的应用程序中工作的脚本语言。
> 现在,随着 Java 版本的更新,希望使用虚拟线程而非采用事件驱动架构的 Java 应用程序,可以通过配置 `JactlContext.async(false)` (https://jactl.io/docs/integration-guide/jactl-context#asyncboolean-enabled) 标志来禁用此处描述的 Jactl 内置异步机制。
响应式应用是由一组事件循环线程处理队列中事件的应用。其黄金法则是事件绝不能阻塞,因为这会暂停一个事件循环线程,阻止其在阻塞操作完成前处理任何其他事件。阻塞操作是指线程不再主动处理代码,而是等待操作结果,例如数据库请求或远程过程调用。如果事件循环线程上可能发生阻塞操作,最终将出现所有线程都在等待长时间运行的操作、没有事件被处理的情况。
我希望有一种脚本语言能从事件循环线程调用,但当它执行任何阻塞操作时,能以某种方式保存其状态并返回,从而释放线程以处理后续事件。当长时间运行的操作结果可用时,脚本将从它离开的地方恢复并继续执行。
在 Java 21 及更高版本中,虚拟线程提供了相同的功能——它们保留了包含所有局部变量的调用栈,并允许线程继续执行其他工作;当阻塞操作完成时,调用栈被恢复,程序从离开处继续执行。目标仅仅是保存 Jactl 代码的执行状态,而非调用 Jactl 脚本的 Java 代码状态。由于 Java 应用程序是基于事件的,脚本将作为事件循环线程上的一个新事件完成,并且在脚本完成后会调用一个完成回调,将脚本结果回调给 Java 应用程序。应用程序提供的回调可以保存应用程序需要的任何状态。
## 延续
(https://jactl.io/blog/2026/08/28/jactl-virtual-threads#continuations)
在 Java 8 中,显然无法在 Java 或 JVM 字节码中保留调用栈,因此我不得不使用不同的机制来实现相同的目的。
想象一下,我们有一个需要调用执行长时间运行操作的函数(或方法)的脚本。为了示例起见,假设该函数需要在执行其他操作之前执行一段时间的 `sleep()`。此时将存在一个 Java 调用栈,每个嵌套方法调用对应一个栈帧,其余栈帧将是 Jactl 栈帧,每个嵌套 Jactl 函数调用对应一个栈帧,最顶层的栈帧是 `sleep()` 函数本身的栈帧。每个栈帧跟踪函数调用在代码中的位置,以及其局部变量的值:
*sleep() 暂停脚本时的调用栈*
为了捕获执行状态,我认为最简单的方法是在像 `sleep()` 这样的长时间运行操作开始时抛出一个异常,并在每个 Jactl 方法/函数中生成代码来捕获该异常、保存其状态并抛出一个链接到刚捕获异常的新异常。我将抛出的异常类命名为 `Continuation`(延续),因为延续是程序执行状态的表示。
`sleep()` 函数的实现将类似于这样:
```java
public static Object sleep(long timeMs) {
Continuation continuation = new Continuation();
scheduleEvent(timeMs, () -> continuation.continueExecution());
throw continuation;
}
```
当这个异常展开调用栈时,每个 Jactl 栈帧生成的代码都会捕获它,创建自己的 `Continuation` 来记录它当时的位置(它正在等待的调用位置)及其局部变量的值,然后抛出一个链接到刚捕获异常的新 `Continuation`。当异常到达 Jactl 调用栈底部时,原始的调用栈已被替换为一个 `Continuation` 对象链,每个栈帧一个,它们共同捕获了整个脚本的执行状态:
*Jactl 调用栈展开为一个 Continuation 对象链*
> 对于做过 Java 应用程序性能调优的人来说,抛出异常的想法立即会让人想到其中涉及的成本。但实际上,抛出异常的成本主要在于生成伴随的堆栈跟踪。只要抛出的异常不填充堆栈跟踪,它实际上是非常高效的。
## 异步函数
(https://jactl.io/blog/2026/08/28/jactl-virtual-threads#async-functions)
Jactl 编译器知道哪些全局函数是可能执行长时间运行操作并抛出 `Continuation` 对象的函数。这些函数被称为 *async*(异步)函数,Jactl 编译器跟踪哪些方法和函数调用了这些异步函数,并将它们也标记为 *async*。这一直延续到调用链上,因此编译器在任何时间点都知道调用是否可能抛出 `Continuation` 对象,即使真正抛出第一个 `Continuation` 的内置全局函数隐藏在嵌套调用集的多层深处。
## 调用异步方法/函数
(https://jactl.io/blog/2026/08/28/jactl-virtual-threads#invoking-async-methodsfunctions)
当编译器生成调用被标记为 *async* 的函数的代码时,它会将调用包装在一个 `try/catch` 中,以捕获任何抛出的 `Continuation`。`catch` 块的代码创建一个新的 `Continuation` 对象并在其中存储一个 `MethodHandle` 和一个 *location*(位置)。`MethodHandle` 指向当前函数,而 location 是一个逻辑位置,记录了在当前函数中调用抛出 `Continuation` 的异步函数的发生位置。
除了 `MethodHandle` 和 location,编译器还会生成代码以存储当时作用域内的局部变量值以及当前位于局部栈上的任何值。使用了两个独立的数组:一个 `long[]` 用于原始类型的局部变量和栈值,一个 `Object[]` 用于所有其他类型。
每个异步函数都隐式地传递一个 `Continuation` 对象作为其第一个参数。第一次调用时,该参数为 null,但如果函数由于长时间运行操作而被暂停,稍后恢复时,它将使用最初暂停时抛出的 `Continuation` 重新调用。生成的代码检查 continuation 参数是否非空,如果是,则使用 `Continuation` 对象中的 location 来确定在函数中跳转到哪里以继续执行。
以下是一些伪代码,展示了编译器为一个调用另一个异步函数的函数可能生成的代码:
```java
static MethodHandle processOrderHandle = MethodHandles.lookup().findStatic("processOrder");
Object processOrder(Continuation cont, ...) {
Order order;
Widget widget;
int count;
if (cont != null) {
// 从上次中断的地方恢复,并恢复任何局部变量
switch (cont.location) {
case 0:
// 恢复局部变量
order = cont.objArr[0];
widget = cont.objArr[1];
count = cont.longArr[0];
goto LOCATION_0;
case 1:
...
goto LOCATION_1;
}
}
// 函数的代码 ...
try {
checkInventory(widget, count);
} catch (Continuation c) {
throw new Continuation(c, processOrderHandle, 0, // 位置
new long[]{ count },
new Object[]{ order, widget });
}
LOCATION_0:
...
}
```
注意 `Continuation` 构造函数将自身链接到刚捕获的 `Continuation`,因此链从栈顶的 `Continuation` 开始。
## 恢复执行
(https://jactl.io/blog/2026/08/28/jactl-virtual-threads#resuming-execution)
一旦长时间运行的操作完成,通过调用链中第一个 `Continuation` 对象的 `continueExecution(Object result)` 方法来恢复它。此方法提取 `MethodHandle` 并调用它,如前所述传入 `Continuation`,以便函数可以恢复任何局部变量并确定从哪里继续。
由于调用栈不再与原始调用栈匹配,当函数返回时,它不会返回到原始的父函数,而是返回到 `Continuation.continueExecution()` 方法,该方法然后提取链中的下一个 `Continuation` 对象并调用其 `MethodHandle`。这一直持续到链中没有更多的 `Continuation` 对象,然后使用最终结果调用应用程序注册的完成回调。
## 调用后续异步函数
(https://jactl.io/blog/2026/08/28/jactl-virtual-threads#invoking-a-subsequent-async-function)
在遍历 `Continuation` 对象链并恢复它们的过程中,可能会调用另一个异步函数,该函数为新的长时间运行操作抛出一个新的 `Continuation` 对象,可能来自嵌套调用更深处的函数。发生这种情况时,我们获取新的延续链,并将现有链的剩余部分添加到该链的末尾:
*在恢复原始链时,checkInventory() 再次挂起,旧链的剩余部分被附加到新链的尾部*
当新的长时间运行的操作完成并恢复其 `Continuation` 链时,该链现在包含所有新的延续以及剩余的旧延续。它将首先恢复每个新的延续,然后继续处理旧链中剩余的延续。
## 检查点执行状态
(https://jactl.io/blog/2026/08/28/jactl-virtual-threads#checkpointing-execution-state)
一旦 Jactl 具备了在延续链中保存当前执行状态的能力,我意识到如果这些延续可以序列化为字节数组,我就可以将其用作脚本状态的检查点。Jactl 提供了一个 `checkpoint()` 函数,允许脚本在其处理过程中的重要步骤检查其状态。
脚本状态一旦被检查点,就可以持久化到磁盘或数据库中,或者通过网络复制到另一个应用程序实例。一旦状态被持久化或复制,例如在原始应用程序主机发生故障时,就可以在任何时间点恢复它。
对于每种内置类型和每个用户定义的类,Jactl 都会生成代码将这些类型的实例存储到字节数组中,以及 Jactl 运行时内部使用的其他类型。然后,Jactl 提供挂钩,应用程序可以使用这些挂钩来持久化或复制这些脚本状态,作为应用程序状态冗余解决方案的一部分。Jactl 还有相应的机制,应用程序可以在需要时用于恢复状态。
有关更多详细信息以及用于应用程序冗余的检查点概念验证实现描述,请参阅 [检查点概念验证](https://jactl.io/blog/2023/11/10/checkpoint-poc)。
## 基准测试
(https://jactl.io/blog/2026/08/28/jactl-virtual-threads#benchmarks)
我使用 [JMH](https://github.com/openjdk/jmh) 库创建了一个基准测试,以展示延续机制对性能的影响。[SuspendResumeBenchmark](https://github.com/jaccomoc/jactl-vertx/blob/main/src/jmh/java/io/jactl/vertx/benchmark/SuspendResumeBenchmark.java) 使用 [Vert.x](https://vertx.io/) 进行事件调度和执行,并对一个处理 200 个订单批次的 Jactl 脚本进行基准测试。该脚本:
```jactl
var totals = [:]
var itemCount = 0
var grandTotal = 0.0
var topCategory = ''
var topAmount = -1.0
var slept = 0
def checkInventory(widget, count) {
sleep(0)
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
```
对于每个批次,`processOrder()` 为批次中的每个订单调用,然后为订单中的每个商品调用 `checkInventory()`(它总是返回 true)。`checkInventory()` 函数在最初被调用的 `n` 次中调用 `sleep(0)`,这样我们就可以测量从嵌套调用栈内部暂停和恢复脚本的开销。对 `sleep(0)` 的调用将通过抛出 `Continuation`(如前所述)来暂停脚本,但由于睡眠时间为 0,它随后将立即安排一个恢复事件以继续脚本执行。
基准测试测量了脚本执行 0、1、2、5 和 10 次 `sleep(0)` 调用时的性能。
以下是结果:
*吞吐量 vs 挂起/恢复操作次数*
如图所示,在此基准测试中,每次挂起/恢复的影响相当小。在真实场景中,相对影响将取决于脚本执行的工作量、栈的嵌套深度以及栈中每一级的局部变量(包括参数)数量。
请注意,测量的开销还包括 Vert.x 调度器在调度和执行脚本及恢复事件时涉及的开销。
## 结论
(https://jactl.io/blog/2026/08/28/jactl-virtual-threads#conclusion)
对于需要运行在旧版本 Java 上的响应式应用程序,Jactl 基于延续的机制为处理阻塞操作提供了一种方便高效的方式,使应用程序能够通过脚本提供定制功能,而无需担心脚本阻塞事件循环线程。
脚本可以以自然的方式编写内联的阻塞操作——不需要用 `async/await` 污染代码,也不需要处理 `Futures`、`Promises` 或编程语言过去用于处理异步行为的其他机制。从脚本的角度来看,Jactl 提供了与 Java 21 中虚拟线程为 Java 程序提供的等效编程模型。
在现代版本的 Java 中,可以禁用 Jactl 基于延续的方法,Jactl 可以利用虚拟线程来支持不阻塞载体线程的阻塞操作。
## 附记
(https://jactl.io/blog/2026/08/28/jactl-virtual-threads#postscript)
我后来意识到其他库...
相似文章
Jaithon 3:语法完美的快速编程语言
Jaithon 3是一种动态执行和垃圾回收的编程语言,融合了Java、Rust和Python的精华,并在开发中使用了AI辅助编码。
1jehuang/jcode
jcode 是一个开源编码代理工具,专为多会话工作流设计,资源占用低,提供CLI安装方式,并在性能上优于Claude Code和Cursor Agent等现有代理。
Clojure 速度几乎媲美 C(需借助一些优化)
本文详细介绍了 Clojure 如何借助 JVM 的 Vector API 和精心优化,在 3D 压力测试中达到接近 C 的帧率(仅差 20%),展示了动态语言在热循环中也能接近底层性能。
为何低延迟Java仍需严谨的编码纪律?
讨论为何在现代JVM优化下,低延迟Java仍需严谨的编码实践。
我构建了Thread Contract:针对长编码代理任务的线程级规则层
Thread Contract是一个开源的MCP工具,用于编码代理管理线程级临时指令,确保这些指令在轮次之间持久化,而不会污染永久规则文件。