JEP 425:虚拟线程(预览)
概括
将_虚拟线程_引入Java 平台。虚拟线程是轻量级线程,可以显着减少编写、维护和观察高吞吐量并发应用程序的工作量。这是一个预览 API。
目标
-
使以简单的每个请求线程风格编写的服务器应用程序能够以接近最佳的硬件利用率进行扩展。
-
使使用 API 的现有代码
java.lang.Thread
能够以最小的更改采用虚拟线程。 -
使用现有 JDK 工具轻松进行虚拟线程故障排除、调试和分析。
非目标
-
删除传统的线程实现或默默地迁移现有应用程序以使用虚拟线程并不是目标。
-
改变Java的基本并发模型并不是目标。
-
在 Java 语言或 Java 库中提供新的数据并行结构并不是我们的目标。 Stream API仍然是并行处理大型数据集的首选方式。
动机
近三十年来,Java 开发人员一直依赖线程作为并发服务器应用程序的构建块。每个方法中的每个语句都在线程内执行,并且由于 Java 是多线程的,因此多个执行线程同时发生。线程是 Java 的_并发单元:_一段与其他此类单元同时运行且很大程度上独立的顺序代码。每个线程都提供一个堆栈来存储局部变量和协调方法调用,以及出现问题时的上下文:同一线程中的方法抛出和捕获异常,因此开发人员可以使用 线程的堆栈跟踪来找出发生的情况。线程也是工具的核心概念:调试器逐步执行线程方法中的语句,分析器可视化多个线程的行为以帮助了解其性能。
每个请求一个线程的风格
服务器应用程序通常处理彼此独立的并发用户请求,因此应用程序通过在请求的整个持续时间内专用一个线程来处理该请求是有意义的。这种_按请求线程的方式_易于理解、易于编程、易于调试和分析,因为它使用平台的并发单位来表示应用程序的并发单位。
服务器应用程序的可扩展性受利特尔定律 (Little's Law)的约束,该定律与延迟、并发性和吞吐量相关:对于给定的请求处理持续时间(即延迟),应用程序同时处理的请求数(即并发性)必须与到达率(即吞吐量)成比例增长。例如,假设平均延迟为 50 毫秒的应用程序通过同时处理 10 个请求,实现了每秒 200 个请求的吞吐量。为了使该应用程序扩展到每秒 2000 个请求的吞吐量,需要同时处理 100 个请求。如果每个请求在请求持续时间内都在一个线程中处理,那么为了让应用程序跟上,线程数量必须随着吞吐量的增长而增长。
不幸的是,可用线程的数量是有限的,因为 JDK 将线程实现为操作系统 (OS) 线程的包装器。操作系统线程的成本很高,因此我们不能拥有太多线程,这使得实现不适合每个请求线程的风格。如果每个请求在其持续时间内消耗一个线程,从而消耗一个操作系统线程,那么在其他资源(例 如 CPU 或网络连接)耗尽之前,线程数量通常会成为限制因素。 JDK 当前的线程实现将应用程序的吞吐量限制在远低于硬件可支持的水平。即使线程被池化,也会发生这种情况,因为池化有助于避免启动新线程的高成本,但不会增加线程总数。
通过异步方式提高可扩展性
一些希望充分利用硬件的开发人员已经放弃了每个请求线程的风格,转而采用线程共享风格。请求处理代码不是在一个线程上从头到尾处理请求,而是在等待 I/O 操作完成时将其线程返回到池中,以便该线程可以为其他请求提供服务。这种细粒度的线程共享(其中代码仅在执行计算时而不是在等待 I/O 时才保留线程)允许大量并发操作,而无需消耗大量线程。虽然它消除了操作系统线程稀缺对吞吐量的限制,但它的代价很高:它需要所谓的_异步_编程风格,采用一组不等待 I/O 的独立 I/O 方法操作完成,而是稍后向回调发出完成信号。如果没有专用线程,开发人员必须将请求处理逻辑分解为小阶段,通常编写为 lambda 表达式,然后使用 API 将它们组合成顺序管道(例如,参见CompletableFuture或所谓的“反应式”框架) )。因此,他们放弃了语言的基本顺序组合运算符,例如循环和try/catch
块。
在异步风格中,请求的每个阶段可能在不同的线程上执行,并且每个线程以交错的方式运行属于不同请求的阶段。这对于理解程序行为具有深远的影响:堆栈跟踪不提供可用的上下文,调试器无法单步执行请求处理逻辑,分析器无法将操作的成本与其调用者关联起来。当使用 Java 的流 API处理短管道中的数据时,编写 lambda 表达式是可以管理的,但当应用程序中的所有请求处理代码必须以这种方式编写时,就会出现问题。这种编程风格与 Java 平台不一致,因为应用程序的并发单位(异步管道)不再是平台的并发单位。
使用虚拟线程保留每个请求的线程风格
为了使应用程序能够扩展,同时与平台保持和谐,我们应该通过更有效地实现线程来努力保留每个请求线程的风格,这样它们就可以更加丰富。操作系统无法更有效地实现操作系统线程,因为不同的语言和运行时以不同的方式使用线程堆栈。然而,Java 运行时可以通过切断 Java 线程与操作系统线程的一对一对应关系的方式来实现 Java 线程。正如操作系统通过将大的虚拟地址空间映射到有限的物理 RAM 来提供充足内存的假象一样,Java 运行时也可以通过将大量_虚拟_线程映射到少量操作系统线程来提供充足线程的假象。
_虚拟线程_是java.lang.Thread
不依赖于特定操作系统线程的实例。相比之下,_平台线程_是以java.lang.Thread
传统方式实现的实例,作为操作系统线程的 薄包装器。
每个请求线程风格的应用程序代码可以在请求的整个持续时间内在虚拟线程中运行,但虚拟线程仅在 CPU 上执行计算时才消耗操作系统线程。结果是与异步风格相同的可扩展性,只不过它是以透明方式实现的:当虚拟线程中运行的代码调用 API 中的阻塞 I/O 操作时java.*
,运行时执行非阻塞 OS 调用并自动挂起虚拟线程直到稍后可以恢复。对于 Java 开发人员来说,虚拟线程只是创建成本低廉且数量几乎无限的线程。硬件利用率接近最佳,允许高水平的并发性,从而实现高吞吐量,同时应用程序与 Java 平台及其工具的多线程设计保持协调。
虚拟线程的含义
虚拟线程便宜且充足,因此永远不应该被池化:应该为每个应用程序任务创建一个新的虚拟线程。因此,大多数虚拟线程都是短暂的,并且具有浅层调用堆栈,只执行单个 HTTP 客户端调用或单个 JDBC 查询。相比之下,平台线程重量级且昂贵,因此通常必须进行池化。它们往往寿命很长,具有很深的调用堆栈,并且在许多任务之间共享。
总之,虚拟线程保留了可靠的每请求线程风格,该风格与 Java 平台的设计相协调,同时最佳地利用硬件。使用虚拟线程不需要学习新概念,尽管它可能需要放弃为应对当今线程的高成本而养成的习惯。虚拟线程不仅可以帮助应用程序开发人员,还可以帮助框架设计人员提供易于使用的 API,这些 API 与平台的设计兼容,而不影响可扩展性。
描述
java.lang.Thread
如今, JDK 中的每个实例都是一个_平台线程_。平台线程在底层操作系统线程上运行 Java 代码,并在代码的整个生命周期内捕获操作系统线程。平台线程数受限于操作系统线程数。
_虚拟线程_是在底层操作系统线程上运行 Java 代码的实例java.lang.Thread
,但不会在代码的整个生命周期内捕获操作系统线程。这意味着许多虚拟线程可以在同一个操作系统线程上运行它们的 Java 代码,从而有效地共享它。虽然平台线程独占宝贵的操作系统线程,但虚拟线程却不然。虚拟线程的数量可以比操作系统线程的数量大得多。
虚拟线程是 JDK 而不是操作系统提供的轻量级线程实现。它们是_用户模式线程_的一种形式,在其他多线程语言中已经取得了成功(例如,Go 中的 goroutine 和 Erlang 中的进程)。在 Java 的早期版本中,当操作系统线程尚未成熟和广泛使用时,用户模式线程甚至被称为所谓的“绿色线程” 。然而,Java 的绿色线程都共享一个操作系统线程(M:1 调度),并且最终被作为操作系统线程包装器实现的平台线程所超越(1:1 调度)。虚拟线程采用 M:N 调度,其中大量 (M) 虚拟线程被调度在较少数量 (N) 个操作系统线程上运行。