Bootstrap

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中不存在相应的类,是虚拟线程的一个载体线程。一个虚拟线程倘若装载到一个平台线程之后,那么这个平台线程就可以称之为虚拟线程的一个载体线程。

注意点(重要):

  1. 创建平台线程,就回创建操作系统线程。若阻塞平台线程,同样会阻塞操作系统线程。
  2. 虚拟线程不能直接执行,需要装载(Mount)到平台线程,依靠平台线程执行任务。
  3. 倘若虚拟线程发生了阻塞,会从平台线程上卸载(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。如果你是Windowscmd一下,输入以下命令:

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=falsessl协议关闭。
  • -Dcom.sun.management.jmxremote.authenticate=false :是否登录验证。直接 false 走起,不校验。

在给对应的UT添加完运行参数之后,就可以执行啦。然后在JConsole控制台中输入地址:

127.0.0.1:8888

如图:
在这里插入图片描述
点击连接之后,可能会弹出下面的框:
在这里插入图片描述
点击不安全的连接即可。

这次我们准备用两种线程去做一个任务:

  1. 启动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
    在这里插入图片描述

总结下就是:

  1. CPU利用率上,虚拟线程更占优。同时虚拟线程的吞吐量要更高一点。试想一下我们正常代码中,往往会设置线程池的活跃线程数。那这里就会限制了吞吐量。而虚拟线程的吞吐量就非常高。
  2. 虚拟线程的吞吐量高,因此整个任务的执行速度就变快了。
  3. 虚拟线程的内存占用要更高。因为虚拟线程是JDK在用户模式实现,所以需要更复杂的数据结构去实现,而传统线程,则依赖于操作系统,所以JVM的内存占用就少了。

三. 虚拟线程注意事项

  1. 虚拟线程可以显著提高程序的吞吐量(而不是提升运行速度),因此适合任务数高或者IO密集型的场景。
  2. 虚拟线程的相关API都是预览特性,生产环境需要添加对应的参数才可执行。
  3. 虚拟线程是JVM级别的线程,由JVM调度,因此非常轻量级,使用完后立即被销毁,即不需要像平台线程一样使用池化。

虚拟线程似乎并没有什么API可以限制线程的数量,跟线程池的活跃线程数、队列、最大线程数相似。那怎么办呢?

回答:

  1. 结合信号量进行控制,避免虚拟线程数量太高,导致内存占用太大。

后面会实战演练一下,改造下公司的一个Job项目。我们这个项目是专门跑不同的Job的,而这类项目具有潮汐特性即在某些情况下,定时任务可能会在同一时间内集中执行,而在其他时间内则相对较少或不执行的现象。 正好适合虚拟线程的一个改造,提高吞吐量

改造之后,会对比改造前后的CPU利用率等相关信息。看下虚拟线程给我们带来的利益有多少。

;