在讨论新特性之前,让我们先看一下当前的状态,以便更好地理解它试图解决什么问题以及带来了哪些好处。
平台线程
在引入虚拟线程之前,我们习惯使用的线程是 java.lang.Thread,它背后是所谓的平台线程 (platform threads)。
这些线程通常与操作系统调度的内核线程一一映射。操作系统线程相当“重”,这使得它们适合执行所有类型的任务。
根据操作系统和配置,它们默认情况下会消耗大约2到10 MB的内存。因此,如果你想在高负载并发应用程序中使用一百万个线程,最好要有超过2 TB的可用内存!
这存在一个明显的瓶颈,限制了我们实际可以在没有缺点的情况下拥有的线程数量。
每个请求一个线程
这很成问题,因为它直接与典型的服务器应用程序“每个请求一个线程”的方法相冲突。使用每个请求一个线程有很多优点,例如更简单的状态管理和清理。但它也创造了可扩展性限制。应用程序的“并发单位”,在这种情况下是一个请求,需要一个“平台并发单位”。因此,线程很容易被原始CPU能力或网络耗尽。
即使“每个请求一个线程”有许多优点,共享重量级的线程可以更均匀地利用硬件,但也需要一种完全不同的方法。
异步救援
而不是在单个线程上运行整个请求,它的每个部分都从池中使用一个线程,当它们的任务完成时,另一个任务可能会重用同一个线程。这允许代码需要更少的线程,但引入了异步编程的负担。
异步编程伴随着它自己的范例,具有一定的学习曲线,并且可能会使程序更难理解和跟踪。请求的每个部分可能都在不同的线程上执行,从而创建没有合理上下文的堆栈跟踪,并使调试某些内容变得非常棘手甚至几乎不可能。
Java有一个用于异步编程的优秀API,CompletableFuture。但这是一个复杂的API,并且不太适合许多Java开发人员习惯的思维方式。
重新审视“每个请求一个线程”模型,很明显,一种更轻量级的线程方法可以解决瓶颈并提供一种熟悉的做事方式。
轻量级线程
由于平台线程的数量是无法在没有更多硬件的情况下改变的,因此需要另一个抽象层,切断可怕的 1:1 映射,它是首先造成瓶颈的原因。
轻量级线程不与特定的平台线程绑定,也不会伴随大量的预分配内存。它们由运行时而不是底层操作系统调度和管理。这就是为什么可以创建大量轻量级线程的原因。
这个概念并不新鲜,许多语言都采用某种形式的轻量级线程:
- Go 语言中的 Goroutine
- Erlang 进程
- Haskell 线程
- 等等
Java最终于第21版中引入了自己的轻量级线程实现:虚拟线程 (Virtual Threads)。
虚拟线程
虚拟线程是一种新的轻量级java.lang.Thread变体,是Project Loom的一部分,它不是由操作系统管理或调度的。相反,JVM负责调度。
当然,任何实际的工作都必须在平台线程中运行,但是JVM使用所谓的“载体线程”(carrier threads) 来“携带”任何虚拟线程,以便在它们需要执行时执行这些线程。
图片
JVM/操作系统线程调度器
所需的平台线程在一个 FIFO 工作窃取 ForkJoinPool 中进行管理,该池默认情况下使用所有可用的处理器,但可以通过调整系统属性jdk.virtualThreadScheduler.parallelism来根据需求进行修改。
ForkJoinPool与其他功能(例如并行流)使用的通用池之间的主要区别在于,通用池以LIFO模式运行。
廉价且丰富的线程
拥有廉价且轻量级的线程,可以使用“每个请求一个线程”模型,而不必担心实际需要多少个线程。如果你的代码在虚拟线程中调用阻塞 I/O 操作,则运行时会挂起虚拟线程,直到它可以稍后恢复。
这样,硬件就可以被优化到几乎最佳的水平,从而实现高水平的并发性,因此也实现高吞吐量。
因为它们非常廉价,所以虚拟线程不会被重用或需要池化。每个任务都由其自己的虚拟线程表示。
设置边界
调度器负责管理载体线程,因此需要一定的边界和分离,以确保可能的“无数”虚拟线程按照预期运行。这是通过在载体线程及其可能携带的任何虚拟线程之间不保持线程关联来实现的:
- 虚拟线程无法访问载体,Thread.currentThread() 返回虚拟线程本身。
- 堆栈跟踪是分开的,任何在虚拟线程中抛出的异常只包含其自己的堆栈帧。
- 虚拟线程的线程局部变量对它的载体不可用,反之亦然。
- 从代码的角度来看,载体及其虚拟线程共享一个平台线程是不可见的。
让我们看看代码
使用Virtual Threads最大的好处是,你不需要学习新的范例或复杂的API,就像使用异步编程一样。相反,你可以像对待非虚拟线程一样处理它们。
创建平台线程
创建平台线程很简单,就像使用 Runnable 创建一样:
Runnable fn = () -> {
// your code here
};
Thread thread = new Thread(fn).start();
随着Project Loom简化了新的并发方法,它还提供了一种创建平台支持线程的新方法:
Thread thread = Thread.ofPlatform().
.start(runnable);
实际上,现在还有一个完整的fluent API,因为ofPlatform()会返回一个Thread.Builder.OfPlatform实例:
Thread thread = Thread.ofPlatform().
.daemon()
.name("my-custom-thread")
.unstarted(runnable);
但你肯定不是来学习创建“旧”线程的新方法的,我们想要一点新的东西。继续看。
创建虚拟线程
对于虚拟线程,也有类似的fluent API:
Runnable fn = () -> {
// your code here
};
Thread thread = Thread.ofVirtual(fn)
.start();
除了构建器方法之外,你还可以直接使用以下方式执行Runnable:
Thread thread = Thread.startVirtualThread(() -> {
// your code here
});
由于所有虚拟线程始终是守护线程,因此如果你想在主线程上等待,请不要忘记调用join()。
创建虚拟线程的另一种方法是使用 Executor:
var executorService = Executors.newVirtualThreadPerTaskExecutor();
executorService.submit(() -> {
// your code here
});
小结
尽管Scoped Values (JEP 446) 和Structured Concurrency (JEP 453) 仍然是Java 21中的预览功能,但Virtual Threads已经成为一个成熟的、适用于生产环境的功能。
它们是Java并发的一种通用且强大的新方法,将对我们未来的程序产生重大影响。它们使用了熟悉的和可靠的“每个请求一个线程”方法,同时以最优化的方式利用所有可用硬件,而不需要学习新的范例或复杂的API。