虚拟线程,来了

471 阅读7分钟

前言

在JDK19中引入了一项新技术,虚拟线程,但你可能也听说过绿色线程,那是在Java 1.0时代的事了,我们现在也不无从体验,只能从一些上了年纪的书中找到一些关于他的一丝事迹,根据古书记载可得知,虚拟线程与绿色线程是有一些相似之处的,它们都由 JVM 而不是操作系统管理,这是一些题外话。

我们知道JVM会把用户创建的线程映射到操作系统上,由操作系统进行调度,关系为1:1,但为了提高应用程序的性能,我们会添加越来越多的线程,但操作系统在调度Java线程时会占用大量资源来处理线程上下文切换,会导致性能下降,所谓高了不行,低了也不行。

而对于虚拟线程,不再直接映射到系统线程,而是JDK有自己的一套调度系统进行调度,但遗憾的是,我无法搞清楚他是如何调度的,也并没有找到一些详细的文章,所以在本文中所展示的只是一些使用方法以及他的特性。

注意

因为虚拟线程是Java19中的一个预览特性,所以本文出现的代码需要加入如下参数

  • 使用 编译程序javac --release 19 --enable-preview Main.java并使用java --enable-preview Main.
  • 或者使用java --source 19 --enable-preview Main.java.

如果在IDEA中运行,可以选择下面选项。

在这里插入图片描述

创建虚拟线程

  1. 方法一
Thread.ofVirtual().start(() -> System.out.println(Thread.currentThread())).join();
  1. 方法二
Thread.startVirtualThread(()-> System.out.println(Thread.currentThread())).join();
  1. 方法三

上面两种方法创建后会立即启动虚拟线程,而下面需要手动启动,这里的un表示未启动的意思,但如果后面没有进行join()调用,那么在主线程结束后,这个虚拟线程也可能来不及被调度。

Thread unstarted = Thread.ofVirtual().unstarted(() -> System.out.println(Thread.currentThread()));
unstarted.start();
unstarted.join();

上述三种方式输出的结果可能如下所示。

VirtualThread[#22]/runnable@ForkJoinPool-1-worker-1

可以看到有ForkJoinPool的踪影,他是JDK的一个工具,也就是说,虚拟线程的调度器是由带有工作窃取算法的ForkJoinPool来完成的,在默认情况下,ForkJoinPool会创建和CPU核数一样的线程来执行任务,但可以通过jdk.virtualThreadScheduler.parallelism 进行调整。

我使用的CPU是一个8核的CPU,在执行下面代码后,会看到输出的结果中存在ForkJoinPool-1-worker-1ForkJoinPool-1-worker-8

public class Main {
    public static void main(String[] args) throws InterruptedException {
        List<Thread> threads = IntStream.range(0, 20).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();
        }
    }
}

阻塞

那么此时有个问题,如果这8个线程所调度的任务同时被阻塞,那么当第9个计算型的任务被加入时,会得到执行吗?

答案是会的,如下代码。

public static void main(String[] args) throws IOException, InterruptedException {
    ServerSocket serverSocket = new ServerSocket(7070);

    List<Thread> threads = IntStream.range(0, 8).mapToObj(value -> Thread.ofVirtual().unstarted(() -> {
        try {
            System.out.println("accept.."+Thread.currentThread());
            Socket accept = serverSocket.accept();
            System.out.println("ok");
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    })).toList();

    System.out.println(threads.size());
    threads .forEach(Thread::start);

    Thread.sleep(2000);
    Thread.ofVirtual().start(()-> System.out.println(Thread.currentThread())).join();
    for (;;){}
}

Java也不会创建一个新线程来执行新任务,这就是Java虚拟线程的厉害之处,当虚拟线程在执行I/O或其他阻塞操作时,会卸载虚拟线程,例如BlockingQueue.take(),当阻塞操作准备好时,调度程序将虚拟线程挂载并恢复执行。

但是,有些阻塞操作不会卸载虚拟线程,这是因为操作系统级别或JDK级别的限制,其中一种情况是在synchronized中运行。

还有就是,假如一个任务开始是由A线程执行,那么当阻塞一段时间后恢复运行,那么可能会在B中执行,可以看下面例子。

private static String read() {
    try {
        URLConnection urlConnection = new URL("http://www.houxinlin.com/").openConnection();
        BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(urlConnection.getInputStream()));
        String temp = null;
        while ((temp = bufferedReader.readLine()) != null) {

        }
        bufferedReader.close();
    } catch (IOException e) {
        throw new RuntimeException(e);
    }
    return "OK";
}

public static void main(String[] args)  {
    List<Thread> threads =new ArrayList<>();
    for (int i = 0; i < 10; i++) {
        final  int value =i;
        threads.add(Thread.ofVirtual().unstarted(() -> {
            try {
                System.out.println(Thread.currentThread()+"  start task="+value);
                read();
                System.out.println(Thread.currentThread() +"  end task="+value);
            } catch (Exception e) {
                throw new RuntimeException(e);
            }
        }));
    }
    System.out.println(threads.size());
    threads .forEach(Thread::start);
    for (;;){}
}

输出如下

VirtualThread[#24]/runnable@ForkJoinPool-1-worker-3  start task=2
VirtualThread[#25]/runnable@ForkJoinPool-1-worker-4  start task=3 //看这行
VirtualThread[#23]/runnable@ForkJoinPool-1-worker-2  start task=1
VirtualThread[#28]/runnable@ForkJoinPool-1-worker-7  start task=6
VirtualThread[#29]/runnable@ForkJoinPool-1-worker-8  start task=7
VirtualThread[#26]/runnable@ForkJoinPool-1-worker-5  start task=4
VirtualThread[#22]/runnable@ForkJoinPool-1-worker-1  start task=0
VirtualThread[#27]/runnable@ForkJoinPool-1-worker-6  start task=5
VirtualThread[#31]/runnable@ForkJoinPool-1-worker-1  start task=9
VirtualThread[#30]/runnable@ForkJoinPool-1-worker-5  start task=8
VirtualThread[#25]/runnable@ForkJoinPool-1-worker-2  end task=3    //与这行
VirtualThread[#29]/runnable@ForkJoinPool-1-worker-8  end task=7
VirtualThread[#28]/runnable@ForkJoinPool-1-worker-5  end task=6
VirtualThread[#23]/runnable@ForkJoinPool-1-worker-2  end task=1
VirtualThread[#31]/runnable@ForkJoinPool-1-worker-8  end task=9
VirtualThread[#30]/runnable@ForkJoinPool-1-worker-2  end task=8
VirtualThread[#24]/runnable@ForkJoinPool-1-worker-3  end task=2
VirtualThread[#26]/runnable@ForkJoinPool-1-worker-5  end task=4
VirtualThread[#27]/runnable@ForkJoinPool-1-worker-3  end task=5
VirtualThread[#22]/runnable@ForkJoinPool-1-worker-3  end task=0

你会发现task3一开始是由ForkJoinPool-1-worker-4 所执行,但end时却是ForkJoinPool-1-worker-2,就这种情况,我无法得知这是如何做到的,猜测是在阻塞后,虚拟线程被卸载,当有数据到达时,Java会把刚才堆栈信息复制到一个空闲的调度线程,从被阻塞后的地方重新执行。

速度对比

在官方介绍中说明虚拟线程适合大部分时间被阻塞以及经常等待 I/O的操作,不适用于长时间运行的 CPU 密集型操作。下面我们使用普通线程池做一个测试。

    public static void main(String[] args) {
        CountDownLatch countDownLatch = new CountDownLatch(100);
        long l = System.currentTimeMillis();

        ExecutorService executorService = Executors.newFixedThreadPool(8);
        for (int i = 0; i < 100; i++) {
            final int value = i;
            executorService.submit(() -> {
                try {
                    System.out.println(Thread.currentThread() + "  start task=" + value);
                    read();
                    System.out.println(Thread.currentThread() + "  end task=" + value);
                    countDownLatch.countDown();
                } catch (Exception e) {
                    throw new RuntimeException(e);
                }
            });
        }
        try {
            executorService.shutdown();
            countDownLatch.await();
            System.out.println(System.currentTimeMillis() - l);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }

和使用虚拟线程做对比。

public static void main(String[] args) {
    CountDownLatch countDownLatch = new CountDownLatch(100);
    long l = System.currentTimeMillis();
    List<Thread> threads =new ArrayList<>();
    for (int i = 0; i < 100; i++) {
        final int value = i;
        Runnable runnable =() -> {
            try {
                System.out.println(Thread.currentThread() + "  start task=" + value);
                read();
                System.out.println(Thread.currentThread() + "  end task=" + value);
                countDownLatch.countDown();
            } catch (Exception e) {
                throw new RuntimeException(e);
            }
        };
        threads.add(Thread.ofVirtual().unstarted(runnable));
    }
    try {
        threads .forEach(Thread::start);
        countDownLatch.await();
        System.out.println(System.currentTimeMillis() - l);
    } catch (InterruptedException e) {
        throw new RuntimeException(e);
    }
}

在这两个对比中,使用线程池最快一次为656毫秒,而使用虚拟线程最快一次为253毫秒。

线程池

虚拟线程也提供了一个虚拟线程池,和以往的使用方式一样。

ExecutorService executorService = Executors.newVirtualThreadPerTaskExecutor();

sleep

看下面一个例子。

IntStream.range(0,1000).forEach(value -> Thread.ofVirtual().start(() -> {
    System.out.println(Thread.currentThread()+" task="+value);
    try {
        Thread.sleep(5000);
        System.out.println(Thread.currentThread()+"  task="+value+" ok");
    } catch (InterruptedException e) {
        throw new RuntimeException(e);
    }
}));
for (;;){}

这个例子启动了1000个虚拟线程,但让你意外的是,这1000个虚拟线程会同时sleep,调度线程会出现还在睡这个的同时,睡其他任务的现象,即使把创建的线程数量调大一点也一样,但如果你使用原始创建线程的方式,那可能会同时创建1000个系统线程,但使用虚拟线程完成同样的任务时,系统只用了不到20个,并且花费的时间也一样,如果想问起原因,可以看JDK19重写的sleep方法,为了完成虚拟线程,JDK中有很多地方都需要对虚拟线程做兼容,不单单是sleep,比如LockSupport中会根据Thread.isVirtual来判断是不是虚拟线程,并调用针对虚拟线程所特有的方法进行阻塞。

但如果加上synchronized,即使每个虚拟线程锁的对象都不一样,不存在锁竞争,Java默认只会启动同处理器核数一样的线程去调度,并且同时最大的调度数也是处理器核数。

IntStream.range(0,1000).forEach(value -> Thread.ofVirtual().start(() -> {
    System.out.println(Thread.currentThread()+" task="+value);
    try {
       synchronized (new Object()){
           Thread.sleep(5000);
           System.out.println(Thread.currentThread()+"  task="+value+" ok");
       }
    } catch (InterruptedException e) {
        throw new RuntimeException(e);
    }
}));
for (;;){}

假设现在处理器核数是8,那么这8个任务都在synchronized块内阻塞,那么会导致新的虚拟线程任务得不到执行。