在Java并发编程中,线程是一种昂贵的资源,每个线程都需要占用一定的内存和CPU时间。当需要处理大量的并发任务时,创建和管理大量线程可能导致性能下降和资源浪费。为了解决这个问题,Java虚拟线程应运而生。
Java虚拟线程是一种轻量级的线程实现,它不需要像传统线程那样占用大量的内存和CPU时间。相反,它们是在Java虚拟机(JVM)层面上管理的,使用更少的系统资源,并且可以在一个或多个底层操作系统线程上运行,它允许开发人员以更高效和简化的方式处理并发任务。
JEP 444 虚拟线程已从 JDK 21 的候选(Candidate)升至建议实现状态(Proposed to Target)。根据在 JDK 20 中交付的 JEP 436 虚拟线程(第二次预览),在 JDK 19 中交付的 JEP 425 虚拟线程(预览),这前两轮的反馈,本次 JEP 预计将最终敲定该项功能。我们有必要提前学习下虚拟线程的相关内容。
Java 中的线程跟操作系统的内核线程是一对一的,Java 线程的调度其实是依赖操作系统的内核线程的,这就导致了我们的线程切换和运行就需要进行上下文切换以及消耗大量的系统资源。即使我们使用了各种线程池来最大化线程的性价比,但是线程往往会在 CPU、网络或者内存资源耗尽之前成为我们应用程序的性能提升瓶颈,不能最大限度的释放硬件应该具有的性能。
Java19 引入的虚拟线程(Virtual Thread)就是为了解决这种问题。原来JDK存在java.lang.Thread类,俗称线程。为了更好地区分虚拟线程和原有的线程类,引入了一个全新类java.lang.VirtualThread(Thread类的一个子类型),直译过来就是"虚拟线程"。更新的JDK文档中也把原来的Thread称为Platform Thread,可以更明晰地与Virtual Thread区分开来。这里Platform Thread直译为"平台线程",其实就是"虚拟线程"出现之前的老生常谈的"线程"。要注意虚拟线程并不是用来直接取代平台线程的,虚拟线程是运行在平台线程之上的,一个平台线程可以对应多个虚拟线程,同时一个平台线程还是一一对应内核线程,因此上面的架构就变成了如下,一个 VT 代表一个虚拟线程。
平台线程,被实现为是对操作系统线程的简单包装。 它是在底层操作系统线程上运行java代码,并在整个生命周期过程中,需要一直捕获操作系统线程。 因此,平台线程的可用数量受限于操作系统线程的数量。 平台线程,通常有大型的线程堆栈和其他资源,它们由操作系统负责维护。 平台线程,支持线程局部变量。 平台线程,适用于运行所有类型的任务,但可能会受限于资源。
虚拟线程,属于一种轻量级线程,可以帮助减少编写、调试和维护高并发高吞吐量应用程序的工作量。 虚拟线程,与平台线程一样,也是java.lang.Thread类的实例。 然而,虚拟线程并不与特定的操作系统线程绑定。 虽然,虚拟线程仍然在操作系统线程上运行java代码。 但是,当运行在虚拟线程的代码调用阻塞I/O操作时,java运行时会挂起虚拟线程,直到虚拟线程可以被恢复为止。 此时,与被挂起的虚拟线程相关联的操作系统线程,可以自由地执行其他虚拟线程的操作。 虚拟线程,它的实现方式与虚拟内存类似。 为了模拟大量内存,操作系统将大量的虚拟地址空间映射到有限数量的RAM上。 同样地,为了模拟大量线程,java运行时将大量的虚拟线程映射到少量的操作系统线程上。 虚拟线程,与平台线程不同,通常只有一个浅调用堆栈,例如只执行一次HTTP请求或者一次JDBC查询。 虚拟线程,虽然也支持线程局部变量,但是应该慎重考虑使用它们,因为单个JVM可能支持数百万的虚拟线程。 虚拟线程,适用于运行大部分时间被阻塞的任务,这些任务通常需要等待I/O操作的完成。 然而,它们并不适用于长时间运行的CPU密集型操作。
在高并发高吞吐量的应用程序中使用虚拟线程,尤其是那些由大量并发任务组成、且需要花费大量时间等待的应用程序。服务器程序就是这样的示例,因为它们通常需要处理许多执行阻塞I/O操作的客户端请求(例如获取资源)。
虚拟线程不是更快的线程;它们运行代码的速度并不比平台线程快。
它们的存在是为了提供规模的扩展(更高的吞吐量),而不是速度的优化(更低的延迟)。
因为加入了虚拟线程,java对Thread进行了兼容性改造,提供了静态工厂方法Thread#ofPlatform()和Thread#ofVirtual()分别用于实例化PlatformThread(Factory)建造器和VirtualThread(Factory)建造器,顾名思义,两种建造器分别用于创建PlatformThread和VirtualThread,例如:
// demo-1 build platform thread
Thread platformThread = Thread.ofPlatform().daemon().name("worker").unstarted(runnable);
// demo-2 create platform thread factory
ThreadFactory platformThreadFactory = Thread.ofPlatform().daemon().name("worker-", 0).factory();
// demo-3 build virtual thread
Thread virtualThread = Thread.ofVirtual().name("virtual-worker").unstarted(runnable);
// demo-4 create virtual thread factory
ThreadFactory virtualThreadFactory = Thread.ofVirtual().name("virtual-worker-", 0).factory();
线程池也支持了虚拟线程,可以通过 Executors.newVirtualThreadPerTaskExecutor()来创建虚拟线程:
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
IntStream.range(0, 10000).forEach(i -> {
executor.submit(() -> {
Thread.sleep(Duration.ofSeconds(1));
return i;
});
});
}
首先使用网上常用的测试用例进行对比 :分别使用平台线程和虚拟线程并行执行一万个 sleep 一秒的任务。
首先是平台线程,代码如下:
public static void main(String[] args) {
//记录系统线程数
ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(1);
scheduledExecutorService.scheduleAtFixedRate(() -> {
ThreadMXBean threadBean = ManagementFactory.getThreadMXBean();
ThreadInfo[] threadInfo = threadBean.dumpAllThreads(false, false);
System.out.println(threadInfo.length + " os thread");
}, 1, 1, TimeUnit.SECONDS);
long l = System.currentTimeMillis();
try(var executor = Executors.newCachedThreadPool()) {
IntStream.range(0, 10000).forEach(i -> {
executor.submit(() -> {
Thread.sleep(Duration.ofSeconds(1));
System.out.println(i);
return i;
});
});
}
System.out.printf("耗时:%d ms", System.currentTimeMillis() - l);
}
然后是虚拟线程,我们只需要将Executors.newCachedThreadPool()替换为Executors.newVirtualThreadPerTaskExecutor()即可使用虚拟线程:
public static void main(String[] args) {
//记录系统线程数
ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(1);
scheduledExecutorService.scheduleAtFixedRate(() -> {
ThreadMXBean threadBean = ManagementFactory.getThreadMXBean();
ThreadInfo[] threadInfo = threadBean.dumpAllThreads(false, false);
System.out.println(threadInfo.length + " os thread");
}, 1, 1, TimeUnit.SECONDS);
long l = System.currentTimeMillis();
try(var executor = Executors.newVirtualThreadPerTaskExecutor()) {
IntStream.range(0, 10000).forEach(i -> {
executor.submit(() -> {
Thread.sleep(Duration.ofSeconds(1));
System.out.println(i);
return i;
});
});
}
System.out.printf("耗时:%d ms", System.currentTimeMillis() - l);
}
分别执行上面两个测试案例,如果第一个案例发生oom(关于应用能创建多少个线程可以单独讨论),我们可以使用固定线程池来优化上面的测试代码:
//记录系统线程数
ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(1);
scheduledExecutorService.scheduleAtFixedRate(() -> {
ThreadMXBean threadBean = ManagementFactory.getThreadMXBean();
ThreadInfo[] threadInfo = threadBean.dumpAllThreads(false, false);
System.out.println(threadInfo.length + " os thread");
}, 1, 1, TimeUnit.SECONDS);
long l = System.currentTimeMillis();
try(var executor = Executors.newFixedThreadPool(200)) {
IntStream.range(0, 10000).forEach(i -> {
executor.submit(() -> {
Thread.sleep(Duration.ofSeconds(1));
System.out.println(i);
return i;
});
});
}
System.out.printf("耗时:%d ms", System.currentTimeMillis() - l);
}
执行结果:使用固定线程池结果
9981
9979
9978
9977
9975
9973
9967
9971
9969
9964
9965
耗时:50656 ms209 os thread
使用虚拟线程结果:
9998
9999
9426
7741
7744
耗时:1462 ms18 os thread
可以看到使用固定200的线程池需要50多秒能执行完10000个任务,使用虚拟线程仅需要1.5s且仅使用了18个平台线程,性能提升很大。
下面换个测试代码,使用NMT参数和jcmd命令查看使用两种线程的具体资源占用情况(需添加jvm参数 -XX:NativeMemoryTracking=detail):
//平台线程
public static void main(String[] args) throws Exception {
for (int i = 0; i < 10000; i++) {
new Thread(() -> {
try {
Thread.sleep(Long.MAX_VALUE);
} catch (Exception e) {
e.printStackTrace();
}
}, String.valueOf(i)).start();
}
Thread.sleep(Long.MAX_VALUE);
}
运行程序,使用jps查看java进程,使用
./jcmd 13880 VM.native_memory scale=MB查看应用具体的信息
13880:
Native Memory Tracking:
(Omitting categories weighting less than 1MB)
Total: reserved=11565MB, committed=853MB
malloc: 69MB #226683
mmap: reserved=11496MB, committed=784MB
- Java Heap (reserved=100MB, committed=100MB)
(mmap: reserved=100MB, committed=100MB)
- Class (reserved=1027MB, committed=3MB)
(classes #1276)
( instance classes #1114, array classes #162)
(malloc=3MB #22910)
(mmap: reserved=1024MB, committed=0MB)
( Metadata: )
( reserved=64MB, committed=1MB)
( used=1MB)
( waste=0MB =6.93%)
( Class space:)
( reserved=1024MB, committed=0MB)
( used=0MB)
( waste=0MB =49.55%)
- Thread (reserved=10046MB, committed=656MB)
(thread #10019)
(stack: reserved=10019MB, committed=629MB)
(malloc=16MB #70744)
(arena=11MB #20037)
- Code (reserved=242MB, committed=7MB)
(mmap: reserved=242MB, committed=7MB)
- GC (reserved=51MB, committed=51MB)
(malloc=17MB #10672)
(mmap: reserved=34MB, committed=34MB)
- Internal (reserved=15MB, committed=15MB)
(malloc=15MB #62691)
- Symbol (reserved=1MB, committed=1MB)
(malloc=1MB #11657)
- Native Memory Tracking (reserved=5MB, committed=5MB)
(malloc=2MB #22186)
(tracking overhead=3MB)
- Shared class space (reserved=12MB, committed=12MB)
(mmap: reserved=12MB, committed=12MB)
- Metaspace (reserved=64MB, committed=1MB)
(mmap: reserved=64MB, committed=1MB)
可以看到总占用853MB的内存,其中平台线程就占用了656MB。
下面运行虚拟线程的程序:
//虚拟线程
public static void main(String[] args) throws Exception {
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);
}
执行jcmd结果如下:
2284:
Native Memory Tracking:
(Omitting categories weighting less than 1MB)
Total: reserved=1528MB, committed=177MB
malloc: 20MB #28249
mmap: reserved=1508MB, committed=157MB
- Java Heap (reserved=100MB, committed=100MB)
(mmap: reserved=100MB, committed=100MB)
- Class (reserved=1024MB, committed=0MB)
(classes #1356)
( instance classes #1186, array classes #170)
(mmap: reserved=1024MB, committed=0MB)
( Metadata: )
( reserved=64MB, committed=1MB)
( used=1MB)
( waste=0MB =4.68%)
( Class space:)
( reserved=1024MB, committed=0MB)
( used=0MB)
( waste=0MB =33.83%)
- Thread (reserved=31MB, committed=2MB)
(thread #31)
(stack: reserved=31MB, committed=2MB)
- Code (reserved=242MB, committed=7MB)
(mmap: reserved=242MB, committed=7MB)
- GC (reserved=51MB, committed=51MB)
(malloc=17MB #1107)
(mmap: reserved=34MB, committed=34MB)
- Symbol (reserved=1MB, committed=1MB)
(malloc=1MB #12233)
- Native Memory Tracking (reserved=1MB, committed=1MB)
- Shared class space (reserved=12MB, committed=12MB)
(mmap: reserved=12MB, committed=12MB)
- Metaspace (reserved=64MB, committed=1MB)
(mmap: reserved=64MB, committed=1MB)
可以看到总占用177MB的内存,其中平台线程仅占用了2MB。由此可以证明虚拟线程确实在极大量创建的前提下不会占据大量内存空间。
基于操作系统线程实现的平台线程,JDK 依赖于操作系统中的线程调度程序来进行调度。而对于虚拟线程,JDK 有自己的调度器。JDK 的调度器没有直接将虚拟线程分配给系统线程,而是将虚拟线程分配给平台线程(这是前面提到的虚拟线程的 M:N 调度)。平台线程由操作系统的线程调度系统调度。
JDK 的虚拟线程调度器是一个在 FIFO 模式下运行的类似ForkJoinPool的线程池。调度器的并行数量取决于调度器虚拟线程的平台线程数量。默认情况下是 CPU 可用核心数量,但可以使用系统属性jdk.virtualThreadScheduler.parallelism进行调整。注意,这里的ForkJoinPool与ForkJoinPool.commonPool()不同,ForkJoinPool.commonPool()用于实现并行流,并在 LIFO 模式下运行。
看源码我们发现虚拟线程建造器屏蔽了执行器Executor实例的公共访问权限,在目前预留功能版本下只能所有虚拟线程的任务最终都是由全局的ForkJoinPool执行。
为了防止线程饥饿问题,当一个线程的等待队列中没有更多的任务时,ForkJoinPool还实现了另一种模式,称为任务窃取, 也就是说:饥饿线程可以从另一个线程的等待队列中窃取一些任务。
JVM把虚拟线程分配给平台线程的操作称为mount(挂载),反过来取消分配平台线程的操作称为unmount(卸载):
mount操作:虚拟线程挂载到平台线程,虚拟线程中包装的Continuation栈数据帧或者引用栈数据会被拷贝到平台线程的线程栈,这是一个从堆复制到栈的过程
unmount操作:虚拟线程从平台线程卸载,大多数虚拟线程中包装的Continuation栈数据帧会留在堆内存中
由于虚拟线程实例占据的资源比较少,可以大量地创建而无须考虑池化,因此满足类似下面的使用场景:
•大批量的处理时间较短的计算任务
•大量的IO阻塞等待处理
对于运算密集型的任务,由于虚拟线程最终还是由平台线程来执行,虚拟线程的切换和挂起操作会引入一定的开销,可能会导致性能下降,因此不建议使用虚拟线程。
欢迎加入 DevXTalk 的程序员、架构师、IT从业者交流群,在这里你可以和大佬们一起交流分享技术,行业经验,职位内推,摸鱼聊天。总之期待你的加入...
添加我的wx,备注:“进群”拉你进群(群二维码有效时间太短维持不了几天)
「看完本文有收获?转发给更多的朋友吧」
「DevXTalk 不止于技术」
原文作者:DevXTalk;如有侵权,请联系管理员删除。