Skip to content

JDK21-虚拟线程

虚拟线程概念

传统的线程 (Platform Thread)和 os 线程是一对一的。

image.png

引入虚拟线程之后,大量(M)的虚拟线程在较小数量(N)的平台线程(与操作系统线程一一对应)上运行(M:N调度)。多个虚拟线程会被 JVM 调度到某一个平台线程上执行,一个平台线程同时只会执行一个虚拟线程。

虚拟线程是由 JVM 管理的,和机器线程概念无关。

  • 创建和切换成本非常低。

之前的 IO 阻塞操作在 Platform Thread 上,引入虚拟线程之后,由 JVM 负责管理。JVM 负责阻塞和唤醒线程,不占用 Platform Thread 资源。

能够大大提高 IO 阻塞型任务的效率。

调度原理

JDK 实现了虚拟线程调度,不直接与 os 线程 交互,而是与 Platform Thread 交互。

JDK的虚拟线程调度是一个 FIFO模式的 ForkJoinPool 线程池,并行数量取决于平台机器的线程数量,默认是CPU可用核心数。

image.png

ForkJoinPool 线程池适合大量任务计算,和 ExecutorService 不同的是,ForkJoinPool 每个线程都有一个任务队列,当一个由线程运行的任务生成另一个任务时,该任务被添加到该线程的等待队列中,当我们运行Parallel Stream,一个大任务划分成两个小任务时就会发生这种情况。

为了防止线程饥饿问题,当一个线程的等待队列中没有更多的任务时,ForkJoinPool还实现了另一种模式,称为任务窃取, 也就是说:饥饿线程可以从另一个线程的等待队列中窃取一些任务。

执行效率

执行 1w 次休眠 1s 的任务,传统线程池需要 50s。

jsx
try(var executor = Executors.newFixedThreadPool(200)) {
            IntStream.range(0, 10000).forEach(i -> {
                executor.submit(() -> {
                    Thread.sleep(Duration.ofSeconds(1));
                    System.out.println(i);
                    return i;
                });
            });
        }

虚拟线程只需要1.2s。

jsx
  try(var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            IntStream.range(0, 10000).forEach(i -> {
                executor.submit(() -> {
                    Thread.sleep(Duration.ofSeconds(1));
                    System.out.println(i);
                    return i;
                });
            });
        }

当任务有大量的IO操作时,由于虚拟线程的 IO 阻塞操作不会影响操作系统线程,操作系统可以用来执行其他任务。

  • 程序并发任务数量很高。
  • IO密集型、工作负载不受 CPU 约束。

执行原理

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

虚拟线程固定

JDK 中的少数阻塞操作不会卸载虚拟线程,因此会阻塞平台线程。因为操作系统级别(例如许多文件系统操作)或  JDK 级别(例如Object.wait())的限制。这些阻塞操作阻塞平台线程时,将通过暂时增加平台线程的数量来补偿其他平台线程阻塞的损失。因此,调度器的ForkJoinPool中的平台线程数量可能会暂时超过 CPU 可用核心数量。调度器可用的平台线程的最大数量可以使用系统属性 jdk.virtualThreadScheduler.maxPoolSize 进行调整。

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

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

如果虚拟线程在被固定时执行 I/O或BlockingQueue.take() 等阻塞操作,则负责运行它的平台线程在操作期间会被阻塞。

sleep 原理

sleep 方法新增了关于虚拟线程的逻辑。

image.png

image.png

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

使用注意

不要池化虚拟线程

因为虚拟线程非常轻量,每个虚拟线程都打算在其生命周期内只运行单个任务,所以没有池化虚拟线程的必要。

虚拟线程可以创建数百万个。

ThreadLocal

虚拟线程的使用和平台线程使用是一样的。

虚拟线程和平台线程是隔离的,而且虚拟线程可以创建数百万个,ThreadLocal的内存负担比较大。

使用 ReentranLock 替换 synchronized

synchronized 依赖于平台线程的本地锁机制实现。

所以如果虚拟线程里面有使用 synchronized 的部分,会导致被固定在平台线程上。即使阻塞不会卸载虚拟线程。

迁移方案

使用虚拟线程替代传统线程池

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

参考链接

mp.weixin.qq.com