Denis Szczukocki 2024-12-26
1. 引言
在本教程中,我们将展示 Java 中传统线程与 Project Loom 引入的虚拟线程之间的区别。
接下来,我们将分享虚拟线程的一些使用场景以及该项目引入的相关 API。
在开始之前,我们需要指出:Project Loom 目前仍在积极开发中。我们的示例将在早期访问版的 Loom 虚拟机上运行:openjdk-15-loom+4-55_windows-x64_bin。
新版本的构建可能会自由更改甚至破坏当前的 API。需要特别说明的是,API 已经发生过一次重大变更——原先使用的 java.lang.Fiber 类已被移除,并由新的 java.lang.VirtualThread 类取代。
2. 线程与虚拟线程的高层概述
从高层角度看,线程由操作系统管理与调度,而虚拟线程则由虚拟机(JVM)管理与调度。创建一个新的内核线程需要进行系统调用,这是一项代价高昂的操作。
因此,我们通常使用线程池,而不是按需频繁地分配和释放线程。此外,如果我们希望通过增加线程数量来扩展应用程序,由于上下文切换开销和内存占用较大,维护这些线程的成本可能会显著增加,从而影响处理性能。
通常情况下,我们不希望阻塞这些线程,这就导致了非阻塞 I/O(NIO)或异步 API 的广泛使用,但这类代码往往显得冗长、难以阅读。
相比之下,虚拟线程由 JVM 管理,因此其创建不需要系统调用,也无需依赖操作系统的上下文切换。虚拟线程运行在所谓的“载体线程”(carrier thread)之上——即底层实际的内核线程。由于摆脱了系统级上下文切换的限制,我们可以轻松地创建成千上万个虚拟线程。
此外,虚拟线程的一个关键特性是:它们不会阻塞载体线程。当一个虚拟线程被阻塞时,JVM 会调度另一个虚拟线程运行,从而保持载体线程处于活跃状态。
最终,我们可能不再需要依赖 NIO 或异步 API,这将使代码更加简洁、易读、易于调试。不过需要注意的是,在某些情况下(例如调用本地方法并执行阻塞操作时),虚拟线程仍有可能阻塞其载体线程。
3. 新的线程构建器 API
在 Loom 中,Thread 类引入了新的构建器 API 和多个工厂方法。下面我们演示如何创建标准线程和虚拟线程的工厂,并用于线程执行:
Runnable printThread = () -> System.out.println(Thread.currentThread());
ThreadFactory virtualThreadFactory = Thread.builder().virtual().factory();
ThreadFactory kernelThreadFactory = Thread.builder().factory();
Thread virtualThread = virtualThreadFactory.newThread(printThread);
Thread kernelThread = kernelThreadFactory.newThread(printThread);
virtualThread.start();
kernelThread.start();
上述代码的输出如下:
Thread[Thread-0,5,main]
VirtualThread[<unnamed>,ForkJoinPool-1-worker-3,CarrierThreads]
第一行是标准内核线程的 toString() 输出。
第二行显示,虚拟线程没有名称,并且运行在 ForkJoinPool 的某个工作线程上,属于 CarrierThreads 线程组。
可以看到,无论底层实现如何,API 是统一的,这意味着我们可以轻松地将现有代码迁移到虚拟线程上运行,而无需学习全新的 API。
4. 虚拟线程的组成结构
虚拟线程由**延续(Continuation)和调度器(Scheduler)**共同构成。这里的用户态调度器可以是任意实现了 Executor 接口的类。前面的例子表明,默认情况下虚拟线程运行在 ForkJoinPool 上。
与内核线程类似——内核线程可以在 CPU 上执行,然后被挂起、重新调度并从中断处恢复执行——延续是一种可以在 JVM 内部启动、挂起(yield)、重新调度并从中断点继续执行的执行单元,整个过程由 JVM 管理,而非依赖操作系统。
需要注意的是,延续是一个低层 API,开发者应优先使用更高层的 API(如线程构建器)来运行虚拟线程。
不过,为了展示其底层工作原理,下面我们运行一个实验性的延续示例:
var scope = new ContinuationScope("C1");
var c = new Continuation(scope, () -> {
System.out.println("Start C1");
Continuation.yield(scope);
System.out.println("End C1");
});
while (!c.isDone()) {
System.out.println("Start run()");
c.run();
System.out.println("End run()");
}
输出结果如下:
Start run()
Start C1
End run()
Start run()
End C1
End run()
在这个例子中,我们运行了延续,并在某个时刻主动暂停了执行。随后再次调用 run() 时,延续从中断的位置继续执行。从输出可以看出,run() 方法被调用了两次,但延续只启动了一次,并在第二次调用时从中断点恢复。
这正是 JVM 处理阻塞操作的方式:一旦发生阻塞,延续会主动让出(yield),从而释放载体线程。
具体来说,主线程在调用 run() 时在其调用栈上创建了一个新的栈帧并开始执行。当延续调用 yield() 后,JVM 保存了当前的执行状态。随后主线程就像 run() 方法正常返回一样,继续执行 while 循环。在第二次调用 run() 时,JVM 恢复了延续之前保存的状态,从中断点继续执行直至完成。
5. 结论
在本文中,我们讨论了内核线程与虚拟线程之间的区别,展示了如何使用 Project Loom 提供的新线程构建器 API 来运行虚拟线程,并深入探讨了延续(Continuation)的工作机制及其在底层的实现原理。
读者可以通过试用早期访问版的 Loom 虚拟机进一步探索 Project Loom 的当前状态,也可以深入研究 Java 并发 API 中已经标准化的部分。