Run, Java run - Java 21 之虚拟线程
技术分享
Sep 20, 2023
type
Post
status
Published
date
Sep 20, 2023
slug
java-21-virtual-thread
summary
2023 年 9月,Java 21 按期正式发布,随之而来的众多新特性中,正式版本的虚拟线程备受关注,这个需要巨大工作量的特性能够给 Java 开发者带来什么呢,而我们又要怎么去享受这种由 runtime 带来的性能加成呢
tags
Java
category
技术分享
icon
password
Property
Oct 18, 2023 03:08 AM

Java 的虚拟线程来了

于近日的发布的 JDK 21,正式带来了大家期待已久的虚拟线程特性,这个特性经历了多个版本的开发和测试反馈,总算是达到了能够正式发布的成熟度,让我们先为它干一杯 🍾
虚拟线程(Virtual Threads)这个特性最初于 2021 年在 JEP-425 提议,在2022 年 9 月发布的 JDK 19 中第一次出现,作为 Preview 的特性接受广大开发者的测试和反馈,之后于2023 年 3 月在新的提议 JEP-436 和 JDK 20 中发布了 Second Preview 版本,这个版本基于用户的使用反馈,也为了进一步提升开发体验,对该特性进行修改和完善,再到最近的 JEP-444 和 JDK 21 中最终定稿,正式发布。
notion image
我们可以看到该特性的最初提案中,JDK 团队对开发工作量的评估为 ‘XL’ 规模,说明整个研发所需的工作量是相当大的,那么我们不禁要想这个特性为什么值得花那么大的力气去开发呢?

什么是线程及虚拟线程

我们首先需要搞清楚什么是线程和什么是虚拟线程,也许有了这两个问题的答案之后,我们就会明白为什么大家都在期待和关注这个特性,以及为什么 JDK 团队会花费巨大的心血来实现这个特性。

线程

众所周知,应用程序由不同的编程语言编写,然后通过编译或者解释运行在不同的操作系统中,运行着的程序在大多数操作系统中体现为“进程”,它们接受操作系统的管理。当然,如果程序中所有语句或者指令都在同一个“执行管道(队列)”中串行执行,其运行效率可以想象得差,所以在单个进程中,我们仍然可以将程序拆分成不同的执行单元,让它们以更小的粒度并发执行,提升整个程序的执行效率,这些同时运行的单元被称为 “线程”,也是计算机体系中 CPU 调度和执行的基本单元。
现在几乎所有操作系统都提供创建线程的接口,但是这并不意味着程序都会直接将线程“托管”给操作系统。现实情况是不同的编程语言对线程模型的抽象和实现也是不同的,通常按照变成语言中的线程跟操作系统线程的对应关系分为以下两类
  • 一一对应:此时编程语言中的线程对应着操作系统中的线程,他们几乎是对等的,对应关系也必然是
  • 多对多:编程语言提供自己对线程的抽象和实现,一般这种线程被称为绿色线程(Green Thread),使用绿色线程的编程语言将在不同数量的操作系统线程的上下文中执行自己的绿色线程,所以他们的对应关系是
这两种模型都有自己的优缺点,此处就不再详述。
不同的编程语言创建者们经过了权衡和取舍,采用不同的方案来实现自己的目标。那么 Java 语言的采用了什么方案呢?其实 Green Thread 这个名字就是来源于 Java 的开发团队 The Green Team,那时他们在JDK 1.1 版本引入线程的支持,并实现了多对多的方案(只不过当时多个绿色线程对应 1 个操作系统线程,也就是 ),但是在 JDK 1.3 版本时,Java 团队将线程方案切换到一对一模型,并延续至今。
线程是 Java 基本的并发单元,所以很多应用程序都会采用线程来达成高并发的目的,而最常见的模式就是“请求每线程 request-pre-thread”,应用程序通过在请求的整个持续时间内专用一个线程来处理该请求,这种方式易于理解、易于编程、易于调试和分析,因为它使用Java 语言的并发单位来表示应用程序的并发单位。

虚拟线程

由上文可知,Java 应用中的线程跟操作系统的线程一一对应,在 hotspot JVM 中,线程只是对操作系统的线程进行了一层简单的封装,它会将 Java 代码放入操作系统线程中执行,并跟随着其对应的底层的操作系统线程的生命周期存在,所以操作系统线程所带来的限制和不足,都会体现在 Java 线程中,比如线程的创建、上下文切换、销毁等开销大,线程所需的内存空间大等。
线程的限制导致使用“request-pre-thread”模型很容易随着请求的攀升达到操作系统中能创建的线程的极限,也就无法提供更高的并发能力或者吞吐量,因为如果每个请求在其持续时间内消耗一个线程,从而消耗一个操作系统线程,那么在其他资源(例如 CPU 或网络连接)耗尽之前,线程数量通常会成为限制因素。
We can solve any problem by introducing an extra level of indirection — David J. Wheeler
这段名言一般也被引用为“所有的计算机难题都能通过引入额外的一层抽象来解决”,所以线程带来的问题其中一个解决方案就是在“线程”模型的上层提供一个轻量级的“虚拟线程”抽象。
Java 运行时通过将大量虚拟线程映射到少量操作系统线程来提供线程充足的假象,这些虚拟线程同样能够完成线程的功能,而且仅在需要使用 CPU 执行计算时才消耗操作系统线程,在执行阻塞性操作的时候,它们会自动挂起,同时它们在 JVM 内部进行调度和管理,所需要的成本也远小于线程,使得硬件利用率接近最佳,允许高水平的并发性,从而实现高吞吐量。
类似的思想也已经有了不同的实现,如 Kotlin 的 coroutine, Go 语言的 goroutine 也都是轻量级的线程,并且在各自的用户里很受欢迎,尽管他们的实现目标和方式各不相同。
Java 中虚拟线程的目标如下
  • 所有“request-pre-thread”模型的服务器应用能够扩展到接近最佳的硬件利用率
  • 现有使用 java.lang.Thread API 的代码能够以最小的更改适配虚拟线程
  • 现有的 JDK 工具也能轻松实现对虚拟线程的故障排除、调试和性能分析
这些目标决定了 Java 虚拟线程专注于扩展现有的线程 API 而不是取代它们,也不会改变现有的并发控制模型,更不会改变异步编程模型和支持数据并发的 Stream API,同时它也支持线程本地变量,支持使用现有的工具体系来监测和调试,还在 jcmd 中增加了新的命令做更具体的分析,所以,对于 Java 开发人员来说,虚拟线程只是创建成本低廉且数量几乎无限的线程,学习和迁移成本极低。
从底层来看,虚拟线程采用 调度,其中大量 (M) 虚拟线程被调度在较少数量 (N) 的操作系统线程上运行,所以现在 Java 可以说同时支持两种模式(1:1 和 M:N)的线程模型,😄

怎么使用它

创建

通过 Thread API 来创建虚拟线程

Thread thread = Thread.ofVirtual().start(() -> System.out.println("Hello")); thread.join();

通过 Thread.Builder 来创建虚拟线程

这样可以为它提供更多细节信息,也类似于创建了一个 ThreadFactory ,然后可以借此创建具有相同属性的多个线程
try { Thread.Builder builder = Thread.ofVirtual().name("MyThread"); Runnable task = () -> { System.out.println("Running thread"); }; Thread t = builder.start(task); System.out.println("Thread t name: " + t.getName()); t.join(); } catch (InterruptedException e) { e.printStackTrace(); }

通过 Executors API 来创建

这样无需关注具体每个虚拟线程的创建和管理,只需要提交任务给它们即可
try (ExecutorService myExecutor = Executors.newVirtualThreadPerTaskExecutor()) { Future<?> future = myExecutor.submit(() -> System.out.println("Running thread")); future.get(); System.out.println("Task completed"); } catch (InterruptedException | ExecutionException e) { e.printStackTrace(); }

提示

虚拟线程与平台线程的不同

  • 虚拟线程始终是守护线程, Thread.setDaemon(boolean) 方法无法将虚拟线程更改为非守护线程
  • 虚拟线程具有固定的优先级 Thread.NORM_PRIORITY , Thread.setPriority(int) 方法对虚拟线程没有影响(未来版本中可能会重新考虑此限制)
  • 虚拟线程不是线程组的活动成员,在虚拟线程上调用时, Thread.getThreadGroup() 返回一个名为 "VirtualThreads" 的占位符线程组, Thread.Builder API 没有定义设置虚拟线程的线程组的方法
  • 虚拟线程没有权限执行 SecurityManager 相关的操作

不要“池化”虚拟线程

线程池是 Java 应用性能优化常用的手段,它会通过共享复用线程来解决线程创建和销毁时开销大的问题,但是虚拟线程没有这方面的问题,它们的生命周期短且调用栈浅,故不要尝试在代码中“池化”虚拟线程,直接给你的每个任务创建一个即可

使用信号量来限制并发访问的规模

有时候,我们会尝试使用线程池中最大线程数来限制程序的并发访问规模,使用虚拟线程之后,需要改变这种做法,使用专门用来达成此目的的信号量来实现

避免在为每个虚拟线程创建昂贵的资源

以前可能会为了在线程之间共享一些昂贵的资源,比如数据库连接之类的,这些资源跟随线程创建,放入 ThreadLocal 中在线程的多个任务之间共享,现在我们必须改变这种做法,选用其他缓存机制,因为虚拟线程可能有成千上万个,大量创建的昂贵的资源会显著降低系统的性能和可用性

避免虚拟线程长时间绑定到平台线程上

当 Java 运行时调度虚拟线程时,它会在平台线程上分配或挂载虚拟线程,然后操作系统照常调度该平台线程。该平台线程称为载体。当执行完或者需要执行阻塞性操作时,运行时会将虚拟线程从载体卸载,载体就空闲出来并能够参与其他虚拟线程的调度。
如果虚拟线程处于一下两种情况时,可能无法从平台线程上卸载,此时即称为绑定
  1. 虚拟线程在 synchronized 块或方法内运行代码
  1. 虚拟线程运行 native 方法或 外部(foreign) 函数
绑定不会使应用程序不正确,但可能会妨碍其可扩展性,因为此时平台线程被长期占用。

未来是美好的

因为此次带来的虚拟线程已经尽可能保留了原有的线程模型和 API,并未修改并发机制和使用习惯,可以运行能在平台线程执行的任何代码,特别是,虚拟线程支持线程局部变量和线程中断,就像平台线程一样,所以原有的生态只需要稍微修改代码即可顺利迁移进入虚拟线程时代,尤其是大量的基于 request-pre-thread 模型构建的框架,会以极小的代价达到到更高的吞吐量和并发程度。
同时,虚拟线程将会在 Java 以后引入类似 Go 中的 channel 模型之后发挥更大的作用,将 Java 语言的并发性方面的表达能力提升到新的高度。
赶紧用起来吧
  • Java
  • 软件研发团队的信息管理
    使用 Vault 保护应用的敏感数据