Java 19 为 Java 平台带来了虚拟线程的第一个预览;这是 OpenJDKs Project Loom的主要可交付成果。这是很长一段时间以来 Java 发生的最大变化之一——同时也是几乎无法察觉的变化。虚拟线程从根本上改变了 Java 运行时与底层操作系统的交互方式,消除了可伸缩性的重大障碍——但对于我们如何构建和维护并发程序的改变相对较小。新的 API 表面几乎为零,虚拟线程的行为几乎与我们已知的线程完全相同。事实上,要有效地使用虚拟线程,需要学习的内容比学习内容要多。
线程
线程是 Java 的基础。当我们运行一个 Java 程序时,它的 main 方法作为第一个调用帧被调用。"main"
线程,由 Java 启动器创建。当一个方法调用另一个方法时,被调用者与调用者在同一个线程上运行,返回到哪里记录在线程堆栈上。当方法使用局部变量时,它们存储在线程堆栈上的方法调用帧中。当出现问题时,我们可以通过遍历当前线程堆栈来重建我们如何到达当前点的上下文——堆栈跟踪。线程每天都为我们提供了许多我们认为理所当然的东西:顺序控制流、局部变量、异常处理、单步调试和分析。线程也是Java程序中调度的基本单位;当线程阻塞等待存储设备、网络连接或锁时,该线程将被取消调度,以便另一个线程可以在该 CPU 上运行。Java 是第一个集成支持基于线程的并发的主流语言,包括跨平台内存模型;线程是 Java 并发模型的基础。
尽管如此,线程常常名声不佳,因为大多数开发人员使用线程的经验是尝试实现或调试共享状态并发。事实上,共享状态并发——通常被称为“使用线程和锁编程”——可能很困难。与 Java 平台上编程的许多其他方面不同,答案并非全部都可以在语言规范或 API 文档中找到。编写管理共享可变状态的安全、高性能的并发代码需要理解一些微妙的概念,如内存可见性,以及大量的纪律。(如果更容易,作者自己的Java Concurrency in Practice不会有将近 400 页。)
尽管开发人员在处理并发时有合理的担忧,但很容易忘记,在其他 99% 的时间里,线程安静而可靠地让我们的生活变得更加轻松,为我们提供了异常处理和信息堆栈跟踪,可服务性工具让我们观察每个线程中发生的事情、远程调试以及使我们的代码更容易推理的顺序错觉。
平台线程
Java 通过确保语言和 API 为线程、线程间协调机制和为线程对内存的影响提供可预测语义的内存模型提供完整的、可移植的抽象,实现了并发程序的一次写入、随处运行,这可以有效地映射到许多不同的底层实现。
今天的大多数 JVM 实现都将 Java 线程实现为操作系统线程的瘦包装器;将这些重量级的、操作系统管理的线程称为平台线程。这不是必需的——事实上,Java 线程模型早于操作系统对线程的广泛支持——但是因为现代操作系统现在对线程有很好的支持(在今天的大多数操作系统中,线程是调度的基本单元),所以有充分的理由依靠底层平台线程。但是这种对操作系统线程的依赖有一个缺点:由于大多数操作系统实现线程的方式,线程创建相对昂贵且资源繁重。这隐含地对我们可以创建多少线程设置了实际限制,这反过来又对我们在程序中使用线程的方式产生了影响。
操作系统通常在线程创建时将线程堆栈分配为单片内存块,以后无法调整大小。这意味着线程携带着兆字节级的内存块来管理本机和 Java 调用堆栈。堆栈大小可以通过命令行开关和Thread
构造函数来调整,但是在两个方向上调整都是有风险的。如果堆栈被过度配置,我们将使用更多的内存;如果它们配置不足,我们会冒StackOverflowException
在错误时间调用错误代码的风险。我们通常倾向于过度配置线程堆栈,因为它的危害较小,但结果是对于给定的内存量我们可以拥有多少并发线程的限制相对较低。
限制我们可以创建的线程数量是有问题的,因为构建服务器应用程序的最简单方法是每个任务线程方法:在任务的生命周期内将每个传入请求分配给单个线程。
以这种方式将应用程序的并发单元(任务)与平台(线程)对齐可以最大限度地简化开发、调试和维护,并利用线程无形地给我们带来的所有好处,尤其是最重要的顺序错觉。它通常需要很少的并发意识(除了为请求处理程序配置线程池),因为大多数请求是相互独立的。不幸的是,随着程序的扩展,这种方法与平台线程的内存特性发生冲突。每个任务线程的扩展性足以满足中等规模的应用程序——我们可以轻松地为 1000 个并发请求提供服务——但我们无法使用相同的技术为 100 万个并发请求提供服务,即使硬件具有足够的 CPU 容量和 IO带宽。
到目前为止,想要为大量并发请求提供服务的 Java 开发人员有几个糟糕的选择:限制代码的编写方式,使其可以使用小得多的堆栈大小(这通常意味着放弃大多数第三方库),投入更多的硬件问题,或者切换到“异步”或“反应式”编程风格。虽然“异步”模型最近有些流行,但它意味着以高度受限的风格进行编程,这要求我们放弃线程给我们带来的许多好处,例如可读的堆栈跟踪、调试和可观察性。由于大多数异步库采用的设计模式,这也意味着放弃 Java 语言给我们带来的许多好处,因为异步库本质上变成了想要管理整个计算的死板的特定领域语言。这牺牲了许多使 Java 编程富有成效的东西。
虚拟线程
虚拟线程是一种替代实现,java.lang.Thread
它将它们的堆栈帧存储在 Java 的垃圾收集堆中,而不是存储在操作系统分配的单片内存块中。我们不必猜测一个线程可能需要多少堆栈空间,或者对所有线程进行一刀切的估计;虚拟线程的内存占用开始时只有几百字节,并随着调用堆栈的扩展和收缩而自动扩展和收缩。
操作系统只知道平台线程,它仍然是调度单元。为了在虚拟线程中运行代码,Java 运行时通过将其安装在某个平台线程(称为载体线程)上来安排它运行。挂载虚拟线程意味着将所需的堆栈帧从堆中临时复制到载体线程的堆栈中,并在挂载时借用载体堆栈。
当在虚拟线程中运行的代码会因 IO、锁定或其他资源可用性而阻塞时,它可以从载体线程中卸载,并且复制的任何修改的堆栈帧都将返回到堆中,从而释放载体线程以进行其他操作(例如就像运行另一个虚拟线程一样。)JDK 中几乎所有的阻塞点都已经过调整,因此当在虚拟线程上遇到阻塞操作时,虚拟线程会从其载体上卸载而不是阻塞。
在载体线程上挂载和卸载虚拟线程是 Java 代码完全不可见的实现细节。Java代码无法观察到当前载体的身份(调用Thread::currentThread
总是返回虚拟线程);ThreadLocal
载体线程的值对已安装的虚拟线程不可见;载体的堆栈帧不会出现在虚拟线程的异常或线程转储中。在虚拟线程的生命周期中,它可能在许多不同的载体线程上运行,但是任何取决于线程标识的东西,例如锁定,都会看到它在哪个线程上运行的一致画面。
虚拟线程之所以如此命名,是因为它们与虚拟内存共享特性。使用虚拟内存,应用程序会产生一种错觉,即他们可以访问整个内存地址空间,而不受可用物理内存的限制。硬件通过根据需要将丰富的虚拟内存临时映射到稀缺的物理内存来完成这种错觉,当其他一些虚拟页面需要该物理内存时,旧的内容首先被分页到磁盘。同样,虚拟线程既便宜又丰富,根据需要共享稀缺和昂贵的平台线程,不活动的虚拟线程堆栈被“分页”到堆中。
虚拟线程具有相对较少的新 API 表面。有几种创建虚拟线程的新方法(例如,Thread::ofVirtual
),但创建后,它们是普通Thread
对象,并且表现得像我们已经知道的线程。现有的 API,如Thread::currentThread
、ThreadLocal
、中断、堆栈遍历等,在虚拟线程上的工作方式与在平台线程上的工作方式完全相同,这意味着我们可以在虚拟线程上自信地运行现有代码。
以下示例说明了使用虚拟线程同时获取两个 URL 并聚合它们的结果作为处理请求的一部分。它创建一个ExecutorService
在一个新的虚拟线程中运行每个任务,向它提交两个任务,然后等待结果。ExecutorService
已经被改造为实现AutoCloseable
,所以可以和 一起使用try-with-resources
,该close
方法关闭执行器并等待任务完成。
void handle(Request request, Response response) {
var url1 = ...
var url2 = ...
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
var future1 = executor.submit(() -> fetchURL(url1));
var future2 = executor.submit(() -> fetchURL(url2));
response.send(future1.get() + future2.get());
} catch (ExecutionException | InterruptedException e) {
response.fail(e);
}
}
String fetchURL(URL url) throws IOException {
try (var in = url.openStream()) {
return new String(in.readAllBytes(), StandardCharsets.UTF_8);
}
}
在阅读这段代码时,我们最初可能会担心为这种短暂的活动创建线程或为很少的任务创建线程池是一种浪费,但这只是我们必须忘记的——这段代码是一个完全负责任的用途虚拟线程数
这不就是“绿线”吗?
Java 开发人员可能还记得在 Java 1.0 时代,一些 JVM 使用用户模式或“绿色”线程来实现线程。虚拟线程与绿色线程有表面上的相似之处,因为它们都由 JVM 而不是 OS 管理,但相似之处到此为止。90 年代的绿色线程仍然有大的、单一的堆栈。它们在很大程度上是他们那个时代的产物,当时系统是单核的,操作系统根本没有线程支持。虚拟线程与其他语言中的用户模式线程有更多共同点,例如Go中的goroutine或Erlang中的进程——但具有与我们已经拥有的线程在语义上相同的优势。
这是关于可扩展性
尽管创建成本不同,但虚拟线程并不比平台线程快;我们不能在一秒钟内用一个虚拟线程做更多的计算,而不是用一个平台线程做的。我们也不能安排比平台线程更积极运行的虚拟线程;两者都受到可用 CPU 内核数量的限制。那么,有什么好处呢?因为它们是如此轻量级,我们可以有更多的不活动虚拟线程比我们可以使用平台线程。起初,这听起来可能根本不是什么大好处!但是“大量非活动线程”实际上描述了大多数服务器应用程序。服务器应用程序中的请求花费在网络、文件或数据库 I/O 上的时间比计算要多得多。因此,如果我们在自己的线程中运行每个任务,大多数时候该线程将在 I/O 或其他资源可用性上被阻塞。虚拟线程允许 IO-bound thread-per-task 应用程序更好地扩展通过消除最常见的扩展瓶颈——最大线程数——从而提高硬件利用率。虚拟线程使我们能够两全其美:一种与平台相协调而不是与之对抗的编程风格,同时允许最佳的硬件利用率。
对于受 CPU 限制的工作负载,我们已经有工具来获得最佳 CPU 利用率,例如fork-join框架和并行流。虚拟线程为这些提供了补充优势。并行流可以更轻松地扩展受 CPU 限制的工作负载,但对受 IO 限制的工作负载提供的功能相对较少;虚拟线程为 IO 密集型工作负载提供可扩展性优势,但对于 CPU 密集型工作负载则相对较少。
利特尔法
稳定系统的可扩展性受Littles Law的约束,它涉及延迟、并发性和吞吐量。如果每个请求的持续时间(或延迟)为d,并且我们可以同时执行N个任务,那么吞吐量T由下式给出
T = N / d
Littles Law 不关心“工作”与“等待”的时间,或者并发单元是线程、CPU、ATM 机还是人工银行柜员。它只是说明要扩大吞吐量,我们要么必须按比例缩小延迟,要么扩大我们可以同时处理的请求数量。当我们达到并发线程的限制时,线程每任务模型的吞吐量受到利特尔定律的限制。虚拟线程通过为我们提供更多并发线程而不是要求我们更改编程模型来优雅地解决这个问题。
虚拟线程在行动
虚拟线程不替代平台线程;它们是互补的。但是,许多服务器应用程序会选择虚拟线程(通常通过框架的配置)来实现更大的可扩展性。
以下示例创建 100,000 个虚拟线程,通过休眠一秒钟来模拟 IO 绑定操作。它为每个任务创建一个虚拟线程执行器,并将任务作为 lambdas 提交。
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
IntStream.range(0, 100_000).forEach(i -> {
executor.submit(() -> {
Thread.sleep(Duration.ofSeconds(1));
return i;
});
});
} // close() called implicitly
在没有特殊配置选项的普通桌面系统上,运行此程序在冷启动时大约需要 1.6 秒,在预热后大约需要 1.1 秒。如果我们尝试使用缓存线程池来运行这个程序,根据可用内存的多少,它可能会OutOfMemoryError
在所有任务提交之前崩溃。如果我们用一个固定大小的线程池运行它,它有 1000 个线程,它不会崩溃,但 Littles Law 准确地预测它需要 100 秒才能完成。
忘掉的东西
因为虚拟线程是线程并且它们自己几乎没有新的 API 表面,所以为了使用虚拟线程,需要学习的东西相对较少。但实际上,为了有效地使用它们,我们需要忘掉很多东西。
所有人都离开了泳池
最需要忘记的是围绕线程创建的模式。Java 5 带来了java.util.concurrent
包,包括ExecutorService
框架,Java 开发人员已经(正确地!)了解到,ExecutorService
以策略驱动的方式管理和池化线程通常比直接创建线程要好得多。但是当涉及到虚拟线程时,池化成为一种反模式。(我们不必放弃使用ExecutorService
或封装它提供的策略;我们可以使用新的工厂方法Executors::newVirtualThreadPerTaskExecutor
来获得一个ExecutorService
为每个任务创建一个新的虚拟线程的方法。)
因为虚拟线程的初始占用空间非常小,所以创建虚拟线程在时间和内存上都比创建平台线程便宜得多——如此之多,以至于我们需要重新审视关于线程创建的直觉。对于平台线程,我们习惯于将它们池化,以限制资源利用率(因为否则容易耗尽内存),并将线程启动的成本分摊到多个请求上。另一方面,创建虚拟线程是如此便宜,以至于它实际上是一个坏事想法汇集他们!我们在限制内存使用方面几乎没有什么收获,因为占用空间太小了;甚至需要数百万个虚拟线程才能使用 1G 内存。我们在摊销创建开销方面也收获甚微,因为创建成本是如此之小。虽然很容易忘记,因为池化在历史上是一种强制措施,但它也有其自身的问题,例如ThreadLocal
污染(ThreadLocal
值被留下并在长寿命线程中累积,导致内存泄漏。)
如果需要将并发限制为线程本身以外的某些资源的绑定消耗,例如数据库连接,我们可以使用 aSemaphore
并让每个需要稀缺资源的虚拟线程获得许可。
虚拟线程非常轻量级,即使是短期任务也可以创建虚拟线程,而尝试重用或回收它们会适得其反。事实上,虚拟线程的设计考虑到了这些短期任务,例如 HTTP 提取或 JDBC 查询。
过度使用 ThreadLocal
库可能还需要ThreadLocal
根据虚拟线程调整它们的使用。有时使用(有人会说被滥用)的一种方式ThreadLocal
是缓存分配昂贵的资源,不是线程安全的,或者只是为了避免重复分配常用对象(例如,ASM 使用 aThreadLocal
来维护per-thread char[]
buffer,用于格式化操作。)当系统有几百个线程时,这种模式的资源使用通常不会过多,并且可能比每次需要重新分配更便宜。但是微积分发生了巨大的变化,有几百万个线程,每个线程只执行一个任务,因为可能分配了更多的实例,并且每个实例被重用的机会要小得多。用一个ThreadLocal
将昂贵资源的创建成本分摊到可能在同一线程中执行的多个任务中是一种特殊的池化形式;如果这些东西需要合并,就应该明确合并。
反应式呢?
许多所谓的“异步”或“反应式”框架通过要求开发人员以支持异步 IO、回调和线程共享的方式交换每个请求的线程样式,提供了更充分利用硬件的途径。在这样的模型中,当一个活动需要执行 IO 时,它会启动一个异步操作,该操作将在完成时调用回调。框架将在某个线程上调用该回调,但不一定是启动操作的同一线程。这意味着开发人员必须将他们的逻辑分解为交替的 IO 和计算步骤,这些步骤被缝合到一个连续的工作流程中。因为请求只在实际计算某事时才使用线程,并发请求的数量不受线程数量的限制,
但是,这种可扩展性需要付出巨大的代价——你经常不得不放弃平台和生态系统的一些基本特性。在每任务线程模型中,如果您想按顺序执行两件事,您只需按顺序执行即可。如果您想使用循环、条件或 try-catch 块来构建您的工作流程,您只需这样做。但是在异步风格中,您通常无法使用该语言为您提供的顺序组合、迭代或其他功能来构建工作流;这些必须通过在异步框架内模拟这些构造的 API 调用来完成。用于模拟循环或条件的 API 永远不会像语言中内置的结构那样灵活或熟悉。如果我们使用的是执行阻塞操作的库,并且还没有适应以异步方式工作,我们也可能无法使用这些。所以我们可能会从这个模型中获得可扩展性,但我们必须放弃使用部分语言和生态系统来获得它。
这些框架也使我们放弃了许多使 Java 开发更容易的运行时特性。因为请求的每个阶段都可能在不同的线程中执行,并且服务线程可能交错属于不同请求的计算,所以我们在出现问题时使用的常用工具,例如堆栈跟踪、调试器和分析器,比在每个任务的线程模型。这种编程风格与 Java 平台不一致,因为框架的并发单元(异步管道的一个阶段)与平台的并发单元不同。另一方面,虚拟线程允许我们在不放弃关键语言和运行时特性的情况下获得相同的吞吐量优势。
异步/等待呢?
许多语言已经将async
方法(一种无堆栈协同程序)作为管理阻塞操作的一种方式,可以通过其他async
方法或使用await
语句的普通方法调用。事实上,像Kotlin一样,有一些流行的呼吁要添加async/await
到 Java中。C#
虚拟线程提供了一些async/await
没有的显着优势。虚拟线程不仅是异步框架的语法糖,而且是对 JDK 库的大修,使其更加“具有阻塞意识”。否则,从异步任务对同步阻塞方法的错误调用仍将在调用期间占用平台线程。仅仅在语法上更容易管理异步操作并不能提供任何可伸缩性优势,除非您找到系统中的每个阻塞操作并将其转化为async
方法。
一个更严重的问题async/await
是“函数颜色”问题,其中方法分为两种——一种是为线程设计的,另一种是为异步方法设计的——并且两者不能完美地互操作。这是一个繁琐的编程模型,通常有大量重复,并且需要将新结构引入到库、框架和工具的每一层中,以获得无缝的结果。为什么我们要实现另一个并发单元——一个只有语法深度的——它与我们已经拥有的线程不一致?这在另一种语言中可能更有吸引力,其中语言-运行时协同进化不是一种选择,但幸运的是我们不必做出那个选择。
API 和平台更改
虚拟线程及其相关 API 是一项预览功能。这意味着需要该--enable-preview
标志来启用虚拟线程支持。
虚拟线程是 的实现java.lang.Thread
,因此没有新的VirtualThread
基本类型。但是,该Thread
API 已通过一些用于创建和检查线程的新 API 点进行了扩展。有新的工厂方法Thread::ofVirtual
and Thread::ofPlatform
,一个新的Thread.Builder
类,并且Thread::startVirtualThread
可以一次性在虚拟线程上创建一个启动任务。现有的线程构造函数继续像以前一样工作,但仅用于创建平台线程。
虚拟线程和平台线程之间存在一些行为差异。虚拟线程始终是守护线程;该Thread::setDaemon
方法对他们没有影响。虚拟线程始终具有Thread.NORM_PRIORITY
无法更改的优先级。虚拟线程不支持某些(有缺陷的)遗留机制,例如方法、ThreadGroup
和。将揭示一个线程是否是虚拟的。Thread
stop
suspend
remove
Thread::isVirtual
与平台线程堆栈不同,如果没有其他东西使虚拟线程保持活动状态,则垃圾收集器可以回收虚拟线程。这意味着如果一个虚拟线程被阻塞,例如 on BlockingQueue::take
,但虚拟线程和队列都不能被任何平台线程访问,那么线程及其堆栈可以被垃圾收集。(这是安全的,因为在这种情况下,虚拟线程永远不会被中断或解除阻塞。)
最初,虚拟线程的载体线程是ForkJoinPool
在 FIFO 模式下运行的线程。此池的大小默认为可用处理器的数量。将来,可能会有更多选项来创建自定义调度程序。
准备 JDK
虽然虚拟线程是 Project Loom 的主要可交付成果,但 JDK 在幕后进行了许多改进,以确保应用程序在使用虚拟线程时拥有良好的体验:
- 新的套接字实现。 JEP 353 (Reimplement the Legacy Socket API) 和JEP 373 (Reimplement the Legacy DatagramSocket API) 替换了 、 和 的实现,
Socket
以更好地支持虚拟线程(包括使阻塞方法在虚拟线程中可中断。)ServerSocket
DatagramSocket
- 虚拟线程感知。JDK 中几乎所有的阻塞点都知道虚拟线程,并且会卸载虚拟线程而不是阻塞它。
- 重新审视
ThreadLocal
. JDK 中的许多ThreadLocal
用法都根据线程使用模式的预期变化进行了修订。 - 重新审视锁定。因为获取内在锁 (
synchronized
) 当前将虚拟线程固定到其载体,所以关键内在锁被替换为ReentrantLock
,它不共享此行为。(虚拟线程和内在锁之间的交互很可能在未来得到改进。) - 改进的线程转储。提供对线程转储(例如由 生成的线程转储)的更好控制
jcmd
,以过滤掉虚拟线程、将相关的虚拟线程组合在一起,或者以机器可读格式生成转储,这些转储可以进行后处理以获得更好的可观察性。
原创文章,作者:ItWorker,如若转载,请注明出处:https://blog.ytso.com/290616.html