虚拟线程调度执行流程及原理

概述

在JDK19刚刚出来时,本人曾对JEP 425: Virtual Threads 做过一篇翻译文Java高并发革命!JDK19新特性——虚拟线程(Virtual Threads) – 羽墨的个人博客 (yumoyumo.top)

本文仅仅讨论虚拟线程的执行流程及底层实现原理,具体关于虚拟线程可看我上面的文章链接。

另外需要对I/O模型有所了解,可看我的另一篇博文IO模型 – 羽墨的个人博客 (yumoyumo.top)

让我们回顾一下虚拟线程的大概说明:

  • JDK 19引入了虚拟线程,这是一种轻量级线程,可以减少编写、维护和调试高吞吐量并发应用程序的工作量。
  • 虚拟线程是java.lang.Thread的一个实例,它不绑定到特定的操作系统线程。虚拟线程仍然在操作系统线程上运行代码。
  • 但是,当在虚拟线程中运行的代码调用阻塞I/O操作时,Java运行时会挂起虚拟线程,直到它可以恢复为止。
  • 与挂起的虚拟线程相关联的操作系统线程现在可以为其他虚拟线程执行操作。
  • 虚拟线程的开销极小,因此可以有很多很多很多个。就像操作系统通过将大型虚拟地址空间映射到有限的物理RAM来给人以丰富内存的错觉一样,JDK通过将许多虚拟线程映射到少量操作系统线程来给人以丰富线程的错觉

调度、执行流程架构图如下:

image-20230328220800697

接下来会对其中的各个部分进行具体阐述


用户模式线程

首先我们应该分清用户模式线程内核模式线程的概念和区别:

User-mode threads(用户模式线程)kernel-mode threads(内核模式线程)是两种不同类型的线程,它们的主要区别在于它们是由谁来管理和调度的。

  • User-mode threads是由应用程序来管理和调度的,而不是操作系统。这意味着,当一个user-mode thread阻塞时,整个进程都会阻塞,除非应用程序实现了一种机制来调度其他线程。User-mode threads通常比kernel-mode threads更快,因为它们不需要在用户模式和内核模式之间进行切换。
  • Kernel-mode threads是由操作系统内核来管理和调度的。当一个kernel-mode thread阻塞时,操作系统可以调度其他线程来运行。Kernel-mode threads通常比user-mode threads更慢,因为它们需要在用户模式和内核模式之间进行切换。

虚拟线程是一种轻量级(用户模式)线程,这种线程是由Java虚拟机调度,而不是操作系统。

虚拟线程占用空间小,任务切换开销几乎可以忽略不计,因此可以极大量地创建和使用。


过去我们在Java中谈论的创建的线程,是以1:1映射到**操作系统线程(OS therad)上的,为此我们用了一个新的词——平台线程(Platform thread)**来描述之前的线程,以此区分现在的虚拟线程。

平台线程是以传统方式实现的线程,作为围绕操作系统线程的简单包装

虚拟线程是没有绑定到特定操作系统线程的线程。

调度

为了完成有用的工作,需要调度一个线程,也就是分配给处理器核心执行。对于作为 OS 线程实现的平台线程,JDK 依赖于 OS 中的调度程序。相比之下,对于虚拟线程,JDK 有自己的调度程序。JDK 的调度程序不直接将虚拟线程分配给处理器,而是将虚拟线程分配给平台线程(这是前面提到的虚拟线程的 M: N 调度)。然后,操作系统像往常一样调度平台线程

这里有两个重要的东西:

  • 调度队列:调度虚拟线程
  • 调度程序(scheduler):调度平台线程

接下来我将对这两个概念详细描述

调度队列

先来看一下调度队列的定义和功能:

虚拟线程的调度队列是由Java运行时内部实现的,它负责跟踪所有挂起的虚拟线程,并根据可用性和其他因素来选择一个虚拟线程来恢复执行。

虚拟线程的调度队列通常是一个先进先出(FIFO)的数据结构,它按照虚拟线程被挂起的顺序来选择虚拟线程来恢复执行。

当虚拟线程被挂起时,它会被添加到调度队列中。

image-20230328205108693

调度程序

再看看调度程序的定义和功能:

Java运行时会使用调度程序(scheduler)来管理运载线程(carrier thread),并根据可用性和其他因素来选择一个运载线程来执行虚拟线程。

  • 当一个运载线程变得可用时,调度程序会从调度队列中选择一个虚拟线程,并将其重新挂载到运载线程上,从之前挂起的位置继续执行

默认情况下,Java运行时使用FIFO模式ForkJoinPool作为虚拟线程的调度程序。

ForkJoinPool是一种特殊类型的ExecutorService,它使用工作窃取算法来平衡任务负载并最大化吞吐量。

  • 当使用ForkJoinPool作为调度程序时,它会管理一组运载线程,并根据可用性和其他因素来选择一个运载线程来执行虚拟线程。

工作窃取算法(work-stealing algorithm)是一种用于动态负载平衡的算法。它用于多线程环境中,当一个线程完成了它的工作队列中的所有任务时,它会从其他线程的工作队列中“窃取”一些任务来执行。这样可以有效地平衡各个线程之间的负载,最大化吞吐量。

在Java中,ForkJoinPool使用工作窃取算法来管理它的工作线程。每个工作线程都有自己的双端队列,用于存储分配给它的任务。当一个工作线程完成了它的队列中的所有任务时,它会尝试从其他工作线程的队列中窃取一些任务来执行。这样可以有效地平衡各个工作线程之间的负载,并最大化吞吐量。

image-20230328205920463


执行流程

现在对于这段代码进行具体分析:

1
2
3
4
5
6
7
8
9
10
11
12
public static void main(String[] args) {
// 1.创建虚拟线程
Thread v = Thread.ofVirtual().factory().newThread(() -> {
System.out.println("before park");
LockSupport.park();
System.out.println("after park");
});
// 2.执行虚拟线程
v.start();
// 3.完成阻塞操作
LockSupport.unpark(v);
}
  1. 创建:主线程代码创建了一个虚拟线程,并把它放进调度队列

  2. 挂载挂载(mount)该虚拟线程:调度程序sheduler选择一个可用的平台线程作为载体,从调度队列取出该虚拟线程并将其挂在到之前选择的载体线程,在这个载体上执行它内部的代码(比如传给Runnale参数的lanbda代码)。这里即

    1
    2
    3
    4
    5
    () -> {
    System.out.println("before park");
    LockSupport.park();
    System.out.println("after park");
    }
  3. 执行执行该虚拟线程,直至遇到阻塞操作

  4. 卸载(unmount):该虚拟线程会被卸载以释放其载体线程,以便为其他虚拟线程执行操作。

  5. 挂起:然后这个虚拟线程会被Java Runtime挂起(suspend),然后放进调度队列

  6. 唤醒:当该虚拟线程的阻塞状态被打破(比如阻塞I/O操作完成,或这里的LockSupport.unpark();),它将被Java Runtime唤醒(await),从调度队列中取出然后挂在到调度程序为其选择的新的载体上

  7. 继续执行:从之前挂起的位置继续执行。

  8. 执行完毕:重复上面第2-7步,直至虚拟线程执行完毕。虚拟线程不是GC roots,当没有其他平台线程引用它时,它将在一次gc中被顺带回收


实现原理

为什么一个虚拟线程可以在上次被挂起的线程继续执行呢?这必须得提到底层的Continuation

Continuation

Continuation是一个重要的组件,它既是任务的包装器,也是任务切换虚拟线程与平台线程之间数据转移的一个句柄。它为程序流程(函数)提供了暂停/继续(yield/resume)的能力可以实现任务上下文的中断和恢复。

在虚拟线程中,任务(通常为java.lang.Runnable)被包装到Continuation实例中。

  • 当任务需要阻塞挂起的时候,会调用Continuationyield操作进行阻塞
  • 当任务需要解除阻塞继续执行的时候,Continuation会被继续执行

操作系统调度系统线程,而Java平台线程与系统线程一一映射,所以平台线程被操作系统调度,但是虚拟线程是由JVM调度。JVM把虚拟线程分配给平台线程的操作称为mount(挂载),反过来取消分配平台线程的操作称为unmount(卸载):

  • mount操作:虚拟线程挂载到平台线程,虚拟线程中包装的Continuation栈数据帧或者引用栈数据会被拷贝到平台线程的线程栈,这是一个从堆复制到栈的过程
  • unmount操作:虚拟线程从平台线程卸载,大多数虚拟线程中包装的Continuation栈数据帧会留在堆内存中

这个mount -> run -> unmount过程用伪代码表示如下:

1
2
3
4
5
6
mount();
try {
Continuation.run();
} finally {
unmount();
}

Java代码的角度来看,虚拟线程和它的载体线程暂时共享一个OS线程实例这个事实是不可见,因为虚拟线程的堆栈跟踪和线程本地变量与平台线程是完全隔离的。JDK中专门是用了一个FIFO模式的ForkJoinPool作为虚拟线程的调度程序,从这个调度程序看虚拟线程任务的执行流程大致如下:

  • 调度器(线程池)中的平台线程等待处理任务
  • 一个虚拟线程被分配平台线程,该平台线程作为运载线程执行虚拟线程中的任务
  • 虚拟线程运行其Continuation,从而执行基于Runnable包装的用户任务
  • 虚拟线程任务执行完成,标记Continuation终结,标记虚拟线程为终结状态,清空一些上下文变量,运载线程”返还”到调度器(线程池)中作为平台线程等待处理下一个任务

当虚拟线程挂载到平台线程时,虚拟线程中包装的Continuation栈数据帧或引用栈数据会被复制到平台线程的线程栈中。当虚拟线程从平台线程卸载时,大多数虚拟线程中包装的Continuation栈数据帧会留在堆内存中。

image-20230328223411198

上面是描述一般的虚拟线程任务执行情况,在执行任务时候首次调用Continuation#run()获取锁(ReentrantLock)的时候会触发Continuationyield操作让出控制权,等待虚拟线程重新分配运载线程并且执行,见下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class VirtualThreadLock {

public static void main(String[] args) throws Exception {
ReentrantLock lock = new ReentrantLock();
Thread.startVirtualThread(() -> {
lock.lock(); // <------ 这里确保锁已经被另一个虚拟线程持有
});
Thread.sleep(1000);
Thread.startVirtualThread(() -> {
System.out.println("first");
lock.lock();
try {
System.out.println("second");
} finally {
lock.unlock();
}
System.out.println("third");
});
Thread.sleep(Long.MAX_VALUE);
}
}
  • 虚拟线程中任务执行时候首次调用Continuation#run()执行了部分任务代码,然后尝试获取锁,会导致Continuationyield操作让出控制权(任务切换),也就是unmount,运载线程栈数据会移动到Continuation栈的数据帧中,保存在堆内存,虚拟线程任务完成(但是虚拟线程没有终结,同时其Continuation也没有终结和释放),运载线程被释放到执行器中等待新的任务;如果Continuationyield操作失败,则会对运载线程进行park调用,阻塞在运载线程上

  • 当锁持有者释放锁之后,会唤醒虚拟线程获取锁(成功后),虚拟线程会重新进行mount,让虚拟线程任务再次执行,有可能是分配到另一个运载线程中执行,Continuation栈会的数据帧会被恢复到运载线程栈中,然后再次调用Continuation#run()恢复任务执行:

  • 最终虚拟线程任务执行完成,标记Continuation终结,标记虚拟线程为终结状态,清空一些上下文变量,运载线程”返还”到调度器(线程池)中作为平台线程等待处理下一个任务


Continuation组件十分重要,它既是用户真实任务的包装器,也是任务切换虚拟线程与平台线程之间数据转移的一个句柄,它提供的yield操作可以实现任务上下文的中断和恢复。由于Continuation被封闭在java.base/jdk.internal.vm下,可以通过增加编译参数--add-exports java.base/jdk.internal.vm=ALL-UNNAMED暴露对应的功能,从而编写实验性案例。

然后编写和运行下面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import jdk.internal.vm.Continuation;
import jdk.internal.vm.ContinuationScope;

public class ContinuationDemo {

public static void main(String[] args) {
ContinuationScope scope = new ContinuationScope("scope");
Continuation continuation = new Continuation(scope, () -> {
System.out.println("Running before yield");
Continuation.yield(scope);
System.out.println("Running after yield");
});
System.out.println("First run");
// 第一次执行Continuation.run
continuation.run();
System.out.println("Second run");
// 第二次执行Continuation.run
continuation.run();
System.out.println("Done");
}
}

运行代码的结果

1
2
3
4
5
First run
Running before yield
Second run
Running after yield
Done

这里可以看出Continuation的奇妙之处,Continuation实例进行yield调用后,再次调用其run方法就可以从yield的调用之处往下执行,从而实现了程序的中断和恢复。

参考

Java虚拟线程的核心: Continuation - 知乎 (zhihu.com)

JEP 425: Virtual Threads (Preview) (openjdk.org)

JEP 436: Virtual Threads (Second Preview) (openjdk.org)