- 发布于
Project Loom 虚拟线程尝鲜
- 作者
- 作者
- 霍浩东
- @huohaodong
Project Loom 与 虚拟线程
Project Loom 可谓是近几年 JVM 生态中最香的一张大饼了,其为 Java 带来了原生的协程支持,Open JDK 开发者称其为虚拟线程(Virtual Thread)。实际上协程并不是什么新鲜事物,JVM 生态内的 Kotlin 早在 2018 年就在语言层面上支持了协程。此外,在 JVM 生态外,C++ 也在 C++ 20 中添加了协程支持,而 GO 语言则在诞生之初就支持了协程(Goroutine)。
历史上用户态线程(协程)曾短暂出现在 Java 最初的几个版本(1997 ~ 2000 年之间)中,当时被称为绿色线程(Green Thread),并在之后的版本迭代后遭到移除。绿色线程被抛弃的一个主要原因是当时的 CPU 都是单核的,且主流操作系统并没有提供一套完善的线程库,在 JVM 上实现用户态线程(绿色线程)在比较困难,而且性能和可用性都不如直接将 JVM 线程和操作系统的内核线程(OS Thread)一对一绑定来的方便(内核线程由操作系统负责调度,用户态线程则需要在 JVM 上额外实现一套调度机制)。如下图所示,目前 JVM 实现的线程模型为 1:1 的 Platform Threads。
在 JVM 目前的线程模型中,用户线程与内核线程是 1:1 对应的,每次 I/O 操作都会导致当前用户线程阻塞,该用户线程对应的内核线程则会在之后经过操作系统调度后去执行其他处于就绪状态(Runnable)的用户线程,即发生了线程切换。线程切换是一个相对而言比较耗时的操作,线程切换时 CPU 需要先保存当前线程栈帧,程序计数器等数据,以完成上下文切换,再将切换到的线程对应的上下文信息保存到 CPU 寄存器中。对于 I/O 密集型任务而言,频繁的 I/O 操作会导致频繁的线程切换,CPU 资源没有得到充分的利用。此外,线程的创建与销毁对于 CPU 而言也是比较重的操作,虽然可以通过将线程池化来缓解,但是仍然无法充分发挥 CPU 的性能,传统的编程模型与思想在此时似乎都失效了。
协程的引入可以轻松解决上述问题:
- 协程是用户态线程,由 JVM 内部实现的调度器进行调度(目前默认的调度器基于 ForkJoinPool),I/O 操作引发阻塞后只会在用户态发生协程之间的上下文切换(非常快),对于线程而言就像是执行了 goto 语句一样,并没有发生广义上的线程上下文切换,进而节省了 CPU 进行线程上下文切换的时间。
- 协程的创建和销毁都在用户态进行,对于操作系统而言是透明的,开销极低,一次性可以快速创建大量的协程,提高了系统的并发程度,有利于提高 CPU 的总体利用率。
协程使得传统的 1:1 模型变成了某种意义上的 M:N 模型,这里的 M 是协程,N 是内核线程。引入协程的另外一个好处是与结合结构化并发(Structured Concurrency)结合后可以极大的减轻开发者在编写“多线程”程序时的心智负担,避免了诸如 Reactive Programming 等复杂并发编程模型的弊端。
这里需要注意的是协程并不是银弹,这里对协程优点的讨论仅限定于 I/O 密集型任务,对于 CPU 密集型任务而言,协程并不能带来理论上的性能提升。
最近,虚拟线程终于在最近发布的 JDK 19 中作为预览特性放出,目前相关 API 接口已基本稳定。最近两年 Open JDK 开发组每隔一段时间都会在一些开发者会议上分享项目进展,相比之前活跃了不少,开源社区对各类框架适配虚拟线程的呼声也有增无减。天时地利人和,根据我的观察以及当前 Project Loom 的开发进度和完成度,即使结构化并发支持仍处在孵化阶段,虚拟线程也非常有望在下一个 Java 长期支持版本 JDK 21 LTS 中作为新特性正式推出。
基本使用与测试
虚拟线程中新增了多个接口用于创建协程,比如构造器风格的 Thread.ofVirtual()
,如下所示:
public class Main {
public static void main(String[] args) throws ExecutionException, InterruptedException {
Thread t1 = Thread.ofVirtual().name("我的第一个 Virtual Thread")
.allowSetThreadLocals(true)
.uncaughtExceptionHandler((thread, throwable) -> throwable.printStackTrace())
.start(() -> System.out.println("Hello, World!"));
t1.join();
}
}
输出结果:
Hello, World!
JDK 中还新增了 Thread.ofPlatform()
来与之对应,用以创建传统的 Java 线程(Platform Thread)。想要判断一个线程是 Virtual Thread 还是 Platform Thread 只需要调用对应线程的 isVirtual()
方法即可。此外,我们还可以通过 Thread.startVirtualThread()
创建虚拟线程。
如果想要一次创建和同时管理大量虚拟线程,JDK 还在 Executors
工具类中提供了 Executors.newVirtualThreadPerTaskExecutor()
方法供使用:
public class Main {
public static void main(String[] args) {
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
AtomicInteger atomicInteger = new AtomicInteger();
for (int i = 0; i < 100000; i++) {
executor.submit(atomicInteger::incrementAndGet);
}
executor.shutdown();
executor.awaitTermination(Long.MAX_VALUE, TimeUnit.MINUTES);
System.out.println(atomicInteger.get());
} catch (Exception ex) {
ex.printStackTrace();
}
}
}
我们也可以传入 FutureTask 来获取虚拟线程的执行结果:
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;
import java.util.concurrent.atomic.AtomicInteger;
public class Main {
public static void main(String[] args) throws ExecutionException, InterruptedException {
FutureTask<Integer> task = new FutureTask<>(() -> {
AtomicInteger integer = new AtomicInteger();
for (int i = 0; i < 100000000; i++) {
integer.incrementAndGet();
}
return integer.intValue();
});
Thread.startVirtualThread(task);
Thread t1 = Thread.ofVirtual().start(task);
System.out.println(t1.isVirtual());
System.out.println(task.get());
}
}
运行结果如下:
true
100000000
可以看到虚拟线程极大的简化了编写并发程序的难度。
下面我们对 Virtual Thread 与 Platform Thread 的创建与销毁速度进行简单测试与对比:
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
public class Main {
public static int COUNT = 1000_000;
public static void main(String[] args) throws InterruptedException {
System.out.printf("Create %d threads to increment AtomicInteger\n", COUNT);
testVirtualThread();
testPlatformThread();
}
public static void testVirtualThread() {
AtomicInteger count = new AtomicInteger();
long start = System.currentTimeMillis();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 1; i <= COUNT; i++) {
executor.submit(count::incrementAndGet);
}
executor.shutdown();
executor.awaitTermination(Long.MAX_VALUE, TimeUnit.HOURS);
} catch (Exception e) {
e.printStackTrace();
}
long end = System.currentTimeMillis();
System.out.printf("Virtual Thread Takes: %d ms\n", end - start);
}
public static void testPlatformThread() throws InterruptedException {
AtomicInteger count = new AtomicInteger();
long start = System.currentTimeMillis();
try (var executor = Executors.newThreadPerTaskExecutor(Thread::new)) {
for (int i = 1; i <= COUNT; i++) {
executor.submit(count::incrementAndGet);
}
executor.shutdown();
executor.awaitTermination(Long.MAX_VALUE, TimeUnit.HOURS);
} catch (Exception e) {
e.printStackTrace();
}
long end = System.currentTimeMillis();
System.out.printf("Platform Thread Takes: %d ms\n", end - start);
}
}
最终结果如下:
Create 1000000 threads to increment AtomicInteger
Virtual Thread Takes: 1307 ms
Platform Thread Takes: 151781 ms
可以看到创建一百万个协程只需要花费不到 2 秒,经过多次测试后平均 2 μs 即可完成对协程的创建与销毁,相比之下创建线程则耗时得多,二者相差约 100 倍。
这里只是非常粗略的测试,测试方式的科学性也有待讨论,考虑到虚拟线程在执行结束后也会被垃圾回收器回收,因而上述测试结果也不具有普适性。想要获取真实客观的结果需要利用 JMeter 等专业工具进行全面的测试。
总结
Project Loom 带来的虚拟线程极大的简化了并发程序的开发流程,降低开发者心智负担的同时也使得写出“高性能”程序变得更加简单。虚拟线程创建和销毁的开销几乎可以忽略不计,传统基于池化思想的线程池在多数以 I/O 为主要目的的场景下有望被虚拟线程取缔(JDK 19 中所有网络相关接口都已全部用虚拟线程重写),最近两年应该会看到很多框架和中间件逐步适配虚拟线程。这样来看,JDK 8 在不远的将来大概率会被扫进垃圾堆里(也许吧 🐶)。此外,相关网络程序架构的最佳实践也极有可能从 OneThreadPerTask 改写为 OneVirtualThreadPerTask。