前言
JDK21正式发布了虚拟线程
虚拟线程类似Golang中的协程,虚拟线程是轻量级线程,它可以大大减少编写、维护和观察高吞吐量并发应用程序的工作量,能够大大提升服务的高并发性能,允许通过 java.lang.Thread API 的现有代码来使用虚拟线程,并且只做最小的更改。
那么虚拟线程和我们之前所认识的普通线程(又称平台线程)又有什么区别呢
平台线程VS虚拟线程
- 平台线程:Java.Lang.Thread 类的每个实例,都是一个平台线程,是 Java 对操作系统线程的包装,与操作系统是 1:1 映射。
- 虚拟线程:一种轻量级,由 JVM 管理的线程。对应的实例 java.lang.VirtualThread 这个类。
- 载体线程:指真正负责执行虚拟线程中任务的平台线程。一个虚拟线程装载到一个平台线程之后,那么这个平台线程就被称为虚拟线程的载体线程。
JDK 中 java.lang.Thread 包下的每个实例都是一个平台线程
。平台线程在底层操作系统线程上运行 Java 代码,并在代码的整个生命周期内独占操作系统线程,平台线程实例本质是由系统内核的线程调度程序进行调度,并且平台线程的数量受限于操作系统线程的数量。
而虚拟线程(Virtual Thread)它不与特定的操作系统线程相绑定。它在平台线程上运行 Java 代码,但在代码的整个生命周期内不独占平台线程。这意味着许多虚拟线程可以在同一个平台线程上运行他们的 Java 代码,共享同一个平台线程。同时虚拟线程的成本很低,虚拟线程的数量可以比平台线程的数量大得多。
虚拟线程创建
方法一:直接创建虚拟线程
Thread vt = Thread.startVirtualThread(() -> {
System.out.println("Virtual Thread");
});
方法二:创建虚拟线程但不自动运行,手动调用start()开始运行
Thread.ofVirtual().unstarted(() -> {
System.out.println("Virtual Thread");
});
vt.start();
方法三:通过虚拟线程的 ThreadFactory 创建虚拟线程
ThreadFactory tf = Thread.ofVirtual().factory();
Thread vt = tf.newThread(() -> {
System.out.println("Start...");
Thread.sleep(1000);
System.out.println("End... ");
});
vt.start();
方法四:Executors.newVirtualThreadPer-TaskExecutor()
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
executor.submit(() -> {
System.out.println("Start...");
Thread.sleep(1000);
System.out.println("End...");
return true;
});
虚拟线程实现原理
虚拟线程是由JVM调度,而不是操作系统。虚拟线程占用空间小,同时使用轻量级的任务队列来调度虚拟线程,避免了线程间基于内核的上下文切换开销,因此可以极大量地创建和使用。
简单来看,虚拟线程实现如下:Virtual Thread =Continuation + Scheduler(执行器) + Runnable(真正的任务包装器)
虚拟线程会把任务(java.lang.Runnable实例)包装到一个 Continuation 实例中:
- 当任务需要阻塞挂起的时候,会调用Continuation的 yield操作进行阻塞,虚拟线程会从平台线程卸载。
- 当任务解除阻塞继续执行的时候,调用 Continuation.run 会从阻塞点继续执行。
Scheduler
也就是执行器,由它将任务提交到具体的载体线程池中执行。
- 它是 java.util.concurrent.Executor 的子类。
- 虚拟线程框架提供了一个默认的 FIFO 的 ForkJoinPool 用于执行虚拟线程任务。
Runnable
则是真正的任务包装器,由 Scheduler 负责提交到载体线程池中执行。
JVM 把虚拟线程分配给平台线程的操作称为 mount(挂载),取消分配平台线程的操作称为 unmount(卸载):
mount操作
:虚拟线程挂载到平台线程,虚拟线程中包装的 Continuation 堆栈帧数据会被拷贝到平台线程的线程栈,这是一个从堆复制到栈的过程。
unmount操作
:虚拟线程从平台线程卸载,此时虚拟线程的任务还没有执行完成,所以虚拟线程中包装的 Continuation 栈数据帧会会留在堆内存中。
从 Java 代码的角度来看,其实是看不到虚拟线程及载体线程共享操作系统线程的,会认为虚拟线程及其载体都在同一个线程上运行,因此,在同一虚拟线程上多次调用的代码可能会在每次调用时挂载的载体线程都不一样。JDK 中使用了 FIFO 模式的 ForkJoinPool 作为虚拟线程的调度器,从这个调度器看虚拟线程任务的执行流程大致如下:
- 调度器(线程池)中的平台线程等待处理任务。
- 一个虚拟线程被分配平台线程,该平台线程作为载体线程执行虚拟线程中的任务。
- 虚拟线程运行其 Continuation,Mount(挂载)平台线程后,最终执行 Runnable 包装的用户实际任务。
- 虚拟线程任务执行完成,标记 Continuation 终结,标记虚拟线程为终结状态,清空上下文,等待 GC 回收,解除挂载载体线程会返还到调度器(线程池)中等待处理下一个任务。
上面是没有阻塞场景的虚拟线程任务执行情况,如果遇到了阻塞(例如 Lock 等)场景,会触发 Continuation 的 yield 操作让出控制权,等待虚拟线程重新分配载体线程并且执行,具体见下面的代码:
ReentrantLock lock = new ReentrantLock();
Thread.startVirtualThread(() -> {
lock.lock();
});
// 确保锁已经被上面的虚拟线程持有
Thread.sleep(1000);
Thread.startVirtualThread(() -> {
System.out.println("first");
//会触发Continuation的yield操作
lock.lock();
try {
System.out.println("second");
} finally {
lock.unlock();
}
System.out.println("third");
});
Thread.sleep(Long.MAX_VALUE);
}
-
虚拟线程中任务执行时候调用 Continuation#run() 先执行了部分任务代码,然后尝试获取锁,该操作是阻塞操作会导致 Continuation 的 yield 操作让出控制权,如果 yield 操作成功,会从载体线程 unmount,载体线程栈数据会移动到 Continuation 栈的数据帧中,保存在堆内存中,虚拟线程任务完成,此时虚拟线程和 Continuation 还没有终结和释放,载体线程被释放到执行器中等待新的任务;如果 Continuation 的 yield 操作失败,则会对载体线程进行 Park 调用,阻塞在载体线程上,此时虚拟线程和载体线程同时会被阻塞,本地方法,Synchronized 修饰的同步方法都会导致 yield 失败。
-
当锁持有者释放锁之后,会唤醒虚拟线程获取锁,获取锁成功后,虚拟线程会重新进行 mount,让虚拟线程任务再次执行,此时有可能是分配到另一个载体线程中执行,Continuation 栈会的数据帧会被恢复到载体线程栈中,然后再次调用Continuation#run() 恢复任务执行。
-
虚拟线程任务执行完成,标记 Continuation 终结,标记虚拟线程为终结状态,清空上下文变量,解除载体线程的挂载载体线程返还到调度器(线程池)中作为平台线程等待处理下一个任务。
Continuation 组件十分重要,它既是用户真实任务的包装器,同时提供了虚拟线程任务暂停/继续的能力,以及虚拟线程与平台线程数据转移功能,当任务需要阻塞挂起的时候,调用 Continuation 的 yield 操作进行阻塞。当任务需要解除阻塞继续执行的时候,则调用 Continuation 的 run 恢复执行。
通过下面的代码可以看出 Continuation 的神奇之处,通过在编译参数加上–add-exports java.base/jdk.internal.vm=ALL-UNNAMED 可以在本地运行。
ContinuationScope scope = new ContinuationScope("scope");
Continuation continuation = new Continuation(scope, () -> {
System.out.println("before yield开始");
Continuation.yield(scope);
System.out.println("after yield 结束");
});
System.out.println("1 run");
// 第一次执行Continuation.run
continuation.run();
System.out.println("2 run");
// 第二次执行Continuation.run
continuation.run();
System.out.println("Done");
通过上述案例可以看出,Continuation
实例进行 yield
调用后,再次调用其 run
方法就可以从 yield
的调用之处继续往下执行
,从而实现了程序的中断和恢复**。
虚拟线程内存占用评估
单个平台线程的资源占用:
- 根据 JVM 规范,预留 1 MB 线程栈空间。
- 平台线程实例,会占据 2000+ byte 数据。
单个虚拟线程的资源占用:
- Continuation 栈会占用数百 byte 到数百 KB 内存空间,是作为堆栈块对象存储在 Java 堆中。
- 虚拟线程实例会占据 200 - 240 byte 数据。
从两者对比结果来看,理论上单个平台线程占用的内存空间至少是KB级别的,而单个虚拟线程实例占用的内存空间是byte级别,两者的内存占用相差1个数量级,这也是虚拟线程可以大批量创建的原因。
适用场景
- 大量的 IO 阻塞等待任务,例如下游 RPC 调用,DB 查询等。
- 大批量的处理时间较短的计算任务。
- Thread-per-request (一请求一线程)风格的应用程序,例如主流的 Tomcat 线程模型或者基于类似线程模型实现的 SpringMVC 框架 ,这些应用只需要小小的改动就可以带来巨大的吞吐提升。