Java虚拟线程其实也就那么回事

493 阅读6分钟

Java虚拟线程(jdk19)

啥是虚拟线程

很多语言都有类似于“虚拟线程”的技术,比如Go、C#、Erlang、Lua等,他们称之为“协程”。 不管是虚拟线程还是协程,他们都是轻量级线程,其目的都是为了提高并发能力。

虚拟线程(virtual threads)应该非常廉价而且可以无需担心系统硬件资源被大量创建,并且不应该被池化。应该为每个应用程序任务创建一个新的虚拟线程。因此,大多数虚拟线程将是短暂的并且具有浅层调用堆栈,只执行单个 HTTP 客户端调用或单个 JDBC 查询。与之对应的平台线程( Platform Threads,也就是现在传统的JVM线程 )是重量级且昂贵的,因此通常必须被池化。它们往往寿命长,有很深的调用堆栈,并且在许多任务之间共享。

总而言之,虚拟线程保留了与 Java 平台的设计相协调的、可靠的每请求线程样式,同时优化了硬件的利用。使用虚拟线程不需要学习新概念,甚至需要改掉现在操作多线程的习惯,使用更加容易上手的API、兼容以前的多线程设计、并且丝毫不会影响代码的拓展性。

平台线程和虚拟线程的不同

平台线程在底层操作系统线程上运行 Java 代码,并在代码的整个生命周期内捕获操作系统线程。平台线程数受限于 OS 线程数。下面来看一下例子,pthread就是平台线程,vthread就是虚拟线程

public class Main {
    public static void main(String[] args) throws InterruptedException {
        var pthread = Thread.ofPlatform().unstarted(() -> System.out.println(Thread.currentThread()));
        pthread.start();
        pthread.join();
​
        var vthread = Thread.ofVirtual().unstarted(() -> System.out.println(Thread.currentThread()));
        vthread.start();
        vthread.join();
        System.out.println("virtual thread class:"+vthread.getClass());
    }
}

输出

Thread[#28,Thread-0,5,main]
VirtualThread[#29]/runnable@ForkJoinPool-1-worker-1
virtual thread class:class java.lang.VirtualThread

ForkJoinPool

ForkJoinPool是自java7开始,jvm提供的一个用于并行执行的任务框架。其主旨是将大任务分成若干小任务,之后再并行对这些小任务进行计算,最终汇总这些任务的结果。此外,ForkJoinPool采取工作窃取算法,以避免工作线程由于拆分了任务之后的join等待过程。这样处于空闲的工作线程将从其他工作线程的队列中主动去窃取任务来执行。

举几个例子

为什么线程变了

来看看下面这个例子

public class Main {
    public static void main(String[] args) throws InterruptedException {
        List<Thread> threads = IntStream.range(0, 10).mapToObj(index -> Thread.ofVirtual().unstarted(() -> {
            if (index == 0) {
                System.out.println(Thread.currentThread()); //1
            }
            try {
                Thread.sleep(10);
            } catch (InterruptedException e) {
​
                throw new RuntimeException();
            }
            if (index == 0) {
                System.out.println(Thread.currentThread()); //2
            }
        })).collect(Collectors.toList());
​
        threads.forEach(Thread::start);
        for (Thread thread : threads) {
            thread.join();
        }
    }
}

输出

PS D:\celi\project\2022-08-17-exam\src> java --source 19 --enable-preview .\Main.java
注: .\Main.java 使用 Java SE 19 的预览功能。
注: 有关详细信息,请使用 -Xlint:preview 重新编译。
VirtualThread[#28]/runnable@ForkJoinPool-1-worker-1
VirtualThread[#28]/runnable@ForkJoinPool-1-worker-7
PS D:\celi\project\2022-08-17-exam\src> java --source 19 --enable-preview .\Main.java
注: .\Main.java 使用 Java SE 19 的预览功能。
注: 有关详细信息,请使用 -Xlint:preview 重新编译。
VirtualThread[#28]/runnable@ForkJoinPool-1-worker-1
VirtualThread[#28]/runnable@ForkJoinPool-1-worker-1
PS D:\celi\project\2022-08-17-exam\src> java --source 19 --enable-preview .\Main.java
注: .\Main.java 使用 Java SE 19 的预览功能。
注: 有关详细信息,请使用 -Xlint:preview 重新编译。
VirtualThread[#28]/runnable@ForkJoinPool-1-worker-1
VirtualThread[#28]/runnable@ForkJoinPool-1-worker-9
PS D:\celi\project\2022-08-17-exam\src> java --source 19 --enable-preview .\Main.java
注: .\Main.java 使用 Java SE 19 的预览功能。
注: 有关详细信息,请使用 -Xlint:preview 重新编译。
VirtualThread[#28]/runnable@ForkJoinPool-1-worker-1
VirtualThread[#28]/runnable@ForkJoinPool-1-worker-1
PS D:\celi\project\2022-08-17-exam\src>

按照常理分析1处,2处打印的线程应该一样,但是我们可以很明显的发现事实上的情况是可能一样也可能不一样。所以同一段代码遇到sleep虚拟线程不会自己傻傻等待而是回去做其他任务等待休眠结束了再来执行,表面上看是一个虚拟线程,实际上背后还是线程池再工作。只不过线程切换的过程被隐匿在了虚拟线程的实现之后。

事实上当了sleep的时候会发生yield,可以从jdk的源码里面看见。yield时平台线程的任务就会被移动到堆内存里面,这是平台线程就可以起另外一个线程去运行它

private boolean yieldContinuation() {
    boolean notifyJvmti = notifyJvmtiEvents;
​
    // unmount
    if (notifyJvmti) notifyJvmtiUnmountBegin(false);
    unmount();
    try {
        //看这里
        return Continuation.yield(VTHREAD_SCOPE);
    } finally {
        // re-mount
        mount();
        if (notifyJvmti) notifyJvmtiMountEnd(false);
    }
}

看看性能如何

public class Main {
    public static void main(String[] args) throws InterruptedException {
        String pattern = "@(.*)";
        String poolP = "@(\w{0,}-\d{1,2})";
        Pattern poolPa = Pattern.compile(poolP);
        Pattern pc = Pattern.compile(pattern);
        Set<String> pthread = ConcurrentHashMap.newKeySet();
        int vpn = 5000;
        Set<String> pool = ConcurrentHashMap.newKeySet();
        List<Thread> collect = IntStream.range(0, vpn).mapToObj(i -> Thread.ofVirtual().unstarted(() -> {
            String s = Thread.currentThread().toString();
            Matcher matcher = pc.matcher(s);
            Matcher matcher1 = poolPa.matcher(s);
            if (matcher1.find()) {
                pool.add(matcher1.group(1));
            }
            if (matcher.find()) {
                pthread.add(matcher.group(1));
            }
​
        })).collect(Collectors.toList());
        Instant begin = Instant.now();
        collect.forEach(Thread::start);
        for (Thread thread : collect) {
            thread.join();
        }
        Instant end = Instant.now();
        System.out.println("======================测试虚拟线程==============");
        System.out.println("启动:" + vpn + "个虚拟线程,耗时" + (Duration.between(begin, end).toMillis() + "ms"));
        //启动的平台线程数
        System.out.println("=========启动的平台线程数=========");
        pthread.forEach(System.out::println);
        System.out.println("=========启动的线程池=========");
        pool.forEach(System.out::println);
    }
}

启动五个

启动:5个虚拟线程,耗时4ms
=========启动的平台线程数=========
ForkJoinPool-1-worker-1
ForkJoinPool-1-worker-2
ForkJoinPool-1-worker-3
=========启动的线程池=========
ForkJoinPool-1

启动5000个虚拟线程

============测试虚拟线程 cpu: i5-12400F==============
启动:5000个虚拟线程,耗时22ms
=========启动的平台线程数=========
ForkJoinPool-1-worker-8
ForkJoinPool-1-worker-9
ForkJoinPool-1-worker-6
ForkJoinPool-1-worker-7
ForkJoinPool-1-worker-12
ForkJoinPool-1-worker-11
ForkJoinPool-1-worker-10
ForkJoinPool-1-worker-1
ForkJoinPool-1-worker-4
ForkJoinPool-1-worker-5
ForkJoinPool-1-worker-2
ForkJoinPool-1-worker-3
=========启动的线程池=========
ForkJoinPool-1

启动50_000个

启动:50000个虚拟线程,耗时75ms
=========启动的平台线程数=========
ForkJoinPool-1-worker-8
ForkJoinPool-1-worker-9
ForkJoinPool-1-worker-6
ForkJoinPool-1-worker-7
ForkJoinPool-1-worker-12
ForkJoinPool-1-worker-11
ForkJoinPool-1-worker-10
ForkJoinPool-1-worker-1
ForkJoinPool-1-worker-4
ForkJoinPool-1-worker-5
ForkJoinPool-1-worker-2
ForkJoinPool-1-worker-3
=========启动的线程池=========
ForkJoinPool-1

启动100万个

============测试虚拟线程 cpu: i5-12400F==============
启动:1000000个虚拟线程,耗时694ms
=========启动的平台线程数=========
ForkJoinPool-1-worker-8
ForkJoinPool-1-worker-9
ForkJoinPool-1-worker-6
ForkJoinPool-1-worker-7
ForkJoinPool-1-worker-12
ForkJoinPool-1-worker-11
ForkJoinPool-1-worker-10
ForkJoinPool-1-worker-1
ForkJoinPool-1-worker-4
ForkJoinPool-1-worker-5
ForkJoinPool-1-worker-2
ForkJoinPool-1-worker-3
=========启动的线程池=========
ForkJoinPool-1

可以发现执行非常快,而且本身调用的平台线程最多12个,最少只有三个,而线程池只用了一个。

总结

所谓的虚拟线程或者说是纤程,协程本质上就是将线程的调度的工作交给了jdk内部去实现,使用者只管创建虚拟线程即可,其实并没有任务黑科技。其内部原理肯定是一个虚拟线程会对应一个或多个平台线程,一个任务由一个虚拟线程完成,但内部可能会在调度的作用下有多个平台线程交替完成这个任务。当遇到任务需要被挂起的时候比如yield或者sleep,就会把任务信息保存到内存中,然后当重新运行该任务的时候会从内存中把任务信息取出来,再起一个平台线程去运行他。

虽说没有银弹,但是此功能会帮助我们减少并发编程的难度。并且在处理I/O方面的会很有用。比如Netty内部实现就可以用它。或者webflux,vert.x等一众响应式web框架也可以替换为虚拟线程实现。