Java虚拟线程:异步编程之死


最近,虚拟线程的第二个预览版作为JEP 436的一部分发布。第一个预览版中引入的一些更改已经完成,我们离获得对虚拟线程的完全访问权又近了一步。在本文中,我们将尝试为您提供有关为什么 JVM 生态系统中非常需要 Java 虚拟线程的扎实背景知识,主要是为您提供理解 Java 虚拟线程的基础知识。

操作系统线程和平台线程之间的奇偶校验
目前,在 JDK 中,Java 线程(也称为“平台”线程)和 OS 线程之间存在一对一的关系。
这意味着当线程正在等待 IO 操作完成时,底层操作系统线程将保持阻塞状态,因此不会被使用,直到该操作完成。这在 Java 生态系统的可扩展性方面一直是一个大问题,因为我们的应用程序受到主机中可用线程的限制。
在过去的十年里,我们试图通过使用异步处理库和使用futures来解决这个问题。例如,使用CompletableFuture我们可以实现非阻塞方法,尽管在许多情况下这些模型的可读性不是我们所期望的。

异步编程的问题
虽然异步编程是解决线程限制的可行解决方案,但编写异步代码比顺序代码更复杂。开发人员必须定义回调以根据对给定任务的响应来应用操作,这使得代码难以理解和推理。
另一个大问题是调试这些应用程序变得很困难。一个给定的请求可以由多个线程处理,因此调试、记录或分析堆栈跟踪变得非常困难。
在灵活性和可维护性方面,异步编程也非常有限。我们必须放弃某些顺序的工作流结构,比如循环,这意味着一段写成顺序的代码不能轻易地转换成异步的。完全相同的情况发生在相反的情况下。
最后,但同样重要的是,由于随之而来的复杂性,编写显式异步代码更容易出错。

昂贵的线程创建
平台线程的另一个问题是它们是重对象,创建起来很昂贵,因此我们需要预先创建它们并将它们存储在线程池中,以避免每次我们需要一个线程来运行我们的代码时都创建新线程。为什么它们很贵?
Java 线程的创建是昂贵的,因为它涉及为线程分配内存、初始化线程堆栈以及进行 OS 调用以注册 OS 线程。
当我们考虑这两个问题时,操作系统线程的限制以及创建平台线程的成本,这意味着我们需要有界的线程池才能安全地运行我们的应用程序。如果我们不使用有界线程池,我们将面临资源耗尽的风险,并对我们的系统造成严重后果。

昂贵的上下文切换
这种设计的另一个问题是上下文切换的代价是多么昂贵。当存在上下文切换时,操作系统线程从一个平台线程切换到另一个平台线程,操作系统必须保存当前线程的本地数据和内存指针,并为新平台线程加载它们。上下文切换是一项非常昂贵的操作,因为它涉及许多 CPU 周期。操作系统负责暂停线程、保存其堆栈并分配新线程,此过程代价高昂,因为它需要加载和卸载线程堆栈。
那么我们如何解决这些问题呢?这就是Project Loom及其虚拟线程发挥作用的地方。

救援的虚拟线程
Java 中的虚拟线程得名于虚拟内存的类比。这是因为我们有一种幻觉,即拥有几乎无限数量的可用线程(打个比方),这与虚拟内存的作用类似。

虚拟线程解决了 JDK 中可伸缩性的主要问题之一,但它是如何解决的呢?答案主要是打破平台线程和操作系统线程之间的关联。
JVM 生态系统中的许多应用程序在达到其 CPU 或内存限制之前就中断了,这主要是由于平台线程和操作系统线程之间的这种奇偶校验。创建平台线程非常昂贵,因此需要使用线程池,而且我们总是受到主机中可用处理单元 (CPU) 数量的限制。

另一方面,虚拟线程对系统的开销很小,因此,我们的应用程序中可以有数千个。每个虚拟线程都需要一个操作系统线程来做一些工作,但是,它在等待资源时不会占用操作系统线程。这意味着虚拟线程可以等待 IO,释放它们当前使用的平台线程,以便另一个虚拟线程可以使用它来做一些工作,并在 IO 操作完成后恢复它们的工作。

这样做的主要优点是什么?答案之一是廉价的上下文切换!让我们看看为什么!

便宜的上下文切换
正如我们之前提到的,上下文切换在 Java 中非常昂贵,因为每次发生时都必须保存和加载线程堆栈。
与虚拟线程不同的是,由于虚拟线程受JVM控制,线程栈存放在堆内存中,而不是栈中。这意味着为唤醒的虚拟线程分配线程堆栈变得更便宜。当虚拟线程被分配给其载体时,将虚拟线程的数据堆栈加载到“载体”线程堆栈的过程称为安装。卸载它的过程称为卸载。
现在让我们简单了解一下线程调度。

调度
传统的平台线程由操作系统调度,而虚拟线程由 JDK 运行时调度。
对于平台线程,操作系统调度程序负责将工作分配给每个操作系统线程。它的做法是给每个进程分配时间片,当这个时间到了,就轮到另一个进程去获取CPU时间来做一些工作。这是操作系统试图确保在不同的现有进程之间公平(大概)分配 CPU 时间的方式。
另一方面,虚拟线程由 JDK 运行时直接调度。它的实现方式是在内部使用ForkJoinPool,这是一个用作虚拟线程调度程序的专用池。这意味着ForkJoinPool.commonPool返回的公共池是这个新线程池的不同实例。

JDK 调度程序不使用时间片,在这种情况下是虚拟线程本身,当它等待阻塞操作响应时,它会屈服并放弃其载体线程。这样做的主要结果是我们将有更好的资源利用率,从而增加我们应用程序的吞吐量。
值得一提的是,底层平台线程,在调度上也称为载体线程,仍然由OS调度器管理。它们现在是一个抽象层,对于编写并发代码的开发人员来说是完全不可见的。
这里要考虑的另一个方面是虚拟线程的使用提供了并行执行工作的错觉。实际发生的是处理单元时间得到更有效的分配。每个处理单元不会并行执行任何工作,只是更频繁、更便宜地从一个虚拟线程切换到另一个虚拟线程。
既然我们已经了解了虚拟线程是如何工作的,那么我们现在可能会遇到一个问题。每个应用程序都会从​​虚拟线程的引入中受益吗?不是真的,让我们看看为什么会这样。

IO 绑定应用程序
并不是每个应用程序在采用虚拟线程后都会受益于性能的大幅提升。只有当我们的应用程序是IO-bound时,我们才会观察到巨大的好处。
这是什么意思?IO-bound 应用程序是那些花费大量时间等待 IO 操作(例如网络调用、文件或数据库访问)响应的应用程序。这些是当今的大多数应用程序。

在 IO-bound 应用程序中使用虚拟线程的好处之所以巨大,是因为虚拟线程非常擅长等待,这意味着线程可以在性能方面以非常便宜的方式等待和恢复。
在这种情况下,虚拟线程在等待时会阻塞,但平台线程不会。平台线程将被分配到不同的虚拟线程继续做有用的工作而不是等待。这意味着我们的系统将有更好的资源利用率!在下面显示的示例中,我们有两个平台线程,它们映射到操作系统中相应的操作系统线程。我们可以看到平台线程是如何总是被占用做一些工作,而不是等待 IO 的完成。
每次虚拟线程等待 IO 时,它都会释放其载体线程。一旦 IO 操作完成,虚拟线程将放回ForkJoinPool的 FIFO 队列中,并等待直到有载体线程可用。

这也意味着我们可以在我们的应用程序中实现吞吐量的大幅增加。虚拟线程通过增加我们可以并发处理的任务数量来实现这一点,而不是通过减少延迟来实现。

明确地说,虚拟线程并不比平台线程快,它们只是在等待方式和工作分配方式方面更有效率。

对于受 CPU 限制的应用程序,我们手头还有其他工具,例如并行任务或ForkJoinPool 中的工作窃取以提高其性能,虚拟线程对其性能的影响最小。请记住两者的区别,以免得到意想不到的结果!

虚拟线程给我们的应用程序带来了哪些其他好处?有一个非常重要的,我们现在可以用同步的方式编写非阻塞并发代码。

非阻塞操作的同步风格
随着 Java 中虚拟线程的引入,编写并发代码得到了极大的简化。我们的代码变得更容易阅读和理解,这是当今异步编程的大问题之一,它的复杂性有时会失控。

我们现在可以编写并发代码,而不必处理可能以异步方式发生的不同交互的编排,JDK 运行时将为我们处理它,在现有虚拟线程中分配可用的 OS 线程。

如果我们使用 Java 中提供的传统并发机制维护旧应用程序会怎样?

向后兼容性
如果您想知道迁移到 Java 19 后如果代码使用同步块或任何传统并发机制会发生什么,那么好消息来了。旧的并发代码将与虚拟线程一起工作,而无需对其进行任何修改。在某些情况下,您甚至可能不需要重新编译和构建新的工件,因为所有这些都由 JDK 运行时处理。在其他情况下,为充分利用虚拟线程所做的更改将是最小的。
目前使用同步块和线程局部变量有一些限制,我们不会详细介绍,但一般建议是避免使用它们。

使用虚拟线程编程
在JEP 425下提议的 JDK 中有一些变化。关于如何编写能够利用虚拟线程的代码,我们会发现它非常简单。
您可以像往常一样编写代码,虚拟线程是 JDK 中的内置功能,因此您无需做太多事情即可利用它。
好处之一是虚拟线程从 Thread 类扩展而来,不需要新的线程类对象。
唯一的变化是我们定义我们创建的线程是代表虚拟线程还是平台线程的方式。为了实现这一点,JDK 带来了一个Thread.Builder以便能够轻松地实例化和配置两者。
Thread.Builder提供了两种实例化线程的方法。其中之一通过使用Thread.Builder.ofPlatform()方法创建一个传统的平台线程。为了实例化一个虚拟线程,我们将不得不使用Thread.Builder.ofVirtual()
另一个变化是包含了一个新的ExecutorService,这个新的执行器服务可以通过运行Executors.newVirtualThreadPerTaskExecutor()方法来实例化。

让我们通过几个例子看看它是如何工作的!

Executors.newVirtualThreadPerTaskExecutor()
这种新方法的引入允许非常容易地从现有的并发代码转换到虚拟线程。让我们看看下面的例子:

final LongAdder adder = new LongAdder();        
        Runnable task = () -> {
            try {
                Thread.sleep(10);
                System.out.println("I'm running in thread " + Thread.currentThread());
                adder.increment();
            } catch (InterruptedException e) {
                Thread.interrupted();
            }
        };
        long start = System.nanoTime();
        try (ExecutorService executorService = Executors.newCachedThreadPool()) {
            IntStream.range(1, 10000)
                    .forEach(number -> executorService.submit(task));
        }
        long end = System.nanoTime();
        System.out.println(
"Completed " + adder.intValue() + " tasks in " + (end - start)/1000000 + "ms");复制

您可以看到上面的示例如何使用缓存线程池提交 10,000 个任务,这些任务模拟了一个小的 IO 操作,该操作需要 10 毫秒加上打印到控制台和递增计数器所花费的时间。
如果我们运行这段代码,我们会得到以下结果:

...
I'm running in thread Thread[#1271,pool-1-thread-1242,5,main]
I'm running in thread Thread[#1260,pool-1-thread-1231,5,main]
I'm running in thread Thread[#928,pool-1-thread-899,5,main]
I'm running in thread Thread[#275,pool-1-thread-246,5,main]
Completed 9999 tasks in 4740ms复制

为了简洁起见,我们只包含最新的元素和最终结果,您可以看到我们如何使用缓存线程池中的平台线程。运行如此简单的程序需要 4.7 秒。
让我们看看当我们使用新的虚拟线程执行器时会发生什么:

long start = System.nanoTime();
        try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
            IntStream.range(1, 10000)
                    .forEach(number -> executor.submit(task));
        }
        long end = System.nanoTime();
        System.out.println("Completed " + adder.intValue() + " tasks in " + (end - start)/1000000 + "ms");复制

您会注意到,切换到虚拟线程就像切换到新的执行程序服务一样简单,其余代码保持不变!这太棒了,对吧?
使用虚拟线程的性能如何?这些是我们得到的结果:

I'm running in thread VirtualThread[#10029]/runnable@ForkJoinPool-1-worker-10
I'm running in thread VirtualThread[#10031]/runnable@ForkJoinPool-1-worker-10
I'm running in thread VirtualThread[#10027]/runnable@ForkJoinPool-1-worker-10
I'm running in thread VirtualThread[#10028]/runnable@ForkJoinPool-1-worker-10
Completed 9999 tasks in 760ms


虚拟线程只用了 760 毫秒!这是为什么?正如我们之前看到的,平台线程在虚拟线程等待 IO 操作时不会被阻塞,因此在虚拟线程等待时可以处理其他任务。这对 JVM 生态系统来说意义重大!

Thread.ofVirtual()
现在让我们看一个类似的例子,在这种情况下,我们将使用Thread.ofPlatform()和Thread.ofVirtual()来指定我们将在测试中使用哪种线程。
我们将Thread.ofPlatform()首先运行一个示例:

long start = System.nanoTime();
        int[] numbers = IntStream.range(1, 10000).toArray();
        List<Thread> threads = Arrays.stream(numbers).mapToObj(num ->
                Thread.ofPlatform()
                        .start(task)
        ).toList();
        threads.parallelStream().forEach(thread -> {
            try {
                thread.join();
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        });
        long end = System.nanoTime();
        System.out.println("Completed " + adder.intValue() + " tasks in " + (end - start)/1000000 + "ms");复制


我们启动 9,999 个线程来运行我们在上一个示例中使用的相同任务,然后我们使用 等待它们完成join()
如果我们运行此测试,大约需要 2-3 秒才能完成。

如果我们使用相同的示例但只是实例化虚拟线程会怎样?

...
List<Thread> threads = Arrays.stream(numbers).mapToObj(num ->
                Thread.ofVirtual()
                        .start(task)
        ).toList();
...

正如我们在前面的示例中观察到的,虚拟线程提供了更好的吞吐量,如下面的结果所示。

I'm running in thread VirtualThread[#10029]/runnable@ForkJoinPool-1-worker-4
I'm running in thread VirtualThread[#10030]/runnable@ForkJoinPool-1-worker-4
I'm running in thread VirtualThread[#10031]/runnable@ForkJoinPool-1-worker-4
I'm running in thread VirtualThread[#9954]/runnable@ForkJoinPool-1-worker-5
Completed 9999 tasks in 722ms

同样,虚拟线程以相当大的差异击败了平台线程。

请记住,从我们没有运行正确基准的意义上说,这些时间安排并不准确。我们没有预热 JVM 来给 JIT 编译器时间来执行改进,我们也运行一个单一的执行。这只是为了向您展示我们的吞吐量可以通过虚拟线程提高多少!

我们想提及的最后一件事是,虚拟线程还打开了在 Java 中引入结构化并发的大门。当在不同的嵌套级别运行多个并发任务时,此更改还将使 Java 代码更加安全。

Java 将很快引入结构化并发和称为作用域的东西作为JEP 429的一部分,这与 Kotlin 在其[url=https://kotlinlang.org/docs/coroutines-overview.html]协程[/url]中所做的非常相似。

结论
在本文中,我们了解了虚拟线程将如何解决 Java 生态系统中的主要问题之一。由于主机中操作系统线程数量的限制,操作系统线程和平台线程之间的现有奇偶校验对于某些应用程序来说是一个巨大的限制因素。
长期以来,异步编程一直是我们的救世主,但是,我们看到虚拟线程是导致我们所知道的异步编程死亡的一个重要因素。在下一个 JDK 版本中提供此更改后,将采用简单的并发范例。