虚拟线程是一种轻量级线程,可大大减少编写、维护和观察高吞吐量并发应用程序的工作量。并且虚拟线程内的程序在等待IO期间会让出平台线程,这会成指数级的提升非CPU重载型的多线程程序吞吐能力。这真是一个超赞的特性。
虚拟线程由 JEP 425 作为预览功能提出,并在 JDK 19 中发布。为了有时间获得反馈并积累更多经验,JEP 436 再次提议将虚拟线程作为预览功能,并在 JDK 20 中发布。本 JEP 建议在 JDK 21 中最终确定虚拟线程,并根据开发人员的反馈意见对 JDK 20 做如下修改:
近三十年来,Java 开发人员一直依赖线程来构建并发服务器应用程序。每个方法中的每条语句都在一个线程内执行,而且由于 Java 是多线程的,因此可以同时执行多个线程。线程是 Java 的并发单元:一段顺序执行的代码运行在单个线程内,它与其他同样结构的线程并发运行,也在很大程度上独立于其他单元。每个线程都提供了一个堆栈,用于存储本地变量和协调方法调用,并在出错时提供上下文:异常由同一线程中的方法抛出和捕获,因此开发人员可以使用线程的堆栈跟踪来找出发生了什么。线程也是工具的核心概念: 调试器(Debug)会逐步检查线程方法中的语句,剖析器(Profilers)会将多个线程的行为可视化,以帮助了解它们的性能。
服务器应用程序通常会处理相互独立的并发用户请求,因此应用程序在处理一个请求时,可以在整个请求持续时间内专门为该请求分配一个线程。这种按请求分配线程的方式易于理解、易于编程、易于调试和配置,因为它使用操作系统的并发单元来代表应用程序的并发单元。
服务器应用程序的可扩展性受利特尔法则(Little’s Law)制约,该法则将延迟、并发性和吞吐量联系在一起: 对于给定的请求处理持续时间(即延迟),应用程序同时处理的请求数(即并发数)必须与到达率(即吞吐量)成比例增长。例如,假设一个平均延迟为 50 毫秒的应用程序通过并发处理 10 个请求,实现了每秒 200 个请求的吞吐量。若要将该应用程序的吞吐量扩展到每秒 2000 个请求,则需要并发处理 100 个请求。如果每个请求在请求持续时间内都由一个线程处理,那么应用程序要想跟上,线程数就必须随着吞吐量的增加而增加。
遗憾的是,可用线程的数量有限,因为 JDK 将线程作为操作系统(OS)线程的包装器来实现。操作系统线程的成本很高,所以我们不能拥有太多的线程,这就使得线程的实现不适合按请求线程的风格。如果每个请求在其持续时间内都要消耗一个线程,也就是一个操作系统线程,那么在 CPU 或网络连接等其他资源耗尽之前,线程数量往往就已经成为限制因素了。JDK 当前的线程实现将应用程序的吞吐量限制在远低于硬件支持的水平。即使对线程进行了池化,也会出现这种情况,因为池化有助于避免启动新线程的高昂成本,但不会增加线程总数。
一些希望充分利用硬件的开发人员放弃了每请求线程(thread-per-request)的方式,转而使用线程共享(thread-sharing)方式。请求处理代码不是自始至终在一个线程上处理一个请求,而是在等待另一个 I/O 操作完成时将其线程返回到一个线程池,以便该线程可以处理其他请求。这种细粒度的线程共享(代码仅在执行计算时保留线程,而不是在等待 I/O 时保留线程)允许大量并发操作,而不会消耗大量线程。虽然它消除了操作系统线程稀缺对吞吐量的限制,但代价也很高: 它要求采用所谓的异步编程风格,使用一组独立的 I/O 方法,这些方法不会等待 I/O 操作完成,而是在稍后向回调发出完成信号。在没有专用线程的情况下,开发人员必须将请求处理逻辑分解为多个小阶段(通常写成 lambda 表达式),然后通过 API(例如,请参见 CompletableFuture 或所谓的“响应式(reactive)”框架)将它们组成一个顺序流水线。因此,它们放弃了语言的基本顺序组合操作符,如循环和 try/catch 块。
在异步风格中,请求的每个阶段都可能在不同的线程上执行,每个线程以交错的方式运行属于不同请求的阶段。这对理解程序行为有着深刻的影响: 堆栈跟踪无法提供可用的上下文,调试器无法逐步检查请求处理逻辑,剖析器(profilers)也无法将操作的成本与其调用者联系起来。当使用 Java 的流 API 在短流水线中处理数据时,组成 lambda 表达式是可以处理的,但当应用程序中的所有请求处理代码都必须以这种方式编写时,就会出现问题。这种编程风格与 Java 平台格格不入,因为应用程序的并发单元(异步流水线)不再是平台的并发单元。
为了使应用程序能够在与平台保持一致的同时进行扩展,我们应努力保留按每请求线程的处理风格。我们可以通过更高效地实现线程来做到这一点,这样能够支撑的线程数量就会更多。操作系统无法更高效地实现操作系统线程,因为不同的语言和运行时使用线程栈的方式各不相同。不过,Java 运行时可以通过一种方式来实现 Java 线程,从而切断它们与操作系统线程的一一对应关系。正如操作系统通过将大量虚拟地址空间映射到有限的物理 RAM 来营造内存充裕的假象一样,Java 运行时也可以通过将大量虚拟线程映射到少量操作系统线程来营造线程充裕的假象。
虚拟线程是 java.lang.Thread 的其中一种实现,它与特定的操作系统线程无关。相比之下,平台线程是以传统方式实现的 java.lang.Thread 实例对象,是操作系统线程的薄包装。
每请求线程方式的应用程序代码可以在请求的整个持续时间内运行在虚拟线程中,但虚拟线程只在 CPU 上执行计算时消耗操作系统线程。其结果是与异步方式相同的可扩展性,只不过是以透明方式实现的: 当虚拟线程中运行的代码调用 java.* API 中的阻塞 I/O 操作时,运行时会执行非阻塞操作系统调用,并自动暂停虚拟线程,直到稍后可以恢复。对于 Java 开发人员来说,虚拟线程只是一种线程,它的创建成本很低,而且几乎无限量。硬件利用率接近最佳,允许高并发性,因此吞吐量也很高,并且用虚拟线程实现的应用程序与 Java 平台的多线程设计以及相关工具都保持一致,这意味着开发者在学习、使用、调试虚拟线程的成本很低,很容易学习、很容易上手。
虚拟线程开销低,所以支持的数量多,因此绝不应池化:每个应用任务都应创建一个新的虚拟线程。因此,大多数虚拟线程的寿命都很短,调用堆栈也很浅,只需执行一次 HTTP 客户端调用或一次 JDBC 查询。相比之下,平台线程成本高,笨重,因此通常必须池化。它们的寿命往往较长,具有较深的调用堆栈,并可在多个任务之间共享。
总之,虚拟线程保留了可靠的每请求线程方式,这种风格与 Java 平台的设计相协调,同时还能优化利用可用硬件。使用虚拟线程不需要学习新的概念,但可能需要放弃为应对当前线程的高成本而养成的习惯。虚拟线程不仅能帮助应用程序开发人员,还能帮助框架设计人员提供易于使用的 API,这些 API 与平台设计兼容,同时又不影响可扩展性。
如今,JDK 中的每个 java.lang.Thread 实例都是一个平台线程。平台线程在底层操作系统线程上运行 Java 代码,并在代码的整个生命周期中捕获操作系统线程。平台线程的数量受限于操作系统线程的数量。
虚拟线程是 java.lang.Thread 的一个实例,它在底层操作系统线程上运行 Java 代码,但不会在代码的整个生命周期内捕获操作系统线程。这意味着许多虚拟线程可以在同一个操作系统线程上运行 Java 代码,从而有效地共享操作系统线程。平台线程会垄断宝贵的操作系统线程,而虚拟线程不会。虚拟线程的数量可以远远大于操作系统线程的数量。
虚拟线程是线程的轻量级实现,由 JDK 而不是操作系统提供。它们是用户模式线程的一种形式,在其他多线程语言(如 Go 中的 goroutines 和 Erlang 中的进程)中取得了成功。用户模式线程在 Java 早期版本中甚至被称为“绿色线程”,当时操作系统线程尚未成熟和普及。然而,Java 的绿色线程都共享一个操作系统线程(M:1 调度),最终被作为操作系统线程包装器(1:1 调度)实现的平台线程所超越。虚拟线程采用 M:N 调度,即大量(M)虚拟线程被安排运行在较少数量(N)的操作系统线程上。
开发人员可以选择使用虚拟线程还是平台线程。下面是一个创建大量虚拟线程的示例程序。程序首先获取一个 ExecutorService,为每个提交的任务创建一个新的虚拟线程。然后,程序会提交 10,000 个任务,并等待所有任务完成:
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
IntStream.range(0, 10_000).forEach(i -> {
executor.submit(() -> {
Thread.sleep(Duration.ofSeconds(1));
return i;
});
});
} // executor.close() is called implicitly, and waits
本例中的任务是简单的代码“休眠一秒”,现代硬件可以轻松支持 10,000 个虚拟线程同时运行此类代码。在幕后,JDK 只在少量操作系统线程上运行代码,可能只有一个线程。
如果该程序使用 ExecutorService(如 Executors.newCachedThreadPool())为每个任务创建一个新的平台线程,情况就会大不相同。ExecutorService 会尝试创建 10,000 个平台线程,从而创建 10,000 个操作系统线程,根据机器和操作系统的不同,程序可能会崩溃。
如果程序使用从线程池中获取平台线程的 ExecutorService,比如 Executors.newFixedThreadPool(200),情况也不会好到哪里去。ExecutorService 将创建 200 个平台线程,供所有 10,000 个任务共享,其中未被执行的任务将会以java.util.concurrent.FutureTask实例缓存在Queue中,Queue的最大大小是Integer.MAX_VALUE,s虽然创建的平台线程数量少了,但是只要任务被200个平台线程中的某个线程认领了,不论这个线程中的代码有多少个IO等待操作,这个线程都会把这个任务执行完,才会释放平台线程资源,所以任务将按照200个并行处理的方式分别排队顺序运行,程序将需要很长时间才能完成。
还是上面这个场景,如果使用虚拟线程技术,因为虚拟线程在等待IO的时期会释放平台线程资源,意味着虚拟线程不会一直独占某个平台线程直到虚拟线程任务完成,所以每个虚拟线程涉及到IO等待的时候都让出平台线程,各自等待,平台线程可以去执行需CPU操作的虚拟线程,这样子吞吐量成指数级的增长了。拥有 200 个平台线程的池每秒只能完成 200 个任务,而虚拟线程每秒可完成约 10,000 个任务(经过充分预热后)。此外,如果将示例程序中的 10_000 改为 1_000_000,那么程序将提交 1,000,000 个任务,创建 1,000,000 个虚拟线程并发运行,(充分预热后)吞吐量将达到每秒约 1,000,000 个任务。
如果该程序中的任务在一秒钟内执行计算(例如,对一个庞大的数组进行排序),而不仅仅是休眠,那么增加线程数量超过处理器内核数量也无济于事,不管它们是虚拟线程还是平台线程。虚拟线程不是更快的线程,它们运行代码的速度并不比平台线程快。它们的存在是为了提供规模(更高的吞吐量),而不是速度(更低的延迟)。虚拟线程的数量可能比平台线程多得多,因此根据利特尔定律,虚拟线程可以提供更高吞吐量所需的更高并发性。
换一种说法,虚拟线程可以在以下情况下显著提高应用程序吞吐量:
虚拟线程有助于提高典型服务器应用程序的吞吐量,这正是因为此类应用程序由大量并发任务组成,而这些任务的大部分时间都在执行各种IO等待。
虚拟线程可以运行平台线程可以运行的任何代码。特别是,虚拟线程支持线程本地变量和线程中断,就像平台线程一样。这意味着处理请求的现有 Java 代码可以轻松地在虚拟线程中运行。许多服务器框架会选择自动执行此操作,为每个传入请求启动一个新的虚拟线程,并在其中运行应用程序的业务逻辑。
下面是一个聚合其他两个服务结果的服务器应用程序示例。假设的服务器框架会为每个请求创建一个新的虚拟线程,并在该虚拟线程中运行应用程序的处理代码。应用程序代码则创建两个新的虚拟线程,通过与第一个示例相同的 ExecutorService 并发获取资源:
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);
}
}
像这样的服务器应用程序,代码直接阻塞,由于可以使用大量虚拟线程,因此扩展性很好。
Executor.newVirtualThreadPerTaskExecutor() 并不是创建虚拟线程的唯一方法。后面会讨论的新 java.lang.Thread.Builder API 可以创建和启动虚拟线程。此外,structured concurrency 为创建和管理虚拟线程提供了更强大的 API,尤其是在类似于本服务器示例的代码中,线程之间的关系已被平台及其工具所知。
开发人员通常会将应用代码从传统的基于线程池的 ExecutorService 迁移到按任务划分的虚拟线程 ExecutorService。线程池与其他资源池一样,旨在共享昂贵的资源,但虚拟线程并不昂贵,因此没有必要将其集中起来。
开发人员有时会使用线程池来限制对有限资源的并发访问。例如,如果一项服务不能处理超过 20 个并发请求,那么通过提交给大小为 20 的线程池的任务来处理对该服务的所有请求就能确保这一点。由于平台线程的高成本使得线程池变得无处不在,因此这一习语已变得无处不在,但切勿为了限制并发性而将虚拟线程池化。相反,请使用专门为此设计的构造,如 信号标(semaphores)。
结合线程池,开发人员有时会使用线程本地变量在共享同一线程的多个任务之间共享昂贵的资源。例如,如果创建数据库连接的成本很高,那么可以只打开一次,然后将其存储在线程本地变量中,供同一线程中的其他任务稍后使用。如果将代码从使用线程池迁移到为每个任务使用一个虚拟线程,那么在使用这个习语时一定要小心,因为为每个虚拟线程创建昂贵的资源可能会大大降低性能。请将此类代码改为使用其他缓存策略,以便在大量虚拟线程之间有效共享昂贵的资源。
要执行有用的工作,线程需要调度,即分配到处理器内核上执行。对于作为操作系统线程实现的平台线程,JDK 依赖于操作系统中的调度器。相比之下,对于虚拟线程,JDK 有自己的调度程序。JDK 的调度器不是直接将虚拟线程分配给处理器,而是将虚拟线程分配给平台线程(这就是前面提到的虚拟线程的 M:N 调度)。然后,操作系统照常调度平台线程。
JDK 的虚拟线程调度器是一个work-stealing(一种线程调度策略,特别是在多核处理器和并行计算中。这种策略允许线程从正在执行其他工作的处理器上窃取(或“工作窃取”)一些工作,从而在不同的处理器之间平衡工作负载,以最大化吞吐量和响应性。这种机制可以帮助实现更好的性能和资源利用率。) ForkJoinPool,以先进先出(FIFO)模式运行。调度器的并行性是指可用来调度虚拟线程的平台线程数。默认情况下,它等于可用处理器的可用线程数,但可以通过系统属性 jdk.virtualThreadScheduler.parallelism 进行调整。ForkJoinPool 与普通池不同,普通池用于实现并行流等,以后进先出(LIFO)模式运行。
调度程序为虚拟线程分配的平台线程称为虚拟线程的载体。虚拟线程在其生命周期内可以被调度到不同的载体上;换句话说,调度程序不会在虚拟线程和任何特定平台线程之间保持亲和性。从 Java 代码的角度来看,运行中的虚拟线程在逻辑上与其当前的载体无关:
此外,从 Java 代码的角度来看,虚拟线程及其载体暂时共享操作系统线程的事实是不可见的。相反,从本地代码的角度来看,虚拟线程及其载体都运行在同一个本地线程上。因此,在同一虚拟线程上被多次调用的本地代码,在每次调用时可能会观察到不同的操作系统线程标识符。
调度程序目前没有为虚拟线程实现时间共享。时间共享是指强制抢占已消耗一定 CPU 时间的线程。虽然在平台线程数量相对较少、CPU 利用率为 100% 的情况下,时间共享可以有效减少某些任务的延迟,但对于数百万个虚拟线程来说,时间共享的效果并不明显。
要利用虚拟线程,无需重写程序。虚拟线程不要求或期望应用程序代码明确地将控制权交还给调度程序;换句话说,虚拟线程不具有被操作性,就像我们不能指定垃圾回收器何时回收垃圾,用户代码也不能控制虚拟线程如何或何时分配给平台线程,也像不能假设平台线程如何或何时分配到处理器内核一样。
要在虚拟线程中运行代码,JDK 的虚拟线程调度程序会通过将虚拟线程挂载到平台线程上的方式,将虚拟线程分配到平台线程上执行。这样,平台线程就成了虚拟线程的载体。之后,在运行一些代码后,虚拟线程可以从其载体上卸载。此时,平台线程是空闲的,因此调度程序可以在其上挂载另一个虚拟线程,从而使其再次成为载体。
通常,当虚拟线程阻塞 I/O 或 JDK 中的其他阻塞操作(如 BlockingQueue.take())时,它就会卸载。当阻塞操作准备完成时(例如,在套接字上接收到字节),它会将虚拟线程提交回调度程序,调度程序会将虚拟线程挂载到载体上以继续执行。
虚拟线程的挂载和卸载频繁且透明,不会阻塞任何操作系统线程。例如,前面显示的服务器应用程序包括以下代码行,其中包含对阻塞操作的调用:
response.send(future1.get() + future2.get());
这些操作将导致虚拟线程多次挂载和卸载,通常是每次调用 get() 时挂载一次,在 send(…) 中执行 I/O 时可能挂载多次。
JDK 中的绝大多数阻塞操作都会卸载虚拟线程,从而释放其载体和底层操作系统线程,以处理新的工作。但是,JDK 中的某些阻塞操作不会卸载虚拟线程,因此会阻塞其载体和底层操作系统线程。这是因为操作系统级别(如许多文件系统操作)或 JDK 级别(如 Object.wait())的限制。这些阻塞操作的实现通过临时扩展调度程序的并行性来弥补操作系统线程的捕获。因此,调度程序 ForkJoinPool 中的平台线程数量可能会暂时超过可用处理器的数量。调度器可用平台线程的最大数量可通过系统属性 jdk.virtualThreadScheduler.maxPoolSize 进行调整。
有两种情况下,虚拟线程在阻塞操作期间无法卸载,因为它被固定在载体上:
虚拟线程固定在载体上不会导致应用程序不正确,但可能会妨碍其可扩展性。如果虚拟线程在被固定在载体上时执行阻塞操作(如 I/O 或 BlockingQueue.take()),那么其载体和底层操作系统线程就会在操作期间被阻塞。长时间频繁将虚拟线程固定在载体上,会导致虚拟线程捕获载体(可以理解为虚拟线程强绑定了平台线程),从而损害应用程序的可扩展性。
调度程序不会通过扩展并行性来补偿虚拟线程固定在载体上的问题。相反,通过修改频繁运行的同步块或方法,并使用 java.util.concurrent.locks.ReentrantLock 代替可能的长时间 I/O 操作,可以避免频繁和长时间的锁定载体。不需要替换不经常使用(如仅在启动时执行)或保护内存操作的同步块和方法。请一如既往地努力保持锁定策略简单明了。
新的诊断功能有助于将代码迁移到虚拟线程,也有助于评估是否应该用 java.util.concurrent 锁替换 synchronized 的特定使用:
虚拟线程的栈以栈块对象的形式存储在堆内存中。虚拟线程的栈会随着应用程序的运行而增大和缩小,这样既能节省内存,又能容纳深度达到 JVM 配置的平台线程堆栈大小的堆栈。正是由于这种高效性,服务器应用程序中才能有大量的虚拟线程,从而使每请求线程(thread-per-request)的方式得以延续。
一般来说,虚拟线程所需的堆空间和垃圾收集器活动量比异步代码消耗更大。首先,从线程本身的消耗来看,一百万个虚拟线程至少需要一百万个对象,一百万个共享平台线程池的任务也需要一百万个对象。虽然在内部分配的细节上还是有一些差异,比如采用每请求线程方式开发的程序可以将数据保存在局部变量中,这些变量存储在堆中的虚拟线程栈中,而异步代码则必须将相同的数据保存在堆对象中,这些堆对象会从流水线的一个阶段传递到下一个阶段,而且虚拟线程所需的堆帧布局比紧凑对象的堆帧布局更浪费,但是,虚拟线程可以在很多情况下(取决于底层 GC 策略)重用堆栈,而异步流水线总是需要分配新对象,因此虚拟线程可能需要较少的分配。总的来说,现阶段每请求线程与异步代码的堆消耗和垃圾回收器活动应该大致相同。未来虚拟线程栈的内部表示可能会更加紧凑。
与平台线程栈不同,虚拟线程栈不是 GC 根。因此,它们所包含的引用不会在垃圾回收器(如 G1)执行并发堆扫描的 Stop-the-World 暂停时被遍历。这也意味着,如果虚拟线程在 BlockingQueue.take() 等操作上被阻塞,并且没有其他线程可以获得对虚拟线程或队列的引用,那么该线程就会被垃圾回收,因为虚拟线程永远不会被中断或解除阻塞。当然,如果虚拟线程正在运行,或者被阻塞并可能被解除阻塞,则不会被垃圾回收。
虚拟线程目前的一个限制是 G1 GC 不支持超大(humongous)堆栈块对象。如果虚拟线程的堆栈达到区域大小的一半(可能小至 512KB),则可能会抛出堆栈溢出错误(StackOverflowError)。
虚拟线程与平台线程一样,支持线程本地变量(ThreadLocal)和可继承线程本地变量(InheritableThreadLocal),因此可以运行使用线程本地变量的现有代码。不过,由于虚拟线程可能非常多,因此只有经过慎重考虑后才能使用线程本地变量。
系统属性 jdk.traceVirtualThreadLocals 可用于在虚拟线程设置任何线程本地变量的值时触发堆栈跟踪。在迁移代码以使用虚拟线程时,该诊断输出可能有助于删除线程本地变量。将系统属性设置为 true 可触发堆栈跟踪;默认值为 false。
JDK 21 发布,新特性概览及字符串模板详细介绍