Java 虚拟线程笔记

近期将 ad-filters-subscriber 升级到了 Java21, 借此体验了一下终于正式推出的虚拟线程。这篇文章就顺便记录一下关于虚拟线程的若干测试用例。


Java 虚拟线程(Virtual Threads)是 JVM 内部轻量级线程实现,于Java 19 引入,Java 21 正式发布。 特点:

  1. 轻量级
    不需要操作系统内核参与,创建和上下文切换成本远低于传统的操作系统线程(即平台线程),且占用的内存资源较少。

  2. 高性能
    由于虚拟线程的轻量化特性,应用程序可以轻松创建和管理成千上万的并发任务,尤其适用于高并发、I/O密集型或大量短生命周期任务的场景。

  3. 灵活调度
    虚拟线程的调度完全在用户空间(Java Runtime)进行,不受操作系统内核调度的限制。JVM 可以根据应用需求和系统负载更精细地控制虚拟线程的执行。

  4. 平台线程的映射
    虚拟线程并非直接对应操作系统线程。JVM会将多个虚拟线程映射到少量的平台线程上,通过协作式多任务(如协程、纤程)技术, 让多个虚拟线程在同一个平台线程上轮流执行,从而避免频繁的上下文切换。

  5. 标准 API 集成
    虚拟线程以标准 Java API 的形式提供,可以直接使用 java.lang.Thread 类创建和管理虚拟线程。

创建方式

根据前文介绍,虚拟线程以标准Java API的形式提供,因此其创建也相当简单,简单来说有4种方式:

1. 直接创建并执行

1
2
3
4
Thread vt = Thread.startVirtualThread(() -> {
    Thread.currentThread().setName("virtual thread");
    log.info("create and start virtual thread");
});

2. 通过构造器

1
2
3
4
5
Thread vt = Thread.ofVirtual().unstarted(() -> {
    Thread.currentThread().setName("virtual thread");
    log.info("create and manual start virtual thread");
});
vt.start();

3. 通过线程工厂

1
2
3
4
5
6
ThreadFactory factory = Thread.ofVirtual().factory();
Thread vt = factory.newThread(() -> {
    Thread.currentThread().setName("virtual thread");
    log.info("create virtual thread by factory");
});
vt.start();

4. 通过线程池

1
2
3
4
5
6
7
// 等同 newThreadPerTaskExecutor(ThreadFactory)
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
    executor.execute(() -> {
        Thread.currentThread().setName("virtual thread");
        log.info("create virtual thread by executor");
    });
}

始终是守护线程

既然虚拟线程与平台线程没有直接联系,那么可以先验证一下其是否是并行的,建立如下用例,为了方便展示直接写成 main 方法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public static void main(String[] args) throws InterruptedException {
    log.info("main thread start");
    Thread.startVirtualThread(() -> {
        Thread.currentThread().setName("virtual thread-1");
        log.info("thread-1 start");
        try {
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        log.info("thread-1 end");
    });

    Thread.startVirtualThread(() -> {
        Thread.currentThread().setName("virtual thread-2");
        log.info("thread-2 start");
        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        log.info("thread-2 end");
    });
    Thread.sleep(6000);
    log.info("main thread end");
}

用例内容很直观:分别建立并运行两个虚拟线程,一个休眠3秒,一个休眠2秒。

如果两个虚拟线程是并行,那么两虚拟线程应该同时执行,且 thread-2 先于thread-1 1秒结束。 如不是并行,那么 thread-1 先开始于3秒后结束,thread-2 才开始。

接下来看下执行结果:

1
2
3
4
5
6
14:37:16.051 [main] -- main thread start
14:37:16.061 [virtual thread-1] -- thread-1 start
14:37:16.061 [virtual thread-2] -- thread-2 start
14:37:18.071 [virtual thread-2] -- thread-2 end
14:37:19.066 [virtual thread-1] -- thread-1 end
14:37:22.064 [main] -- main thread end

结果符合预期。
但是,不难注意到代码后面有一个 Thread.sleep(6000),为什么要阻塞主线程呢?

先把 Thread.sleep(6000) 注释掉再执行一次看看:

1
2
3
4
14:53:32.175 [main] -- main thread start
14:53:32.187 [main] -- main thread end
14:53:32.187 [virtual thread-1] -- thread-1 start
14:53:32.187 [virtual thread-2] -- thread-2 start

可以看到两个虚拟线程开始的同时,主线程也直接结束了,然后执行就结束了…
这似乎与常识不符,主线程结束并不应该影响到其他线程。那么把虚拟线程替换为常规线程再看看结果:

1
2
3
4
5
6
14:57:36.289 [main] -- main thread start
14:57:36.292 [virtual thread-1] -- thread-1 start
14:57:36.293 [main] -- main thread end
14:57:36.293 [virtual thread-2] -- thread-2 start
14:57:38.306 [virtual thread-2] -- thread-2 end
14:57:39.304 [virtual thread-1] -- thread-1 end

两相对比之下不难验证,虚拟线程始终是守护线程,所以 JVM 会在所有常规线程完成后退出, 因此在虚拟线程中通过 Thread.currentThread().isDaemon() 可以得到 true。 而若是执行:Thread.currentThread().setDaemon(false) 将得到一个 IllegalArgumentException: 'false' not legal for virtual threads

ThreadLocal

虚拟线程也可以使用 ThreadLocal 吗?简单修改一下用例,验证一下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
ThreadLocal<String> threadLocal = new ThreadLocal<>();
threadLocal.set("I am main thread");
log.info("get thread local: {}", threadLocal.get());

Thread.startVirtualThread(() -> {
    Thread.currentThread().setName("virtual thread-1");
    log.info("get thread local: {}", threadLocal.get());
    threadLocal.set("I am thread-1");
    log.info("get thread local: {}", threadLocal.get());
    try {
        Thread.sleep(3000);
    } catch (InterruptedException e) {
        throw new RuntimeException(e);
    }
});

Thread.startVirtualThread(() -> {
    Thread.currentThread().setName("virtual thread-2");
    log.info("get thread local: {}", threadLocal.get());
    threadLocal.set("I am thread-2");
    log.info("get thread local: {}", threadLocal.get());
    try {
        Thread.sleep(2000);
    } catch (InterruptedException e) {
        throw new RuntimeException(e);
    }
});
Thread.sleep(6000);

看下结果:

1
2
3
4
5
14:57:22.362 [main] -- get thread local: I am main thread
14:57:22.374 [virtual thread-1] -- get thread local: null
14:57:22.374 [virtual thread-2] -- get thread local: null
14:57:22.374 [virtual thread-1] -- get thread local: I am thread-1
14:57:22.374 [virtual thread-2] -- get thread local: I am thread-2

结果显然是支持的。
但是使用应当谨慎,虚拟线程创建开销很小,甚至单机可以达到百万数量级,不当的使用 ThreadLocal 对内存来说无疑是一场灾难。

虚拟线程和Spring

在 spring 中,进行异步调用一般会使用 @Async 注解,它会将任务提交到默认的 TaskExecutor 线程池中以异步执行。
那么,能否使用虚拟线程来替代呢?

创建一个 spring-boot 程序来测试一下,首先需要在启动类上加入 @EnableAsync 注解,而后定义如下bean:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
@Bean
public TaskExecutor executors() {
    return new TaskExecutor() {
        private static ExecutorService executorService = Executors.newVirtualThreadPerTaskExecutor();
        @Override
        public void execute(Runnable task) {
            executorService.execute(task);
        }
    };
}

接下来写一个测试用的方法,并加上 @Async 注解:

1
2
3
4
5
@Async
public void asyncFunc() throws InterruptedException {
    Thread.sleep(5000);
    log.info("async func finished");
}

最后创建http端点作为入口:

1
2
3
4
5
6
7
@GetMapping("/test")
public String test() throws InterruptedException {
    log.info("start call");
    asyncTest.asyncFunc();
    log.info("end call");
    return "async func called";
}

按照经验,调用 /test 时,会立即得到响应: async func called ,而方法将在 5 秒后完成。启动程序调用一下试试:

1
2
3
11:04:19.704 INFO [nio-8080-exec-2] -- start call
11:04:19.704 INFO [nio-8080-exec-2] -- end call
11:04:24.720 INFO [               ] -- async func finished

结果符合预期。