虚拟线程详解

前言

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 代码,共享同一个平台线程。同时虚拟线程的成本很低,虚拟线程的数量可以比平台线程的数量大得多。

虚拟线程创建

方法一:直接创建虚拟线程

java 复制代码
Thread vt = Thread.startVirtualThread(() -> {
    System.out.println("Virtual Thread");
});

方法二:创建虚拟线程但不自动运行,手动调用start()开始运行

java 复制代码
Thread.ofVirtual().unstarted(() -> {
    System.out.println("Virtual Thread");
});
vt.start();

方法三:通过虚拟线程的 ThreadFactory 创建虚拟线程

java 复制代码
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()

java 复制代码
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 操作让出控制权,等待虚拟线程重新分配载体线程并且执行,具体见下面的代码:

java 复制代码
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 可以在本地运行。

java 复制代码
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 框架 ,这些应用只需要小小的改动就可以带来巨大的吞吐提升。
相关推荐
艾迪的技术之路16 分钟前
redisson使用lock导致死锁问题
java·后端·面试
今天背单词了吗98034 分钟前
算法学习笔记:8.Bellman-Ford 算法——从原理到实战,涵盖 LeetCode 与考研 408 例题
java·开发语言·后端·算法·最短路径问题
天天摸鱼的java工程师36 分钟前
使用 Spring Boot 整合高德地图实现路线规划功能
java·后端
东阳马生架构1 小时前
订单初版—2.生单链路中的技术问题说明文档
java
咖啡啡不加糖1 小时前
暴力破解漏洞与命令执行漏洞
java·后端·web安全
风象南1 小时前
SpringBoot敏感配置项加密与解密实战
java·spring boot·后端
DKPT1 小时前
Java享元模式实现方式与应用场景分析
java·笔记·学习·设计模式·享元模式
Percep_gan1 小时前
idea的使用小技巧,个人向
java·ide·intellij-idea
缘来是庄1 小时前
设计模式之迭代器模式
java·设计模式·迭代器模式