一文搞定虚拟线程!java21最重要的新特性

120 阅读24分钟

前言

对于Java21的版本更新,我认为最重要的就是虚拟线程的正式使用(19为预览)。刚看到这个特性时,就感觉和go中的协程很像,应该是差不多的实现方式,也是java在进化之路上常见的的海纳百川了。

这次同样是前文的填坑之作,阅读了比较多的文章总结而成,自认为已经介绍的很清楚了。如果对您有一点点收获还请点个赞!如果您还是有疑惑,可以在评论区留言。

前文如下:

介绍

虚拟线程在 Java 19 为预览特性 ,Java 21 转正,是Java 8以后最重要的特性之一。

官方文档如下:openjdk.org/jeps/436

翻译版本:juejin.cn/post/714687…

为什么需要虚拟线程

在虚拟线程推出之前,我们使用的线程都是通过 java.lang.Thread 为我们提供了对操作系统线程的抽象,Java中的线程和操作系统线程是一对一的关系,我们可以称之为“平台线程”。平台线程在底层 OS 线程上运行 Java 代码,并在代码的整个生命周期中占用该 OS 线程,因此平台线程的数量受限于 OS 线程的数量。

所以平台线程本身是会有一定缺陷,或者说可以优化的点。

  • 代价昂贵:创建平台线程的成本很高。每当创建一个平台线程时,操作系统都必须在堆栈中分配大量内存来存储线程的上下文、原生调用堆栈和 Java 调用堆栈。由于堆栈大小是固定的,这就导致了高昂的内存开销。
  • 上下文切换成本高:在多线程环境下,需要在不同线程间切换,这种上下文切换会消耗时间和资源。
  • 线程数量有限:Java 线程仅仅只是对操作系统线程的封装,而操作系统线程的数量是有限的,这就限制了 Java 同时运行的线程数量,从而限制了应用程序的并发能力。

什么是虚拟线程

虚拟线程是 Java 中的一种轻量级线程,它旨在解决传统线程模型中的一些限制,提供了更高效的并发处理能力,允许创建数千甚至数万个虚拟线程,而无需占用大量操作系统资源。

虚拟线程是 java.lang.Thread 的一个实例,它在底层 OS 线程上运行 Java 代码,但不会在代码的整个生命周期中占用该 OS 线程。也就是说,多个虚拟线程可以在同一个 OS 线程上运行其 Java 代码,可以有效地共享该线程。平台线程独占宝贵的 OS 线程,而虚拟线程则不会,因此虚拟线程的数量可以比 OS 线程的数量多得多,执行阻塞任务的整体吞吐量也就大了很多。

QQ_1733111888091.png

相比与传统线程,它的优势:

更加轻量级:虚拟线程相比传统线程更加轻量级。因为它们不是直接映射到操作系统线程上,而是在用户空间内被管理。这种设计减少了线程创建和销毁的开销,允许在同一应用中运行成千上万的线程。

资源消耗更少:由于不是直接映射到操作系统线程,虚拟线程显著降低了内存和其他资源的消耗。这使得在有限资源下可以创建更多的线程。

上下文切换开销更低:由于虚拟线程在用户空间,而不是通过操作系统,所以它的上下文切换开销更低。

改善阻塞操作的处理:在传统线程模型中,阻塞操作(如 I/O)会导致整个线程被阻塞,浪费宝贵的系统资源。然而当一个虚拟线程阻塞时,它可以被挂起,底层的操作系统线程则可以用来运行其他虚拟线程。

简化并发编程:可以像编写普通顺序代码一样编写并发代码,而不需要过多考虑线程管理和调度。它简化了 Java 的并发编程模型。

提升性能:在 I/O 密集型应用中,虚拟线程能够显著地提升性能。而且由于它们的创建和销毁成本低,能够更加高效地利用系统资源。

不适用场景

虽然虚拟线程这么厉害,但是它不做以下几件事:

  • 不替代传统线程:虚拟线程并不旨在完全替代传统的操作系统线程,而是作为一个补充。对于需要密集计算和精细控制线程行为的场景,传统线程仍然是主流。
  • 非针对最低延迟:虚拟线程主要针对高并发和高吞吐量,而不是最低延迟。对于需要极低延迟的应用,传统线程可能是更好的选择。
  • 不改变基本的线程模型:虚拟线程改进了线程的实现方式,但并未改变Java基本的线程模型和同步机制。锁和同步仍然是并发控制的重要工具。

虚拟线程可以发挥的最大作用是,可以让采用单请求单线程(thread-per-request)的方式编写的服务器程序最大化地利用CPU计算资源 其原因在于服务器程序有两大特点,一是需要处理较大吞吐量的请求,二是请求处理的过程大多是由IO密集型逻辑组成,这就导致采用平台线程实现的单请求单线程编写方式,可能会有大量的IO阻塞占据了平台线程资源,从而不能充分利用CPU资源。我们在使用真实应用压测时观察到,当服务请求IO耗时增大时,使用虚拟线程的吞吐量会明显高于线程池,尤其是当服务下游依赖出现故障导致耗时增大时,虚拟线程带来的服务可用性提升会非常明显。

虚拟线程和Go语言中的协程对比

使用方式对比

Go 协程对比 Java 虚拟线程

定义一个 say() 方法,方法体是循环 sleep 100ms,然后输出index,将这个方法使用协程执行。

Go 实现:

package main
 
 import (
     "fmt"
     "time"
 )

 func say(s string) {
     for i := 0; i < 5; i++ {
         time.Sleep(100 * time.Millisecond)
         fmt.Println(s)
     }
 }

 func main() {
     go say("world")
     say("hello")
 }

Java 实现:

 public final class VirtualThreads {
      static void say(String s) {
          try {
              for (int i = 0; i < 5; i++) {
                  Thread.sleep(Duration.ofMillis(100));
                  System.out.println(s);
              }
          } catch (InterruptedException e) {
              throw new RuntimeException(e);
          }
      }
  ​
      public static void main(String[] args) throws InterruptedException {
          var worldThread = Thread.startVirtualThread(
              () -> say("world")
          );
          
          say("hello");
          
          // 等待虚拟线程结束
          worldThread.join();
      }
  }

可以看到两种语言协程的写法很相似,总体来说 Java 虚拟线程的写法稍微麻烦一点,Go 使用一个关键字就能方便的创建协程。

Go 管道对比 Java 阻塞队列

在 Go 语言编程中,协程与管道的配合相得益彰,使用协程计算数组元素的和(分治思想):

Go 实现:

package main
import "fmt"
func sum(s []int, c chan int) {
    sum := 0
    for _, v := range s {
        sum += v
    }
    c <- sum // send sum to c
}
    
func main() {
    s := []int{7, 2, 8, -9, 4, 0}
​
    c := make(chan int)
    go sum(s[:len(s)/2], c)
    go sum(s[len(s)/2:], c)
    x, y := <-c, <-c // receive from c
​
    fmt.Println(x, y, x+y)
}

Java 实现:

import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.Executors;
public class Main {
    static void sum(int[] s, int start, int end, BlockingQueue<Integer> queue) throws InterruptedException {
        int sum = 0;
        for (int i = start; i < end; i++) {
            sum += s[i];
        }
        queue.put(sum);
    }

    public static void main(String[] args) throws InterruptedException {
        int[] s = {7, 2, 8, -9, 4, 0};
        var queue = new ArrayBlockingQueue<Integer>(1);
        Thread.startVirtualThread(() -> {
            sum(s, 0, s.length / 2, queue);
        });
        Thread.startVirtualThread(() -> {
            sum(s, s.length / 2, s.length, queue);
        });
        int x = queue.take();
        int y = queue.take();
​
        System.out.printf("%d %d %d\n", x, y, x + y);
    }
}

因为 Java 中没有数组切片,所以使用数组和下标来代替。Java 中没有管道,用与管道相似的 BlockingQueue 来代替,可以实现功能。

协程实现原理对比

GO G-M-P 模型

Go 语言采用两级线程模型,协程与系统内核线程是 M:N 的,这一点与 Java 虚拟线程一致。最终 goroutine 还是会交给 OS 线程执行,但是需要一个中介,提供上下文。这就是 G-M-P 模型。

  • G: goroutine, 类似进程控制块,保存栈,状态,id,函数等信息。G 只有绑定到 P 才可以被调度。
  • M: machine, 系统线程,绑定有效的 P 之后,进行调度。
  • P: 逻辑处理器,保存各种队列 G。对于 G 而言,P 就是 cpu 核心。对于 M 而言,P 就是上下文。
  • sched: 调度程序,保存 GRQ(全局运行队列),M 空闲队列,P 空闲队列以及 lock 等信息。

image-20220920115604057.png

队列

Go 调度器有两个不同的运行队列:

  • GRQ,全局运行队列,尚未分配给 P 的 G(在 Go1.1 之前只有 GRO 全局运行队列,但是因为全局队列加锁的性能问题加上了LRQ,以减少锁等待)。
  • LRQ,本地运行队列,每个 P 都有一个 LRQ,用于管理分配给P执行的 G。当 LRQ 中没有待执行的 G 时会从 GRQ 中获取。
hand off 机制

当 G 执行阻塞操作时,G-M-P 为了防止阻塞 M,影响 LRQ 中其他 G 的执行,会调度空闲 M 来执行阻塞 M LRQ 中的其他 G:

  1. G1 在 M1 上运行,P 的 LRQ 有其他 3 个 G;
  2. G1 进行同步调用,阻塞 M;
  3. 调度器将 M1 与 P 分离,此时 M1 下只运行 G1,没有 P。
  4. 将 P 与空闲 M2 绑定,M2 从 LRQ 选择其他 G 运行。
  5. G1 结束堵塞操作,移回 LRQ。M1 会被放置到空闲队列中备用。
work stealing机制

G-M-P 为了最大限度释放硬件性能,当 M 空闲时会使用任务窃取机制执行其他等待执行的 G:

  1. 有两个 P,P1,P2。
  2. 如果 P1 的 G 都执行完了,LRQ 为空,P1 就开始任务窃取。
  3. 第一种情况,P1从 GRQ 获取 G。
  4. 第二种情况,P1 从 GRQ 没有获取到 G,则 P1 从 P2 LRQ 中窃取G。

hand off 机制是防止 M 阻塞,任务窃取是防止 M 空闲。

Java 虚拟线程调度

基于操作系统线程实现的平台线程,JDK 依赖于操作系统中的线程调度程序来进行调度。而对于虚拟线程,JDK 有自己的调度器。JDK 的调度器没有直接将虚拟线程分配给系统线程,而是将虚拟线程分配给平台线程(这是前面提到的虚拟线程的 M:N 调度)。平台线程由操作系统的线程调度系统调度。

JDK 的虚拟线程调度器是一个在 FIFO 模式下运行的类似ForkJoinPool的线程池。调度器的并行数量取决于调度器虚拟线程的平台线程数量。默认情况下是 CPU 可用核心数量,但可以使用系统属性jdk.virtualThreadScheduler.parallelism进行调整。注意,这里的ForkJoinPoolForkJoinPool.commonPool()不同,ForkJoinPool.commonPool()用于实现并行流,并在 LIFO 模式下运行。

ForkJoinPoolExecutorService的工作方式不同,ExecutorService有一个等待队列来存储它的任务,其中的线程将接收并处理这些任务。而ForkJoinPool的每一个线程都有一个等待队列,当一个由线程运行的任务生成另一个任务时,该任务被添加到该线程的等待队列中,当我们运行Parallel Stream,一个大任务划分成两个小任务时就会发生这种情况。

为了防止线程饥饿问题,当一个线程的等待队列中没有更多的任务时,ForkJoinPool还实现了另一种模式,称为任务窃取, 也就是说:饥饿线程可以从另一个线程的等待队列中窃取一些任务。这和 Go G-M-P 模型中 work stealing 机制有异曲同工之妙。

image-20220921113049916.png

虚拟线程的执行

通常,当虚拟线程执行 I/O 或 JDK 中的其他阻止操作(如BlockingQueue.take()时,虚拟线程会从平台线程上卸载。当阻塞操作准备完成时(例如,网络 IO 已收到字节数据),调度程序将虚拟线程挂载到平台线程上以恢复执行。

JDK 中的绝大多数阻塞操作会将虚拟线程从平台线程上卸载,使平台线程能够执行其他工作任务。但是,JDK 中的少数阻塞操作不会卸载虚拟线程,因此会阻塞平台线程。因为操作系统级别(例如许多文件系统操作)或 JDK 级别(例如Object.wait())的限制。这些阻塞操作阻塞平台线程时,将通过暂时增加平台线程的数量来补偿其他平台线程阻塞的损失。因此,调度器的ForkJoinPool中的平台线程数量可能会暂时超过 CPU 可用核心数量。调度器可用的平台线程的最大数量可以使用系统属性jdk.virtualThreadScheduler.maxPoolSize进行调整。这个阻塞补偿机制与 Go G-M-P 模型中 hand off 机制有异曲同工之妙。

在以下两种情况下,虚拟线程会被固定到运行它的平台线程,在阻塞操作期间无法卸载虚拟线程:

  1. 当在synchronized块或方法中执行代码时。
  2. 当执行native方法或foreign function时。

虚拟线程被固定不会影响程序运行的正确性,但它可能会影响系统的并发度和吞吐量。如果虚拟线程在被固定时执行 I/O或BlockingQueue.take() 等阻塞操作,则负责运行它的平台线程在操作期间会被阻塞。(如果虚拟线程没有被固定,那会执行 I/O 等阻塞操作时会从平台线程上卸载)

如何卸载虚拟线程

我们通过 Stream 创建 5 个未启动的虚拟线程,这些线程的任务是:打印当前线程,然后休眠 10 毫秒,然后再次打印线程。然后启动这些虚拟线程,并调用join()以确保控制台可以看到所有内容:

public static void main(String[] args) throws Exception {
  var threads = IntStream.range(0, 5).mapToObj(index -> Thread.ofVirtual().unstarted(() -> {
    System.out.println(Thread.currentThread());
    try {
      Thread.sleep(10);
    } catch (InterruptedException e) {
      throw new RuntimeException(e);
    }
    System.out.println(Thread.currentThread());
  })).toList();
  threads.forEach(Thread::start);
  for (Thread thread : threads) {
    thread.join();
  }
}
//output
src [main] ~/Downloads/jdk-19.jdk/Contents/Home/bin/java --enable-preview main7                   
VirtualThread[#23]/runnable@ForkJoinPool-1-worker-3
VirtualThread[#22]/runnable@ForkJoinPool-1-worker-2
VirtualThread[#21]/runnable@ForkJoinPool-1-worker-1
VirtualThread[#25]/runnable@ForkJoinPool-1-worker-5
VirtualThread[#24]/runnable@ForkJoinPool-1-worker-4
VirtualThread[#25]/runnable@ForkJoinPool-1-worker-3
VirtualThread[#24]/runnable@ForkJoinPool-1-worker-2
VirtualThread[#21]/runnable@ForkJoinPool-1-worker-4
VirtualThread[#22]/runnable@ForkJoinPool-1-worker-2
VirtualThread[#23]/runnable@ForkJoinPool-1-worker-3

由控制台输出,我们可以发现,VirtualThread[#21] 首先运行在 ForkJoinPool 的线程 1 上,当它从 sleep 中返回时,继续在线程 4 上运行。

sleep 之后为什么虚拟线程从一个平台线程跳转到另一个平台线程?

我们阅读一下 sleep 方法的源码,会发现在 Java19 中 sleep 方法被重写了,重写后的方法里还增加了虚拟线程相关的判断:

public static void sleep(long millis) throws InterruptedException {
   if (millis < 0) {
       throw new IllegalArgumentException("timeout value is negative");
   }

   long nanos = MILLISECONDS.toNanos(millis);
   ThreadSleepEvent event = beforeSleep(nanos);
   try {
       if (currentThread() instanceof VirtualThread vthread) {
           vthread.sleepNanos(nanos);
       } else {
           sleep0(nanos);
       }
   } finally {
       afterSleep(event);
   }
}

深追代码发现,虚拟线程 sleep 时真正调用的方法是 Continuation.yield

@Hidden
private boolean yieldContinuation() {
    notifyJvmtiUnmount(/*hide*/true);
    try {
        return Continuation.yield(VTHREAD_SCOPE);
    } finally {
        notifyJvmtiMount(/*hide*/false);
    }
}

也就是说 Continuation.yield 会将当前虚拟线程的堆栈由平台线程的堆栈转移到 Java 堆内存,然后将其他就绪的虚拟线程的堆栈由 Java 堆中拷贝到当前平台线程的堆栈中继续执行。执行 IO 或BlockingQueue.take() 等阻塞操作时会跟 sleep 一样导致虚拟线程切换。虚拟线程的切换也是一个相对耗时的操作,但是与平台线程的上下文切换相比,还是轻量很多的。

详细实现原理

虚拟线程是由 Java 虚拟机调度,而不是操作系统。虚拟线程占用空间小,同时使用轻量级的任务队列来调度虚拟线程,避免了线程间基于内核的上下文切换开销,因此可以极大量地创建和使用。

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

public static void main(){
  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() 先执行了部分任务代码,然后尝试获取锁,获取锁的过程中,如果失败会调用lock的Park方法,其中会判断是否是虚拟线程,如果是就会调用JLA.parkVirtualThread(),该操作是阻塞操作会导致 Continuation yield 操作让出控制权。

如果 yield 操作成功,会从载体线程 unmount,载体线程栈数据会移动到 Continuation 栈的数据帧中,保存在堆内存中,虚拟线程任务完成,此时虚拟线程和 Continuation 还没有终结和释放,载体线程被释放到执行器中等待新的任务;如果 Continuation 的 yield 操作失败,则会对载体线程进行 Park 调用,阻塞在载体线程上,此时虚拟线程和载体线程同时会被阻塞,本地方法,Synchronized 修饰的同步方法都会导致 yield 失败。

图片

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

图片

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

Continuation 组件十分重要,它既是用户真实任务的包装器,同时提供了虚拟线程任务暂停/继续的能力,以及虚拟线程与平台线程数据转移功能,当任务需要阻塞挂起的时候,调用 Continuationyield 操作进行阻塞。当任务需要解除阻塞继续执行的时候,则调用 Continuationrun 恢复执行

通过下面的代码可以看出 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");

图片

解释一下代码:

  • ContinuationScope scope = new ContinuationScope("scope");:定义一个新的 ContinuationScope,用于组织虚拟线程的生命周期。

  • Continuation continuation = new Continuation(scope, () -> {...});:创建一个新的 Continuation,将一个任务包装进这个 Continuation 中,任务的内容是打印 "before yield开始",然后调用 yield() 暂停线程,再打印 "after yield 结束"

  • continuation.run();:第一次调用 run() 时,任务开始执行,首先打印 "before yield开始",然后虚拟线程挂起。

  • 第二次调用 continuation.run() 时,虚拟线程恢复执行,因为是同一个scope,继续执行 "after yield 结束"

通过上述案例可以看出,Continuation 实例进行 yield 调用后,再次调用其 run 方法就可以从 yield 的调用之处继续往下执行,从而实现了程序的中断和恢复。

注意事项

虚拟线程与异步编程

响应式编程解决了平台线程需要阻塞等待其他系统响应的问题。使用异步 API 不会阻塞等待响应,而是通过回调通知结果。当响应到达时,JVM 将从线程池中分配另一个线程来处理响应。这样,处理单个异步请求会涉及多个线程

在异步编程中,我们可以降低系统的响应延迟,但由于硬件限制,平台线程的数量仍然有限,因此我们的系统吞吐量仍有瓶颈。另一个问题是,异步程序在不同的线程中执行,很难调试或分析它们

虚拟线程通过较小的语法调整来提高代码质量(降低编码、调试、分析代码的难度),同时具有响应式编程的优点,能大幅提高系统吞吐量。

使用 CompletableFuture 和虚拟线程

CompletableFuture 是 Java 提供的异步编程工具,它可以执行异步任务并在完成时继续处理结果。结合虚拟线程,CompletableFuture 可以以同步的方式书写并发代码,同时享受虚拟线程带来的性能优势。

示例:虚拟线程与 CompletableFuture 的结合
import java.util.concurrent.*;
import java.util.List;
import java.util.stream.Collectors;

public class VirtualThreadAsyncExample {
    public static void main(String[] args) throws InterruptedException {
        Executor executor = Executors.newVirtualThreadPerTaskExecutor();
        List<String> tasks = List.of("task1", "task2", "task3");

        // 使用虚拟线程执行异步任务
        List<CompletableFuture<String>> futures = tasks.stream()
                .map(task -> CompletableFuture.supplyAsync(() -> executeTask(task), executor))
                .collect(Collectors.toList());

        // 等待所有任务完成并处理结果
        CompletableFuture<Void> allOf = CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]));
        allOf.join();  // 阻塞直到所有任务完成

        // 打印任务执行结果
        futures.forEach(future -> {
            try {
                System.out.println(future.get());
            } catch (Exception e) {
                e.printStackTrace();
            }
        });
    }

    private static String executeTask(String taskName) {
        System.out.println("Executing " + taskName);
        try {
            Thread.sleep(1000);  // 模拟长时间阻塞任务
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        return taskName + " done";
    }
}

不要池化虚拟线程

化虚拟线程的做法是通过预先创建一定数量的虚拟线程并将它们放入池中,从而重用它们来处理并发任务。这种做法本质上就像传统的线程池管理平台线程一样,但池化虚拟线程可能会带来以下问题:

(1) 失去虚拟线程的优势

虚拟线程的优势在于它们的创建和销毁几乎是“零成本”的。如果我们池化虚拟线程,反而会引入一些额外的管理和生命周期管理开销。池化虚拟线程类似于传统的线程池,它的创建和销毁仍然需要一些管理工作,可能会导致额外的内存和资源消耗。因此,池化虚拟线程并没有带来预期的性能提升。

(2) 池化不适合虚拟线程的设计

虚拟线程是为了处理大量并发任务而设计的,目标是减少对操作系统线程的依赖,避免线程池和线程管理带来的开销。虚拟线程本身是高效的,适合直接为每个任务创建新的虚拟线程。池化虚拟线程会降低这种高效性,反而让虚拟线程变得类似于传统线程池中管理的线程,增加了不必要的复杂性。

(3) JVM调度的优势无法发挥

JVM 会根据系统的负载动态调度虚拟线程,如果我们池化虚拟线程,就失去了这种灵活的调度优势。虚拟线程的设计目标之一是通过按需创建虚拟线程来充分利用系统资源,而池化会限制线程的创建和调度,降低系统的响应性和资源利用率。

虚拟线程的正确使用方式

避免池化虚拟线程,正确的做法是直接为每个任务创建新的虚拟线程,并让 JVM 负责调度和管理。这与传统的线程池管理的方式不同,但正是因为虚拟线程的轻量性,才适合这种模式。

正确的做法:
  • 使用 Executors.newVirtualThreadPerTaskExecutor() 来为每个任务创建一个虚拟线程。
  • 如果任务是 I/O 密集型的,虚拟线程可以高效地处理大量并发请求,JVM 会自动管理它们的生命周期。

例如:

import java.util.concurrent.*;

public class VirtualThreadExample {
    public static void main(String[] args) {
        // 为每个任务创建一个新的虚拟线程
        Executor executor = Executors.newVirtualThreadPerTaskExecutor();

        // 提交任务
        executor.execute(() -> {
            System.out.println("Task running on virtual thread " + Thread.currentThread());
        });
    }
}

在这个例子中,每个任务都会分配一个新的虚拟线程,JVM 会负责调度虚拟线程,不需要我们担心线程池管理问题。

虚拟线程下的 ThreadLocal

public class main {
    private static ThreadLocal<String> stringThreadLocal = new ThreadLocal<>();
    public static void getThreadLocal(String val) {
        stringThreadLocal.set(val);
        System.out.println(stringThreadLocal.get());
    }
    public static void main(String[] args) throws InterruptedException {
        Thread testVT1 = Thread.ofVirtual().name("testVT1").unstarted(() ->main5.getThreadLocal("testVT1 local var"));
        Thread testVT2 = Thread.ofVirtual().name("testVT2").unstarted(() ->main5.getThreadLocal("testVT2 local var"));
        testVT1.start();
        testVT2.start();
        System.out.println(stringThreadLocal.get());
        stringThreadLocal.set("main local var");
        System.out.println(stringThreadLocal.get());
        testVT1.join();
        testVT2.join();
    }
}
//output
null
main local var
testVT1 local var
testVT2 local var

虚拟线程支持 ThreadLocal 的方式与平台线程相同,平台线程不能获取到虚拟线程设置的变量,虚拟线程也不能获取到平台线程设置的变量,对虚拟线程而言,负责运行虚拟线程的平台线程是透明的。但是由于虚拟线程可以创建数百万个,在虚拟线程中使用 ThreadLocal 请三思而后行。如果我们在应用程序中创建一百万个虚拟线程,那么将会有一百万个 ThreadLocal 实例以及它们引用的数据。大量的对象可能会给内存带来较大的负担。

使用 ReentrantLock 替换 Synchronized

因为 Synchronized 会使虚拟线程被固定在平台线程上,导致阻塞操作不会卸载虚拟线程,影响程序的吞吐量,所以需要使用ReentrantLock 替换 Synchronized:

befor:

public synchronized void m() {
  try {
    // ... access resource
  } finally {
    //
  }
}

after:

private final ReentrantLock lock = new ReentrantLock();
public void m() {
  lock.lock();  // block until condition holds
  try {
    // ... access resource
  } finally {
    lock.unlock();
  }
}

如何迁移

  1. 直接替换线程池为虚拟线程池。如果你的项目使用了 CompletableFuture 你也可以直接替换执行异步任务的线程池为Executors.newVirtualThreadPerTaskExecutor()
  2. 取消池化机制。虚拟线程非常轻量级,无需池化。
  3. synchronized 改为 ReentrantLock,以减少虚拟线程被固定到平台线程。

参考

juejin.cn/post/728074…

mp.weixin.qq.com/s/vdLXhZdWy…

Java21手册(一):虚拟线程 Virtual Threads

虚拟线程原理及性能分析|得物技术

Java 21 最牛逼的虚拟线程到底是个啥?