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

简介: 【5月更文挑战第13天】一次惨痛的面试:“网易提前批,我被虚拟线程问倒了”

一、写在开头

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

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.png


五、如何使用虚拟线程

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

七、总结

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

优点:

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

缺点

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

八、结尾彩蛋

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

目录
相关文章
|
30天前
|
监控 Kubernetes Java
阿里面试:5000qps访问一个500ms的接口,如何设计线程池的核心线程数、最大线程数? 需要多少台机器?
本文由40岁老架构师尼恩撰写,针对一线互联网企业的高频面试题“如何确定系统的最佳线程数”进行系统化梳理。文章详细介绍了线程池设计的三个核心步骤:理论预估、压测验证和监控调整,并结合实际案例(5000qps、500ms响应时间、4核8G机器)给出具体参数设置建议。此外,还提供了《尼恩Java面试宝典PDF》等资源,帮助读者提升技术能力,顺利通过大厂面试。关注【技术自由圈】公众号,回复“领电子书”获取更多学习资料。
|
21天前
|
安全 Java 程序员
面试必看:如何设计一个可以优雅停止的线程?
嘿,大家好!我是小米。今天分享一篇关于“如何停止一个正在运行的线程”的面试干货。通过一次Java面试经历,我明白了停止线程不仅仅是技术问题,更是设计问题。Thread.stop()已被弃用,推荐使用Thread.interrupt()、标志位或ExecutorService来优雅地停止线程,避免资源泄漏和数据不一致。希望这篇文章能帮助你更好地理解Java多线程机制,面试顺利! 我是小米,喜欢分享技术的29岁程序员。欢迎关注我的微信公众号“软件求生”,获取更多技术干货!
86 53
|
6天前
|
数据采集 Java Linux
面试大神教你:如何巧妙回答线程优先级这个经典考题?
大家好,我是小米。本文通过故事讲解Java面试中常见的线程优先级问题。小明和小华的故事帮助理解线程优先级:高优先级线程更可能被调度执行,但并非越高越好。实际开发需权衡业务需求,合理设置优先级。掌握线程优先级不仅能写出高效代码,还能在面试中脱颖而出。最后,小张因深入分析成功拿下Offer。希望这篇文章能助你在面试中游刃有余!
29 4
面试大神教你:如何巧妙回答线程优先级这个经典考题?
|
2天前
|
Java 程序员 开发者
Java社招面试题:一个线程运行时发生异常会怎样?
大家好,我是小米。今天分享一个经典的 Java 面试题:线程运行时发生异常,程序会怎样处理?此问题考察 Java 线程和异常处理机制的理解。线程发生异常,默认会导致线程终止,但可以通过 try-catch 捕获并处理,避免影响其他线程。未捕获的异常可通过 Thread.UncaughtExceptionHandler 处理。线程池中的异常会被自动处理,不影响任务执行。希望这篇文章能帮助你深入理解 Java 线程异常处理机制,为面试做好准备。如果你觉得有帮助,欢迎收藏、转发!
35 14
|
5天前
|
安全 Java 程序员
Java 面试必问!线程构造方法和静态块的执行线程到底是谁?
大家好,我是小米。今天聊聊Java多线程面试题:线程类的构造方法和静态块是由哪个线程调用的?构造方法由创建线程实例的主线程调用,静态块在类加载时由主线程调用。理解这些细节有助于掌握Java多线程机制。下期再见! 简介: 本文通过一个常见的Java多线程面试题,详细讲解了线程类的构造方法和静态块是由哪个线程调用的。构造方法由创建线程实例的主线程调用,静态块在类加载时由主线程调用。理解这些细节对掌握Java多线程编程至关重要。
34 13
|
9天前
|
缓存 安全 Java
面试中的难题:线程异步执行后如何共享数据?
本文通过一个面试故事,详细讲解了Java中线程内部开启异步操作后如何安全地共享数据。介绍了异步操作的基本概念及常见实现方式(如CompletableFuture、ExecutorService),并重点探讨了volatile关键字、CountDownLatch和CompletableFuture等工具在线程间数据共享中的应用,帮助读者理解线程安全和内存可见性问题。通过这些方法,可以有效解决多线程环境下的数据共享挑战,提升编程效率和代码健壮性。
37 6
|
25天前
|
算法 安全 Java
Java线程调度揭秘:从算法到策略,让你面试稳赢!
在社招面试中,关于线程调度和同步的相关问题常常让人感到棘手。今天,我们将深入解析Java中的线程调度算法、调度策略,探讨线程调度器、时间分片的工作原理,并带你了解常见的线程同步方法。让我们一起破解这些面试难题,提升你的Java并发编程技能!
65 16
|
1月前
|
安全 Java 程序员
面试直击:并发编程三要素+线程安全全攻略!
并发编程三要素为原子性、可见性和有序性,确保多线程操作的一致性和安全性。Java 中通过 `synchronized`、`Lock`、`volatile`、原子类和线程安全集合等机制保障线程安全。掌握这些概念和工具,能有效解决并发问题,编写高效稳定的多线程程序。
66 11
|
1月前
|
Java Linux 调度
硬核揭秘:线程与进程的底层原理,面试高分必备!
嘿,大家好!我是小米,29岁的技术爱好者。今天来聊聊线程和进程的区别。进程是操作系统中运行的程序实例,有独立内存空间;线程是进程内的最小执行单元,共享内存。创建进程开销大但更安全,线程轻量高效但易引发数据竞争。面试时可强调:进程是资源分配单位,线程是CPU调度单位。根据不同场景选择合适的并发模型,如高并发用线程池。希望这篇文章能帮你更好地理解并回答面试中的相关问题,祝你早日拿下心仪的offer!
38 6
|
3天前
|
Python
python3多线程中使用线程睡眠
本文详细介绍了Python3多线程编程中使用线程睡眠的基本方法和应用场景。通过 `time.sleep()`函数,可以使线程暂停执行一段指定的时间,从而控制线程的执行节奏。通过实际示例演示了如何在多线程中使用线程睡眠来实现计数器和下载器功能。希望本文能帮助您更好地理解和应用Python多线程编程,提高程序的并发能力和执行效率。
32 20