浅析Java中的虚拟线程
作者:翟睿
在最近发布的JDK 21 LTS版本中,加入了许多新特性。其中对我们开发人员影响最大的应该是分代ZGC以及Java的虚拟线程。在本篇文章中,我将带大家深入了解Java虚拟线程的原理、如何使用、使用的注意事项以及其他相似技术的差别。
什么是虚拟线程
首先,我们需要了解什么是虚拟线程。
在平时的开发过程中,我们所使用的多线程往往意味着平台线程。平台线程代表着JVM直接与操作系统交互,创建了一个一个的线程,并且在JVM中还要为这个线程单独开辟内存使用。一般在JVM中创建一个平台线程,开销大约在1M左右。为了避免创建线程和销毁线程带来的巨大开销,我们通常选择使用池化技术来维护一些活跃的线程。
此外,线程的数量也需要严格控制。如果在一个线程池中维护成千上百个线程,往往效率并不尽如人意。因为线程在切换时涉及到CPU上下文的切换,如果线程数过多,反而会降低执行效率。因此,如何控制线程池的大小也是考验工程师经验的难点。
平台线程与系统线程关系如下
为了解决这个问题,虚拟线程应运而生,虚拟线程并不是Java的首创,它在很多其他语言中被称为协程、纤程、绿色线程、用户态线程等,虚拟线程相对平台线程,并不直接与操作系统交互,虚拟线程的数据是维护在堆内存中,由JVM创建的平台线程来持有,由平台线程来决定什么时候来切换虚拟线程,大概图如下
虽然图中只画了几个虚拟线程,但是在实际使用中,我们可以创建成百上千的虚拟线程而不用担心资源消耗的问题
首先原因在于虚拟线程的开销极其廉价,一个虚拟线程可能才使用几百字节,所以几遍创建成百上千也不会消耗太多内存资源
其次虽然我们有极多的虚拟线程,但是实际上执行线程依旧只有几个平台线程,所以在线程使用中不会由于CPU上下文的切换导致的额外开销。
使用
接下来我们用代码来实践一下虚拟线程,JDK的工程师为了方便我们快速进行虚拟线程的升级,可以使用Thread来快速创建虚拟线程
Thread vt = Thread.startVirtualThread(() -> { Thread.sleep(1000); });
这种方法创建的虚拟线程会立刻启动,那么如果我们想创建一个需要手动启动的虚拟线程,可以参考如下的方式
// 创建VirtualThread Thread.ofVirtual().unstarted(() -> { Thread.sleep(1000); }); // 运行 vt.start();
还可以创建虚拟线程的工厂来使用
// 创建ThreadFactory: ThreadFactory tf = Thread.ofVirtual().factory(); // 创建VirtualThread: Thread vt = tf.newThread(() -> { Thread.sleep(1000); }); // 运行: vt.start();
我们将线程的上下文信息打印出来看看
VirtualThread[#21]/runnable@ForkJoinPool-1-worker-1
从这个打印内容可以大概看出,虚拟线程底层采用了ForkJoinPool来维护平台线程,而后面的worker-1则代表具体的平台线程的名称,前面的#21代表当前虚拟线程的数量,那么可能有同学就会问了 那如果我全局有成千上百个虚拟线程,我怎么知道是哪块业务的线程除了问题呢。
我们可以对线程工厂进行命名,使用该线程工厂命名后,在线程上下文中就会打印对应虚拟线程工厂的信息。
ThreadFactory factory = Thread.ofVirtual().name("myVirtual").factory(); try(ExecutorService executor = Executors.newThreadPerTaskExecutor(factory)) { for (int i=0; i<100000; i++) { // 也可以直接传入Runnable或Callable: executor.submit(() -> { System.out.println(Thread.currentThread()); Thread.sleep(1000); return true; }); } }
VirtualThread[#23,myVirtual]/runnable@ForkJoinPool-1-worker-2
VirtualThread[#275,myVirtual]/runnable@ForkJoinPool-1-worker-7
VirtualThread[#282,myVirtual]/runnable@ForkJoinPool-1-worker-5
VirtualThread[#285,myVirtual]/runnable@ForkJoinPool-1-worker-4
VirtualThread[#288,myVirtual]/runnable@ForkJoinPool-1-worker-3
VirtualThread[#234,myVirtual]/runnable@ForkJoinPool-1-worker-10
从日志中不难看出,我们已经成功在线程上下文标记了这一块虚拟线程属于哪个工厂,并且通过信息后缀中的worker-10可以看出底层的ForkJoinPool已经创建了10个平台线程来操作虚拟线程,因为我这台电脑是10核的,所以底层的线程池会创建与核心数相等的线程来操作虚拟线程,在我的实践中发现,无论你创建多少调度器,所有底层的虚拟线程都是由同一个平台线程池来操控的。
而且在示例代码中,我创建了十万个虚拟线程,如果我是用的是平台线程,且不说执行效率,但是内存分配就已经使项目OOM了。
注意事项
ThreadLocal支持
有同学可能会问,在虚拟线程中能否使用ThreadLocal对象呢?官方给出的答案是,能够使用但不建议。在过去,我们在维护线程池时,线程的数量是固定的且相对较少。我们通过手动清理的方式来重用ThreadLocal对象。然而,在使用虚拟线程后,我们不再关注具体的线程数,这导致ThreadLocal对象的数量无法控制,从而占用了额外的内存。需要注意的是,虚拟线程与平台线程不共享同一个ThreadLocal。
永远不要池化虚拟线程
之前我们提到过,虚拟线程的开销非常低,每个虚拟线程可能只消耗几百字节的内存。这意味着我们不需要为虚拟线程创建所谓的线程池。相应地,根据我们之前的编码习惯,如果某个功能只能接受20个并发请求,我们可能会创建一个固定大小为20的线程池来进行限流。然而,如果使用虚拟线程,我们应尽量改用类似信号量的方式来实现。
协程、IO多路复用与虚拟线程的关系
有很多同学可能对对这几个概念相对比较混淆,我们可以来梳理下这几个概念之间的异同,首先协程与虚拟线程从概念上是相同的,只是不同语言之间的实现方式是不同的,都是在用户态线程中维护多个子线程进行切换,只是如Python语言可能需要手动去唤起协程,而Java中的虚拟线程都是交由JVM调度的。
而IO多路复用,我们以Netty为例,它内部使用了IO多路复用技术来管理和处理大量的并发IO操作。Netty的IO多路复用机制可以有效地管理和调度多个连接,提高系统的吞吐量和性能。它基于事件驱动的设计模型,通过选择器(Selector)来同时监控多个IO通道的状态,当有IO事件就绪时,通过回调机制来进行处理。而虚拟线程通常用于解决IO阻塞的问题,通过在IO操作或时间等待点上主动释放执行权,来实现更好的并发性能。虚拟线程可以减少线程切换的开销,但它仍然是在应用程序内部执行的,并不能直接替代IO多路复用来管理和处理大量的并发IO操作。可以说术业有专攻,但是虚拟线程确实会对多IO操作有效率提升的。
到此这篇关于浅析Java中的虚拟线程的文章就介绍到这了,更多相关Java虚拟线程内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!