原文地址:Virtual Threads: A Definite Advantage
一、前言
深入了解虚拟线程如何提高应用程序的性能和可扩展性,同时将线程管理开销降到最低。
探索虚拟线程是一件很棒的事情,它是 Java 的一项强大功能,有望彻底改变多线程应用程序。在本文中,我们将深入探讨虚拟线程如何提高应用程序的性能和可扩展性,同时将线程管理的开销降到最低。让我们开始这段旅程,充分发挥虚拟线程的潜力!
为了证明这个用例,我们将创建一百万个平台线程和虚拟线程。生成这些线程后,我们将使用 HeapHero 和 fastThread 工具分析它们的堆和线程行为。通过这种探索,我们旨在突出平台线程和虚拟线程在性能上的区别。
二、什么是 Java 中的虚拟线程(VT)?
虚拟线程是一种不与特定操作系统线程绑定的线程创建方式。这对需要创建大量线程的应用程序非常有用,因为它可以减少创建和管理每个线程的开销。对于需要创建非临时线程的应用程序来说,它们也很有用,因为它们可以保证每个线程都有机会运行。
Java 21 引入了这一功能。虚拟线程也被称为 “绿色线程” 或 “轻量级线程”。它是线程的一种软件实现,使用操作系统的线程来实现并发。它们由 Java 虚拟机(JVM)管理,程序员无感知。
三、为什么虚拟线程很特别?
虚拟线程是一种特殊类型的线程,由平台线程创建,创建时只占用极少量的资源。由于具有这种功能,因此可以生成许多虚拟线程,用于多线程编程。
由于创建虚拟线程的成本很低,它不会像平台线程那样产生任何错误。虚拟线程的另一个优点是不需要像 Java 中的平台线程那样将它们池化。
四、平台线程(Platform Threads)
平台线程是 JDK 中的本地线程(java.lang.Thread)。
我们将使用 Java 中的线程类生成一百万个线程。在创建这些庞大线程的过程中,操作系统会变得极不稳定,并抛出 OutOfMemoryError。我们将在 Ubuntu Linux 中对这种行为进行实验。本实验使用的 JDK 版本为 21。
static int cnt = 1;
public static void main(String[] args) {
for(int i = 0; i < 1000000; i++) {
new Thread(
new Runnable() {
@Override
public void run() {
try {
TimeUnit.HOURS.sleep(1);
} catch (Exception ex) {}
}
}
).start();
cnt++;
}
}
在上面的代码中,我们在 for 循环中创建了一百万个线程,每个线程的休眠期最长可达 1 小时。线程休眠时,操作系统会缓存所有资源。在这种特殊情况下,操作系统需要将每个线程的所有资源保存较长时间,这是一项非常耗费资源的操作。请记住:在 Java 中创建线程是一项非常昂贵的操作。这就是在多线程编程模式下,应用程序启动时需要池化线程的原因。
很快,上述代码就会出现 OutOfMemory 错误。您可以在下图中看到该错误:
五、虚拟线程(Virtual Threads)
现在,让我们来开发虚拟线程的代码。用例相同,但我们要在一个循环中动态生成一百万个虚拟线程。
static int cnt = 1;
public static void main(String[] args) {
var executor = Executors.newVirtualThreadPerTaskExecutor();
IntStream.range(1, 1000000).forEach(i ->{
Future result = executor.submit(() ->{
try {
TimeUnit.HOURS.sleep(1);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
String uuid = UUID.randomUUID().toString();
});
if(cnt == 999999) {
generateHeapDump(2);
}
cnt++;
});
}
在上面的代码中,通过调用 java.util.concurrent
包中 ExecutorService
类的 newVirtualThreadPerTaskExecutor()
方法,我们动态创建了一百万个虚拟线程。
在代码执行过程中,我们将使用 generateHeapDump()
方法获取堆内存快照。我们会在计数器达到 999999 时进行堆转储。这样,我们就能确保在堆内存日志中捕获最多的数据。
六、100万个虚拟线程居然没有OOM错误!
什么是 OutOfMemoryError
?当应用程序没有足够的内存来处理事务时,系统就会抛出这个错误。那么,为什么在平台线程中会出现 OutOfMemoryError
而在虚拟线程中不会呢?
我们将借助下图进一步了解:
内存有三个部分:堆(Heap)、元空间(Metaspace)和其他(Others)。就平台线程而言,线程栈存储在其他区域。
每个线程都有独立的内存,并存储在其他区域。为线程分配内存后,在进程结束后,应释放该内存。在平台线程方案中,线程等待时间为一小时,与之相关的内存不会很快释放。而且,新线程会再次生成,它们也要等待一小时。这样,就需要在 Others 区域分配大量内存,而 JVM 无法快速释放内存。因此,它就会抛出这个错误。
平台线程容易出现 OOM 错误的另外一个原因是,JDK5.0之后,默认线程栈内存是1M,这个内存分配量远超过虚拟线程方案中把虚拟线程当成对象来管理的方式。
在虚拟线程的情况下,虚拟线程被存储在堆区域,该区域由 JVM 进程控制,因为它们被视为对象。当你运行虚拟线程场景的代码时,你可以看到它会创建一百万个虚拟线程并休眠一小时。这些虚拟线程保存在堆内存中,生成线程所需的资源非常少。因此,在这种情况下不会抛出 OutOfMemoryError。
注意:有时,在虚拟线程的情况下,它也会抛出 OutOfMemoryError。这是因为当创建大量虚拟线程时,"堆内存"将被耗尽。但在上述情况下,它不会抛出 OOM 错误,因为默认内存足以容纳 100 万个虚拟线程!
我们将通过分析平台线程和虚拟线程的线程和内存行为来证实上述理论。
七、平台线程性能比较
我们将使用 fastThread 和 HeapHero 工具集,分别进行线程和堆转储分析,对平台线程性能进行比较研究。
7.1. 线程转储分析(Thread Dump Analysis)
这是 fastthread.io 为平台线程生成的线程转储报告。该报告非常智能,可提供发生 OutOfMemoryError 的可能性。
它显示 JVM 中有近 1600 个线程,这些数字令人震惊。报告的第一部分为我们提供了有关应用程序状态的足够信息。
现在,让我们用相同的堆栈跟踪快速检查线程。下图就是相关示意图。
该图显示了具有相同堆栈跟踪的多个线程。这些线程处于等待阶段。这是因为应用程序创建了大量线程,并要求这些线程等待一小时(平台线程代码请参考 Thread.sleep(…))。大约有 1600 个线程被要求等待。因此,报告显示的堆栈跟踪具有相同的行为。
不过,值得注意的是报告的其余部分。可以查看详细报告,以便您更好地查看和理解。
7.2. 堆转储分析
下面是使用 heaphero.io 对同一平台线程进行的堆转储分析。
可以看到,这里的堆大小要小得多。因此我们可以说,在平台线程的情况下,如果这些数字非常高,这很可能会给应用程序带来问题。可以查看 HeapHero 工具集对平台线程进行的故障排除报告。
八、虚拟线程性能比较
我们将使用 fastThread 和 HeapHero 工具集,分别进行线程和堆转储分析,对虚拟线程的性能进行比较研究。
8.1. 线程转储分析
下面是虚拟线程的线程转储分析。可以看到,线程数量约为 37 个。为什么会出现这种情况?为什么报告中没有显示所有这一百万个线程?
这是因为虚拟线程不被视为线程,所以在进行线程转储时,报告中不包括虚拟线程。这份线程转储情报报告会告诉你,堆的大小会增加。
8.2. 堆转储分析
现在,让我们使用 HeapHero 网站分析虚拟线程的报告。生成的报告可能有点笨重,您需要等待一段时间才能看到详细报告。
首先,请看一下报告,并在其中花点时间。报告显示有 999999 个 java.lang.VirtualThreads 实例。所有这些线程都从一个 jdk.internal.misc.CarrierThread 实例引用。
这份报告的有趣之处在于堆的大小为 401 MB。在执行与虚拟线程相关的代码时,JVM 会将这一百万个虚拟线程的所有信息保存到堆区。因此,在这种情况下,堆的大小非常大。这就是问题的关键所在。这些数据肯定也符合垃圾回收的条件。下面是堆分析报告,重点说明了这一点。
九、平台与虚拟线程性能比较
现在,让我们根据下表比较线程数与堆大小:
线程数 | 堆大小 | 线程分析报告 | 堆内存分析报告 | |
---|---|---|---|---|
平台线程测试 | 1599个之后报OutOfMemoryError | 1.85 MB | Platform Thread – Thread analysis report | Platform Thread – Heap analysis report |
虚拟线程测试 | 100万个,没有问题 | 401 MB | Virtual Thread – Thread analysis report | Virtual Thread – Heap analysis report |
当平台线程代码运行时,在抛出 OutOfMemoryError 之前会产生近 1600 个线程。但在这种情况下,堆的大小相对较小。这是因为,正如本文前文所述,线程栈保存在 Others 区域内,而不是堆内。
在虚拟线程的情况下,应用程序创建的线程数量相对较少,但堆的大小却非常大。这是因为虚拟线程使用了堆内存。
十、结论
虚拟线程是创建多线程应用程序的有用工具。通过使用多个线程并行执行任务,虚拟线程可以提高应用程序的性能。虚拟线程的使用方法与多线程应用程序中使用平台线程的方法相同。在创建和管理每个线程时没有任何开销,但仍能产生更好的效果。这是 Java 语言的一项强大功能,有了这项功能,应用程序的扩展就变得非常容易。这是使用虚拟线程的一个明显优势。