一次惨痛的面试:“网易提前批,我被虚拟线程问倒了”

一、写在开头

昨晚收到一个粉丝在私信的留言如下:

build哥,今天参加了网易的提前批,可以说是一次惨痛的面试体验?,直接被虚拟线程问倒了,无论是在校学习的时候还是在公司实习的时候,都使用的是Java8更多,或者Java11,比较点子背的是面试我的这一个面试官,他们团队刚好在做Java21的切换,因此,虚拟线程似乎是一个逃脱不掉的重点拷问对象,虽然21出来的时候知道有虚拟线程这个事情,但从没有认真研究过,被问及时说不出个123来,当场憋得脸通红,真羞愧啊!

确实,我们现在在国内的大部分企业中使用的Java版本还是8居多,Java21是Oracle公司于2023年9月20号发布的版本,是一个最新且会被长期维护的稳定版本,很少有面试官会针对这部分更新内容着重拷问,但是!若你遇到了像这位粉丝一样,面试官的项目刚好在用Java21,那它的相关特性你就必须要知道了!而Java21带来的重磅内容就是 虚拟线程 。今天我们就抽个时间来聊一聊它。


二、虚拟线程的诞生背景

虚拟线程 在Java19时被作为预览特性提出,经过了2个版本的迭代后,在Java21成功上位,是一个十分重要的新增特性,对于I/O密集型程序的性能带来了大幅度的提升!

随着企业应用的规模壮大,大量的网络请求或读写I/O场景越来越多,这种情况下,很多语言如Go、C#、Erlang、Lua等,都有“协程”来优化性能,曾经我们 Java 开发者面对这种平凡而又高级的技术只能干瞪眼,遇到I/O密集型程序,我们只能通过多线程来优化,实际上这种优化的效果有限,使用不当还会带来OOM问题,但在Java21推出虚拟线程后,一扫沉疴!虚拟线程的特性让它对高IO场景得心应手。

  • I/O密集型程序: 指的是系统的CPU性能相对硬盘、内存要好很多,此时,系统运作,大部分的状况是CPU在等I/O (硬盘/内存) 的读/写操作,但CPU的使用率不高。具体场景如读文件、写文件、传输文件、网络请求。
  • CPU密集性程序: 也叫计算密集型,指的是系统的硬盘、内存性能相对CPU要好很多,此时,系统运作大部分的状况是CPU Loading 100%,CPU要读/写I/O(硬盘/内存),I/O在很短的时间就可以完成,而CPU还有许多运算要处理,CPU Loading很高。具体场景如科学计算、图像处理和加密解密等。

三、什么是虚拟线程

那么什么是虚拟线程呢?在搞清楚这个定义之前,我们先来了解一下普通线程,基于过往的学习积累,我们知道JVM 是一个多线程环境,它通过 java.lang.Thread 为我们提供了对 操作系统线程(OS线程) 的抽象,但是 Java 中的线程都只是对操作系统线程的一种简单封装,我们可以称之为 “平台线程(platform thread)” ,平台线程在底层 OS 线程上运行 Java 代码,并在代码的整个生命周期中占用该 OS 线程,因此平台线程的数量受限于 OS 线程的数量。

而虚拟线程是Thread的一个实例,虽然也在OS线程上运行Java代码,但它不会在整个生命周期内都占用该OS线程,换句话说,一个OS线程上支持多个虚拟线程的运行,因此,同样的操作系统配置下,可以创建更多的虚拟线程数量,执行阻塞任务的整体吞吐量也就大了很多。

【一句话总结虚拟线程定义】

虚拟线程 : Java 中的一种轻量级线程,它旨在解决传统线程模型中的一些限制,提供了更高效的并发处理能力,允许创建数千甚至数万个虚拟线程,而无需占用大量操作系统资源。


四、虚拟线程的工作原理

上面我们了解什么是虚拟线程后,我们紧接着来看一下它的原理,我们知道线程是需要被调度分配相应的CPU时间片的。对于由操作系统线程实现的平台线程,JDK 依赖于操作系统中的调度程序;而对于虚拟线程,JDK 先将虚拟线程分配给平台线程,然后平台线程按照通常的方式由操作系统进行调度。

JDK 的虚拟线程调度器是一个以 FIFO 模式运行的 ForkJoinPool,调度器的默认并行度是可用于调度虚拟线程的平台线程数量,并行度可以通过设置启动参数调整。调度器函数代码如下:

private static ForkJoinPool createDefaultScheduler() {
    ForkJoinWorkerThreadFactory factory = pool -> {        
        PrivilegedAction<ForkJoinWorkerThread> pa = () -> new CarrierThread(pool);        
        return AccessController.doPrivileged(pa);    
    };    
    PrivilegedAction<ForkJoinPool> pa = () -> {        
        int parallelism, maxPoolSize, minRunnable;        
        String parallelismValue = System.getProperty("jdk.virtualThreadScheduler.parallelism");        
        String maxPoolSizeValue = System.getProperty("jdk.virtualThreadScheduler.maxPoolSize");        
        String minRunnableValue = System.getProperty("jdk.virtualThreadScheduler.minRunnable");        
        ... //略过一些赋值操作        
        Thread.UncaughtExceptionHandler handler = (t, e) -> { };
        boolean asyncMode = true; // FIFO        
        return new ForkJoinPool(parallelism, factory, handler, asyncMode,                                
                                0, maxPoolSize, minRunnable, pool -> true, 30, SECONDS);
    };    
    return AccessController.doPrivileged(pa);
}

调度器分配给虚拟线程的平台线程称为虚拟线程的 载体线程(carrier) ,载体线程的信息对虚拟线程不可见,Thread.currentThread() 返回的值始终是虚拟线程本身,载体线程和虚拟线程的堆栈跟踪是分开的。在虚拟线程中抛出的异常将不包括载体线程的堆栈帧。线程dump不会在虚拟线程的堆栈中显示载体线程的堆栈帧,反之亦然。从 Java 代码的角度来看,开发者不能感知到虚拟线程和其载体线程临时共享了一个操作系统线程。但从本地代码(native code)的角度来看,虚拟线程和其载体在同一个本地线程上运行。

OS线程、载体线程、虚拟线程三者关系图


五、如何使用虚拟线程

了解了虚拟线程之后,我们最重要的一环来了,如何使用虚拟线程!其实,Oracle官网在这一点上做的很人性化,为了让大家平滑的过渡到JDK21的使用上,虚拟线程的创建方式和之前的传统线程非常相似,几乎都是借助Thread来构建,大致分为如下4种方式。

方法1️⃣:使用 Thread.startVirtualThread() 创建

  public void virtualThreadTest() {
        Thread.startVirtualThread(() -> {
            // 这里放置你的任务代码
            System.out.println("Method ONE");
        });
    } 

方法2️⃣:使用 Thread.ofVirtual()创建

    public void virtualThreadTest() {
        Thread.ofVirtual()
                .name("virtualThreadTest")//为虚拟线程设置名称
                .uncaughtExceptionHandler((t,e)-> System.out.println("线程[" + t.getName() + "发生了异常。message:" + e.getMessage()))//处理线程异常
                .start(()->{
                    System.out.println("Method TWO");
                });//创建时直接启动
    }

Thread.Builder是一个流式API,用于构建和配置线程。它提供了设置线程属性(如名称、守护状态、优先级、未捕获异常处理器等)的方法。相比直接使用 Thread 来构建线程,Thread.Builder提供了更多的灵活性和控制力。

以上测试代码是创建时直接启动,也可以创建时不启动,通过手动调用 start() 来运行:

    public void virtualThreadTest() {
        var vt = Thread.ofVirtual()
                .unstarted(()->{
                    System.out.println("Method TWO");
                });
       	//创建时通过unstarted设置不启动,手动调用start启动
        vt.start();
    }

方法3️⃣:使用 ThreadFactory 创建

 public void virtualThreadTest() {
        ThreadFactory factory = Thread.ofVirtual().factory();
        factory.newThread(() -> {
            // 这里放置你的任务代码
            System.out.println("Method THREE");
        }).start();
    }

方法4️⃣:使用 Executors.newVirtualThreadPerTaskExecutor()创建

ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
executor.submit(() -> {
    System.out.println("Method FOUR");
});
or
Future<String> future = executor.submit(() -> {
    return "Method FOUR";
});

这是通过虚拟线程池来构建虚拟线程;
注意: 使用完线程池后,我们可以使用shutdown() 来关闭线程池,它会等待正在执行的任务完成,但不会接受新的任务。如果需要立即停止所有任务,可以使用 shutdownNow()。


六、IO密集型程序性能优化

为了验证虚拟线程的优点,我们准备了一个小测试案例,向一个固定200个线程的线程池提交1000个sleep 1s的任务并遍历获取结果,用平台线程和虚拟线程分别实现,对比耗时。

6.1 平台线程实现

public class TestPlatformThread {
    public static void main(String[] args) {
        AtomicInteger a = new AtomicInteger(0);
        // 创建一个固定200个线程的线程池
        try {
            ExecutorService executor =  Executors.newFixedThreadPool(200);
            List<Future<Integer>> futures = new ArrayList<>();
            long begin = System.currentTimeMillis();
            // 向线程池提交1000个sleep 1s的任务
            for (int i=0; i<1_000; i++) {
                   Future future = executor.submit(() -> {
                    Thread.sleep(1000);
                    return a.addAndGet(1);
                });

                futures.add(future);
            }
            // 获取这1000个任务的结果
            for (Future<Integer> future : futures) {
                Integer integer = future.get();
                if(integer % 100 ==0){
                    System.out.println(integer + " ");
                }
            }
            // 打印总耗时
            System.out.println("Exec finish!!!");
            System.out.printf("Exec time: %dms.%n", System.currentTimeMillis() - begin);
        } catch (InterruptedException e) {
            e.printStackTrace();
        } catch (ExecutionException e) {
            e.printStackTrace();
        }
    }
}

输出:

100 
200 
300 
400 
500 
600 
700 
800 
900 
1000 
Exec finish!!!
Exec time: 5120ms.

这里为什么不采用Executors.newCachedThreadPool()而是采用固定线程数量的线程池呢?
因为当我们的并发任务数不是1000,而是1万,甚至于10万的时候,newCachedThreadPool会创建相应的线程数,而Java的平台线程于操作系统线程又是一一对应的,不可能提供那么多可用线程,会导致程序OOM。

6.2 虚拟线程实现

我们将上述代码做一个简单的修改,采用Executors.newVirtualThreadPerTaskExecutor();创建一个虚拟线程池,通过虚拟线程进行同样的任务处理!

// ExecutorService executor =  Executors.newFixedThreadPool(200);
//通过虚拟线程实现
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();

输出:

100 
200 
300 
400 
500 
600 
700 
800 
900 
1000 
Exec finis!!!
Exec time: 1025ms.

一个是5秒多,一个是1秒多,当并发任务数来到100万的时候,虚拟线程耗时在27秒左右,而传统的平台线程,直接卡死,最终抛出OOM。

100000 
200000 
300000 
400000 
500000 
600000 
700000 
800000 
900000 
1000000 
Exec finis!!!
Exec time: 27125ms.

但是,我们在测试程序中是以sleep(1s)来模拟IO处理的场景,虚拟线程对性能的提升十分显著,若将程序中的sleep()换为如下代码:

long t0 = System .currentTimeMillis();
do {
    int x=1;
    x++;
} while (t0+1000 > System .currentTimeMillis());

以及模拟处理计算的场景,这时候耗时会反过来,下图为本地测试结果对比

七、总结

最后,我们对虚拟线程的学习做一个言简意赅的总结:

优点:

  1. 非常轻量级: 可以在单个线程中创建成百上千个虚拟线程而不会导致过多的线程创建和上下文切换;
  2. 减少资源开销: 相比于操作系统线程,虚拟线程的资源开销更小。本质上是提高了线程的执行效率,从而减少线程资源的创建和上下文切换。

缺点

  1. 不适用于计算密集型任务: 虚拟线程适用于 I/O 密集型任务,但不适用于计算密集型任务,因为密集型计算始终需要 CPU 资源作为支持。
  2. 依赖于语言或库的支持: 协程需要编程语言或库提供支持。不是所有编程语言都原生支持协程。比如 Java 实现的虚拟线程。

八、结尾彩蛋

如果本篇博客对您有一定的帮助,大家记得 留言+点赞+收藏 呀。原创不易,转载请联系Build哥!

如果您想与Build哥的关系更近一步,还可以关注“JavaBuild888”,在这里除了看到《Java成长计划》系列博文,还有提升工作效率的小笔记、读书心得、大厂面经、人生感悟等等,欢迎您的加入!

热门手游下载
下载排行榜