很多语言都有类似于“虚拟线程”的技术,比如Go、C#、Erlang、Lua等,他们称之为“协程”。 不管是虚拟线程还是协程,他们都是轻量级线程,其目的都是为了提高并发能力。 本节详细介绍Java平台的“虚拟线程”的技术——“JEP 425: Virtual Threads (Preview)”。
Java平台计划引入虚拟线程,可显著减少编写、维护和观察高吞吐量并发应用程序的工作量。“JEP 425: Virtual Threads (Preview)”目是一个预览性的API。
使以简单的线程每请求风格编写的服务器应用程序能够以近乎最佳的硬件利用率进行扩展。
启用使用java.lang.Thread API的现有代码,以最小的更改采用虚拟线程。
使用现有的JDK工具,轻松地对虚拟线程进行故障排除、调试和分析。
目标不是删除线程的传统实现,也不是静默迁移现有应用程序以使用虚拟线程。
改变Java的基本并发模型并不是目标。
在Java语言或Java库中提供新的数据并行结构并不是目标。Stream API仍然是并行处理大型数据集的首选方式。
近30年来,Java开发人员一直依赖线程作为并发服务器应用程序的构建块。每个方法中的每个语句都在线程内执行,由于Java是多线程的,多个执行线程同时发生。线程是Java的并发单元:一段顺序代码,与其他此类单元同时运行,而且在很大程度上独立于其他此类单元。每个线程都提供一个堆栈来存储局部变量和协调方法调用,以及出错时的上下文:异常被同一线程中的方法抛出和捕获,因此开发人员可以使用线程的堆栈跟踪来查找发生了什么。线程也是工具的核心概念:调试器逐步浏览线程方法中的语句,分析器可视化多个线程的行为,以帮助了解它们的性能。
服务器应用程序通常处理相互独立的并发用户请求,因此应用程序通过在请求的整个持续时间内将线程专用于该请求来处理请求是有意义的。这种线程每请求风格易于理解、易于编程、易于调试和分析,因为它使用平台的并发单位来表示应用程序的并发单位。
服务器应用程序的可扩展性遵循利特尔定律(Little's Law),它与延迟、并发和吞吐量有关:对于给定的请求处理持续时间(即延迟),应用程序同时处理的请求数(即,并发)必须与到达速率(即吞吐量)成比例增长。例如,假设平均延迟为50ms的应用程序通过同时处理10个请求,实现每秒200个请求的吞吐量。为了使该应用程序扩展到每秒2000个请求的吞吐量,它需要同时处理100个请求。如果每个请求在请求的持续时间内都在线程中处理,那么,要使应用程序跟上,线程数量必须随着吞吐量的增长而增长。
不幸的是,可用线程的数量是有限的,因为JDK将线程作为操作系统(OS)线程的包装器实现。操作系统线程成本高昂,因此我们不能拥有太多线程,这使得实现不适合线程每请求风格。如果每个请求在其持续时间内消耗一个线程,从而消耗一个操作系统线程,那么线程数量通常在其他资源(如CPU或网络连接)耗尽之前很久就成为限制因素。JDK当前的线程实现将应用程序的吞吐量限制在远低于硬件所能支持的水平。即使线程池化,也会发生这种情况,因为池化有助于避免启动新线程的高成本,但不会增加线程总数。
一些希望充分利用硬件的开发人员放弃了每请求线程风格,转到线程共享风格。请求处理代码不是从头到尾处理一个线程上的请求,而是在等待I/O操作完成时将其线程返回到池,以便线程可以为其他请求提供服务。这种细粒度的线程共享--在这种共享中,代码仅在线程执行计算时保留线程,而不是在等待I/O时保留线程--允许大量并发操作,而不会消耗大量线程。虽然它消除了操作系统线程稀缺性对吞吐量的限制,但它的代价很高:它需要所谓的异步编程风格,使用一组单独的I/O方法,这些方法不等待I/O操作完成,而是,稍后,向回调表示它们的完成。如果没有专用线程,开发人员必须将其请求处理逻辑分解为小阶段,通常编写为lambda表达式,然后使用API将它们组合成顺序管道(请参见CompletableFuture,或所谓的“反应性”(reactive)框架。因此,它们放弃了语言的基本顺序组成运算符,如循环和try/catch块。
在异步风格中,请求的每个阶段都可能在不同的线程上执行,每个线程以交错的方式运行属于不同请求的阶段。这对理解程序行为具有深刻的影响:堆栈跟踪不提供可用的上下文,调试器无法逐步完成请求处理逻辑,分析器无法将操作的成本与其调用者关联起来。在使用Java时,编写lambda表达式是可以管理的Stream API在短管道中处理数据,但当应用程序中的所有请求处理代码都必须以这种方式编写时,就会有问题。这种编程风格与Java平台不一致,因为应用程序的并发单位--异步管道--不再是平台的并发单位。
为了使应用程序能够扩展,同时与平台保持和谐,我们应该通过更有效地实现线程来努力保留每个请求的线程风格,这样它们就可以更丰富。操作系统无法更有效地实现操作系统线程,因为不同的语言和运行时以不同的方式使用线程堆栈。但是,Java运行时可以以一种将Java线程与操作系统线程的一对一对应关系分开的方式实现Java线程。就像操作系统通过将大量虚拟地址空间映射到有限数量的物理RAM来给人丰富内存的错觉一样,Java运行时也可以通过将大量虚拟线程映射到少量操作系统线程来给人丰富线程的错觉。
虚拟线程是java.lang.Thread的一个实例,它不绑定到特定的操作系统线程。相比之下,平台线程是java.lang.Thread的实例,以传统方式实现,作为操作系统线程周围的精简包装器。
线程每请求样式中的应用程序代码可以在虚拟线程中运行整个请求持续时间,但虚拟线程仅在CPU上执行计算时消耗操作系统线程。结果是与异步风格相同的可扩展性,只是它是透明的:当在虚拟线程中运行的代码调用java中的阻塞I/O操作的java.*
API时,运行时执行非阻塞操作系统调用,并自动挂起虚拟线程,直到稍后可以恢复。对于Java开发人员来说,虚拟线程只是创建起来很便宜,而且几乎无限丰富的线程。硬件利用率接近最佳,允许高水平的并发性,从而实现高吞吐量,同时应用程序与Java平台及其工具的多线程设计保持和谐。
虚拟线程既便宜又丰富,因此永远不应该池化:应该为每个应用程序任务创建一个新的虚拟线程。因此,大多数虚拟线程都是短暂的,并且具有浅调用堆栈,执行的时间只需单个HTTP客户端调用或单个JDBC查询。相比之下,平台线程是重量级和昂贵的,因此通常必须池化。它们往往是长寿命的,具有深度的调用堆栈,并在许多任务中共享。
总之,虚拟线程保留了可靠的每请求线程风格,该风格与Java平台的设计和谐,同时优化地利用硬件。使用虚拟线程不需要学习新的概念,尽管它可能需要养成不学习的习惯,以应对当今线程的高成本。虚拟线程不仅将帮助应用程序开发人员,还将帮助框架设计人员提供易于使用的API,这些API与平台的设计兼容,而不影响可扩展性。
今天,每一个例子 java.lang.Thread在JDK中,是一个平台线程。平台线程在底层操作系统线程上运行Java代码,并在代码的整个生命周期内捕获操作系统线程。平台线程数限制为操作系统线程数。
虚拟线程是java.lang.Thread的一个实例,它在基础操作系统线程上运行Java代码,但在代码的整个生命周期内不捕获操作系统线程。这意味着许多虚拟线程可以在同一操作系统线程上运行其Java代码,有效地共享它。虽然平台线程垄断了宝贵的操作系统线程,但虚拟线程却不垄断。虚拟线程的数量可以远大于操作系统线程的数量。
虚拟线程是由JDK而不是操作系统提供的线程的轻量级实现。它们是用户模式线程的一种形式,在其他多线程语言中已经成功(例如,Go中的goroutine和Erlang中的进程)。用户态线程甚至被称为“green threads”在Java的早期版本中,当时操作系统线程还不成熟和广泛。然而,Java的green threads都共享一个操作系统线程(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
本例中的任务是简单的代码--睡眠一秒钟--现代硬件可以轻松支持10000个虚拟线程同时运行此类代码。在幕后,JDK在少量的操作系统线程上运行代码,也许只有一个线程。
如果此程序使用为每个任务创建新的平台线程的ExecutorService,如Executor.newCachedThreadPool(),情况就会大不相同。ExecutorService将尝试创建10000个平台线程,从而创建10000个操作系统线程,并且该程序将在大多数操作系统上崩溃。
相反,如果程序使用从池获取平台线程的ExecutorService,如Executor.newFixedThreadPool(200),情况也不会好太多。ExecutorService将创建200个平台线程,供所有10000个任务共享,因此许多任务将顺序运行,而不是并发运行,程序将需要很长时间才能完成。对于此程序,具有200个平台线程的池只能实现每秒200个任务的吞吐量,而虚拟线程的吞吐量则约为每秒10000个任务(在充分预热后)。此外,如果示例程序中的10_000
更改为1_000_000
,则程序将提交100万个任务,创建100万个同时运行的虚拟线程,并(在充分预热后)实现每秒约100万个任务的吞吐量。
如果此程序中的任务执行了一秒钟的计算(例如,排序一个巨大的数组),而不仅仅是睡眠,那么将线程数量增加到处理器核心数量之外都没有帮助,不管它们是虚拟线程还是平台线程。虚拟线程不是更快的线程-它们运行代码的速度并不比平台线程快。它们的存在是为了提供规模(更高的吞吐量),而不是速度(更低的延迟)。它们可能比平台线程多得多,因此根据利特尔定律,它们实现了更高吞吐量所需的更高并发。
换一种方式说,虚拟线程可以显著提高应用程序吞吐量,当
并发任务数量较高(超过几千个),并且
工作负载不绑定CPU,因为在这种情况下,线程数比处理器内核多得多不能提高吞吐量。
虚拟线程有助于提高典型服务器应用程序的吞吐量,正是因为此类应用程序由大量并发任务组成,这些任务花费了大量时间等待。
虚拟线程可以运行平台线程可以运行的任何代码。特别是,虚拟线程支持线程局部变量和线程中断,就像平台线程一样。这意味着处理请求的现有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.BuilderAPI,下面讨论,可以创建和启动虚拟线程。此外,结构化并发提供了一个更强大的API来创建和管理虚拟线程,特别是在类似于此服务器示例的代码中,通过该API,线程之间的关系将被平台及其工具所知道。
上面的程序使用Executors.newVirtualThreadPerTaskExecutor()方法,因此要在JDK XX上运行它们,必须按以下方式启用预览API:
使用javac --release XX --enable-preview Main.java
编译程序,并使用java--enable-preview Main
运行程序;或,
当使用源代码启动器,使用java --release XX --enable-preview Main.java;
运行程序;或,
使用时jshell,以jshell --enable-preview
开头
开发人员通常会将应用程序代码从基于线程池的传统ExecutorService迁移到虚拟线程每任务ExecutorService。线程池和所有资源池一样,旨在共享昂贵的资源,但虚拟线程并不昂贵,而且永远不需要将它们池化。
开发人员有时使用线程池来限制对有限资源的并发访问。例如,如果一个服务不能处理超过20个并发请求,则通过提交到大小为20的池的任务执行对该服务的所有访问将确保这一点。由于平台线程的高成本使线程池无处不在,这种成语也变得无处不在,但开发人员不应该被诱惑池虚拟线程以限制并发。专门为此目的设计的构造,如信号量,应用于保护对有限资源的访问。这比线程池更有效和方便,也更安全,因为线程本地数据不会意外从一个任务泄露到另一个任务的风险。
编写清晰的代码并不是完整的故事。清楚地表示正在运行的程序的状态对于故障排除、维护和优化也是必不可少的,JDK长期以来一直提供调试、分析和监控线程的机制。这些工具应该对虚拟线程执行同样的操作--也许可以对其大量的操作进行一些调节--因为它们毕竟是java.lang.Thread的实例。
Java调试器可以逐步浏览虚拟线程、显示调用堆栈和检查堆栈帧中的变量。JDK Flight Recorder (JFR)是JDK的低开销分析和监控机制,可以将应用程序代码中的事件(如对象分配和I/O操作)与正确的虚拟线程关联。这些工具不能为以异步样式编写的应用程序执行这些操作。在这种风格中,任务与线程无关,因此调试器无法显示或操作任务的状态,分析器也无法判断任务等待I/O的时间。
线程转储是另一个流行的工具,用于对以线程每请求风格编写的应用程序进行故障排除。不幸的是,JDK的传统线程转储,使用jstack或jcmd获得,提供了一个线程的扁平列表。这适合几十个或数百个平台线程,但不适合数千个或数百万个虚拟线程。因此,我们不会扩展传统的线程转储以包括虚拟线程,而是在jcmd中引入一种新的线程转储,以将虚拟线程与平台线程一起呈现,所有这些线程都以有意义的方式分组。当程序使用时,可以显示出线程之间更丰富的关系结构化并发。
由于可视化和分析大量线程可以从工具中受益,jcmd除了纯文本外,还可以以JSON格式发出新的线程转储:
$ jcmd <pid> Thread.dump_to_file -format=json <file>
新的线程转储格式列出了在网络I/O操作中被阻止的虚拟线程,以及由上面所示的new-thread-per-task(一任务一线程)ExecutorService创建的虚拟线程。它不包括对象地址、锁、JNI统计信息、堆统计信息和传统线程转储中出现的其他信息。此外,由于它可能需要列出大量线程,因此生成新的线程转储不会暂停应用程序。 以下是这样的线程转储的示例,取自与上面第二个示例类似的应用程序,在JSON查看器中呈现(见下图):
由于虚拟线程是在JDK中实现的,并且不绑定到任何特定的操作系统线程,因此它们对操作系统是不可见的,而操作系统不知道它们的存在。操作系统级监控将观察到JDK进程使用的操作系统线程比虚拟线程少。
要做有用的工作,需要调度线程,即分配在处理器核心上执行。对于作为操作系统线程实现的平台线程,JDK依赖于操作系统中的调度程序。相比之下,对于虚拟线程,JDK有自己的调度程序。JDK的调度程序将虚拟线程分配给平台线程,而不是直接将虚拟线程分配给处理器(这就是前面提到的虚拟线程的M:N调度)。然后,操作系统将像往常一样调度平台线程。
JDK的虚拟线程调度程序是一个窃取工作的工具ForkJoinPool在FIFO模式下工作。调度程序的并行性是可用于调度虚拟线程的平台线程数。默认情况下,它等于可用处理器,但它可以使用系统属性jdk.virtualThreadScheduler.parallelism进行调整。请注意,此ForkJoinPool不同于公共池,例如,它用于并行流的实现,并在后进先出模式下工作。
调度程序分配虚拟线程的平台线程称为虚拟线程的载体。虚拟线程可以在其生命周期内在不同的载体上调度;换句话说,调度程序不保持虚拟线程和任何特定平台线程之间的亲和性。但是,从Java代码的角度来看,正在运行的虚拟线程在逻辑上独立于其当前载体:
虚拟线程无法使用运营商的身份。Thread.currentThread()返回的值始终是虚拟线程本身。
载体和虚拟线程的堆栈跟踪是分开的。虚拟线程中抛出的异常将不包括载体的堆栈帧。线程转储不会在虚拟线程的堆栈中显示载体的堆栈帧,反之亦然。
载体的线程局部变量对虚拟线程不可用,反之亦然。
此外,从Java代码的角度来看,虚拟线程及其载体暂时共享操作系统线程的事实是不可见的。相比之下,从本机代码的角度来看,虚拟线程和它的载体都运行在同一个本机线程上。因此,在同一虚拟线程上多次调用的本机代码可能会在每次调用时观察到不同的操作系统线程标识符。
调度程序当前不为虚拟线程实现分时。分时是对占用分配数量的CPU时间的线程的强制抢占。虽然分时使用几百个平台线程可以有效,但目前还不清楚分时使用一百万个虚拟线程是否会那么有效。
要利用虚拟线程,没有必要重写程序。虚拟线程不要求或期望应用程序代码显式地将控制权交回调度程序;换句话说,虚拟线程是不合作的。用户代码不得假设虚拟线程如何或何时分配给平台线程,就如假设平台线程如何或何时分配给处理器内核一样。
要在虚拟线程中运行代码,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(),则在操作期间,其载体和基础操作系统线程将被阻塞。长时间频繁固定可能会通过捕获运营商而损害应用程序的可扩展性。
调度程序不会通过扩展其并行度来补偿固定。相反,通过修改频繁运行的同步块或方法,并保护要使用的潜在长I/O操作,避免频繁和长期的固定java.util.concurrent.locks.ReentrantLock相反,不需要替换不经常使用的同步块和方法(例如,仅在启动时执行)或保护内存操作。和往常一样,努力保持锁定策略简单明了。
新的诊断有助于将代码迁移到虚拟线程,并评估是否应将同步的特定使用替换为java.util.concurrent锁:
当线程在被固定时阻塞时,将发出JDK Flight Recorder (JFR) 事件.
系统属性jdk.tracePinnedThreads
在线程被固定时阻塞时触发堆栈跟踪。使用-Djdk.tracePinnedThreads=full
运行时,当线程在固定时阻塞时,打印完整的堆栈跟踪,本机帧和持有监视器的帧高亮显示。使用-Djdk.tracePinnedThreads=short
将输出限制为仅有问题的帧。
我们也许能够在未来的版本中删除上面的第一个限制。第二个限制是与本机代码正确交互所必需的。
虚拟线程的堆栈作为堆栈块对象存储在Java的垃圾收集堆中。堆栈随着应用程序运行而增长和收缩,既是为了提高内存效率,又是为了容纳任意深度的堆栈(最高可达JVM配置的平台线程堆栈大小)。这种效率使大量虚拟线程得以实现,从而使服务器应用程序中线程每请求风格的持续生存能力得以实现。
在上面的第二个示例中,请记住,假设框架通过创建新的虚拟线程并调用句柄方法来处理每个请求;即使它在深度调用堆栈的末尾调用句柄(在身份验证、事务等之后),句柄本身会生成多个仅执行短期任务的虚拟线程。因此,对于每个具有深调用栈的虚拟线程,将有多个具有浅调用栈的虚拟线程消耗很少的内存。
一般来说,虚拟线程所需的堆空间和垃圾收集器活动量很难与异步代码相比。处理请求的应用程序代码通常必须跨I/O操作维护数据;线程每请求代码可以将数据保留在局部变量中,局部变量存储在堆中的虚拟线程堆栈上。而异步代码必须在堆对象中保留相同数据,这些数据从管道的一个阶段传递到下一个阶段。一方面,虚拟线程所需的堆栈帧布局比紧凑对象更浪费;另一方面,虚拟线程在许多情况下(取决于低级GC交互)可以变异和重用其堆栈,而异步管道总是需要分配新对象。总体而言,线程每请求与异步代码的堆消耗和垃圾收集器活动应该大致相似。随着时间的推移,我们希望使虚拟线程堆栈的内部表示更加紧凑。
与平台线程栈不同,虚拟线程栈不是GC根,因此其中包含的引用不会被执行并发扫描的垃圾收集器在停止世界暂停中遍历。这也意味着,如果虚拟线程在上被阻塞,例如 BlockingQueue.take(),并且没有其他线程可以获得对虚拟线程或队列的引用,那么该线程可以被垃圾收集-这很好,因为虚拟线程永远不会被中断或解除阻塞。当然,如果虚拟线程正在运行,或者如果它被阻止,并且可能会被取消阻止,则它不会被垃圾收集。
虚拟线程的当前限制是G1 GC不支持巨大的堆栈块对象。如果虚拟线程的堆栈达到区域大小的一半(通常为512KB),则可能会引发StackOverflowError。
其余小节详细介绍了在Java平台及其实现中提出的更改:
java.lang.Thread
Thread-local variables
java.util.concurrent
Networking
java.io
Java Native Interface (JNI)
Debugging (JVM TI, JDWP, and JDI)
JDK Flight Recorder (JFR)
Java Management Extensions (JMX)
java.lang.ThreadGroup
更新 java.lang.Thread API如下:
Thread.Builder、Thread.ofVirtual()和Thread.ofPlatform()是用于创建虚拟线程和平台线程的新API。例如,Thread thread = Thread.ofVirtual().name("duke").unstarted(runnable);
创建一个名为“duke”的新未启动虚拟线程。
Thread.startVirtualThread(Runnable)
是创建和启动虚拟线程的方便方法。
Thread.Builder可以创建一个线程,也可以创建一个ThreadFactory,然后可以创建具有相同属性的多个线程。
Thread.isVirtual()测试线程是否为虚拟线程。
Thread.join 和 Thread.sleep接受等待和睡眠时间作为java.time.Duration.
新的最终方法Thread.threadId() 返回线程的标识符。现有非最终方法 Thread.getId() 现在已弃用。
Thread.getAllStackTraces() 现在返回所有平台线程而不是所有线程的映射。
java.lang.Thread API在其他方面保持不变。线程类定义的构造函数创建平台线程,与前面一样。没有新的公共构造函数。
虚拟线程和平台线程之间的主要API区别是:
公共线程构造函数无法创建虚拟线程。
虚拟线程始终是守护线程。Thread.setDaemon(boolean) 方法无法将虚拟线程更改为非守护线程。
虚拟线程的固定优先级为 Thread.NORM_PRIORITY。 Thread.setPriority(int) 方法对虚拟线程没有影响。此限制可能会在未来的版本中重新讨论。
虚拟线程不是线程组的活动成员。在虚拟线程上调用时,Thread.getThreadGroup() 返回名为“VirtualThreads”的占位线程组。Thread.Builder API没有定义设置虚拟线程线程的线程组的方法。
使用SecurityManager集运行时,虚拟线程没有权限。
虚拟线程不支持 stop()、suspend()、 resume()方法。这些方法在虚拟线程上调用时引发异常。
虚拟线程支持线程局部变量(ThreadLocal)和可继承的线程局部变量(InheritableThreadLocal),就像平台线程一样,因此它们可以运行使用线程本地程序的现有代码。但是,由于虚拟线程可能非常多,请在仔细考虑后使用线程本地。特别是,不要使用线程本地在线程池中共享同一线程的多个任务之间汇集昂贵的资源。虚拟线程不应池化,因为每个线程在其生命周期内只运行一个任务。我们已经从java.base模块中删除了线程本地的许多使用,以准备虚拟线程,以减少在使用数百万线程运行时的内存占用。
此外:
Thread.Builder API定义了创建线程时选择退出线程本地的方法。它还定义了选择退出继承可继承线程局部的初始值的方法。当从不支持线程本地的线程调用时,ThreadLocal.get() 返回初始值和线程本地集(T)抛出异常。
遗产上下文类加载器现在指定为像本地可继承线程一样工作。如果Thread.setContextClassLoader(ClassLoader)在不支持线程本地的线程上调用,然后引发异常。
作用域-局部变量对于某些用例,可能会被证明是线程本地的更好替代方案。
支持锁定的原始API。java.util.concurrent.LockSupport,现在支持虚拟线程:驻留虚拟线程释放基础载体线程以执行其他工作,取消驻留虚拟线程计划它继续。对LockSupport的此更改使所有使用它的API(锁、信号量、阻塞队列等)在虚拟线程中调用时都能优雅地停放。
此外:
Executors.newThreadPerTaskExecutor(ThreadFactory) 和 Executors.newVirtualThreadPerTaskExecutor() 创建一个ExecutorService,为每个任务创建一个新线程。这些方法支持迁移和与使用线程池和ExecutorService的现有代码的互操作性。
执行器服务现在延伸可自动关闭,因此允许此API与上面示例中所示的try-with-resource构造一起使用。
Future现在定义了获取已完成任务的结果或异常以及获取任务状态的方法。结合起来,这些添加使我们可以很容易地将Future对象用作流的元素,过滤Future流以查找已完成的任务,然后映射以获得结果流。这些方法也将有助于为结构化并发。
java.net和java.nio.channels包中的网络API的实现现在与虚拟线程一起工作:对虚拟线程的操作,该操作阻止建立网络连接或从套接字读取,释放基础载体线程以执行其他工作。
为了允许中断和取消,阻塞I/O方法定义为java.net.Socket、ServerSocket和DatagramSocket现在指定为可中断的在虚拟线程中调用时:中断套接字上阻塞的虚拟线程将取消锁定线程并关闭套接字。阻止这些类型套接字上的I/O操作,当从 InterruptibleChannel一直是可中断的,因此此更改将这些API在创建时的行为与从通道获取时的行为对齐。
java.io包提供字节流和字符流的API。这些API的实现是高度同步的,需要进行更改,以避免在虚拟线程中使用它们时固定。
作为背景,面向字节的输入/输出流没有被指定为线程安全,也没有指定当线程在读或写方法中被阻塞时调用close()时的预期行为。在大多数情况下,使用来自多个并发线程的特定输入或输出流是没有意义的。面向字符的读取器/写入器也没有被指定为线程安全的,但它们确实公开了子类的锁对象。除了固定之外,这些类中的同步是有问题的和不一致的;例如,InputStreamReader和OutputStreamWriter使用的流解码器和编码器在流对象上同步,而不是在锁对象上同步。
为防止固定,实现现在的工作方式如下:
BufferedInputStream、BufferedOutputStream、BufferedReader、BufferedWriter、PrintStream和PrintWriter现在在直接使用时使用显式锁而不是监视器。这些类在子类时与以前一样同步。
InputStreamReader和OutputStreamWriter使用的流解码器和编码器现在使用与封闭的InputStreamReader或OutputStreamWriter相同的锁。
更进一步,消除所有这些通常不必要的锁定超出了本JEP的范围。
此外,BufferedOutputStream、BufferedWriter和OutputStreamWriter的流编码器使用的缓冲区的初始大小现在更小,以便在堆中有许多流或写入程序时减少内存使用-如果有是一百万个虚拟线程,每个线程在套接字连接上都有一个缓冲流。
JNI定义了一个新函数IsVirtualThread,用于测试对象是否为虚拟线程。
JNI规范在其他方面保持不变。
调试体系结构由三个接口组成:JVM Tool Interface (JVM TI)、Java Debug Wire Protocol (JDWP)和Java Debug Interface (JDI)。现在,所有三个接口都支持虚拟线程。
更新到JVM TI是:
大多数用jthread 调用的函数(即,对线程对象的JNI引用)都可以用对虚拟线程的引用调用。虚拟线程不支持少量函数,即PopFrame、ForceEarlyReturn、StopThread、AgentStartFunction和GetThreadCpuTime。SetLocal*
函数仅限于在断点或单步事件挂起的虚拟线程的最顶层帧中设置局部变量。
现在,GetAllThreads和GetAllStackTraces函数被指定为返回所有平台线程,而不是所有线程。
除在早期VM启动或堆迭代期间发布的事件外,所有事件都可以在虚拟线程的上下文中调用事件回调。
挂起/恢复实现允许调试器挂起和恢复虚拟线程,并允许在挂起虚拟线程时挂起载体线程。
一项新功能can_support_virtual_threads,使代理可以更精细地控制虚拟线程的线程开始和结束事件。
新函数支持虚拟线程的批量挂起和恢复;这些函数需要can_support_virtual_threads功能。
现有的JVM TI代理将与以前一样工作,但如果它们调用虚拟线程不支持的函数,则可能会遇到错误。当不知道虚拟线程的代理与使用虚拟线程的应用程序一起使用时,就会出现这些情况。对某些代理来说,更改GetAllThreads以返回仅包含平台线程的数组可能是一个问题。启用ThreadStart和ThreadEnd事件的现有代理可能会遇到性能问题,因为它们无法将这些事件限制在平台线程上。
更新到JDWP是:
一个新命令允许调试器测试线程是否为虚拟线程。
EventRequest命令上的新修饰符允许调试器将线程开始和结束事件限制在平台线程上。
更新到JDI是:
一种新的方法com.sun.jdi.ThreadReference测试线程是否为虚拟线程。
中的新方法com.sun.jdi.request.ThreadStartRequest和com.sun.jdi.request.ThreadDeathRequest将为请求生成的事件限制为平台线程。
如上所述,虚拟线程不被视为线程组中的活动线程。因此,JVM TI函数GetThreadGroupCalt、JDWP命令ThreadGroupReference/Children和JDI方法 com.sun.jdi.ThreadGroupReference.threads() 返回的线程列表仅包括平台线程。
JFR支持具有多个新事件的虚拟线程:
jdk.VirtualThreadStart和jdk.VirtualThreadEnd表示虚拟线程的开始和结束。默认情况下,这些事件是禁用的。
jdk.VirtualThreadPinned表示虚拟线程在被固定时被驻留,即,没有释放其载体线程(参见限制)。此事件默认启用,阈值为20ms。
jdk.VirtualThreadSubmitFailed表示启动或取消驻留虚拟线程失败,可能是由于资源问题。默认情况下,此事件处于启用状态。
一种新的方法com.sun.management.HotSpotDiagnosticsMXBean 生成描述的新式线程转储以上。该方法也可以通过平台间接调用 MBeanServer从本地或远程JMX工具。
java.lang.management.ThreadMXBean 仅支持平台线程的监控和管理。
java.lang.ThreadGroup是一个用于分组线程的旧API,在现代应用程序中很少使用,不适合分组虚拟线程。我们现在不推荐并降级它,并希望在未来引入一个新的线程组织结构,作为结构化并发。
作为背景,ThreadGroup API可以追溯到Java 1.0。它最初旨在提供作业控制操作,如停止组中的所有线程。现代代码更倾向于使用java.util.concurrent的线程池API(在Java 5中引入)。ThreadGroup在早期Java版本中支持小程序的隔离,但Java安全体系结构在Java 1.2中发生了显著的发展,ThreadGroup不再发挥重要作用。ThreadGroup也旨在用于诊断目的,但该角色已被Java 5中引入的监控和管理功能所取代,包括java.lang.管理API. 除了现在基本上不相关之外,ThreadGroup API和实现还存在许多重大问题:
销毁线程组的API和机制存在缺陷。
API要求实现引用组中的所有活动线程。这将为线程创建、线程启动和线程终止增加同步和竞争开销。
API定义了 enumerate() 方法,这些方法本身是有效的。
API定义了suspend()、resume()和stop()方法,这些方法本身就容易死锁和不安全。
现在指定、不建议使用和降级ThreadGroup ,如下所示:
删除了显式销毁线程组的能力:最终不建议使用的destroy()方法什么都不做。
守护程序线程组的概念已删除:守护程序状态设置并由最终弃用的setDaemon(boolean) 和 isDaemon() 方法被忽略。
该实现不再保留对子组的强引用。当线程组中没有活动线程,并且没有其他任何其他线程保持线程组活动时,线程组现在有资格被垃圾收集。
最终不建议使用的suspend()、resume()和stop()方法总是抛出异常。
原文同步至:<https: waylau.com jep-425-virtual-threads-preview>
更多Java最新特性可见柳伟卫所著《Java核心编程》:<https: github.com waylau modern-java-demos>
<https: openjdk.java.net jeps 425