JDK19 - Virtual Thread 虚拟线程探究
背景
JDK19
中,推出了一个新特性:Virtual Thread
虚拟线程。它是一种轻量级线程,由Java
虚拟机实现,可以在同一个线程中执行多个任务,从而减少线程切换的开销,主要用来提高应用程序的性能和吞吐量。
一. 虚拟线程简介
先说下我们一般 JDK8
开发过程中使用到的线程,这部分线程我们称之为平台线程(为的就是和虚拟线程做区分)。平台线程在底层操作系统线程上运行 Java
代码,并在代码的整个生命周期中捕获操作系统线程。
但是在JDK19
当中的虚拟线程,他们则是用户模式线程。即由应用程序自己实现和管理的,而不是由操作系统内核实现和管理。
1.1 线程术语
- 操作系统线程(
OS Thread
):由操作系统管理的“类似线程”的数据结构。 - 平台线程(
Platform Thread
):在虚线程概念出来前,java.Lang.Thread
类的每个实例,都是一个平台线程,是操作系统线程的包装。 - 虚拟线程(
Virtual Thread
):一种轻量级,由JVM
管理的线程。对应java.lang.VirtualThread
这个类。 - 载体线程(
Carrier Thread
):一个命名约定,Java
中不存在相应的类,是虚拟线程的一个载体线程。一个虚拟线程倘若装载到一个平台线程之后,那么这个平台线程就可以称之为虚拟线程的一个载体线程。
注意点(重要):
- 创建平台线程,就回创建操作系统线程。若阻塞平台线程,同样会阻塞操作系统线程。
- 虚拟线程不能直接执行,需要装载(
Mount
)到平台线程,依靠平台线程执行任务。 - 倘若虚拟线程发生了阻塞,会从平台线程上卸载(
Unount
),达到不影响平台线程的目的。
1.2 虚拟线程的启动方式
虚拟线程是一个预览API
,默认情况下被禁用,如果要使用虚拟线程,则需要添加对应的参数设置。 如图:
Idea
里面添加参数(注意!别忘了使用JDK19
来开发):
--enable-preview
如图:
由于虚拟线程在设计的时候,其地位就是和其他普通线程同级的。因此为了方便,相关的API
也在Thread
类下面。
1.1.1 通过 Thread.startVirtualThread() 创建
@org.junit.Test
public void testStartVirtualThread() {
Thread.startVirtualThread(() -> System.out.println("hello"));
}
1.1.2 通过 Thread.ofVirtual() 创建
@org.junit.Test
public void testOfVirtual(){
// 创建一个虚拟线程,但是不启动
Thread unStarted = Thread.ofVirtual().unstarted(() -> System.out.println("hello"));
unStarted.start();
// 创建一个虚拟线程并启动
Thread.ofVirtual().start(() -> System.out.println("hello2"));
}
1.1.3 通过 ThreadFactory 创建
@org.junit.Test
public void testThreadFactory(){
// 创建一个虚拟线程,但是不启动
ThreadFactory factory = Thread.ofVirtual().factory();
Thread t = factory.newThread(() -> System.out.println("hello"));
t.start();
}
1.1.4 通过 newVirtualThreadPerTaskExecutor() 创建
@org.junit.Test
public void testVirtualThreadPerTaskExecutor() throws Exception {
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
executor.submit(() -> System.out.println("hello"));
}
二. 虚拟线程和平台线程对比(JConsole)
我们用 JConsole
来简单对比一下。只要你本地机器正确安装了JDK
,就会默认安装对应版本的JConsole
。如果你是Windows
,cmd
一下,输入以下命令:
jconsole
输入完毕就会弹出旁边的窗口:
我们可以看到,这里有两种连接方式:
- 本地进程:已经在跑的进程。
- 远程进程:手动连接对应的进程。适合我们这种写
UT
去测试的。
2.1 JConsole 使用
我们这里采用第二种方式连接。那么远程的地址、端口号、用户名和口令我们又是哪里设置呢?我们可以在UT
的编辑页面,添加以下参数:
-Djava.rmi.server.hostname=127.0.0.1 -Dcom.sun.management.jmxremote -Dcom.sun.management.jmxremote.port=8888 -Dcom.sun.management.jmxremote.ssl=false -Dcom.sun.management.jmxremote.authenticate=false
如图:
对应参数解释如下:
-Djava.rmi.server.hostname=127.0.0.1
: 服务器端的ip
地址。-Dcom.sun.management.jmxremote
:设置JVM
允许远程jmx
进行调用查看-Dcom.sun.management.jmxremote.port=8888
: 此为运行服务的端口。-Dcom.sun.management.jmxremote.ssl=false
:ssl
协议关闭。-Dcom.sun.management.jmxremote.authenticate=false
:是否登录验证。直接false
走起,不校验。
在给对应的UT添加完运行参数之后,就可以执行啦。然后在JConsole
控制台中输入地址:
127.0.0.1:8888
如图:
点击连接之后,可能会弹出下面的框:
点击不安全的连接即可。
这次我们准备用两种线程去做一个任务:
- 启动100000个任务,每个任务睡眠1秒钟。
我们先写一个自定义的任务:
public class Task implements Callable<Integer> {
private final int number;
public Task(int number) {
this.number = number;
}
@Override
public Integer call() throws Exception {
System.out.printf("Thread %s - Task %d waiting...%n", Thread.currentThread().getName(), number);
try {
Thread.sleep(1000);
return 1;
} catch (InterruptedException e) {
System.out.printf("Thread %s - Task %d canceled.%n", Thread.currentThread().getName(), number);
}
System.out.printf("Thread %s - Task %d finished.%n", Thread.currentThread().getName(), number);
return ThreadLocalRandom.current().nextInt(100);
}
}
2.2 平台线程测试
我们对应代码如下:
@org.junit.Test
public void test2() throws Exception {
// 睡眠20秒,是为了让你有足够的时间,去连接JConsole。觉得自己手速慢的可以调久一点
TimeUnit.SECONDS.sleep(20);
AtomicInteger count = new AtomicInteger();
long start = System.currentTimeMillis();
try (var executor = Executors.newCachedThreadPool()) {
IntStream.range(0, 100000).forEach(i -> {
executor.submit(() -> {
System.out.println("参数" + (count.getAndIncrement()));
Thread.sleep(Duration.ofSeconds(1));
return i;
});
});
}
System.out.println("耗时" + (System.currentTimeMillis() - start));
System.in.read();
}
结果:
总耗时:10秒
2.3 虚拟线程测试
@org.junit.Test
public void test1() throws Exception {
TimeUnit.SECONDS.sleep(20);
AtomicInteger count = new AtomicInteger();
long start = System.currentTimeMillis();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
IntStream.range(0, 100000).forEach(i -> {
executor.submit(() -> {
System.out.println("参数" + (count.getAndIncrement()));
Thread.sleep(Duration.ofSeconds(1));
return i;
});
});
}
System.out.println("耗时" + (System.currentTimeMillis() - start));
System.in.read();
}
结果如下:
耗时:3秒
2.4 平台线程和虚拟线程对比
对比一下两者的细节部分。首先是线程数:
- 平台线程:峰值14525个
- 虚拟线程:峰值35个。
内存:
-
平台线程:
CPU
阶段性的上升
-
虚拟线程:
CPU
一下子飙升,然后再迅速回落。
程序的执行速度上:
- 平台线程:10.5秒。
- 虚拟线程:3.2秒。
内存的占用上:
-
平台线程:大概在
150M
。
-
虚拟线程:大概在
372M
。
总结下就是:
CPU
利用率上,虚拟线程更占优。同时虚拟线程的吞吐量要更高一点。试想一下我们正常代码中,往往会设置线程池的活跃线程数。那这里就会限制了吞吐量。而虚拟线程的吞吐量就非常高。- 虚拟线程的吞吐量高,因此整个任务的执行速度就变快了。
- 虚拟线程的内存占用要更高。因为虚拟线程是
JDK
在用户模式实现,所以需要更复杂的数据结构去实现,而传统线程,则依赖于操作系统,所以JVM
的内存占用就少了。
三. 虚拟线程注意事项
- 虚拟线程可以显著提高程序的吞吐量(而不是提升运行速度),因此适合任务数高或者
IO
密集型的场景。 - 虚拟线程的相关
API
都是预览特性,生产环境需要添加对应的参数才可执行。 - 虚拟线程是
JVM
级别的线程,由JVM
调度,因此非常轻量级,使用完后立即被销毁,即不需要像平台线程一样使用池化。
虚拟线程似乎并没有什么API可以限制线程的数量,跟线程池的活跃线程数、队列、最大线程数相似。那怎么办呢?
回答:
- 结合信号量进行控制,避免虚拟线程数量太高,导致内存占用太大。
后面会实战演练一下,改造下公司的一个Job
项目。我们这个项目是专门跑不同的Job
的,而这类项目具有潮汐特性:即在某些情况下,定时任务可能会在同一时间内集中执行,而在其他时间内则相对较少或不执行的现象。 正好适合虚拟线程的一个改造,提高吞吐量
改造之后,会对比改造前后的CPU
利用率等相关信息。看下虚拟线程给我们带来的利益有多少。