当前位置: 首页 > news >正文

Loom篇之java虚拟线程那些事儿

我们在之前的文章中提到了java推出纤程的背景和原因。在近三十年来,Java 开发人员一直依赖线程作为并发服务器应用程序的构建块。每个方法中的每个语句都在线程内执行,并且由于 Java 是多线程的,因此多个执行线程会同时发生。线程是 Java 的并发单元:一段顺序代码,与其他此类单元同时运行,并且在很大程度上独立于其他此类单元运行。每个线程都提供了一个堆栈来存储局部变量和协调方法调用,以及出现问题时的上下文:异常由同一线程中的方法引发和捕获,因此开发人员可以使用线程的堆栈跟踪来找出发生了什么。线程也是工具的中心概念:调试器逐步执行线程方法中的语句,探查器可视化多个线程的行为以帮助了解其性能。
在jdk19发布的时候,java正式推出了纤程的实现,叫做虚拟线程。在JEP425中提出第一次预览。后续分别在JEP436中提出第二次预览,并且在JEP444中完成最终交付,后续只针对其中的bug进行修复。
那么我们就来看一下关于虚拟线程的内容。

一、摘要

将虚拟线程引入 Java 平台。虚拟线程是轻量级线程,可显著减少编写、维护和观察高吞吐量并发应用程序的工作量。这是一个预览版 API。

虚拟线程现在始终支持线程局部变量ThreadLocal,用户以前如何使用ThreadLocal,,那么在虚拟线程中依然保持原来的使用ThreadLocal。现在不再可能像在预览版中那样创建不能具有线程局部变量的虚拟线程。保证对线程局部变量的支持可确保更多现有库可以原封不动地与虚拟线程一起使用,并有助于将面向任务的代码迁移到使用虚拟线程。

默认情况下,直接使用 Thread.Builder API 创建的虚拟线程(而不是通过 Executors.newVirtualThreadPerTaskExecutor() 创建的虚拟线程)现在也在其整个生命周期内受到监视,并且可以通过 观察虚拟线程 部分中描述的新线程转储进行观察。该目的主要是因为虚拟线程不推荐池化,你并不需要使用线程池来使用虚拟线程。而是在你使用的地方new即可。

二、目标

1、支持以简单的“每个请求一个线程”样式编写的服务器应用程序,以近乎最佳的硬件利用率进行扩展。
2、启用使用 java.lang.Thread API 的现有代码,以最小的更改采用虚拟线程。他的api和之前的线程api高度一致,你之前的任务代码依然可以运行在虚拟线程中,无需进行改变。
3、使用现有 JDK 工具轻松对虚拟线程进行故障排除、调试和分析。我们之前的jdk分析工具,在虚拟线程中依然可以使用,比如JFR。

三、非目标

1、删除线程的传统实现,或静默迁移现有应用程序以使用虚拟线程,这不是一个目标。之前的线程依然存在,不会移除掉。
2、在 Java 语言或 Java 库中提供新的数据并行结构并不是一个目标。Stream API 仍然是并行处理大型数据集的首选方式。
3、改变 Java 的基本并发模型并不是一个目标。

四、设计动机

我们其实在第一篇就看过了他的设计背景,我们这里依然来看一下。
服务器应用程序通常处理彼此独立的并发用户请求,因此应用程序可以通过在整个持续时间内将线程专用于该请求来处理请求。这种“每个请求一个线程”样式易于理解、易于编程、易于调试和分析,因为它使用平台的并发单位来表示应用程序的并发单位。这个可能比较晦涩,我来简单解释一下,我们之前的线程模型在程序运行在线程Thread的时候,任务代码是无法终止的,只能执行完毕,或者执行崩溃。也就是任务(task) + 线程(thread)无法分离。这使得一个请求无法在结束之前从当前运行线程上卸载下来。当我们在spring web上可能更好的理解这个问题。
在这里插入图片描述
我们的springboot默认的servlet容器是tomcat,默认的worker线程是200,当我们发送请求给springboot服务的时候,每一个请求都会交给一个worker线程去处理,在原来的线程模型下这个请求不结束,不处理完,这个worker线程是无法结束去处理别的请求的。如果我们每个controller请求方法处理需要一秒,那么我们一秒钟的吞吐量就是200,那么如果你想提高单体的吞吐呢,那就加线程。别无他法。
这种模型下,因为task无法和thread分离。即便你的worker被阻塞了,比如你去访问数据库,访问rpc远程服务。此时这个线程也无法去处理其他请求,依然是阻塞在那里。

在这种并发模型下,服务器应用程序的可扩展性受利特尔定律的约束,该定律与延迟、并发性和吞吐量相关:对于给定的请求处理持续时间(即延迟),应用程序同时处理的请求数量(即并发)必须与到达速率(即吞吐量)成比例增长。例如,假设平均延迟为 50 毫秒的应用程序通过并发处理 10 个请求实现了每秒 200 个请求的吞吐量。为了使该应用程序扩展到每秒 2000 个请求的吞吐量,它需要同时处理 100 个请求。如果在请求的持续时间内在线程中处理每个请求,则为了使应用程序跟上,线程数必须随着吞吐量的增长而增加。

遗憾的是,可用线程的数量是有限的,因为 JDK 将线程实现为操作系统 (OS) 线程的包装器,当你一个java线程被创建的时候,同时意味着你会在os层面创建一个原生线程。OS 线程成本高昂,因此我们不能拥有太多,这使得实现不适合 Thread-per-request 风格。如果每个请求在其持续时间内消耗一个线程,从而消耗一个 OS 线程,那么线程数通常会在其他资源(如 CPU 或网络连接)耗尽之前很久就成为限制因素。JDK 当前的线程实现将应用程序的吞吐量限制在远低于硬件可以支持的水平。即使线程池化,也会发生这种情况,因为池化有助于避免启动新线程的高成本,但不会增加线程总数。

在这种背景下,一些希望充分利用硬件的开发人员已经放弃了每个请求一个线程的样式,转而使用线程共享样式(netty)。请求处理代码在等待另一个 I/O 操作完成时将其线程返回到池中,以便该线程可以为其他请求提供服务,而不是从头到尾处理一个线程上的请求。这种细粒度的线程共享(其中代码仅在执行计算时保留线程,而不是在等待 I/O 时保留线程)允许大量并发操作,而不会消耗大量线程。虽然它消除了 OS 线程稀缺性对吞吐量的限制,但它的代价很高:它需要所谓的异步编程风格,采用一组单独的 I/O 方法,这些方法不等待 I/O 操作完成,而是稍后向回调发出完成信号(注册回调)。如果没有专用线程,开发人员必须将其请求处理逻辑分解为小阶段,通常编写为 lambda 表达式,然后使用 API 将它们组合成一个顺序管道(例如,请参阅 CompletableFuture 或所谓的“反应式”框架)。因此,它们放弃了语言的基本顺序组合运算符,例如循环和 try/catch 块。

在异步样式中,请求的每个阶段可能在不同的线程上执行,并且每个线程都以交错方式运行属于不同请求的阶段。这对理解程序行为具有深远的影响:堆栈跟踪不提供可用的上下文(响应式的框架堆栈无法全部跟踪),调试器无法单步执行请求处理逻辑,探查器无法将操作的成本与其调用方相关联。当使用 Java 的流 API 在短管道中处理数据时,编写 lambda 表达式是可管理的,但当应用程序中的所有请求处理代码都必须以这种方式编写时,则会出现问题。这种编程风格与 Java 平台不一致,因为应用程序的并发单元 — 异步管道 — 不再是平台的并发单元。这种把函数当成了第一公民,他是注册函数回调函数实现的。所以和java不一样。

为了使应用程序能够扩展,同时保持与平台的和谐,我们应该努力保持每个请求一个线程的样式。我们可以通过更高效地实现线程来做到这一点,因此它们可以更加丰富。操作系统无法更高效地实现 OS 线程,因为不同的语言和运行时以不同的方式使用线程堆栈。但是,Java 运行时可以以切断 Java 线程与 OS 线程的一对一对应关系的方式实现 Java 线程。正如操作系统通过将大型虚拟地址空间映射到有限数量的物理 RAM 来产生大量内存的错觉一样,Java 运行时可以通过将大量虚拟线程映射到少量的 OS 线程来产生大量线程的错觉。这就是虚拟线程的设计目标。

他在我们原有的thread(称之为平台线程),以及操作系统线程(称之为原生线程),之外设计出了第三种线程(称之为虚拟线程),虚拟线程在用户态层面存在,类似于一个对象一样的地位,他和原生线程以及平台线程不存在一对一的关系,不存在必然的关系。虚拟线程是 java.lang.Thread 的一个实例,它不与特定的 OS 线程绑定。相比之下,平台线程是以传统方式实现的 java.lang.Thread 实例,作为 OS 线程的薄包装器。

每个请求一个线程样式的应用程序代码可以在整个请求持续时间内在虚拟线程中运行,但虚拟线程仅在 CPU 上执行计算时消耗操作系统线程。结果是与异步样式相同的可伸缩性,只是它是透明地实现的:当在虚拟线程中运行的代码调用 java.* API 中的阻塞 I/O 操作时,运行时将执行非阻塞 OS 调用并自动挂起虚拟线程,直到以后可以恢复为止。对于 Java 开发人员来说,虚拟线程只是创建成本低廉且几乎无限丰富的线程。硬件利用率接近最佳,允许高级别的并发性,从而实现高吞吐量,同时应用程序与 Java 平台及其工具的多线程设计保持和谐。

在这种模型下虚拟线程你需要理解为一个任务,他运行在平台线程之上,当发生阻塞的时候该任务会"挂起",从而卸载到其他地方(堆上的一个队列)。此时平台线程可以运行其他的虚拟线程。从而无需和任务一一绑定。而这一切都是透明的对于用户,用户无感知这一切,都是在jvm中实现的。

五、虚拟线程的含义

虚拟线程既便宜又丰富,因此永远不应该共用:应该为每个应用程序任务创建一个新的虚拟线程。因此,大多数虚拟线程的生存期较短,并且具有较浅的调用堆栈,执行的次数少至单个 HTTP 客户端调用或单个 JDBC 查询。相比之下,平台线程很重且成本高昂,因此通常必须共用。它们往往生存期很长,具有较深的调用堆栈,并在许多任务之间共享。这里就有一条虚拟线程的金科玉律,我们永远不到池化虚拟线程。

总之,虚拟线程保留了可靠的“每个请求一个线程”样式,该样式与 Java 平台的设计相协调,同时以最佳方式利用了可用的硬件。使用虚拟线程不需要学习新概念,尽管它可能需要养成忘却习惯,以应对当今高昂的线程成本。虚拟线程不仅会帮助应用程序开发人员,还会帮助框架设计人员提供易于使用的 API,这些 API 与平台设计兼容,而不会影响可扩展性。

如今,JDK 中的每个 java.lang.Thread 实例都是一个平台线程。平台线程在底层操作系统线程上运行 Java 代码,并在代码的整个生命周期内捕获操作系统线程。平台线程数限制为 OS 线程数(收到os线程数的限制,you cat not create os thread)。

虚拟线程是 java.lang.Thread 的一个实例,它在底层操作系统线程上运行 Java 代码,但在代码的整个生命周期内不捕获操作系统线程。这意味着许多虚拟线程可以在同一个操作系统线程上运行他们的 Java 代码,从而有效地共享它。虽然平台线程垄断了宝贵的 OS 线程,但虚拟线程不会。虚拟线程的数量可能比操作系统线程的数量大得多。

虚拟线程是由 JDK 而不是 OS 提供的线程的轻量级实现。它们是用户态线程的一种形式,在其他多线程语言(例如,Go 中的 goroutines 和 Erlang 中的 processes)中已经成功。用户模式线程甚至在 Java 的早期版本中被称为所谓的“绿色线程”,当时 OS 线程尚未成熟和广泛使用。但是,Java 的绿色线程都共享一个操作系统线程(M:1 调度),并最终被平台线程超越,平台线程作为操作系统线程的包装器实现(1:1 调度)。虚拟线程采用 M:N 调度,其中大量 (M) 虚拟线程被调度在较少数量 (N) 的操作系统线程上运行。
他的运行模式如下。
我们在用户态存在海量的虚拟线程,我们同时拥有一组调度虚拟线程的平台线程,和以前一样,每一个平台线程依然对应一个os线程。
在这里插入图片描述
在一个时刻,每一个平台线程可以运行一个虚拟线程。如图所示。
在这里插入图片描述
当某一个虚拟线程运行的时候,发生了阻塞操作。此时该虚拟线程会从运行他的平台线程上卸载或者叫解绑(unmount)。该虚拟线程会被卸载到堆上的一个队列中,而此时其他的虚拟线程会被调度到该平台线程(mount)。
在这里插入图片描述
基于这种模式,吞吐量得以大幅度提升。而我们只需要很少量的平台线程就能运行海量的虚拟线程,尤其是在io密集型任务中,大部分任务都是阻塞式的。

如何定义线程:我们从结构上可以定义为任务+调度器
我们给任务取一个名字叫做task,调度器我们取一个名字叫做scheduler
我们传统的线程task就是你的Runnable,而scheduler的角色由操作系统线程扮演。
但是如我们所言,这种模型task和scheduler无法解绑。task只能一直运行下去。
在虚拟线程的设计中,或者说所有的协程设计中,我们需要task可以阻塞的时候挂起
可以结束阻塞的时候恢复。所以task无法解决这个问题,我们需要给task升级,此时我们
需要continution的实现,continution是升级版的task,他可以随时挂起,随时恢复执行。
一般的continution有两种实现,一个是one-shot模式的,他把任务拆分为多份,每一份
只会被执行一次,而且是单向的。而另一种则是多份可以反复执行。
java的虚拟线程的continution是第一种,这种模式可以有效的利用空间,
当我们挂起之后,后面的任务可以利用前面的任务的空间,因为他是单向的,一旦执行完
之前的任务所占的空间就会被释放掉。而且这种模式毫无疑问是更好实现的。

在虚拟线程中的实现中,你可以把continution理解为虚拟线程,如同我们上面说的,你需要把虚拟线程理解为一个任务,而他的scheduler就是平台线程,我们当然需要一个优秀的调度器,在java中正好存在一个ForkJoinPool的线程池,该线程池充当了调度器。他会每次默认启动cpu core个平台线程来调度我们的虚拟线程。当然你可以通过jdk.virtualThreadScheduler.maxPoolSize来改变这个默认线程数,但是最大不会超过256。这些我们会在后面的实战篇来描述。

六、如何使用

我们这里给出简单的使用,更加复杂的使用后面我们会详细描述。

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {IntStream.range(0, 10_000).forEach(i -> {executor.submit(() -> {Thread.sleep(Duration.ofSeconds(1));return i;});});
} 

开发人员可以选择是使用虚拟线程还是平台线程。下面是一个创建大量虚拟线程的示例程序。程序首先获取一个 ExecutorService,它将为每个提交的任务创建一个新的虚拟线程。然后,它提交 10000 个任务并等待所有任务完成:此示例中的任务是简单的代码 — 休眠一秒钟 — 现代硬件可以轻松支持 10,000 个虚拟线程并发运行此类代码。在后台,JDK 在少量 OS 线程上运行代码,可能只有一个。
如果此程序使用为每个任务创建新的平台线程的 ExecutorService,则情况将大不相同,例如 Executors.newCachedThreadPool() .ExecutorService 将尝试创建 10,000 个平台线程,从而创建 10,000 个 OS 线程,并且程序可能会崩溃,具体取决于计算机和操作系统。

如果程序使用从池中获取平台线程的 ExecutorService,情况也不会好多少,例如 Executors.newFixedThreadPool(200) .ExecutorService 将创建 200 个平台线程,由所有 10,000 个任务共享,因此许多任务将按顺序运行,而不是并发运行,并且程序需要很长时间才能完成。对于此程序,具有 200 个平台线程的池只能实现每秒 200 个任务的吞吐量,而虚拟线程可实现每秒约 10000 个任务的吞吐量(在充分预热后)。此外,如果示例程序中的 10_000 更改为 1_000_000,则程序将提交 1,000,000 个任务,创建 1,000,000 个并发运行的虚拟线程,并且(在充分预热后)实现每秒约 1,000,000 个任务的吞吐量。

如果此程序中的任务执行了一秒钟的计算(例如,对一个巨大的数组进行排序),而不仅仅是休眠,那么将线程数增加到超过处理器内核数将无济于事,无论它们是虚拟线程还是平台线程。虚拟线程不是更快的线程 — 它们运行代码的速度不会比平台线程快。它们的存在是为了提供规模 (更高的吞吐量),而不是速度 (更低的延迟)。它们的数量可能比平台线程多得多,因此根据利特尔定律,它们支持更高吞吐量所需的更高并发性。

换句话说,虚拟线程可以显著提高应用程序吞吐量。当并发任务数很多(超过几千个),并且工作负载不受 CPU 限制,因为在这种情况下,线程数多于处理器内核数无法提高吞吐量。
虚拟线程有助于提高典型服务器应用程序的吞吐量,正是因为这些应用程序由大量并发任务组成,这些任务花费大量时间等待。那么在cpu 密集的任务上是否依然如此呢?我们在后面给出测试结果。

虚拟线程可以运行平台线程可以运行的任何代码。具体而言,虚拟线程支持线程局部变量和线程中断,就像平台线程一样。这意味着处理请求的现有 Java 代码将很容易在虚拟线程中运行。许多服务器框架将选择自动执行此操作,为每个传入请求启动一个新的虚拟线程,并在其中运行应用程序的业务逻辑。

下面是聚合其他两个服务的结果的服务器应用程序示例。假设的服务器框架 (未显示) 为每个请求创建一个新的虚拟线程,并在该虚拟线程中运行应用程序的 handle 代码。反过来,应用程序代码会创建两个新的虚拟线程,通过与第一个示例相同的 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 可以创建和启动虚拟线程。此外,结构化并发还提供了一个更强大的 API 来创建和管理虚拟线程,尤其是在类似于此服务器示例的代码中,平台及其工具可以了解线程之间的关系。

七、永远不要池化

开发人员通常会将应用程序代码从基于传统线程池的 ExecutorService 迁移到每个任务一个虚拟线程的 ExecutorService。与任何资源池一样,线程池旨在共享昂贵的资源,但虚拟线程并不昂贵,因此永远不需要将它们池化。

开发人员有时会使用线程池来限制对有限资源的并发访问。例如,如果服务无法处理超过 20 个并发请求,则通过提交到大小为 20 的线程池的任务向服务发出所有请求将确保这一点。这种说法已经无处不在,因为平台线程的高成本使线程池无处不在,但不要为了限制并发而试图池化虚拟线程。相反,请使用专为此目的设计的构造,例如信号量。

与线程池结合使用时,开发人员有时会使用线程局部变量在共享同一线程的多个任务之间共享昂贵的资源。例如,如果创建数据库连接的成本很高,那么您可以打开它一次并将其存储在线程局部变量中,以供同一线程中的其他任务稍后使用。如果将代码从使用线程池迁移到每个任务使用虚拟线程,请警惕此惯用语的用法,因为为每个虚拟线程创建昂贵的资源可能会显著降低性能。更改此类代码以使用替代缓存策略,以便可以在大量虚拟线程之间有效地共享昂贵的资源。

八、可观测性

编写清晰的代码并不是全部。清晰地表示正在运行的程序的状态对于故障排除、维护和优化也至关重要,JDK 长期以来一直提供调试、分析和监视线程的机制。这样的工具应该对虚拟线程做同样的事情 — 也许对它们的大量内容有所适应 — 因为它们毕竟是 java.lang.Thread 的实例。

Java 调试器可以单步调试虚拟线程、显示调用堆栈并检查堆栈帧中的变量。JDK Flight Recorder (JFR) 是 JDK 的低开销分析和监控机制,可以将应用程序代码中的事件(例如对象分配和 I/O 操作)与正确的虚拟线程相关联。这些工具无法为以异步样式编写的应用程序执行这些操作。在这种样式中,任务与线程无关,因此调试器无法显示或操作任务的状态,并且分析器无法判断任务等待 I/O 所花费的时间。

线程转储是另一种流行的工具,用于对以“每个请求一个线程”样式编写的应用程序进行故障排除。遗憾的是,JDK 的传统线程转储(使用 jstack 或 jcmd 获取)呈现的是线程的平面列表。这适用于数十或数百个平台线程,但不适用于数千或数百万个虚拟线程。因此,我们不会将传统线程转储扩展为包含虚拟线程;相反,我们将在 JCMD 中引入一种新的线程转储,以将虚拟线程与平台线程一起呈现,所有这些都以有意义的方式分组。当程序使用结构化并发时,可以显示线程之间更丰富的关系。

因为可视化和分析大量线程可以从工具中受益,所以除了纯文本之外,jcmd 还可以以 JSON 格式发出新的线程转储:因为传统的线程不会很多,那时候我们使用文本来查看就可以,但是虚拟线程的堆栈很大,此时需要一种结构化的文本来展示,所以设计了json。

$ jcmd <pid> Thread.dump_to_file -format=json <file>

新的线程转储格式不包括对象地址、锁、JNI 统计信息、堆统计信息以及传统线程转储中出现的其他信息。此外,由于它可能需要列出大量线程,因此生成新的线程转储不会暂停应用程序。

如果系统属性 jdk.trackAllThreads 设置为 false,即使用 -Djdk.trackAllThreads=false 命令行选项,则直接使用 Thread.Builder API 创建的虚拟线程将不会始终被运行时跟踪,并且可能不会出现在新的线程转储中。在这种情况下,新的线程转储将列出在网络 I/O 操作中被阻止的虚拟线程,以及由上面所示的 new-thread-per-task ExecutorService 创建的虚拟线程。

由于虚拟线程是在 JDK 中实现的,并且不绑定到任何特定的操作系统线程,因此它们对操作系统不可见,操作系统不知道它们的存在。OS 级监视将观察到 JDK 进程使用的 OS 线程数少于虚拟线程数。

九、调度虚拟线程

要执行有用的工作,需要调度线程,即分配线程在处理器内核上执行。对于作为 OS 线程实现的平台线程,JDK 依赖于 OS 中的调度程序。相比之下,对于虚拟线程,JDK 有自己的调度程序。JDK 的计划程序不是直接将虚拟线程分配给处理器,而是将虚拟线程分配给平台线程(这就是前面提到的虚拟线程的 M:N 计划)。然后,OS 会照常安排平台线程。

JDK 的虚拟线程调度程序是在 FIFO 模式下运行的窃取工作的 ForkJoinPool。调度程序的并行度是可用于调度虚拟线程的平台线程数。默认情况下,它等于可用处理器的数量,但可以使用 system 属性 jdk.virtualThreadScheduler.parallelism 进行调整。此 ForkJoinPool 不同于公共池,例如,用于并行流的实现,并在 LIFO 模式下运行。

调度程序为其分配虚拟线程的平台线程称为虚拟线程的载体。虚拟线程可以在其生命周期内调度到不同的运营商上;换句话说,计划程序不维护虚拟线程与任何特定平台线程之间的关联。从 Java 代码的角度来看,正在运行的虚拟线程在逻辑上独立于其当前载体:

  • Thread.currentThread() 返回的值始终是虚拟线程本身。不会因为你是调度在平台线程给你返回平台线程,这个一点对客户保持直观的语义。
  • 平台线程和虚拟线程的堆栈跟踪是分开的。在虚拟线程中引发的异常将不包括平台线程的堆栈帧。线程转储不会在虚拟线程的堆栈中显示平台线程的堆栈帧,反之亦然。
  • 平台线程的线程局部变量(ThreadLocal)对虚拟线程不可用,反之亦然。

此外,从 Java 代码的角度来看,虚拟线程及其载体暂时共享一个 OS 线程的事实是不可见的。相比之下,从本机代码的角度来看,虚拟线程及其载体都运行在同一个本机线程上。因此,在同一虚拟线程上多次调用的本机代码可能会在每次调用时观察到不同的 OS 线程标识符。

调度程序当前不为虚拟线程实现分时。分时是指强制抢占已消耗已分配 CPU 时间的线程。虽然当平台线程数量相对较少且 CPU 利用率为 100% 时,分时可以有效地减少某些任务的延迟,但目前尚不清楚分时是否与一百万个虚拟线程一样有效。

十、执行虚拟线程

要利用虚拟线程,无需重写程序。虚拟线程不需要或期望应用程序代码将控制权显式地交还给调度程序;换句话说,虚拟线程不是协作的。用户代码不得对如何或何时将虚拟线程分配给平台线程做出假设,就像它不能对如何或何时将平台线程分配给处理器内核做出假设一样。

为了在虚拟线程中运行代码,JDK 的虚拟线程调度程序通过将虚拟线程挂载到平台线程上来分配虚拟线程以在平台线程上执行。这使得平台线程成为虚拟线程的载体。稍后,在运行一些代码后,虚拟线程可以从其运营商中卸载。此时,平台线程是空闲的,因此调度程序可以在其上挂载不同的虚拟线程,从而再次使其成为运营商。

通常,当虚拟线程阻塞 I/O 或 JDK 中的某些其他阻塞操作(如 BlockingQueue.take())时,它将卸载。当阻塞操作准备好完成时(例如,套接字上已收到字节),它会将虚拟线程提交回调度程序,调度程序会将虚拟线程挂载到运营商上以恢复执行。

虚拟线程的挂载和卸载频繁且透明地进行,并且不会阻止任何 OS 线程。例如,前面显示的服务器应用程序包括以下代码行,其中包含对阻塞操作的调用:

response.send(future1.get() + future2.get());

这些操作将导致虚拟线程多次挂载和卸载,通常每次调用 get() 一次,在 send(…) 中执行 I/O 的过程中可能多次。

JDK 中的绝大多数阻塞操作将卸载虚拟线程,从而释放其载体和底层操作系统线程以执行新的工作。但是,JDK 中的某些阻塞操作不会卸载虚拟线程,因此会阻塞其载体和底层 OS 线程。这是因为 OS 级别(例如,许多文件系统操作)或 JDK 级别(例如 Object.wait())的限制。这些阻塞操作的实现通过临时扩展调度程序的并行度来补偿 OS 线程的捕获。jdk会给我们创建临时的平台线程来调度。因此,计划程序的 ForkJoinPool 中的平台线程数可能会暂时超过可用处理器的数量。调度程序可用的最大平台线程数可以使用 system 属性 jdk.virtualThreadScheduler.maxPoolSize 进行调整。
在以下两种情况下,虚拟线程在阻塞操作期间无法卸载,因为它被固定到其调度平台线程上:当它在synchronized或方法内执行代码时,或者当它执行本机方法(native)或外部函数时。
这种固定不会使应用程序不正确,但可能会阻碍其可伸缩性。如果虚拟线程在固定时执行阻塞操作,例如 I/O 或 BlockingQueue.take(),则其载体和底层操作系统线程在操作期间将被阻塞。长时间频繁固定可能会捕获平台线程,从而损害应用程序的可扩展性。(当发生虚拟线程无法从平台线程中卸载的时候,此时模型退化为原来的那种1:1:1的模型,吞吐量降低)。
调度程序不会通过扩展其并行度来补偿固定。相反,通过修改频繁运行的同步块或方法,并保护可能较长的 I/O 操作来避免频繁和长期存在的 java.util.concurrent.locks.ReentrantLock 固定。无需替换不经常使用(例如,仅在启动时执行)或保护内存中操作的同步块和方法。与往常一样,请努力使锁定策略保持简单明了。(因为不经常使用的,就算阻塞的时候无法卸载,那等他执行完了就好了,影响不大,如果是在内存中的,因为不涉及io阻塞操作,而且速度很快,所以他本身也不会卸载,因为本身就不阻塞或者阻塞很短,所以影响也不大)。

新的诊断工具有助于将代码迁移到虚拟线程,并评估是否应将 synchronized 的特定用法替换为 java.util.concurrent 锁:

  • 当线程在固定时阻塞时,将发出 JDK Flight Recorder (JFR) 事件(请参见JDK Flight Recorder)。
  • 系统属性 jdk.tracePinnedThreads 在线程固定时阻塞时触发堆栈跟踪。当线程在固定时阻塞时,使用 -Djdk.tracePinnedThreads=full 运行会打印完整的堆栈跟踪,突出显示本机帧和保存监视器的帧。Running with -Djdk.tracePinnedThreads=short 将输出限制为仅有问题的帧。我们可以适当在测试环境打开这些参数,可以有效的帮我们发现问题,而不是在生产中暴露。

在未来的版本中,我们或许能够消除上述第一个限制,即在 synchronized 中固定。第二个限制是与本机代码正确交互所必需的(jdk24中已经修复)。

十一、关于GC

虚拟线程堆栈作为堆栈块对象存储在 Java 的垃圾收集堆中。堆栈会随着应用程序的运行而增长和收缩,这既可以提高内存效率,也可以容纳深度达到 JVM 配置的平台线程堆栈大小的堆栈。这种效率使大量虚拟线程成为可能,从而使 Thread-per-request 样式在服务器应用程序中的持续可行性成为可能。

在上面的第二个示例中,回想一下,假设的框架通过创建新的虚拟线程并调用 handle 方法来处理每个请求。即使它在深度调用堆栈的末尾(在身份验证、事务等之后)调用 handle,handle 本身也会生成多个仅执行短期任务的虚拟线程。因此,对于每个具有深度调用堆栈的虚拟线程,将有多个具有浅调用堆栈的虚拟线程,占用的内存很少。

通常,虚拟线程所需的堆空间量和垃圾回收器活动量很难与异步代码进行比较。100 万个虚拟线程至少需要 100 万个对象,但共享平台线程池的 100 万个任务也需要。此外,处理请求的应用程序代码通常会跨 I/O 操作维护数据。每个请求线程代码可以将该数据保存在局部变量中,这些变量存储在堆中的虚拟线程堆栈上,而异步代码必须将相同的数据保存在从管道的一个阶段传递到下一个阶段的堆对象中。一方面,虚拟线程所需的堆栈帧布局比紧凑对象更浪费;另一方面,虚拟线程可以在许多情况下(取决于低级 GC 交互)改变和重用其堆栈,而异步管道始终需要分配新对象,因此虚拟线程可能需要较少的分配。总体而言,每个请求线程与异步代码的堆消耗和垃圾回收器活动应该大致相似。随着时间的推移,我们希望使虚拟线程堆栈的内部表示更加紧凑。

与平台线程堆栈不同,虚拟线程堆栈不是 GC 根。因此,它们包含的引用不会被执行并发堆扫描的垃圾回收器(如 G1)在 stop-the-world 暂停中遍历。这一点很好理解,因为虚拟线程你要把它理解为一个任务,当他被运行的时候(mount),此时他被你当前的调用栈所引用,无法被回收。
当他不被运行的时候(unmount),他被你的队列保存,依然无法回收,。所以他不需要向Thread那样设置为GC Root。
虚拟线程的当前限制是 G1 GC 不支持humongous stack chunk。如果虚拟线程的堆栈达到region大小的一半,可能小到 512KB,则可能会引发 StackOverflowError。

十二、Thread-local

虚拟线程支持线程局部变量 (ThreadLocal) 和可继承的线程局部变量 (InheritableThreadLocal),就像平台线程一样,因此它们可以运行使用线程局部变量的现有代码。但是,由于虚拟线程可能非常多,因此只有在仔细考虑后才能使用线程局部变量。特别是,不要使用线程局部变量在线程池中共享同一线程的多个任务之间共用昂贵的资源。虚拟线程永远不应该被池化,因为每个虚拟线程在其生命周期内都只运行一个任务。我们从 JDK 的 java.base 模块中删除了线程局部变量的许多用法,以便为虚拟线程做准备,以便在使用数百万个线程运行时减少内存占用。因为量变产生质变,当你海量的虚拟线程中保存Thread-local 变量可能会产生大量的内存占用。
系统属性 jdk.traceVirtualThreadLocals 可用于在虚拟线程设置任何线程局部变量的值时触发堆栈跟踪。此诊断输出可能有助于在迁移代码以使用虚拟线程时删除线程局部变量。将 system 属性设置为 true 以触发堆栈跟踪;默认值为 false。未来可能会使用Scoped values作为更好的替换实现。

支持锁定 java.util.concurrent.LockSupport 的原始 API 现在支持虚拟线程:停放虚拟线程会释放底层平台线程以执行其他工作,而取消停放虚拟线程会安排它继续。对 LockSupport 的这一更改使使用它的所有 API(Locks、Semaphore、blocking queues 等)能够在虚拟线程中调用时正常停放。

此外, Executors.newThreadPerTaskExecutor(ThreadFactory) 并 Executors.newVirtualThreadPerTaskExecutor() 创建一个 ExecutorService,为每个任务创建一个新线程。这些方法支持迁移以及与使用线程池和 ExecutorService 的现有代码的互操作性。

十三、IO/网络操作

java.net 和 java.nio.channels 包中的网络 API 的实现现在可以与虚拟线程一起使用:虚拟线程上的操作会发生阻塞(例如,建立网络连接或从套接字读取数据),从而释放底层平台线程以执行其他工作(unmount)。

为了允许中断和取消,现在将 java.net.Socket、ServerSocket 和 DatagramSocket 定义的阻塞 I/O 方法指定为在虚拟线程中调用时可中断:中断在套接字上阻塞的虚拟线程将取消停放线程并关闭套接字。从 InterruptibleChannel 获取时,对这些类型的套接字的阻塞 I/O 操作始终是可中断的,因此此更改使这些 API 在使用构造函数创建时的行为与从通道获取时的行为保持一致。

java.io 包为字节流和字符流提供 API。这些 API 的实现是高度同步的,并且在虚拟线程中使用时需要更改以避免无法卸载。

作为背景,面向字节的 Input/Output 流未指定为线程安全,并且未指定在读取或写入方法中阻塞线程时调用 close() 时的预期行为。在大多数情况下,使用来自多个并发线程的特定输入或输出流是没有意义的。面向字符的读取器/写入器也没有指定为线程安全的,但它们确实为子类公开了锁对象。除了固定之外,这些类中的同步还存在问题且不一致;例如,InputStreamReader 和 OutputStreamWriter 使用的流解码器和编码器在流对象上同步,而不是在锁定对象上同步。

为了防止固定,现在的实现工作方式如下:

  • BufferedInputStream、BufferedOutputStream、BufferedReader、BufferedWriter、PrintStream 和 PrintWriter 现在在直接使用时使用显式锁(juc锁)而不是监视器(sync锁)。当这些类被子类化时,它们会像以前一样同步。
  • InputStreamReader 和 OutputStreamWriter 使用的流解码器和编码器现在使用与封闭的 InputStreamReader 或 OutputStreamWriter 相同的锁。
  • 此外,BufferedOutputStream、BufferedWriter 和 OutputStreamWriter 的流编码器使用的缓冲区的初始大小现在更小,以便在堆中有许多流或写入器时减少内存使用量——如果有一百万个虚拟线程,每个虚拟线程在套接字连接上都有一个缓冲流,则可能会出现这种情况。

十四、JDK 飞行记录器 (JFR)

JFR 通过几个新事件支持虚拟线程:

  • JDK 的。VirtualThreadStart 和 jdk.VirtualThreadEnd 表示虚拟线程的开始和结束。默认情况下,这些事件处于禁用状态。

  • JDK 的。VirtualThreadPinned 表示虚拟线程在固定时停放,即没有释放其平台线程(见上文)。默认情况下,此事件处于启用状态,阈值为 20 毫秒。

  • JDK 的。VirtualThreadSubmitFailed 指示启动或取消停放虚拟线程失败,可能是由于资源问题。默认情况下,此事件处于启用状态。

十五、注意点

1、java.io.BufferedInputStream、BufferedOutputStream、BufferedReader、BufferedWriter、PrintStream 和 PrintWriter 类中使用的内部(和未记录的)锁定协议的修订可能会影响假定 I/O 方法在调用它们的流上同步的代码。这些更改不会影响扩展这些类并假定由超类锁定的代码,也不会影响扩展 java.io.Reader 或 java.io.Writer 并使用这些 API 公开的锁对象的代码。
2、一些源代码和二进制文件不兼容的更改可能会影响扩展 java.lang.Thread 的代码:

  • Thread 定义了几个新方法。如果现有源文件中的代码扩展了 Thread,并且子类中的方法与任何新的 Thread 方法冲突,则文件如果不进行更改将无法编译。
  • Thread.Builder 是一个新的嵌套接口。如果现有源文件中的代码扩展了 Thread,导入了名为 Builder 的类,并且子类中的代码引用 Builder 作为简单名称,则文件不会在不更改的情况下编译。
  • Thread.isVirtual() 是一种新的 final 方法。如果存在扩展 Thread 的现有编译代码,并且子类声明了具有相同名称和返回类型的方法,则如果加载了子类,则将在运行时引发 IncompatibleClassChangeError。

3、将现有代码与利用虚拟线程或新 API 的较新代码混合时,可能会观察到平台线程和虚拟线程之间的一些行为差异:

  • Thread.setPriority(int) 方法对虚拟线程没有影响,虚拟线程的优先级始终为 Thread.NORM_PRIORITY。
  • Thread.setDaemon(boolean) 方法对虚拟线程没有影响,虚拟线程始终是守护程序线程。
  • Thread.getAllStackTraces() 现在返回所有平台线程的映射,而不是所有线程的映射。
  • 现在,在虚拟线程的上下文中调用时,由 java.net.Socket、ServerSocket 和 DatagramSocket 定义的阻塞 I/O 方法可中断。当阻塞在套接字操作上的线程中断时,现有代码可能会中断,这将唤醒线程并关闭套接字。
  • 虚拟线程不是 ThreadGroup 的活动成员。在虚拟线程上调用 Thread.getThreadGroup() 将返回一个空的虚拟 “VirtualThreads” 组。
  • 在 JVM TI 中,GetAllThreads 和 GetAllStackTraces 函数不返回虚拟线程。启用 ThreadStart 和 ThreadEnd 事件的现有代理可能会遇到性能问题,因为它们无法将事件限制为平台线程。
  • API java.lang.management.ThreadMXBean 支持监控和管理平台线程,但不支持虚拟线程。
  • -XX:+PreserveFramePointer 标志对虚拟线程性能有巨大的负面影响。
  • java.lang.management.ThreadMXBean 仅支持对平台线程的监控和管理。findDeadlockedThreads() 方法查找处于死锁状态的平台线程的循环;它不会找到处于死锁状态的虚拟线程的循环。

以上为一些需要注意的点,后面我们会以实际的代码来验证并且使用虚拟线程。


http://www.mrgr.cn/news/78002.html

相关文章:

  • java 集合 菱形内定义封装类 而非基本数据类型 原因解释 详解
  • 利用 Watchtower 自动监听并更新正在运行的 Docker 容器
  • 互联网摸鱼日报(2024-11-22)
  • oneplus6线刷、trwp、magisk(apatch)、LSPosed、Shamiko、Hide My Applist
  • Thymeleaf模板引擎生成的html字符串转换成pdf
  • 2023年3月GESPC++一级真题解析
  • ajax (一)
  • WordPress添加类似说说、微博的时间轴微语页面
  • URL在线编码解码- 加菲工具
  • GPT 中的核心部分 Transformer 和RNN 具体别和应用领域
  • ThingsBoard规则链节点:AWS SQS 节点详解
  • 图片预览 图片上传到服务器
  • ubuntu中使用ffmpeg和nginx推流rtmp视频
  • RFdiffusion sample_vec函数解读
  • PAL(Program-Aided Language Model)
  • AGCR自适应杂波滤除算法for GPR
  • Image fusion meets deep learning: A survey and perspective译文
  • 【电路设计】基于CD4511的八路抢答器电路解析
  • 鸿蒙主流路由详解
  • FreeRTOS——软件定时器
  • 设计模式之创建模式篇
  • vue3项目部署在阿里云轻量应用服务器上
  • goframe开发一个企业网站 MongoDB 完整工具包18
  • 类和对象--中--运算符重载、日期类实现(重要)
  • 09 —— Webpack搭建开发环境
  • 【前端】JavaScript 作用域全面解析