在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.比较平台线程和虚拟线程的性能
让我们了解一下这两种线程在提交相同的可执行代码时的区别。
为了演示,我们有一个非常简单的任务,在控制台中打印一条信息之前等待1 秒。我们创建这个任务是为了保持例子的简单性,这样我们就可以专注于这个概念。
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个平台线程的池子。**这样一来,*Executor*将能够一次运行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);
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);
Total elapsed time : 1589 //Approx 1.5 seconds
注意到虚拟线程的极快性能,在Runnable代码没有改变的情况下,将执行时间从100秒降至1.5秒。
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);
对于创建平台线程,也存在类似的APIThread.ofPlatform() 。
Thread.Builder builder = Thread.ofPlatform().name("Platform-Thread");
Thread t1 = builder.start(() -> {...});
Thread t2 = builder.start(() -> {...});
4.3.使用Executors.newVirtualThreadPerTaskExecutor()
该方法为每个任务创建一个新的虚拟线程。由Executor创建的线程的数量是没有限制的。
在下面的例子中,我们要提交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线程池的设计是为了避免创建新的操作系统线程的开销,因为创建这些线程是一个昂贵的操作。但是,创建虚拟线程并不昂贵,所以,从来没有必要将它们集中起来。我们建议每次需要时都创建一个新的虚拟线程。
请注意,使用虚拟线程后,我们的应用程序可能能够处理数百万个线程,但其他系统或平台每次只处理几个请求。例如,我们可能只有几个数据库连接或与其他服务器的网络连接。
在这些情况下,也不要使用线程池。相反,使用semaphores来确保只有指定数量的线程在访问该资源。
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*实例,以及它们所引用的数据。这样大量的实例会给物理内存带来足够的负担,应该避免。
Extent-Local变量,如果包含在Java中,可能会被证明是一个更好的选择。
5.3.使用ReentrantLock而不是同步区块
有两种特殊情况,虚拟线程可以阻塞平台线程(称为操作系统线程的钉住)。
- 当它在一个同步块或方法内执行代码时,或
- 当它执行一个本地方法或一个外来函数时。
这样的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历史上的一个里程碑式的功能。
有了虚拟线程,一个程序可以用少量的物理内存和计算资源处理数百万个线程,否则传统的平台线程是不可能的。当与结构化并发相结合时,它还将导致更好地编写程序。
学习愉快!!