Java在其发展的早期就具有良好的多线程和并发能力,可以有效地利用多线程和多核CPU。Java开发工具包(JDK)1.1对平台线程(或操作系统(OS)线程)有基本支持,JDK 1.5有更多的实用程序和更新,以改善并发和多线程。JDK 8带来了异步编程支持和更多的并发性改进。虽然事情在多个版本中不断改进,但在过去三十年中,除了对使用操作系统线程的并发和多线程的支持外,Java没有任何突破性的进展。
尽管Java中的并发模型作为一种功能是强大而灵活的,但它并不是最容易使用的,而且开发者的体验也不是很好。这主要是由于默认使用的共享状态并发模型。人们不得不求助于同步线程来避免数据竞赛和线程阻塞等问题。我在《现代编程语言的并发性》中写了更多关于Java并发性的内容。Java的帖子。
什么是Project Loom?
Project Loom旨在大幅减少编写、维护和观察高吞吐量并发应用程序的工作,以最佳方式利用现有硬件。
- Ron Pressler(Project Loom的技术负责人)
操作系统线程是Java并发模型的核心,围绕它们有一个非常成熟的生态系统,但它们也有一些缺点,在计算上很昂贵。让我们来看看并发的两个最常见的用例,以及当前Java并发模型在这些情况下的缺点。
最常见的并发性用例之一是使用服务器在网上提供请求。对于这一点,首选的方法是每请求线程模型,即一个单独的线程处理每个请求。这种系统的吞吐量可以用Little定律来解释,该定律指出,在一个稳定的系统中,平均并发量(服务器并发处理的请求数)L等于吞吐量(请求的平均速率)λ乘以延迟(处理每个请求的平均时间)W。
因此,在每请求线程模型中,吞吐量将受到可用的操作系统线程数的限制,这取决于硬件上可用的物理核心/线程数。为了解决这个问题,你必须使用共享线程池或异步并发,这两种方法都有其缺点。线程池有很多限制,如线程泄漏、死锁、资源激增等。异步并发意味着你必须适应更复杂的编程风格,并仔细处理数据竞赛。还有内存泄漏、线程锁定等的机会。
另一个常见的用例是并行处理或多线程,你可能会把一个任务分成多个线程的子任务。在这里,你必须编写解决方案以避免数据损坏和数据竞赛。在某些情况下,当执行一个分布在多个线程上的并行任务时,你还必须确保线程同步。这种实现变得更加脆弱,并将更多的责任放在开发者身上,以确保没有线程泄漏和取消延迟等问题。
Project Loom旨在通过引入两个新特性来解决当前并发模型中的这些问题:虚拟线程和结构化并发。
虚拟线程
Java 19计划于2022年9月发布,而虚拟线程将是一个预览功能。Yayyy!
虚拟线程是轻量级线程,不与操作系统线程绑定,而是由JVM管理。它们适用于按请求的线程编程风格,而不具有操作系统线程的限制。你可以创建数以百万计的虚拟线程而不影响吞吐量。这与Go编程语言(Golang)的coroutines(如goroutines)相当相似。
Java 19中的新虚拟线程将相当容易使用。将其与Golang的goroutines或Kotlin的coroutines作如下比较。
虚拟线程
Thread.startVirtualThread(() -> {
System.out.println("Hello, Project Loom!");
});
协作线程
go func() {
println("Hello, Goroutines!")
}()
Kotlin Coroutine
runBlocking {
launch {
println("Hello, Kotlin coroutines!")
}
}
有趣的是:在JDK 1.1之前,Java支持绿色线程(又称虚拟线程),但该功能在JDK 1.1中被移除,因为该实现并不比平台线程好。虚拟线程的新实现是在JVM中完成的,它将多个虚拟线程映射为一个或多个操作系统线程,开发者可以根据自己的需要使用虚拟线程或平台线程。这个虚拟线程的实现还有几个重要方面:
- 它是代码、运行时、调试器和剖析器中的一个
Thread - 它是一个Java实体,而不是一个围绕本地线程的封装器
- 创建和阻塞它们是廉价的操作
- 它们不应该被集中起来
- 虚拟线程使用的是一个偷工减料的
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个虚拟线程后挂起,但没有崩溃,随着内存变得可用,它一直在缓慢进行。
任务吞吐量
让我们尝试使用平台线程运行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 Microbenchmark Harness(JMH),得到的结果如下,你可以看到,虚拟线程的性能比平台线程要好很多:
# Throughput
Benchmark Mode Cnt Score Error Units
LoomBenchmark.platformThreadPerTask thrpt 5 0.362 ± 0.079 ops/s
LoomBenchmark.platformThreadPool thrpt 5 0.528 ± 0.067 ops/s
LoomBenchmark.virtualThreadPerTask thrpt 5 1.843 ± 0.093 ops/s
# Average time
Benchmark Mode Cnt Score Error Units
LoomBenchmark.platformThreadPerTask avgt 5 5.600 ± 0.768 s/op
LoomBenchmark.platformThreadPool avgt 5 3.887 ± 0.717 s/op
LoomBenchmark.virtualThreadPerTask avgt 5 1.098 ± 0.020 s/op
你可以在GitHub上找到该基准的源代码。下面是一些其他有意义的虚拟线程的基准测试。
- 在GitHub上使用ApacheBench的一个有趣的基准测试,作者Elliot Barlas
- Alexander Zakusylo 在Medium上使用Akka actors的基准测试
- GitHub上的I/O和非I/O任务的JMH基准,作者Colin Cachia
结构化并发
结构化并发将是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()方法在调用inventory.get()时抛出了一个异常。到目前为止,这很好,但是updateOrder()呢?由于它在自己的线程上运行,所以它可以成功完成。但是现在我们有一个库存和订单不匹配的问题。假设updateOrder()是一个昂贵的操作。在这种情况下,我们只是白白浪费了资源,我们将不得不写一些防护逻辑来恢复对订单所做的更新,因为我们的整体操作已经失败。 - 想象一下,
updateInventory()是一个昂贵的长期运行的操作,而updateOrder()抛出一个错误。handleOrder()任务将在inventory.get()上被阻塞,即使updateOrder()抛出了一个错误。理想情况下,我们希望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()之前或期间被打断,那么当线程退出范围时,两个分叉都会自动取消。 -
可观察性--线程转储将清楚地显示任务层次,运行
updateInventory()和updateOrder()的线程被显示为范围的子代。
Loom项目的状况
Loom项目开始于2017年,经历了许多变化和建议。虚拟线程最初被称为纤维,但后来为了避免混淆而被重新命名。如今随着Java 19越来越接近发布,该项目已经交付了上面讨论的两个功能。一个是预览版,另一个是孵化器。因此,这些功能的稳定化之路应该更加精确。
这对普通的Java开发者意味着什么?
当这些功能准备就绪时,它应该不会对普通的Java开发者产生太大的影响,因为这些开发者可能正在使用库来处理并发的用例。但是,在那些罕见的场景中,如果你不使用库就进行大量的多线程,这可能是个大问题。虚拟线程可以毫不费力地替代你现在使用线程池的所有用例。根据现有的基准测试,这将在大多数情况下提高性能和可扩展性。结构化并发可以帮助简化多线程或并行处理用例,使其不那么脆弱,更易于维护。
这对Java库开发者意味着什么?
当这些功能准备就绪时,对于使用线程或并行的库和框架来说,这将是一个大问题。库作者将看到巨大的性能和可扩展性改进,同时简化代码库,使其更易维护。大多数使用线程池和平台线程的Java项目将从切换到虚拟线程中受益。候选项目包括Tomcat、Undertow和Netty等Java服务器软件;以及Spring和Micronaut等Web框架。我预计大多数Java网络技术将从线程池迁移到虚拟线程。Java网络技术和新潮的反应式编程库如RxJava和Akka也可以有效地使用结构化并发。这并不意味着虚拟线程将成为所有的解决方案;异步和反应式编程仍然会有用例和好处。