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

· 浏览次数 : 0

小编点评

**原因** 使用 `Executors.newFixedThreadPool(200)` 创建的线程池,即使并发任务数不是1000,也会创建相应的线程数,而Java的平台线程于操作系统线程又是一一对应的,因此无法提供那么多可用线程,导致程序OOM。 **虚拟线程** `Executors.newVirtualThreadPerTaskExecutor()` 创建的虚拟线程池,通过虚拟线程进行同样的任务处理,可以有效减少线程创建和上下文切换的次数,降低资源开销,提升线程执行效率。 **性能提升** 在模拟IO处理场景中,虚拟线程对性能的提升十分显著,因为虚拟线程可以利用操作系统的多线程池,并根据实际需求动态创建和关闭线程,避免ഒ一个线程处理多个任务的问题。 **结论** 虚拟线程可以有效降低线程创建和上下文切换的次数,降低资源开销,提升线程执行效率,但由于它们不适用于计算密集型任务,因此不适用于计算密集型任务。

正文

一、写在开头

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

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线程、载体线程、虚拟线程三者关系图

image


五、如何使用虚拟线程

   了解了虚拟线程之后,我们最重要的一环来了,如何使用虚拟线程!其实,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());

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

七、总结

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

优点:

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

缺点

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

八、结尾彩蛋

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

image

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

image

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

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

一、写在开头 昨晚收到一个粉丝在私信的留言如下: build哥,今天参加了网易的提前批,可以说是一次惨痛的面试体验,直接被虚拟线程问倒了,无论是在校学习的时候还是在公司实习的时候,都使用的是Java8更多,或者Java11,比较点子背的是面试我的这一个面试官,他们团队刚好在做Java21的切换,

[转帖]一次fork引发的惨案!

https://www.cnblogs.com/xuanyuan/p/15502289.html “你还有什么要说的吗?没有的话我就要动手了”,kill程序最后问道。 这一次,我没有再回答。 只见kill老哥手起刀落,我短暂的一生就这样结束了··· 我是一个网络程序,一直以来都运行在Windows系

除了运行、休眠…进程居然还有僵尸、孤儿状态

摘要:本章我们将认识几种进程状态——运行状态、休眠状态、暂停状态、退出状态等。还要介绍两种具有惨烈身世的僵尸进程与孤儿进程~ 本文分享自华为云社区《僵尸进程?孤儿进程?为什么他有如此惨烈的身世...》,作者: 花想云 。 认识进程状态 Linux中进程状态一般有: R(运行状态):并不意外着真正的在

一起单测引起的项目加载失败惨案

最近在开发一个功能模块时,在功能自测阶段,通过使用单测测试功能的完整性,在测试单测联通性使用到静态方法测试时,发现单测报错,通过查阅解决方案发现需要对Javaassist包进行排包或者升版本处理。通过排包解决掉单测报错,在部署项目时发现频繁报bean注入失败问题,最终定位发现是因为对Javaassist包排包引起的bean加载失败。故而对Javaassist包相关知识进行学习整理文章如下。

一次nginx文件打开数的问题排查处理

现象:nginx域名配置合并之后,发现consul-template无法完成nginx重载,然后发现需要重启nginx,才能让配置生效。 注意:下次哪个服务有报错,就看重启时所有日志输出,各种情况日志输出。不要忽略细节。很多时候其实已经看到了问题,却没有深入查看问题。 查看进程最大打开文件个数 #c

一次glide内存泄漏排查分析

glide是一款非常优秀的图片加载框架,目前很多项目在使用。提供了非常方法,在此,笔者就不一一列举了,可以到官网查找。 目前项目在做内存排查,因为是车机项目,之前开发的时候没有注意内存方面的问题(车机项目你懂的),现在ota期间系统提出让我们优化内存,说出现过应用内存一直增加的情况。 一脸懵逼,第一

记一次栈溢出异常问题的排查

刚修改的服务,推到开发环境之后,总是时不时的崩溃,但是不知道为什么。尝试找到他的最后一次调用,也没有复现。 没有办法,只能抓dump了。 开启崩溃自动dump,网络上很多,不赘述了。 拿到dump之后,首先看看是什么类型的异常 如图所示,是个栈溢出的异常。 打印一下堆栈,发现密密麻麻的全是这个代码。

记一次字符串末尾空白丢失的排查 → MySQL 是会玩的!

开心一刻 今天答应准时回家和老婆一起吃晚饭,但临时有事加了会班,回家晚了点 回到家,本以为老婆会很生气,但老婆却立即从厨房端出了热着的饭菜 老婆:还没吃饭吧,去洗下,来吃饭吧 我洗好,坐下吃饭,内心感动十分;老婆坐旁边深情的看着我 老婆:你知道谁最爱你吗 我毫不犹豫道:你 老婆:谁最关心你? 我:你

记一次线上问题 → Deadlock 的分析与优化

开心一刻 今天女朋友很生气 女朋友:我发现你们男的,都挺单纯的 我:这话怎么说 女朋友:脑袋里就只想三件事,搞钱,跟谁喝点,还有这娘们真好看 我:你错了,其实我们男人吧,每天只合计一件事 女朋友:啥事呀? 我:这娘们真好看,得搞钱跟她喝点 问题复现 需求背景 MySQL8.0.30 ,隔离级别是默认

记一次 Redisson 线上问题 → ERR unknown command 'WAIT' 的排查与分析

开心一刻 昨晚和一个朋友聊天 我:处对象吗,咱俩试试? 朋友:我有对象 我:我不信,有对象不公开? 朋友:不好公开,我当的小三 问题背景 程序在生产环境稳定的跑着 直到有一天,公司执行组件漏洞扫描,有漏洞的 jar 要进行升级修复 然后我就按着扫描报告将有漏洞的 jar 修复到指定的版本 自己在开发