本文来自InfoQ 中文站,原作者 Deepu K Sasidharan。

推荐语:

关于咱们开发的网站,假如访问量太大,恳求激增,就需要考虑相关的并发问题。异步并发,意味着要习惯更杂乱的编程风格。Java中的传统线程十分繁重,并且与操作系统线程1对1绑定。而Loom是jave生态里一个较新的项目,它试图处理传统并发模型中的约束。但详细怎样完结,本文做了详细的说明。

跟着Project Loom的进入,或许未来,java生态系统的功用将产生数量级进步的天翻地覆的改变。

—— MobTech袤博科技资深java开发工程师 零零发

Java 19现已于日前发布,其间最有目共睹的特性就要数虚拟线程了,本文介绍了 Loom 项目中虚拟线程和结构化编程的基础常识,并将其与操作系统线程进行了对比分析。

Java 在其发展早期就具有杰出的多线程和并发才能,能够高效地运用多线程和多核 CPU。Java 开发工具包(Java Development Kit,JDK)1.1 对渠道线程(或操作系统(OS)线程)供给了基本的支撑,JDK 1.5 供给了更多的实用工具和更新,以改善并发和多线程。JDK 8 带来了异步编程支撑和更多的并发改善。虽然在多个不同的版本中都进行了改善,但在过去三十多年中,除了根据操作系统的并发和多线程支撑之外,Java 并没有任何突破性的进展。

虽然 Java 中的并发模型十分强大和灵活,但它并不是最易于运用的,而且开发人员的体验也不是很好。这首要是由于它默认运用的同享状况并发模型。咱们有必要凭借同步线程来防止数据竞赛(data race)和线程阻塞这样的问题。我从前在一篇名为“现代编程言语中的并发:Java”的博客文章中讨论过 Java 并发问题。

Loom 项目是什么?

Loom 项目致力于大幅减少编写、保护和观察高吞吐量并发应用相关的作业,以最佳的办法运用现有的硬件。——Ron Pressler(Loom 项目的技能负责人)

操作系统线程是 Java 并发模型的中心,围绕它们有一个十分老练的生态系统,但是它们也有一些缺点,如核算办法很贵重。咱们来看一下并发的两个最常见运用场景,以及其时的 Java 并发模型在这些场景下的缺点。

最常见的并发运用场景之一就是凭借服务器在网络上为恳求供给服务。在这样的场景中,首选的办法是“每个恳求一个线程(thread-per-request)”模型,即由一个独自的线程处理每个恳求。这种系统的吞吐量能够用Little规律来核算,该规律指出,在一个安稳的系统中,平均并发量(服务器并发处理的恳求数)L 等于吞吐量(恳求的平均速率)乘以推迟(处理每个恳求的平均时刻)W。根据此,咱们能够得出,吞吐量等于平均并发除以推迟( = L/W)。

因而,在“每个恳求一个线程”模型中,吞吐量将遭到操作系统线程数量的约束,这取决于硬件上可用的物理中心/线程数。为了处理这个问题,咱们有必要运用同享线程池或异步并发,这两种办法各有缺点。线程池有许多约束,如线程走漏、死锁、资源激增等。异步并发意味着有必要要习惯更杂乱的编程风格,并慎重处理数据竞赛。它们还有或许呈现内存走漏、线程确定等问题。

另一个常见的运用场景是并行处理或多线程,咱们或许会把一个使命分红跨多个线程的子使命。此时,咱们有必要编写防止数据损坏和数据竞赛的处理方案。在有些情况下,当履行分布在多个线程上的并行使命时,还有必要要保证线程同步。这种完结会十分脆弱,并且将许多的责任推给了开发人员,以保证没有像线程走漏和撤销推迟这样的问题。

Loom 项目旨在通过引进两个新特性来处理其时并发模型中的这些问题,即虚拟线程(virtual thread)和结构化并发(structured concurrency)。

虚拟线程

Java 19 现已于 2022 年 9 月 20 日发布,虚拟线程是其间的一项预览功用。

虚拟线程轻量级的线程,它们不与操作系统线程绑定,而是由 JVM 来办理。它们适用于“每个恳求一个线程”的编程风格,一起没有操作系统线程的约束。咱们能够创立数以百万计的虚拟线程而不会影响吞吐。这与 Go 编程言语(Golang)的协程(如goroutines)十分相似。

Java 19 中的虚拟线程新特性很易于运用。在这里,我将其与 Golang 的 goroutines 以及 Kotlin 的 coroutines 进行了对比。

虚拟线程

Thread.startVirtualThread(() -> {    System.out.println("Hello, Project Loom!");});

Goroutine

go func() {    println("Hello, Goroutines!")}()

Kotlin coroutine

runBlocking {    launch {        println("Hello, Kotlin coroutines!")    }}

冷常识:在 JDK 1.1 之前,Java 从前支撑过绿色线程(又称虚拟线程),但该功用在 JDK 1.1 中移除了,由于其时该完结并没有比渠道线程更好。

虚拟线程的新完结是在 JVM 中完结的,它将多个虚拟线程映射为一个或多个操作系统线程,开发人员能够按需运用虚拟线程或渠道线程。这种虚拟线程完结还有如下几个注意事项:

  • 在代码、运转时、调试器和分析器(profiler)中,它是一个Thread。

  • 它是一个 Java 实体,并不是对原生线程的封装。

  • 创立和阻塞它们是价值低价的操作。

  • 它们不应该放到池中。

  • 虚拟线程运用了一个根据使命窃取(work-stealing)的ForkJoinPool调度器。

  • 能够将可插拔的调度器用于异步编程中。

  • 虚拟线程会有自己的栈内存。

  • 虚拟线程的 API 与渠道线程十分相似,因而更简单运用或移植。

咱们看几个展现虚拟线程威力的样例。

线程的总数量

首先,咱们看一下在一台机器上能够创立多少个渠道线程和虚拟线程。我的机器是英特尔酷睿 i9-11900H 处理器,8 个中心、16 个线程、64GB 内存,运转的操作系统是 Fedora 36。

渠道线程​​​​​​​

var counter = new AtomicInteger();while (true) {    new Thread(() -> {        int count = counter.incrementAndGet();        System.out.println("Thread count = " + count);        LockSupport.park();    }).start();}

在我的机器上,在创立 32,539 个渠道线程后代码就崩溃了。

虚拟线程​​​​​​​

var counter = new AtomicInteger();while (true) {    Thread.startVirtualThread(() -> {        int count = counter.incrementAndGet();        System.out.println("Thread count = " + count);        LockSupport.park();    });}

在我的机器上,进程在创立 14,625,956 个虚拟线程后被挂起,但没有崩溃,跟着内存逐步可用,它一直在缓慢进行。你或许想知道为什么会呈现这种情况。这是由于被 park 的虚拟线程会被垃圾回收,JVM 能够创立更多的虚拟线程并将其分配给底层的渠道线程。

使命吞吐量

咱们测验运用渠道线程来运转 100,000 个使命。​​​​​​​

try (var executor = Executors.newThreadPerTaskExecutor(Executors.defaultThreadFactory())) {    IntStream.range(0, 100_000).forEach(i -> executor.submit(() -> {        Thread.sleep(Duration.ofSeconds(1));        System.out.println(i);        return i;    }));}

在这里,咱们运用了带有默认线程工厂的newThreadPerTaskExecutor办法,因而运用了一个线程组。运转这段代码并计时,我得到了如下的成果。当运用Executors.newCachedThreadPool()线程池时,我得到了更好的功用。

# 'newThreadPerTaskExecutor' with 'defaultThreadFactory'0:18.77 real,   18.15 s user,   7.19 s sys,     135% 3891pu,    0 amem,         743584 mmem# 'newCachedThreadPool' with 'defaultThreadFactory'0:11.52 real,   13.21 s user,   4.91 s sys,     157% 6019pu,    0 amem,         2215972 mmem

看着还不错。现在,让咱们用虚拟线程完结相同的使命。​​​​​​​

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {    IntStream.range(0, 100_000).forEach(i -> executor.submit(() -> {        Thread.sleep(Duration.ofSeconds(1));        System.out.println(i);        return i;    }));}

运转这段代码并计时,我得到了如下成果:

0:02.62 real,   6.83 s user,    1.46 s sys,     316% 14840pu,   0 amem,         350268 mmem

Java 19 发布,Loom 怎么解决 Java 的并发模型缺陷?丨未来源码

这比根据渠道线程的线程池要好得多。当然,这些都是很简单的运用场景,线程池和虚拟线程的完结都能够进一步优化以获得更好的功用,但这不是这篇文章的要点。

用相同的代码运转Java Microbenchmark Harness(JMH),得到的成果如下。能够看到,虚拟线程的功用比渠道线程要好许多。

# ThroughputBenchmark                             Mode  Cnt  Score   Error  UnitsLoomBenchmark.platformThreadPerTask  thrpt    5  0.362  0.079  ops/sLoomBenchmark.platformThreadPool     thrpt    5  0.528  0.067  ops/sLoomBenchmark.virtualThreadPerTask   thrpt    5  1.843  0.093  ops/s
# Average timeBenchmark                             Mode  Cnt  Score   Error  UnitsLoomBenchmark.platformThreadPerTask   avgt    5  5.600  0.768   s/opLoomBenchmark.platformThreadPool      avgt    5  3.887  0.717   s/opLoomBenchmark.virtualThreadPerTask    avgt    5  1.098  0.020   s/op

你能够在 GitHub 上找到该基准测验的源代码。如下是其他几个有价值的虚拟线程基准测验:

  • 在GitHub上,Elliot Barlas 运用 ApacheBench 做的一个有趣的基准测验。

  • Alexander Zakusylo在 Medium 上运用 Akka actors 的基准测验。

  • 在GitHub上,Colin Cachia 做的 I/O 和非 I/O 使命的 JMH 基准测验。

结构化并发

结构化并发是 Java 19 中的一个孵化功用。

结构化并发的目的是简化多线程和并行编程。它将在不同线程中运转的多个使命视为一个作业单元,简化了错误处理和使命撤销,一起进步了可靠性和可观测性。这有助于防止线程走漏和撤销推迟等问题。作为一个孵化功用,在安稳过程中或许会经历进一步的变更。

咱们考虑如下这个运用java.util.concurrent.ExecutorService的样例。void handleOrder() throws ExecutionException, InterruptedException {

    try (var esvc = new ScheduledThreadPoolExecutor(8)) {        Future<Integer> inventory = esvc.submit(() -> updateInventory());        Future<Integer> order = esvc.submit(() -> updateOrder());
        int theInventory = inventory.get();   // Join updateInventory        int theOrder = order.get();           // Join updateOrder
        System.out.println("Inventory " + theInventory + " updated for order " + theOrder);    }}

咱们期望updateInventory()和updateOrder()这两个子使命能够并发履行。每一个使命都能够独登时成功或失利。理想情况下,假如任何一个子使命失利,handleOrder()办法都应该失利。然而,假如某个子使命产生失利的话,事情就会变得难以预料。

  • 设想一下,updateInventory()失利并抛出了一个反常。那么,handleOrder()办法在调用invent.get()时将会抛出反常。到目前为止,还没有什么大问题,但updateOrder()呢?由于它在自己的线程上运转,所以它或许会成功完结。但是现在咱们就有了一个库存和订单不匹配的问题。假定updateOrder()是一个价值昂扬的操作。在这种情况下,咱们白白浪费了资源,不得不编写某种防护逻辑来撤销对订单所做的更新,由于咱们的整体操作现已失利。

  • 假定updateInventory()是一个价值昂扬的长时刻运转操作,而updateOrder()抛出一个错误。即便updateOrder()抛出了错误,handleOrder()使命仍然会在inventory.get()办法上阻塞。理想情况下,咱们期望handleOrder()使命在updateOrder()产生故障时撤销updateInventory(),这样就不会浪费时刻了。

  • 假如履行handleOrder()的线程被中止,那么中止不会被传达到子使命中。在这种情况下,updateInventory()和updateOrder()会走漏并持续在后台运转。

关于这些场景,咱们有必要小心翼翼地编写变通方案和故障防护办法,把一切的责任推到了开发人员身上。

咱们能够运用下面的代码,用结构化并发完结相同的功用。​​​​​​​

void handleOrder() throws ExecutionException, InterruptedException {    try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {        Future<Integer> inventory = scope.fork(() -> updateInventory());        Future<Integer> order = scope.fork(() -> updateOrder());
        scope.join();           // Join both forks        scope.throwIfFailed();  // ... and propagate errors
        // Here, both forks have succeeded, so compose their results        System.out.println("Inventory " + inventory.resultNow() + " updated for order " + order.resultNow());    }}

与之前运用ExecutorService的样例不同,咱们现在运用StructuredTaskScope来完结相同的成果,并将子使命的生命周期约束在词法的效果域内,在本例中,也就是 try-with-resources 句子体内。这段代码更易读,而且目的也很清楚。StructuredTaskScope还自动保证以下行为:

  • 根据短路的错误处理:假如updateInventory()或updateOrder()失利,另一个将被撤销,除非它现已完结。这是由ShutdownOnFailure()完结的撤销战略来办理的,咱们还能够运用其他战略。

  • 撤销传达:假如运转handleOrder()的线程在调用join()之前或调用过程中被中止的话,当该线程退出效果域时,两个分支(fork)都会被自动撤销。

  • 可观察性:线程转储文件将清楚地显现使命层次,运转updateInventory()和updateOrder()的线程被显现为效果域的子线程。

Loom 项目状况

Loom 项目开始于 2017 年,经历了许多改变和提议。虚拟线程最初被称为 fibers,但后来为了防止混淆而从头进行了命名。如今跟着 Java 19 的发布,该项目现已交付了上文讨论的两个功用。其间一个是预览状况,另一个是孵化状况。因而,这些特性的安稳化之路应该会更加明晰。

这对普通的 Java 开发人员意味着什么?

当这些特性出产环境就绪时,应该不会对普通的 Java 开发人员产生太大的影响,由于这些开发人员或许正在运用某些库来处理并发的场景。但是,在一些比较稀有的场景中,比方你或许进行了许多的多线程操作但是没有运用库,那么这些特性就是很有价值的了。虚拟线程能够毫不费力地替代你现在运用的线程池。根据现有的基准测验,在大多数情况下它们都能进步功用和可扩展性。结构化并发有助于简化多线程或并行处理,使其能加强健,更易于保护。

这对 Java 库开发人员意味着什么?

当这些特性出产环境就绪时,关于运用线程或并行的库和结构来说,将是一件大事。库作者能够完结巨大的功用和可扩展性进步,一起简化代码库,使其更易保护。大多数运用线程池和渠道线程的 Java 项目都能够从切换至虚拟线程的过程中获益,候选项目包含 Tomcat、Undertow 和 Netty 这样的 Java 服务器软件,以及 Spring 和 Micronaut 这样的 Web 结构。我预计大多数 Java web 技能都将从线程池迁移到虚拟线程。Java web 技能和新兴的反应式编程库,如 RxJava 和 Akka,也能够有效地运用结构化并发。但这并不意味着虚拟线程将成为一切问题的处理方案,异步和反应式编程仍然有其适用场景和收益。

了解更多关于 Java、多线程和 Loom 项目的信息:

  • On the Performance of User-Mode Threads and Coroutines

  • State of Loom

  • Project Loom: Modern Scalable Concurrency for the Java Platform

  • Thinking About Massive Throughput? Meet Virtual Threads!

  • Does Java 18 finally have a better alternative to JNI?

  • OAuth for Java Developers

  • Cloud Native Java Microservices with JHipster and Istio