深入了解Java的Project Loom和虚拟线程

(译自Ben Evans的文章《Going inside Java’s Project Loom and virtual threads》)
本文是对该文章的翻译,旨在帮助更多人深入了解Java的Project Loom和虚拟线程。如有侵权,请联系我删除相关内容。
Loom and virtual threads”. If there is any infringement, please contact me to delete the text.


Java的虚拟线程如何将Java的绿色线程带回来,即不与操作系统线程绑定的Java线程。这意味着Java的虚拟线程不会像操作系统线程那样受到限制,从而打破了这种限制并解锁了新的并发编程风格。

下载原文的PDF版本


让我们谈谈Project Loom,它正在探索新的Java语言特性、API和轻量级并发的运行时-包括虚拟线程的新构造。

Java是第一个将线程集成到核心语言中的主流编程平台。在有线程之前,最先进的技术是使用多个进程和各种不令人满意的机制(例如UNIX共享内存)来进行通信。

在操作系统(OS)级别,线程是属于进程的独立调度执行单元。每个线程都有一个执行指令计数器和一个调用堆栈,但与同一进程中的每个其他线程共享堆。

不仅如此,Java堆只是进程堆的一个单一连续子集,至少在HotSpot JVM实现中是这样的(其他JVM可能不同),因此操作系统级别的线程内存模型自然地延伸到了Java语言领域。

线程的概念自然导致了轻量级上下文切换的概念:在同一进程中切换两个线程比在不同进程中切换线程更便宜。这主要是因为将虚拟内存地址转换为物理内存地址的映射表对于同一进程中的线程大多相同。

顺便说一句,创建一个线程也比创建一个进程更便宜。当然,这是否属实取决于操作系统的具体细节。

Java语言规范并未规定Java线程和OS线程之间的任何特定映射,前提是主机操作系统具有合适的线程概念-这并非总是如此。

事实上,在非常早期的Java版本中,JVM线程被复用到OS线程(也称为平台线程)上,在所谓的绿色线程中被称为绿色线程,因为那些最早的JVM实现实际上只使用了一个平台线程。

然而,这种单一平台线程实践在Java 1.2和Java 1.3时代(以及稍早些时候在Sun的Solaris OS上)就消失了。现代运行在主流操作系统上的Java版本则实现了一条规则:一个Java线程恰好等于一个OS线程。

这意味着使用Thread.start()调用创建系统调用(例如Linux上的clone())并实际创建一个新的OS线程。

OpenJDK的Project Loom旨在作为其主要目标,重新审视这种长期存在的实现,并启用新的Thread对象,它们可以执行代码但不直接对应专用OS线程。

或者,换句话说,Project Loom创建了一种执行模型,在这种模型中,表示执行上下文的对象不一定是需要由操作系统调度的东西。因此,在某些方面,Project Loom是回归类似于绿色线程的东西。

然而,在这些年里世界发生了巨大变化,有时计算中的想法超前于时代。

例如,您可以将EJB(即Jakarta Enterprise Beans,以前称为Enterprise JavaBeans)视为一种受限环境,它过于雄心勃勃地试图将环境虚拟化。EJB是否可以被认为是后来在现代PaaS系统中受欢迎的想法(在较小程度上,在Docker和Kubernetes中也是如此)的原型形式?

因此,如果Loom是对绿色线程想法的(部分)回归,那么接近它的一种方法可能是通过这个问题:环境中发生了什么变化,使得重新回到过去未被发现有用的旧想法变得有趣?

具体来说,让我们尝试通过创建大量的线程来崩溃JVM。为了稍微探讨一下这个问题,让我们看一个例子。

//
// 请不要实际运行此代码…它可能会崩溃您的虚拟机或笔记本电脑。
//
public class CrashTheVM {
    private static void looper(int count) {
        var tid = Thread.currentThread().getId();
        if (count > 500) {
            return;
        }
        try {
            Thread.sleep(10);
            if (count % 100 == 0) {
                System.out.println("Thread id: "+ tid +" : "+ count);
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        looper(count + 1);
    }

    public static Thread makeThread(Runnable r) {
        return new Thread(r);
    }

    public static void main(String[] args) {
        var threads = new ArrayList<Thread>();
        for (int i = 0; i < 20_000; i = i + 1) {
            var t = makeThread(() -> looper(1));
            t.start();
            threads.add(t);
            if (i % 1_000 == 0) {
                System.out.println(i + " thread started");
            }
        }
        // 加入所有线程。
        threads.forEach(t -> {
            try {
                t.join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });
    }
}

该代码启动了20,000个线程,并在每个线程中进行最少量的处理,或者至少尝试这样做。实际上,在达到稳定状态之前,应用程序可能会死掉或锁定机器,尽管如果机器或操作系统被限制并且无法快速创建线程以诱发资源饥饿,则可能使示例运行到完成。

图1显示了在我的2019年MacBook Pro变得完全无响应之前发生的情况的一个例子。此图像显示不一致的统计数据,例如线程计数,因为操作系统已经在努力跟上。
在这里插入图片描述

图1是一个示例,用来说明当线程数量过多时可能会发生什么情况,请读者不要在非测试电脑上尝试。
为什么现代程序可能需要比它能创建的线程更多的可执行上下文。例如,一个现代高性能的Web服务器可能需要处理成千上万(甚至更多)的并发连接,但是这个例子清楚地表明了线程-每连接架构对于这种情况的失败。

另一种看法是,线程可能比大多数人认为的要昂贵得多,并且代表着现代JVM应用程序的扩展瓶颈。开发人员多年来一直试图解决这个问题,要么通过驯服线程的成本,要么通过使用不是线程的执行上下文表示。

其中一种尝试实现这一目标的方法是分阶段事件驱动架构(SEDA)方法,该方法首次出现在15年前。SEDA可以被认为是一个系统,在该系统中,域对象沿着一个多阶段管道从A移动到Z,沿途发生各种不同的转换。这可以在分布式系统中使用消息系统来实现,或者在单个进程中使用阻塞队列和每个阶段的线程池来实现。

在SEDA方法的每一步中,域对象的处理都由包含实现步骤转换代码的Java对象描述。为了正确工作,代码必须保证终止;不能有无限循环。然而,这个要求不能由框架强制执行。

SEDA方法有一些明显的缺点,其中最重要的是程序员使用架构所需的纪律。让我们寻找一个更好的替代方案,那就是Project Loom。


Project Loom 介绍

Project Loom,这是一个OpenJDK项目,旨在通过添加新的构造来实现“易于使用,高吞吐量的轻量级并发和新的编程模型在Java平台上。”该项目旨在通过添加以下新构造来实现这一目标:

  • 虚拟线程
  • 有界延续
  • 尾调用消除

其中关键是虚拟线程,它们被设计成看起来像程序员一样普通、熟悉的线程。然而,虚拟线程由Java运行时管理,并不是薄薄的一对一的OS线程包装器。相反,虚拟线程由Java运行时在用户空间中实现。(本文不涵盖有界延续或尾调用消除,但您可以在此处阅读有关它们的信息。)

虚拟线程旨在带来的主要优势包括

  • 创建和阻塞它们的成本很低
  • 可以使用Java执行调度程序(线程池)
  • 没有用于堆栈的操作系统级数据结构

通过消除操作系统在虚拟线程生命周期中的参与,消除了可扩展性瓶颈。大型JVM应用程序可以应对拥有数百万甚至数十亿个对象,那么为什么它们应该仅限于几千个可由操作系统调度的对象(这是考虑线程的一种方式)?打破这种限制并解锁新的并发编程风格是Project Loom的主要目标。

您可以下载Project Loom测试版并启动jshell来查看虚拟线程的实际操作。

$ jshell 
|  Welcome to JShell -- Version 16-loom
|  For an introduction type: /help intro

jshell> Thread.startVirtualThread(() -> {
   ...>     System.out.println("Hello World");
   ...> });
Hello World
$1 ==> VirtualThread[<unnamed>,<no carrier thread>]

jshell>

您可以直接在输出中看到虚拟线程结构。代码还使用了一个新的静态方法startVirtualThread()来启动一个新的执行上下文中的lambda,这是一个虚拟线程。就这么简单!

虚拟线程必须被选择:现有的代码库必须继续以与Project Loom出现之前完全相同的方式运行。没有任何东西会崩溃,每个人都必须保守地假设所有现有的Java代码都真正需要一直以来一直是唯一选择的“轻量级包装器在操作系统上”的线程架构。

那么,好处是什么呢?嗯,虚拟线程的到来以其他方式开辟了新的视野。到目前为止,Java语言提供了两种主要的创建新线程的方法:

  • 子类化java.lang.Thread并调用继承的start()方法。
  • 创建一个Runnable实例并将其传递给一个Thread构造函数;然后启动生成的对象。

由于线程的概念正在改变,重新审视您用来创建线程的方法是有意义的。您已经遇到了用于快速忘记虚拟线程的新静态工厂方法,但现有的线程API还需要在其他几个方面进行改进。


线程构建器

一个重要的新概念是Thread.Builder类,它被添加为Thread的内部类。您可以通过用以下代码替换前面示例中的makeThread()方法来查看它的实际操作:

public static Thread makeThread(Runnable r) {
    return Thread.builder().virtual().task(r).build();
}

这段代码在构建器上调用virtual()方法来显式创建一个将执行Runnable的虚拟线程。当然,您可以省略对virtual()的调用,这将创建一个传统的、可由操作系统调度的线程对象。但那样有什么乐趣呢?

如果您用虚拟版本的makeThread()替换并使用支持Loom的Java版本重新编译示例,您可以执行生成的二进制文件。

这次,程序顺利运行完成,没有任何问题,整体负载概况看起来像图2

在这里插入图片描述

图2创建大量虚拟线程而不是传统的Java线程

这只是Project Loom实践中的一个例子,即将您需要对Java应用程序进行的更改定位到仅创建线程的代码位置。

新线程库鼓励开发人员从旧范式转型的一种方式是,Thread的子类不能是虚拟的。因此,继承Thread的代码将继续使用传统的操作系统线程创建。目的是保护使用Thread子类的现有代码并遵循最小惊讶原则。

最小惊讶原则是指在解释某个事件时,应该选择那种最少程度上违背我们的期望的解释。这个原则是由计算机科学家Judea Pearl提出的,他认为这个原则是人类思考和推理的基础。这个原则也被称为奥卡姆剃刀原则,即在多种解释中,应该选择那种最简单的解释。

随着时间的推移,随着虚拟线程变得更加普遍,开发人员不再关心虚拟线程和操作系统线程之间的区别,这应该会阻止使用子类化机制,因为它总是会创建一个可由操作系统调度的线程。

需要注意的是,线程库的其他部分需要升级以更好地支持Project Loom。例如,ThreadBuilder还可以构建ThreadFactory实例,可以传递给各种Executors,如下所示:

jshell> var tb = Thread.builder();
tb ==> java.lang.Thread$BuilderImpl@2e0fa5d3

jshell> var tf = tb.factory();
tf ==> java.lang.Thread$KernelThreadFactory@2e5d6d97

jshell> var vtf = tb.virtual().factory();
vtf ==> java.lang.Thread$VirtualThreadFactory@377dca04

显然,在某个时候,虚拟线程必须附加到实际的操作系统线程上才能执行。虚拟线程执行的这些操作系统线程称为载体线程。在其生命周期内,单个虚拟线程可能会在多个不同的载体线程上运行。这有点类似于常规线程会随着时间在不同的物理CPU核心上执行的方式——这两者都是执行调度的例子。

您已经在前面的一些示例中的jshell输出中看到了载体线程。

显然,在某个时候,虚拟线程必须附加到实际的操作系统线程上才能执行。虚拟线程执行的这些操作系统线程称为载体线程。在其生命周期内,单个虚拟线程可能会在多个不同的载体线程上运行。这有点类似于常规线程会随着时间在不同的物理CPU核心上执行的方式——这两者都是执行调度的例子。 您已经在前面的一些示例中的jshell输出中看到了载体线程。


使用虚拟线程编程

虚拟线程的到来带来了一种思维方式的改变。编写Java并发应用程序的程序员习惯于处理(有意识或无意识地)线程固有的可扩展性限制。

Java开发人员习惯于创建基于RunnableCallable的任务对象,并将它们交给由线程池支持的执行器,以保存宝贵的线程资源。如果所有这些突然都不同了呢?

Project Loom试图通过引入一种比现有概念更便宜且不直接映射到操作系统线程的新线程概念来解决线程的可扩展性限制。这种新能力仍然看起来和表现得像Java程序员已经熟悉的今天的线程。

这意味着,与其需要学习一种完全新的编程风格(例如延续传递风格或promise/future方法或回调),Project Loom运行时保留了您从今天的线程中所熟知的相同编程模型。换句话说,虚拟线程就是线程,至少就程序员而言。

虚拟线程是抢占式的,因为用户代码不需要显式放弃CPU控制权。调度点由虚拟调度程序和JDK决定。开发人员不能对何时发生收益做出任何假设,因为这纯粹是一个实现细节。

为了理解虚拟线程与平台线程的不同之处,有必要了解支持调度的操作系统理论的基础知识。

当操作系统调度平台线程时,它会为一个线程分配一段CPU时间片。当时间片用完时,会产生硬件中断,内核能够恢复控制,移除正在执行的平台(用户)线程,并用另一个线程替换它。

这种机制是UNIX(和其他各种操作系统)能够在不同任务之间实现处理器时间共享的方式,即使在几十年前计算机只有一个处理核心的时代也是如此。

然而,虚拟线程与平台线程的处理方式不同。现有的虚拟线程调度程序都不使用时间片来抢占虚拟线程。

对虚拟线程使用时间片进行抢占是可能的,JVM已经能够控制正在执行的Java线程。例如,在JVM安全点处就可以这样做。

相反,当发生阻塞调用(如I/O)时,虚拟线程会自动放弃(或让出)它们的载体线程。这由库和运行时处理,并且不受程序员的显式控制。

因此,与其强迫程序员显式管理让步,或依赖非阻塞或基于回调的操作的复杂性,Project Loom允许Java程序员以传统的线程顺序风格编写代码。这还带来了额外的好处,例如允许调试器和分析器以通常的方式工作。

Loom的设计师期望,由于虚拟线程永远不需要汇集,因此它们永远不应该被汇集。相反,模型是无限制地创建虚拟线程。为此,已添加了一个无限制的执行器。它可以通过新的工厂方法Executors.newVirtualThreadExecutor()访问。

虚拟线程的默认调度程序是在ForkJoinPool中引入的抢占式调度程序。(抢占式fork/join的这个方面变得比递归分解任务更重要是有趣的。)

Project Loom的设计基于开发人员理解其应用程序中不同线程上将存在的计算开销。

简单地说,如果有大量线程始终需要大量CPU时间,那么您的应用程序就会出现资源紧缺,巧妙的调度也无济于事。另一方面,如果只有少数几个线程预计会受到CPU限制,那么这些线程应该被放置在一个单独的池中,并配备平台线程。

虚拟线程也旨在在许多线程仅偶尔受到CPU限制的情况下工作良好。目的是抢占式调度程序将平滑CPU利用率,并且实际代码最终将调用一个通过收益点(例如阻塞I/O)的操作。


一个警示故事

当使用自定义调度虚拟线程时,Project Loom的设计可能会导致一些意外的行为。这里有一个例子:

public final class TangledLoom {
    public static void main(String[] args) {
        var scheduler = Executors.newFixedThreadPool(2);
        Runnable r = () -> {
            System.out.println(Thread.currentThread().getName() +" starting ");
            while (true) {
                int total = 0;
                for (int i = 0; i < 10; i++) {
                    total = total + hashing(i, 'X');
                }
                System.out.println(Thread.currentThread().getName() +" : "+ total);
            }
        };
        var tA = Thread.builder().virtual(scheduler).name("A").task(r).build();
        var tB = Thread.builder().virtual(scheduler).name("B").task(r).build();
        var tC = Thread.builder().virtual(scheduler).name("C").task(r).build();
        tA.start();
        tB.start();
        tC.start();
        try {
            tA.join();
            tB.join();
            tC.join();
        } catch (Throwable tx) {
            tx.printStackTrace();
        }
    }

    private static int hashing(int length, char c) {
        final StringBuilder sb = new StringBuilder();
        for (int j = 0; j < length * 1_000_000; j++) {
            sb.append(c);
        }
        final String s = sb.toString();
        return s.hashCode();
    }
}

当您运行这段代码时,应该会看到以下行为:

$ java TangledLoom
B starting 
A starting 
B : -1830232064
C starting 
C : -1830232064
B : -1830232064
C : -1830232064
B : -1830232064
C : -1830232064
B : -1830232064
C : -1830232064

这是一个线程饥饿的例子;可悲的线程A似乎永远无法取得进展。

随着时间的推移,随着Java开发人员对Project Loom更加熟悉,一组通用的模式将作为最佳实践浮现出来。但是现在,每个人仍处于学习如何有效使用这项新技术的早期阶段,您应该谨慎,正如这个例子所示。


何时能够使用Project Loom

Project Loom的开发正在一个单独的存储库中进行;它不在JDK主线上。这意味着现在谈论这些变化何时会在Java的官方版本中到来还为时过早。

早期访问二进制文件是可用的,但仍然有一些粗糙的边缘。崩溃仍然不少见。基本API正在成形,但几乎可以肯定它还没有完全确定。在虚拟线程之上构建的API(如结构化并发和其他更高级的功能)仍然有很多工作要做。

开发人员总是关心性能这个关键问题。在新技术开发的早期阶段,这个问题很难回答。事情根本还没有到可以进行有意义比较的地步,而且当前的性能并不被认为真正能够反映最终版本。

与OpenJDK内部的其他长期项目一样,真正的答案是它将在准备好时准备好。目前,已经有足够的原型可以开始尝试Project Loom,因此您可以先尝试一下Java未来线程开发可能会是什么样子。


总结

这篇文章讨论了OpenJDK的Project Loom,它正在探索新的Java语言特性、API和轻量级并发的运行时,包括虚拟线程的新构造。文章详细介绍了虚拟线程的概念、实现和优势,并通过示例展示了它们如何使用。文章还讨论了Project Loom的开发进度和未来计划。总之,这篇文章为读者提供了一个深入了解Project Loom和虚拟线程的机会。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值