本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!
在谈虚拟线程之前,笔者这里提供了一个小例子,来先帮助各位读者理解一下 CPU 利用率的问题(笔者的电脑是一台 8核 16G 的 mac air)。
static int core = 8;
public static void main(String[] args) {
for (int i = 0; i < core; i++) {
new Thread(() -> {
while (true) {
}
}).start();
}
// 不退出
while (true) {
}
}
执行此程序,通过 top 命令查看当前 CPU 的使用情况如下,CPU 使用率为 742.4%;
PID COMMAND %CPU TIME #TH #WQ #PORT MEM PURG CMPRS PGRP PPID STATE BOOSTS %CPU_ME %CPU_OTHRS UID FAULTS COW
7114 java 742.4 05:41.13 33/9 1 106 28M 0B 15M 651 651 running *0[1] 0.00000 0.00000 501 3244 148
系统的整体 CPU 利用率则是 96.67%
Load Avg: 16.14, 8.54, 4.97 CPU usage: 96.67% user, 3.32% sys, 0.0% idle SharedLibs: 616M resident, 114M data, 44M linkedit.
这里笔者把 man top 中关于 CPU 使用率的计算方式贴出来:
1. %CPU -- CPU Usage
The task's share of the elapsed CPU time since the last screen update, expressed as a percentage of total CPU time.
In a true SMP environment, if a process is multi-threaded and top is not operating in Threads mode, amounts greater than 100% may be reported. You toggle
Threads mode with the `H' interactive command.
Also for multi-processor environments, if Irix mode is Off, top will operate in Solaris mode where a task's cpu usage will be divided by the total number
of CPUs. You toggle Irix/Solaris modes with the `I' interactive command.
总结来说某个进程的CPU使用率就是这个进程在一段时间内占用的CPU时间占总的CPU时间的百分比。那么结合这个,我们来解释下 top 命令中展示出来的两个指标:
- %CPU:这是
top
命令显示的每个进程使用的 CPU 百分比。在多核系统上,该值可以超过 100%,因为每个核心都可以使用 100% 的 CPU。例如,4 个核心的系统中,如果某个进程占用了所有核心的 100%,那么它的 %CPU 就会显示为 400%。 - CPU usage:这是系统总体的 CPU 使用率,通常显示为一个百分比。这个值表示所有核心的平均使用率,在 0% 到 100% 之间。
好了,通过上面的这个案例,笔者先交代了关于现代多核系统下关于 CPU 使用率相关的问题。下面来讨论对于 JAVA 语言而言,为什么需要虚拟线程这个特性。
为什么需要虚拟线程
首先从工程化的角度来看,目前在 web 领域,JAVA 还是占据了绝对的优势,体量大,生态全。基于这个背景,我们再从成本的角度来思考,你的服务需要 100% 的CPU 算力资源,如果你一台机器能扛住 100%,那么就只需要一台,如果你的机器只能到 50%,那么你需要两台;这个道理很简单。所以,你能让你的 CPU 保持 100% 的满负荷运行吗?
那么在回答这个问题之前,我们先来计算一下,你需要多少线程才能达到这个目标。假如一个线程的 CPU usage 是 0.0001%,那么 10 个线程的 CPU usage 是 0.001%,以此类推:
线程数 | CPU usage |
---|---|
1 | 0.0001% |
10 | 0.001% |
100 | 0.01% |
... | ... |
10000 | 1% |
1000000 | 100% |
可以看到,如果需要跑满 100%,大概需要 100万个线程。还是以笔者的电脑为例,经测试 8C16G 可以创建的线程总数是 4072 个(一般情况下不会超过 7000);暂且抛开数量上的限制,同样我们也可以测算一下 100万个线程所需要的内存空间,假设一个线程需要 1M,那么 100万个线程需要的内存就是 1TB;再说回到线程数量这块,我们即使按照可以创建 10000 个线程来测算,CPU 的使用率也仅仅是 1%。所以不管是从数量上还是占用的内存资源上来讲,JAVA 中的这种平台线程绑定的传统线程模型都有很大的局限性;这也就是为什么市面上主流商业化的网关大多数都是基于 golang,而不是基于 JAVA 的了。
Reached the limit of traditional threadsjava.lang.OutOfMemoryError: unable to create native thread: possibly out of memory or process/resource limits reached
虚拟线程的基本 API 应用
本小节笔者简单介绍下 虚拟线程的使用方式(PS:JDK 版本笔者使用的是 21)
- 1、使用 Thread.startVirtualThread 提供的静态方法构建虚拟线程
Runnable runnable = () -> {
System.out.println("Hello, www.glmapper.com");
};
// 使用静态构建器方法
Thread virtualThread = Thread.startVirtualThread(runnable);
Thread.sleep(1000);
- 2、使用 Thread.ofVirtual 构建虚拟线程
Runnable runnable = () -> {
System.out.println("Hello, www.glmapper.com");
};
Thread.ofVirtual()
.name("glmapper-virtual-thread")
.start(runnable);
- 3、使用 ThreadFactory 创建虚拟线程
ThreadFactory virtualThreadFactory = Thread.ofVirtual()
.name("glmapper", 0)
.factory();
Thread factoryThread = virtualThreadFactory.newThread(runnable);
factoryThread.start();
- 4、使用 Executors 线程池静态类创建虚拟线程
Runnable runnable = () -> {
System.out.println("Hello, www.glmapper.com");
};
try (ExecutorService executorService = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 100; i++) {
executorService.submit(runnable);
}
}
从 api 来看,还是沿用了 Thread 类来提供创建线程的能力,对于开发者来讲,基本能够延续之前的一些编程习惯。
池化技术在虚拟线程场景下的必要性
这里笔者想先通过 JEP 中的描述直接给出结论:不要池化虚拟线程。在并发编程这块,线程池想必是每位 JAVA 开发工程师都绕不过去的技术点,当然这也是面试中出现频次相当高的知识点。先从池化技术角度来分析,池化技术(Pooling)本质是一种资源管理技术,常用于数据库连接池、线程池、对象池等场景。它的核心思想是通过复用资源来减少创建和销毁资源的开销,提高系统性能和资源利用率。
在 JAVA 语言中,创建线程(平台线程,JAVA 中的线程是对平台线程的包装)的开销相对来说是很大的,但是更大的消耗是对于池的管理,这就包括资源争夺(在高并发环境下,如果池中可用资源耗尽,后续请求需要等待资源释放,这可能导致资源争夺和性能瓶颈)、内存泄漏(资源回收不当(例如由于逻辑错误),可能导致资源未能及时释放,引发内存泄漏问题) 等等;此外实现一个高效且可靠的池化机制需要复杂的设计和大量的细节处理,如资源的初始化、回收、异常处理等。这些因为创建开销问题和平台线程资源有限问题的后置补偿设计,使得整体的研发复杂度和设计复杂度都提高了一个量级。
在没有虚拟线程之前,程序员常常会利用线程池来控制对有限资源的并发访问量。比如说某个服务能够承受的最大并发请求数是20个,那么我们可以通过将所有对该服务的访问任务提交给一个容量为20的线程池来处理,以此确保服务不会因请求过多而受到影响;这也是因为平台线程的昂贵才不得不使用线程池来一定程度上的规这种开销。抛开资源成本的问题,更合理的方式是选择那些专门为资源访问控制而设计的机制,比如信号量。这些方法不仅效率更高、操作更简便、安全性更强,而且还能有效避免线程本地数据意外地泄露给其他任务的风险。
虚拟线程与普通线程的资源消耗测试
这块主要是测试虚拟线程和普通线程在同一台机器上创建出来的数量对比以及创建同等数量情况下的内存使用对比。首先是数量的代码:
-
1、用于创建平台线程的测试
private static int testPlatformThreads() { int count = 0; try { while (true) { // 使用平台线程 Thread thread = Thread.ofPlatform().unstarted(() -> { try { Thread.sleep(Long.MAX_VALUE); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); thread.start(); count++; System.out.println("Platform thread count: " + count); } } catch (Throwable e) { System.out.println("Reached the limit of traditional threads"); e.printStackTrace(); } return count; }
-
2、用于创建虚拟线程的测试
private static int testVirtualThreads() { AtomicInteger count = new AtomicInteger(0); try { while (true) { long begin = System.currentTimeMillis(); // 使用虚拟线程 Thread thread = Thread.ofVirtual().unstarted(() -> { try { Thread.sleep(Long.MAX_VALUE); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); thread.start(); count.incrementAndGet(); System.out.println("Virtual thread count: " + count.get() + ", time: " + (System.currentTimeMillis() - begin) + "ms"); } } catch (Throwable e) { System.out.println("Reached the limit of virtual threads"); e.printStackTrace(); } return count.get(); }
同等数量情况的内存在用对比(使用 jcmd 命令查看内存占用情况,笔者下面表中直接给出结果,有兴趣的同学可以自行尝试):
-
创建 4000 个平台线程
private static void testPlatformThreads_mem() throws InterruptedException { for (int i = 0; i < COUNT; i++) { Thread.ofPlatform().name("glmapper-platform-thread-" + i).start(() -> { try { Thread.sleep(Long.MAX_VALUE); } catch (Exception e) { e.printStackTrace(); } }); } Thread.sleep(Long.MAX_VALUE); }
-
创建 4000 个虚拟线程
private static void testVirtualThreads_mem() throws InterruptedException { for (int i = 0; i < COUNT; i++) { Thread.startVirtualThread(() -> { try { Thread.sleep(Long.MAX_VALUE); } catch (Exception e) { e.printStackTrace(); } }); } Thread.sleep(Long.MAX_VALUE); }
测试执行结果对比:
对比项目 | 平台线程 | 虚拟线程 |
---|---|---|
数量 | 4072 | 10781811 |
内存占用(创建 4000个) | Thread (reserved=8296352KB, committed=8296352KB) (thread #4021) (stack: reserved=8283260KB, committed=8283260KB) (malloc=8381KB #32386) (peak=8421KB #32392) (arena=4711KB #8041) (peak=4838KB #8040) | Thread (reserved=61890KB, committed=61890KB) (thread #30) (stack: reserved=61800KB, committed=61800KB) (malloc=56KB #191) (peak=64KB #197) (arena=34KB #59) (peak=161KB #58) |
结论:从测试结果看,虚拟线程和平台线程在同一台机器上的数据差异差不多是 2500倍(不同的机器可以差异也不一样,这里是笔者自己的机器测试结果,仅供参考);内存占用上,平台线程每个占用约 2M,虚拟线程每个占用约 15KB,相差约 135倍。从个结果来看,也算是证明了官方一直提的说法:更便宜的线程
虚拟线程与 golang 协程的对比测试
这块主要是测试 JAVA 的虚拟线程和 golang 的协程在同一台机器上创建出来的数量和内存消耗的对比。关于 golang 的测试,这里笔者使用了What is a goroutine? And what is their size? 文章中的一个脚本;JAVA 的脚本是通过参考 golang 的编写。
- golang 测试结果
Number of goroutines: 5000000
Per goroutine:
Memory: 2701.92 bytes
Time: 0.870233 µs
Exiting.
- Java
Number of virtual threads: 5000000
Per virtual thread:
Memory: 548.20 bytes
Time: 0.233 µs
虚拟线程占用内存进一步探究
经过上面的测试,笔者又基于对比的方式,同样是创建 500 万虚拟线程,分别从代码计算、jcmd 和 jconsole 三个角度来看看内存占用情况(基于运行平稳之后)
- 代码
private static void testVirtualThreads_mem() throws InterruptedException {
// Take initial memory snapshot
MemoryUsage m0 = ManagementFactory.getMemoryMXBean().getHeapMemoryUsage();
for (int i = 0; i < 5000000; i++) {
Thread thread = Thread.ofVirtual().unstarted(() -> {
try {
Thread.sleep(Long.MAX_VALUE);
} catch (Exception e) {
e.printStackTrace();
}
});
thread.start();
}
// Take final memory snapshot
new Thread(() -> {
try {
while (true) {
Thread.sleep(5000);
MemoryUsage m1 = ManagementFactory.getMemoryMXBean().getHeapMemoryUsage();
System.out.printf("Committed: %.2f KB Memory: %.2f KB%n",(double) m1.getCommitted() / 1024, (double) (m1.getCommitted() - m0.getCommitted()) / 1024);
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
Thread.sleep(Long.MAX_VALUE);
}
- 代码计算结果
# Committed = ManagementFactory.getMemoryMXBean().getHeapMemoryUsage().getCommitted()
Committed: 3020800.00 KB Memory: 2756608.00 KB
Committed: 4194304.00 KB Memory: 3930112.00 KB
Committed: 4194304.00 KB Memory: 3930112.00 KB
- 通过 jcmd 查看结果(也是指定进程的
jcmd [pid] VM.native_memory summary
)
Total: reserved=12797211KB, committed=11450059KB
malloc: 7012727KB #40244765
mmap: reserved=5784484KB, committed=4437332KB
- Java Heap (reserved=4194304KB, committed=4194304KB)
(mmap: reserved=4194304KB, committed=4194304KB)
- Class (reserved=1048945KB, committed=1393KB)
(classes #2758)
( instance classes #2491, array classes #267)
(malloc=369KB #8181) (peak=439KB #8967)
(mmap: reserved=1048576KB, committed=1024KB)
( Metadata: )
( reserved=65536KB, committed=7936KB)
( used=7553KB)
( waste=383KB =4.82%)
( Class space:)
( reserved=1048576KB, committed=1024KB)
( used=822KB)
( waste=202KB =19.73%)
- Thread (reserved=96954KB, committed=96954KB)
(thread #47)
(stack: reserved=96820KB, committed=96820KB)
(malloc=80KB #289) (peak=96KB #317)
(arena=54KB #93) (peak=220KB #93)
- Console memory 的走势图
- 线程
- vm summary
以 3930112 KB(折算为 4024434688 bytes),500万虚拟线程平均下来是 800 bytes,与 虚拟线程与 golang 协程的对比测试 小节中的测试结果 548 bytes 有不小的差异,考虑到计算方式和机器当前运行环境的影响,笔者这里暂不提供结论,仅提供测试的思路。
虚拟线程的基本原理
前面经过一系列的对比测试,也对虚拟线程的 api 简单的介绍,本小节主要来从代码层面分析下虚拟线程的基本原理。
状态转换
首先是线程的状态转换,我们先看看平台线程和虚拟线程在状态转换上的差异:
-
平台线程主要包括 6 种状态
// Thread.State 源码 public enum State { NEW, RUNNABLE, BLOCKED, WAITING, TIMED_WAITING, TERMINATED; }
状态流转关系图如下:
-
虚拟线程状态包括 9 种
// VirtualThread private static final int NEW = 0; private static final int STARTED = 1; private static final int RUNNABLE = 2; // runnable-unmounted private static final int RUNNING = 3; // runnable-mounted private static final int PARKING = 4; private static final int PARKED = 5; // unmounted private static final int PINNED = 6; // mounted private static final int YIELDING = 7; // Thread.yield private static final int TERMINATED = 99; // final state
PARKING
(线程尝试进入停驻状态)、PARKED
(线程处于停驻状态)、PINNED
(线程处于锁定状态)、YIELDING
(线程尝试让出运行权) 是虚拟线程中特有的;这块的原因是虚拟线程与平台线程是 M 对 N 的关系,虚拟线程需要绑定到平台线程上之后才能运行。在运行过程中,如果由于某种原因无法继续执行,可以调用 VirtualThread 的 park 方法来尝试停驻,让出运行权。如果让出成功,该虚拟线程会从平台线程上解除绑定,转为停驻状态;如果让出失败,该虚拟线程会被锁定在平台线程上,导致其他虚拟线程无法使用该平台线程。另外一种方式是应用代码主动调用 Thread.yield 来让出运行权。如果让出成功,虚拟线程会处于 RUNNABLE 状态,等待下次调度;如果让出失败,虚拟线程仍然继续运行。
载体线程
下图显示了虚拟线程和平台线程之间的关系:
上图中,虚拟线程和平台线程是由 JVM 自己维护,os 线程则是操作系统调度,Java平台线程与系统线程一一映射(与传统的模式保持一致)。JVM 把虚拟线程分配给平台线程的操作称为 mount(挂载),反过来取消分配平台线程的操作称为unmount(卸载)。下面的代码片段截取于 java.lang.VirtualThread
类中,runContinuation 是实际执行业务逻辑的地方。
private void runContinuation() {
// 省略部分代码
// notify JVMTI before mount
notifyJvmtiMount(/*hide*/true);
try {
// 运行业务代码
cont.run();
} finally {
// notify JVMTI that unmount has completed, thread is parked
// 下面的两个方法都会调用 notifyJvmtiUnmount()
if (cont.isDone()) {
afterTerminate();
} else {
afterYield();
}
}
}
这段代码可以简单的描述虚拟线程在执行时和平台线程的关系,即:mount -> run -> unmount
。从 JAVA 代码的角度来看,vt 和它的载体线程
暂时共享一个 OS线程实例这个事实是不可见的,因为虚拟线程的堆栈跟踪和线程本地变量与平台线程是完全隔离的。JVM 维护了一个平台线程池(ForkJoinPool),对于每个创建的虚拟线程,JVM 都会在平台线程上调度其执行,从而暂时将虚拟线程的堆栈块从堆复制到平台线程的堆栈中,使得平台线程成为虚拟线程的 载体线程。
默认的平台线程池 ForkJoinPool
下面简单看下 java.lang.VirtualThread
中 ForkJoinPool 的初始化。
private static ForkJoinPool createDefaultScheduler() {
// 载体线程工厂,CarrierThread 是 ForkJoinWorkerThread 的子类
ForkJoinWorkerThreadFactory factory = pool -> {
PrivilegedAction<ForkJoinWorkerThread> pa = () -> new CarrierThread(pool);
return AccessController.doPrivileged(pa);
};
PrivilegedAction<ForkJoinPool> pa = () -> {
int parallelism, maxPoolSize, minRunnable;
// 虚拟线程调度器的并行度(ForkJoinPool 的并行度),可以由 Runtime.availableProcessors()个数来决定
String parallelismValue = System.getProperty("jdk.virtualThreadScheduler.parallelism");
// 调度器可用的平台线程的最大数量
String maxPoolSizeValue = System.getProperty("jdk.virtualThreadScheduler.maxPoolSize");
// 允许的未阻塞的最小核心线程数(未被 join 或 ForkJoinPool 阻塞的最小允许核心线程数)
String minRunnableValue = System.getProperty("jdk.virtualThreadScheduler.minRunnable");
if (parallelismValue != null) {
parallelism = Integer.parseInt(parallelismValue);
} else {
// 默认池大小(并行度)等于 CPU 核心数
parallelism = Runtime.getRuntime().availableProcessors();
}
if (maxPoolSizeValue != null) {
maxPoolSize = Integer.parseInt(maxPoolSizeValue);
parallelism = Integer.min(parallelism, maxPoolSize);
} else {
maxPoolSize = Integer.max(parallelism, 256);
}
// 允许的未阻塞的最小核心线程数是池大小的一半
if (minRunnableValue != null) {
minRunnable = Integer.parseInt(minRunnableValue);
} else {
minRunnable = Integer.max(parallelism / 2, 1);
}
Thread.UncaughtExceptionHandler handler = (t, e) -> { };
// FIFO
boolean asyncMode = true;
return new ForkJoinPool(parallelism, factory, handler, asyncMode,
0, maxPoolSize, minRunnable, pool -> true, 30, SECONDS);
};
return AccessController.doPrivileged(pa);
}
其中:
jdk.virtualThreadScheduler.parallelism
:用于调度的平台线程数(并行度)jdk.virtualThreadScheduler.maxPoolSize
:用于运行虚拟线程的 ForkJoinPool 的大小,默认为256。jdk.virtualThreadScheduler.minRunnable
:未被 join 或 ForkJoinPool 阻塞的最小允许核心线程数
这三个参数分别决定了 ForkJoinPool 的并行度(线程池中并发执行任务的线程数)、最大线程数(线程池中允许的最大线程数)和最小可运行线程数(表示在减少线程数之前,允许线程池中保留的最小可运行线程数);后两个参数和普通的线程池从语义上来讲基本差不多。这里笔者提供一段程序,然后通过执行的结果来帮助各位更好的理解这里面的逻辑。
// 创建 Runtime.getRuntime().availableProcessors()+1 个虚拟线程的任务在Runtime.getRuntime().availableProcessors() 个平台线程上运行
private static void test_CarrierThreadPoolSize() {
final ThreadFactory factory = Thread.ofVirtual().name("glmapper-", 0).factory();
try (var executor = Executors.newThreadPerTaskExecutor(factory)) {
// Runtime.getRuntime().availableProcessors() 笔者机器是 8
IntStream.range(0, Runtime.getRuntime().availableProcessors() + 1)
.forEach(i -> executor.submit(() -> {
log("Hello, I'm vt " + i);
try {
sleep(Duration.ofSeconds(1L));
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}));
}
}
输出的结果如下:
15:36:48.253 [glmapper-5] INFO com.glmapper.vt.FullTest - VirtualThread[#31,glmapper-5]/runnable@ForkJoinPool-1-worker-6 | Hello, I'm vt 5
15:36:48.253 [glmapper-0] INFO com.glmapper.vt.FullTest - VirtualThread[#23,glmapper-0]/runnable@ForkJoinPool-1-worker-1 | Hello, I'm vt 0
15:36:48.253 [glmapper-6] INFO com.glmapper.vt.FullTest - VirtualThread[#32,glmapper-6]/runnable@ForkJoinPool-1-worker-7 | Hello, I'm vt 6
15:36:48.253 [glmapper-4] INFO com.glmapper.vt.FullTest - VirtualThread[#30,glmapper-4]/runnable@ForkJoinPool-1-worker-5 | Hello, I'm vt 4
15:36:48.253 [glmapper-1] INFO com.glmapper.vt.FullTest - VirtualThread[#25,glmapper-1]/runnable@ForkJoinPool-1-worker-2 | Hello, I'm vt 1
15:36:48.253 [glmapper-3] INFO com.glmapper.vt.FullTest - VirtualThread[#28,glmapper-3]/runnable@ForkJoinPool-1-worker-4 | Hello, I'm vt 3
15:36:48.253 [glmapper-7] INFO com.glmapper.vt.FullTest - VirtualThread[#33,glmapper-7]/runnable@ForkJoinPool-1-worker-8 | Hello, I'm vt 7
15:36:48.253 [glmapper-2] INFO com.glmapper.vt.FullTest - VirtualThread[#27,glmapper-2]/runnable@ForkJoinPool-1-worker-3 | Hello, I'm vt 2
15:36:48.256 [glmapper-8] INFO com.glmapper.vt.FullTest - VirtualThread[#34,glmapper-8]/runnable@ForkJoinPool-1-worker-2 | Hello, I'm vt 8
这里笔者先解释下日志的内容(从 VirtualThread
开始):
VirtualThread\[#32,glmapper-x]
:VirtualThread
表明这是一个虚拟线程,#32
是虚拟线程的标识符,表示这是系统中的第 31个虚拟线程,glmapper-x
是虚拟线程的名称。/runnable
:虚拟线程当前的状态,runnable
表示虚拟线程正在运行或准备运行@ForkJoinPool-1-worker-x
:@
用来分隔虚拟线程的信息和其执行的载体线程的信息,ForkJoinPool-1-worker-x
是载体线程的名称ForkJoinPool-1
:指的是系统中默认的ForkJoinPool
,通常用于并行任务的执行。worker-x
:指的是ForkJoinPool-1
中的第 x 个工作线程,具体名称为worker-x
。
针对上面的案例,从输出结果可以看到,runnable@ForkJoinPool-1-worker-1
~ runnable@ForkJoinPool-1-worker-8
是 8 个载体线程,载体线程 runnable@ForkJoinPool-1-worker-2
被重用一次(在任务小于核数的情况下,不会存在复用的情况),这也说明了当分配超过核数之外的任务时,载体线程会被被复用。
总结
本篇文章主要是针对虚拟线程的一些情况进行了描述,包括虚拟线程 api 、内存占用、ForkJoinPool 等等,笔者也通过一些具体的案例对这些提及的问题进行了测试验证和说明,希望能够更好的帮助各位理解。笔者期望通过本篇文章让各位读者对虚拟线程有个基本的认识,基于此后续会再通过源码分析的方式来进一步深入研究虚拟线程中的一些关键技术。