Java19发布,带来了 Java 开发者期待已久的新特性——虚拟线程。在 Java 有这个新特性之前,Golang 的 协程已经流行了很长时间,在并发编程领域大获成功。随着 Golang 的快速发展和推广,协程似乎已经成为世界上最好的语言的必备特性之一。
Java19 虚拟线程可以填补这一空白。在这篇文章中,我们将带你通过对虚拟线程的介绍以及与 Golang 协程的对比,带你领略 Java19 虚拟线程的风采。
一、Java 19线程模型引入虚拟线程概念
java线程与虚拟线程
我们常见的Java线程与系统内核线程是一一对应的,系统内核线程调度器负责调度Java线程。为了提高应用程序的性能,我们会创建越来越多的Java线程,显然系统在调度Java线程时会消耗大量资源,来处理线程上下文切换。

近几十年来,我们一直依靠上述多线程模型来解决 Java 中的并发编程问题。为了提高系统的吞吐量,我们必须不断增加线程的数量,但是机器的线程很昂贵,可用线程的数量是有限的。尽管我们使用各种线程池来最大限度地提高线程的成本效益,但在 CPU、网络或内存资源被耗尽之前,线程往往成为我们应用程序性能的瓶颈,无法释放硬件应具有的最大性能。

为了解决这个问题,Java19 引入了虚拟线程。在 Java19 中,我们以前使用的线程称为平台线程,仍然与系统内核线程一一对应。大量 (M个) 的虚拟线程,运行在少量 (N个) 的平台线程上(与 OS 线程一一对应)(M:N 调度)。JVM调度多个虚拟线程在特定平台线程上执行,并且在平台线程上一次只执行一个虚拟线程。
如何创建 Java 虚拟线程?
新的线程相关 API
Thread.ofVirtual()和Thread.ofPlatform()分别用于创建虚拟和平台线程的新 API。
| //output thread ID including virtual threads and system threads Thread.getId() deprecated from jdk19 |
使用Thread.startVirtualThread(Runnable)快速创建虚拟线程并启动它。
| // Output thread IDs including virtual threads and system threads |
使用Thread.isVirtual()方法定义一个线程是否为虚拟线程的。
| Runnable runnable = () -> System.out.println(Thread.currentThread().isVirtual()); |
使用Thread.join()等待虚拟线程完成作业调度,使用Thread.sleep()讲虚拟线程致为休眠状态。
| Runnable runnable = () -> System.out.println(Thread.sleep(10)); |
使用 Executors.newVirtualThreadPerTaskExecutor()。创建ExecutorService 。ExecutorService 可以为每个任务创建一个新的虚拟线程。
| try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
|
也可以使用线程池和 ExecutorService ,完成现有代码的替换和迁移。
因为虚拟线程是Java19中的一个预览特性,所以本文出现的代码用到的运行命令如下。
- 使用 编译程序javac --release 19 --enable-preview Main.java并使用java --enable-preview Main.
- 或者使用java --source 19 --enable-preview Main.java.
二、Java虚拟线程 VS Go 协程
实现原理对比
GO GMP模型
Go 语言采用两级线程模型,其中 Goroutine 是 M:N 与系统内核线程,符合 Java 虚拟线程。 最终的 goroutine 仍然交给 OS 线程执行,但需要一个中介来提供上下文。 这是 G-M-P 模型。
- G:goroutine,类似于进程控制块,持有栈、状态、id、函数等。G只有绑定到P才能被调度。
- M:机器,系统线程,绑定到一个有效的 P 然后被调度。
- P:逻辑处理器,持有各种队列G。对于G来说,P是cpu核心。对于 M,P 是上下文。
- sched:调度器,保存GRQ(全局运行队列)、M空闲队列、P空闲队列、锁等信息。

排队
Go 调度程序有两个不同的运行队列。
- GRQ,全局运行队列,尚未分配给 G for P(在 Go 1.1 之前只有 GRO 全局运行队列,但由于全局队列锁定的性能问题,添加了 LRQ 以减少锁定等待)。
- LRQ,本地运行队列,每个P都有一个LRQ,负责管理分配给P的G。当LRQ中没有G要执行时,从GRQ中取出。
交接机制
当G执行阻塞操作时,GMP调度空闲M执行阻塞M LRQ中的其他G,以防止阻塞M影响LRQ中其他G的执行。
- G1 在 M1 上运行,P 的 LRQ 有 3 个其他 G。
- G1 进行同步调用,阻塞 M。
- 当 M1 下只有 G1 运行且没有 P 时,Scheduler 将 M1 与 P 分离。
- 将P绑定到空闲的M2,M2从LRQ中选择其他G运行。
- G1 结束阻塞操作并返回到 LRQ。M1 被放置在空闲队列中进行备份。
偷工减料机制
GMP 为了最大化硬件的性能,任务窃取机制用于在 M 空闲时执行其他等待的 G。
- 有两个P,P1和P2。
- 如果 P1 的 Gs 都被执行,并且 LRQ 为空,则 P1 开始任务窃取。
- 在第一种情况下,P1 从 GRQ 获得 G。
- 在第二种情况下,P1 没有从 GRQ 获得 G,然后 P1 从 P2 LRQ 窃取 G。
切换机制是为了防止 M 阻塞,任务窃取是为了防止 M 空闲。
Java 虚拟线程调度模型
JDK 依赖于操作系统中的线程调度器来调度基于操作系统线程实现的平台线程。对于虚拟线程,JDK 有自己的调度程序。JDK的调度器不是直接将虚拟线程分配给系统线程,而是将虚拟线程分配给平台线程(这就是前面提到的虚拟线程的M:N调度)。平台线程由操作系统的线程调度系统调度。
JDK 的虚拟线程调度器是一个类似ForkJoinPoolFIFO 模式的线程池。调度程序中的并行量取决于调度程序虚拟线程中的平台线程数。默认值是可用的 CPU 内核数,但可以使用系统属性进行调整jdk.virtualThreadScheduler.parallelism。注意ForkJoinPool这里与 不同ForkJoinPool.commonPool(),后者用于实现并行流,以 LIFO 模式运行。
ForkJoinPool并ExecutorService以不同的方式工作。ExecutorService有一个等待队列来存储其任务,其中的线程将接收并处理这些任务。虽然ForkJoinPool每个线程都有一个等待队列,但当一个线程运行的任务生成另一个任务时,该任务会添加到该线程的等待队列中,当我们运行Parallel Stream并将一个大任务分为两个较小的任务时会发生这种情况。
为了防止线程饥饿问题,当一个线程的等待队列中没有更多任务时,ForkJoinPool还实现了另一种称为任务窃取的模式,这意味着一个饥饿的线程可以从另一个线程的等待队列中窃取一些任务。这类似于 Go GMP 模型中的工作窃取机制。

虚拟线程执行
通常,当虚拟线程在 JDK 中执行 I/O 或其他阻塞操作时,会从平台线程中卸载虚拟线程,例如BlockingQueue.take(). 当阻塞操作准备好完成时(例如,网络 IO 已接收到字节数据),调度程序将虚拟线程挂载到平台线程上以恢复执行。
JDK 中的大多数阻塞操作从平台线程中卸载虚拟线程,从而允许平台线程执行其他工作任务。但是,JDK 中的一些阻塞操作不会卸载虚拟线程,因此会阻塞平台线程。这是因为操作系统级别(例如,许多文件系统操作)或 JDK 级别(例如Object.wait())的限制。当这些阻塞操作阻塞平台线程时,它们会通过临时增加平台线程的数量来补偿其他平台线程阻塞的损失。因此,调度程序中的平台线程数ForkJoinPool可能会

本文介绍Java19引入的虚拟线程特性,探讨其使用方法及内部机制,并与Go协程进行对比,帮助读者理解虚拟线程如何提高并发性能。
最低0.47元/天 解锁文章
939

被折叠的 条评论
为什么被折叠?



