在最近发布的JDK 21 LTS版本中,加入了许多新特性。其中对我们开发人员影响最大的应该是分代ZGC以及Java的虚拟线程。在本篇文章中,我将带大家深入了解Java虚拟线程的原理、如何使用、使用的注意事项以及其他相似技术的差别。
什么是虚拟线程
首先,我们需要了解什么是虚拟线程。
在平时的开发过程中,我们所使用的多线程往往意味着平台线程。平台线程代表着JVM直接与操作系统交互,创建了一个一个的线程,并且在JVM中还要为这个线程单独开辟内存使用。一般在JVM中创建一个平台线程,开销大约在1M左右。为了避免创建线程和销毁线程带来的巨大开销,我们通常选择使用池化技术来维护一些活跃的线程。
此外,线程的数量也需要严格控制。如果在一个线程池中维护成千上百个线程,往往效率并不尽如人意。因为线程在切换时涉及到CPU上下文的切换,如果线程数过多,反而会降低执行效率。因此,如何控制线程池的大小也是考验工程师经验的难点。
平台线程与系统线程关系如下
为了解决这个问题,虚拟线程应运而生,虚拟线程并不是Java的首创,它在很多其他语言中被称为协程、纤程、绿色线程、用户态线程等,虚拟线程相对平台线程,并不直接与操作系统交互,虚拟线程的数据是维护在堆内存中,由JVM创建的平台线程来持有,由平台线程来决定什么时候来切换虚拟线程,大概图如下
虽然图中只画了几个虚拟线程,但是在实际使用中,我们可以创建成百上千的虚拟线程而不用担心资源消耗的问题
首先原因在于虚拟线程的开销极其廉价,一个虚拟线程可能才使用几百字节,所以几遍创建成百上千也不会消耗太多内存资源
其次虽然我们有极多的虚拟线程,但是实际上执行线程依旧只有几个平台线程,所以在线程使用中不会由于CPU上下文的切换导致的额外开销。
使用
接下来我们用代码来实践一下虚拟线程,JDK的工程师为了方便我们快速进行虚拟线程的升级,可以使用Thread来快速创建虚拟线程
jsx
Thread vt = Thread.startVirtualThread(() -> {
Thread.sleep(1000);
});
这种方法创建的虚拟线程会立刻启动,那么如果我们想创建一个需要手动启动的虚拟线程,可以参考如下的方式
jsx
// 创建VirtualThread
Thread.ofVirtual().unstarted(() -> {
Thread.sleep(1000);
});
// 运行
vt.start();
还可以创建虚拟线程的工厂来使用
jsx
// 创建ThreadFactory:
ThreadFactory tf = Thread.ofVirtual().factory();
// 创建VirtualThread:
Thread vt = tf.newThread(() -> {
Thread.sleep(1000);
});
// 运行:
vt.start();
我们将线程的上下文信息打印出来看看
jsx
VirtualThread[#21]/runnable@ForkJoinPool-1-worker-1
从这个打印内容可以大概看出,虚拟线程底层采用了ForkJoinPool来维护平台线程,而后面的worker-1则代表具体的平台线程的名称,前面的#21代表当前虚拟线程的数量,那么可能有同学就会问了 那如果我全局有成千上百个虚拟线程,我怎么知道是哪块业务的线程除了问题呢。
我们可以对线程工厂进行命名,使用该线程工厂命名后,在线程上下文中就会打印对应虚拟线程工厂的信息。
jsx
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;
});
}
}
jsx
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操作有效率提升的。