使用虚拟线程


虚拟线程(Virtual Thread)是Java 19引入的一种轻量级线程,它在很多其他语言中被称为协程、纤程、绿色线程、用户态线程等。

在理解虚拟线程前,我们先回顾一下线程的特点:

  • 线程是由操作系统创建并调度的资源;
  • 线程切换会耗费大量CPU时间;
  • 一个系统能同时调度的线程数量是有限的,通常在几百至几千级别。

因此,我们说线程是一种重量级资源。在服务器端,对用户请求,通常都实现为一个线程处理一个请求。由于用户的请求数往往远超操作系统能同时调度的线程数量,所以通常使用线程池来尽量减少频繁创建和销毁线程的成本。

对于需要处理大量IO请求的任务来说,使用线程是低效的,因为一旦读写IO,线程就必须进入等待状态,直到IO数据返回。常见的IO操作包括:

  • 读写文件;
  • 读写网络,例如HTTP请求;
  • 读写数据库,本质上是通过JDBC实现网络调用。

我们举个例子,一个处理HTTP请求的线程,它在读写网络、文件的时候就会进入等待状态:

  1. Begin
  2. ────────
  3. Blocking ──▶ Read HTTP Request
  4. Wait...
  5. Wait...
  6. Wait...
  7. ────────
  8. Running
  9. ────────
  10. Blocking ──▶ Read Config File
  11. Wait...
  12. ────────
  13. Running
  14. ────────
  15. Blocking ──▶ Read Database
  16. Wait...
  17. Wait...
  18. Wait...
  19. ────────
  20. Running
  21. ────────
  22. Blocking ──▶ Send HTTP Response
  23. Wait...
  24. Wait...
  25. ────────
  26. End

真正由CPU执行的代码消耗的时间非常少,线程的大部分时间都在等待IO。我们把这类任务称为IO密集型任务。

为了能高效执行IO密集型任务,Java从19开始引入了虚拟线程。虚拟线程的接口和普通线程是一样的,但是执行方式不一样。虚拟线程不是由操作系统调度,而是由普通线程调度,即成百上千个虚拟线程可以由一个普通线程调度。任何时刻,只能执行一个虚拟线程,但是,一旦该虚拟线程执行一个IO操作进入等待时,它会被立刻“挂起”,然后执行下一个虚拟线程。什么时候IO数据返回了,这个挂起的虚拟线程才会被再次调度。因此,若干个虚拟线程可以在一个普通线程中交替运行:

  1. Begin
  2. ───────────
  3. V1 Runing
  4. V1 Blocking ──▶ Read HTTP Request
  5. ───────────
  6. V2 Runing
  7. V2 Blocking ──▶ Read HTTP Request
  8. ───────────
  9. V3 Runing
  10. V3 Blocking ──▶ Read HTTP Request
  11. ───────────
  12. V1 Runing
  13. V1 Blocking ──▶ Read Config File
  14. ───────────
  15. V2 Runing
  16. V2 Blocking ──▶ Read Database
  17. ───────────
  18. V1 Runing
  19. V1 Blocking ──▶ Read Database
  20. ───────────
  21. V3 Runing
  22. V3 Blocking ──▶ Read Database
  23. ───────────
  24. V2 Runing
  25. V2 Blocking ──▶ Send HTTP Response
  26. ───────────
  27. V1 Runing
  28. V1 Blocking ──▶ Send HTTP Response
  29. ───────────
  30. V3 Runing
  31. V3 Blocking ──▶ Send HTTP Response
  32. ───────────
  33. End

如果我们单独看一个虚拟线程的代码,在一个方法中:

  1. void register() {
  2. config = readConfigFile("./config.json"); // #1
  3. if (config.useFullName) {
  4. name = req.firstName + " " + req.lastName;
  5. }
  6. insertInto(db, name); // #2
  7. if (config.cache) {
  8. redis.set(key, name); // #3
  9. }
  10. }

涉及到IO读写的#1、#2、#3处,执行到这些地方的时候(进入相关的JNI方法内部时)会自动挂起,并切换到其他虚拟线程执行。等到数据返回后,当前虚拟线程会再次调度并执行,因此,代码看起来是同步执行,但实际上是异步执行的。

使用虚拟线程

虚拟线程的接口和普通线程一样,唯一区别在于创建虚拟线程只能通过特定方法。

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

  1. // 传入Runnable实例并立刻运行:
  2. Thread vt = Thread.startVirtualThread(() -> {
  3. System.out.println("Start virtual thread...");
  4. Thread.sleep(10);
  5. System.out.println("End virtual thread.");
  6. });

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

  1. // 创建VirtualThread:
  2. Thread.ofVirtual().unstarted(() -> {
  3. System.out.println("Start virtual thread...");
  4. Thread.sleep(1000);
  5. System.out.println("End virtual thread.");
  6. });
  7. // 运行:
  8. vt.start();

方法三:通过虚拟线程的ThreadFactory创建虚拟线程,然后手动调用start()开始运行:

  1. // 创建ThreadFactory:
  2. ThreadFactory tf = Thread.ofVirtual().factory();
  3. // 创建VirtualThread:
  4. Thread vt = tf.newThread(() -> {
  5. System.out.println("Start virtual thread...");
  6. Thread.sleep(1000);
  7. System.out.println("End virtual thread.");
  8. });
  9. // 运行:
  10. vt.start();

直接调用start()实际上是由ForkJoinPool的线程来调度的。我们也可以自己创建调度线程,然后运行虚拟线程:

  1. // 创建调度器:
  2. ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
  3. // 创建大量虚拟线程并调度:
  4. ThreadFactory tf = Thread.ofVirtual().factory();
  5. for (int i=0; i<100000; i++) {
  6. Thread vt = tf.newThread(() -> { ... });
  7. executor.submit(vt);
  8. // 也可以直接传入Runnable或Callable:
  9. executor.submit(() -> {
  10. System.out.println("Start virtual thread...");
  11. Thread.sleep(1000);
  12. System.out.println("End virtual thread.");
  13. return true;
  14. });
  15. }

由于虚拟线程属于非常轻量级的资源,因此,用时创建,用完就扔,不要池化虚拟线程。

最后注意,虚拟线程在Java 19中是预览功能,默认关闭,需要添加参数--enable-preview启用:

  1. java --source 19 --enable-preview Main.java

使用限制

注意到只有以虚拟线程方式运行的代码,才会在执行IO操作时自动被挂起并切换到其他虚拟线程。普通线程的IO操作仍然会等待,例如,我们在main()方法中读写文件,是不会有调度和自动挂起的。

可以自动引发调度切换的操作包括:

  • 文件IO;
  • 网络IO;
  • 使用Concurrent库引发等待;
  • Thread.sleep()操作。

这是因为JDK为了实现虚拟线程,已经对底层相关操作进行了修改,这样应用层的Java代码无需修改即可使用虚拟线程。无法自动切换的语言需要用户手动调用await来实现异步操作:

  1. async function doWork() {
  2. await readFile();
  3. await sendNetworkData();
  4. }

在虚拟线程中,如果绕过JDK的IO接口,直接通过JNI读写文件或网络是无法实现调度的。此外,在synchronized块内部也无法调度。

练习

使用虚拟线程调度10万个任务并观察耗时:

  1. public class Main {
  2. public static void main(String[] args) {
  3. ExecutorService es = Executors.newVirtualThreadPerTaskExecutor();
  4. for (int i=0; i<100000; i++) {
  5. es.submit(() -> {
  6. Thread.sleep(1000);
  7. return 0;
  8. });
  9. }
  10. es.close();
  11. }
  12. }

再将ExecutorService改为线程池模式并对比结果。

使用虚拟线程 - 图1下载练习:VirtualThread练习 (推荐使用IDE练习插件快速下载)

小结

Java 19引入的虚拟线程是为了解决IO密集型任务的吞吐量,它可以高效通过少数线程去调度大量虚拟线程;

虚拟线程在执行到IO操作或Blocking操作时,会自动切换到其他虚拟线程执行,从而避免当前线程等待,能最大化线程的执行效率;

虚拟线程使用普通线程相同的接口,最大的好处是无需修改任何代码,就可以将现有的IO操作异步化获得更大的吞吐能力。

计算密集型任务不应使用虚拟线程,只能通过增加CPU核心解决,或者利用分布式计算资源。

读后有收获可以支付宝请作者喝咖啡:

使用虚拟线程 - 图2