在Java中,虚拟线程(JEP-425)是JVM管理的轻量级线程,有助于编写高吞吐量的并发应用程序(吞吐量是指系统在给定时间内可以处理多少个信息单位)。
1. Java 线程模型和虚拟线程
1.1. 经典线程或平台线程
在 Java 中,经典线程是java.lang.Thread类的实例。展望未来,我们也将称它们为平台线程。
传统上,Java 将平台线程视为操作系统 (OS) 线程的精简包装器。创建这样的平台线程总是代价高昂的(由于大型堆栈和操作系统维护的其他资源),因此 Java 一直在使用线程池来避免线程创建中的开销。
还必须限制平台线程的数量,因为这些资源密集型线程会影响整个机器的性能。这主要是因为平台线程1:1映射到操作系统线程。
1.2. 平台线程的可伸缩性问题
平台线程一直易于建模、编程和调试,因为它们使用平台的并发单位来表示应用程序的并发单位。它称为每个请求的线程模式。
但是此模式限制了服务器的吞吐量,因为并发请求数(服务器可以处理)与服务器的硬件性能成正比。因此,即使在多核处理器中,也必须限制可用线程的数量。
除了线程数量之外,延迟也是一个大问题。如果您仔细观察,在当今的微服务世界中,请求是通过在多个系统和服务器上获取/更新数据来处理的。当应用程序等待来自其他服务器的信息时,当前平台线程保持空闲状态。这是对计算资源的浪费,也是实现高吞吐量应用程序的主要障碍。
1.3. 响应式编程的问题
响应式编程解决了平台线程等待其他系统响应的问题。异步 API 不等待响应,而是通过回调工作。每当线程调用异步 API 时,平台线程都会返回到池中,直到响应从远程系统或数据库返回。稍后,当响应到达时,JVM 将从池中分配另一个线程来处理响应,依此类推。这样,处理单个异步请求时涉及多个线程。
在异步编程中,延迟被移除,但由于硬件限制,平台线程的数量仍然受到限制,因此我们对可扩展性有限制。另一个大问题是这样的异步程序在不同的线程中执行,因此很难调试或分析它们。
此外,我们必须采用一种新的编程风格,远离典型的循环和条件语句。新的 lambda 样式语法使得理解现有代码和编写程序变得困难,因为我们现在必须将程序分解为多个可以独立和异步运行的较小单元。
因此,我们可以说虚拟线程还通过调整传统语法来提高代码质量,同时具有响应式编程的好处。
1.4. 虚拟线程看起来很有前途
与传统线程类似,虚拟线程也是 java.lang.Thread 的一个实例,它在底层操作系统线程上运行其代码,但它不会在代码的整个生命周期内阻塞操作系统线程。保持操作系统线程空闲意味着许多虚拟线程可以在同一操作系统线程上运行其 Java 代码,从而有效地共享它。
值得一提的是,我们可以在应用程序中创建非常多的虚拟线程(数百万个),而无需
依赖于平台线程的数量。这些虚拟线程由 JVM 管理,因此它们也不会增加额外的上下文切换开销,因为它们作为普通 Java 对象存储在 RAM 中。
与传统线程类似,应用程序的代码在请求的整个持续时间内在虚拟线程中运行(采用每个请求的线程样式),但虚拟线程仅在 CPU 上执行计算时才使用操作系统线程。它们在等待或睡眠时不会阻塞操作系统线程。
虚拟线程有助于实现与具有相同硬件配置的异步 API 相同的高可伸缩性和吞吐量,而不会增加语法复杂性。
例如,虚拟线程最适合执行大部分时间被阻塞的代码,等待数据到达网络套接字或等待队列中的元素。
2. 平台线程和虚拟线程的区别
- 虚拟线程始终是守护线程。Thread.setDaemon(false)方法不能将虚拟线程更改为非守护程序线程。请注意,当所有启动的非守护进程线程都终止时,JVM 将终止。这意味着 JVM 不会等待虚拟线程完成再退出。
Thread virtualThread = ...; //Create virtual thread
//virtualThread.setDaemon(true); //It has no effect
- 虚拟线程始终具有正常优先级,即使使用方法也无法更改优先级。在虚拟线程上调用setPriority(n)方法不起作用。
Thread virtualThread = ...; //Create virtual thread
//virtualThread.setPriority(Thread.MAX_PRIORITY); //It has no effect
- 虚拟线程不是线程组的活动成员。在虚拟线程上调用Thread.getThreadGroup()时,返回名为“VirtualThreads”的占位符线程组。
- 虚拟线程不支持 stop()、suspend() 或 resume() 方法。这些方法在虚拟线程上调用时引发 UnsupportedOperationException。
3. 比较平台线程和虚拟线程的性能
让我们了解这两种线程在使用相同的可执行代码提交时之间的区别。
为了演示它,我们有一个非常简单的任务,在控制台中打印消息之前等待一秒钟。我们正在创建此任务以保持示例简单,以便我们可以专注于概念。
final AtomicInteger atomicInteger = new AtomicInteger();
Runnable runnable = () -> {
try {
Thread.sleep(Duration.ofSeconds(1));
} catch(Exception e) {
System.out.println(e);
}
System.out.println("Work Done - " + atomicInteger.incrementAndGet());
};
现在,我们将从这个 Runnable 创建 10,000 个线程,并使用虚拟线程和平台线程执行它们,以比较两者的性能。我们将使用 Duration.between() api 来测量执行所有任务所经过的时间。
首先,我们使用 100 个平台线程的池。通过这种方式,执行器将能够一次运行 100 个任务,其他任务将需要等待。由于我们有 10,000 个任务,因此完成执行的总时间约为 100 秒。
Instant start = Instant.now();
try (var executor = Executors.newFixedThreadPool(100)) {
for(int i = 0; i < 10_000; i++) {
executor.submit(runnable);
}
}
Instant finish = Instant.now();
long timeElapsed = Duration.between(start, finish).toMillis();
System.out.println("Total elapsed time : " + timeElapsed);
Output
Total elapsed time : 101152 //Approx 101 seconds
截至目前,虚拟线程是一个预览 API,默认情况下处于禁用状态。用于运行代码。$ java --source 19 --enable-preview Main.java
接下来,我们将
Executors.newFixedThreadPool(100) 替换为
Executors.newVirtualThreadPerTaskExecutor()。这将在虚拟线程而不是平台线程中执行所有任务。
Instant start = Instant.now();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for(int i = 0; i < 10_000; i++) {
executor.submit(runnable);
}
}
Instant finish = Instant.now();
long timeElapsed = Duration.between(start, finish).toMillis();
System.out.println("Total elapsed time : " + timeElapsed);
Output
Total elapsed time : 1589 //Approx 1.5 seconds
请注意虚拟线程的超快性能,它将执行时间从 100 秒缩短到 1.5 秒,而 Runnable 代码没有变化。
4. 如何创建虚拟线程
4.1. 使用Thread.startVirtualThread()
此方法创建一个新的虚拟线程来执行给定的 Runnable 任务,并计划它执行。
Runnable runnable = () -> System.out.println("Inside Runnable");
Thread.startVirtualThread(runnable);
//or
Thread.startVirtualThread(() -> {
//Code to execute in virtual thread
System.out.println("Inside Runnable");
});
4.2. 使用Thread.Builder
如果我们想在创建线程后显式启动线程,我们可以使用Thread.ofVirtual()它返回一个 VirtualThreadBuilder 实例。它的start()方法启动一个虚拟线程。
值得注意的是,Thread.ofVirtual().start(runnable)等同于Thread.startVirtualThread(runnable)。
Runnable runnable = () -> System.out.println("Inside Runnable");
Thread virtualThread = Thread.ofVirtual().start(runnable);
我们可以使用 Thread.Builder 引用来创建和启动多个线程。
Runnable runnable = () -> System.out.println("Inside Runnable");
Thread.Builder builder = Thread.ofVirtual().name("JVM-Thread");
Thread t1 = builder.start(runnable);
Thread t2 = builder.start(runnable);
也存在用于创建平台线程的类似Thread.ofPlatform() API。
Thread.Builder builder = Thread.ofPlatform().name("Platform-Thread");
Thread t1 = builder.start(() -> {...});
Thread t2 = builder.start(() -> {...});
4.3. 使用Executors.newVirtualThreadPerTaskExecutor()
此方法为每个任务创建一个新的虚拟线程。执行程序创建的线程数是无限的。
在以下示例中,我们将提交 10,000 个任务并等待所有任务完成。该代码将创建 10,000 个虚拟线程来完成这 10,000 个任务。
请注意,以下语法是结构化并发的一部分,结构化并发是 Project Loom 中提出的另一个新功能。我们将在另一篇文章中讨论它。
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
IntStream.range(0, 10_000).forEach(i -> {
executor.submit(() -> {
Thread.sleep(Duration.ofSeconds(1));
return i;
});
});
}
5. 最佳实践
5.1. 不要池化虚拟线程
Java 线程池旨在避免创建新操作系统线程的开销,因为创建它们是一项成本高昂的操作。但是创建虚拟线程并不昂贵,因此永远不需要将它们池化。建议每次需要时创建一个新的虚拟线程。
请注意,使用虚拟线程后,我们的应用程序可能能够处理数百万个线程,但其他系统或平台一次只能处理几个请求。例如,我们可以只有几个数据库连接或与其他服务器的网络连接。
在这些情况下,也不要使用线程池。相反,请使用信号量来确保只有指定数量的线程正在访问该资源。
private static final Semaphore SEMAPHORE = new Semaphore(50);
SEMAPHORE.acquire();
try {
// semaphore limits to 50 concurrent access requests
//Access the database or resource
} finally {
SEMAPHORE.release();
}
5.2. 避免使用线程局部变量
虚拟线程支持线程局部行为的方式与平台线程相同,但由于虚拟线程可以创建数百万个,因此只有在仔细考虑后才应使用线程局部变量。
例如,如果我们在应用程序中扩展一百万个虚拟线程,就会有一百万个 ThreadLocal 实例以及它们引用的数据。如此大量的实例可能会给物理内存带来足够的负担,应避免这种情况。
范围局部变量 [JEP-429] 是更好的选择。请注意,在 Java 21 [JEP-444] 中,虚拟线程现在始终支持线程局部变量。不再可能像预览版那样创建不能具有线程局部变量的虚拟线程。
5.3. 使用重入锁代替同步块
在两种特定情况下,虚拟线程可以阻止平台线程(称为 OS 线程固定)。
- 当它在同步块或方法中执行代码时,或者
- 当它执行本机方法或外来函数时。
此类synchronized块不会使应用程序不正确,但它会限制应用程序的可伸缩性,类似于平台线程。
作为最佳实践,如果某个方法使用非常频繁并且它使用同步块,请考虑将其替换为 ReentrantLock 机制。
因此,不要像这样使用synchronized块:
public synchronized void m() {
try {
// ... access resource
} finally {
//
}
}
像这样使用ReentrantLock:
private final ReentrantLock lock = new ReentrantLock();
public void m() {
lock.lock(); // block until condition holds
try {
// ... access resource
} finally {
lock.unlock();
}
}
建议无需替换不经常使用的同步块和方法(例如,仅在启动时执行)或保护内存中操作。
6. 结论
传统的Java线程已经很好地服务了很长时间。随着微服务领域对可伸缩性和高吞吐量的需求不断增长,虚拟线程将被证明是 Java 历史上的一个里程碑式特征。
使用虚拟线程,程序可以用少量的物理内存和计算资源处理数百万个线程,否则传统平台线程无法处理。当与结构化并发相结合时,它还将导致编写更好的程序。
本文暂时没有评论,来添加一个吧(●'◡'●)