目录
虚拟线程
话题
什么是平台线程?
什么是虚拟线程?
为什么要使用虚拟线程?
创建和运行虚拟线程
使用线程类和线程创建虚拟线程。生成器界面
使用Executor.newVirtualThreadPerTaskExecutor()方法创建和运行虚拟线程
调度虚拟线程和固定虚拟线程
调试虚拟线程
JDK虚拟线程的飞行记录器事件
官方文档的翻译版本
官方文档:Virtual Threads
虚拟线程
虚拟线程是轻量级线程,可以减少编写、维护和调试高吞吐量并发应用程序的工作量。
有关虚拟线程的背景信息,请参阅JEP444。
线程是可以调度的最小处理单元。它与其他此类单元同时运行,并且在很大程度上独立于其他此类单元。它是java.lang.Thread的一个实例。有两种线程,平台线程和虚拟线程。
话题
- 什么是平台线程?
- 什么是虚拟线程?
- 为什么要使用虚拟线程?
- 创建和运行虚拟线程
- 调度虚拟线程和固定虚拟线程
- 调试虚拟线程
- 虚拟线程:采用指南
什么是平台线程?
平台线程被实现为操作系统(OS)线程周围的瘦包装器。平台线程在其底层操作系统线程上运行Java代码,平台线程在平台线程的整个生命周期中捕获其操作系统线程。因此,可用平台线程的数量被限制为OS线程的数量。
平台线程通常具有由操作系统维护的大型线程堆栈和其他资源。它们适用于运行所有类型的任务,但可能是有限的资源。
什么是虚拟线程?
与平台线程一样,虚拟线程也是java.lang.thread的一个实例。然而,虚拟线程并没有绑定到特定的操作系统线程。虚拟线程仍然在操作系统线程上运行代码。但是,当虚拟线程中运行的代码调用阻塞I/O操作时,Java运行时会挂起虚拟线程,直到可以恢复为止。与挂起的虚拟线程相关联的OS线程现在可以自由地执行其他虚拟线程的操作。
虚拟线程的实现方式与虚拟内存类似。为了模拟大量内存,操作系统将大量虚拟地址空间映射到有限的RAM。同样,为了模拟大量线程,Java运行时将大量虚拟线程映射到少量操作系统线程。
与平台线程不同,虚拟线程通常有一个浅调用堆栈,只执行一个HTTP客户端调用或一个JDBC查询。尽管虚拟线程支持线程本地变量和可继承的线程本地变量,但您应该仔细考虑使用它们,因为单个JVM可能支持数百万个虚拟线程。
虚拟线程适用于运行大部分时间被阻塞的任务,这些任务通常等待I/O操作完成。然而,它们并不适用于长时间运行的CPU密集型操作。
为什么要使用虚拟线程?
在高吞吐量并发应用程序中使用虚拟线程,尤其是那些由大量并发任务组成、花费大量时间等待的应用程序。服务器应用程序是高吞吐量应用程序的示例,因为它们通常处理许多执行阻塞I/O操作(如获取资源)的客户端请求。
虚拟线程不是更快的线程;它们运行代码的速度并不比平台线程快。它们的存在是为了提供规模(更高的吞吐量),而不是速度(更低的延迟)。
创建和运行虚拟线程
线程和线程。生成器API提供了创建平台线程和虚拟线程的方法。java.util.concurrent。Executors类还定义了创建ExecutorService的方法,该方法为每个任务启动一个新的虚拟线程。
话题
- 使用线程类和线程创建虚拟线程。生成器界面
- 使用Executor.newVirtualThreadPerTaskExecutor()方法创建和运行虚拟线程
- 多线程客户端服务器示例
使用线程类和线程创建虚拟线程。生成器界面
调用Thread.ofVirtual()方法来创建Thread的实例。用于创建虚拟线程的生成器。
以下示例创建并启动一个打印消息的虚拟线程。它调用联接方法以等待虚拟线程终止。(这使您能够在主线程终止之前看到打印的消息。)
Thread thread = Thread.ofVirtual().start(() -> System.out.println("Hello"));
thread.join();
生成器界面允许您创建具有通用线程属性(如线程名称)的线程。线程。建设者OfPlatform子接口创建平台线程,而Thread。建设者OfVirtual创建虚拟线程。
以下示例使用thread创建一个名为MyThread的虚拟线程。生成器界面:
Thread.Builder builder = Thread.ofVirtual().name("MyThread");
Runnable task = () -> {
System.out.println("Running thread");
};
Thread t = builder.start(task);
System.out.println("Thread t name: " + t.getName());
t.join();
以下示例使用Thread创建并启动两个 Thread.Builder
Thread.Builder builder = Thread.ofVirtual().name("worker-", 0);
Runnable task = () -> {
System.out.println("Thread ID: " + Thread.currentThread().threadId());
};
// name "worker-0"
Thread t1 = builder.start(task);
t1.join();
System.out.println(t1.getName() + " terminated");
// name "worker-1"
Thread t2 = builder.start(task);
t2.join();
System.out.println(t2.getName() + " terminated");
此示例打印类似于以下内容的输出:
Thread ID: 21
worker-0 terminated
Thread ID: 24
worker-1 terminated
使用Executor.newVirtualThreadPerTaskExecutor()方法创建和运行虚拟线程
执行器允许您将线程管理和创建与应用程序的其余部分分开。
以下示例使用Executors.newVirtualThreadPerTaskExecutor()方法创建ExecutorService。每当调用ExecutorService.submit(Runnable)时,都会创建并启动一个新的虚拟线程来运行任务。此方法返回Future的一个实例。请注意,方法Future.get()等待线程的任务完成。因此,此示例在虚拟线程的任务完成后打印一条消息。
try (ExecutorService myExecutor = Executors.newVirtualThreadPerTaskExecutor()) {
Future<?> future = myExecutor.submit(() -> System.out.println("Running thread"));
future.get();
System.out.println("Task completed");
// ...
多线程客户端服务器示例
以下示例由两个类组成。EchoServer是一个服务器程序,它侦听端口并为每个连接启动一个新的虚拟线程。EchoClient是一个连接到服务器并发送在命令行上输入的消息的客户端程序。
EchoClient创建一个套接字,从而连接到EchoServer。它在标准输入流上读取用户的输入,然后通过将文本写入套接字将文本转发到EchoServer。EchoServer通过插座将输入回波至EchoClient。EchoClient读取并显示从服务器传回的数据。EchoServer可以通过虚拟线程同时为多个客户端提供服务,每个客户端连接一个线程
public class EchoServer {
public static void main(String[] args) throws IOException {
if (args.length != 1) {
System.err.println("Usage: java EchoServer <port>");
System.exit(1);
}
int portNumber = Integer.parseInt(args[0]);
try (
ServerSocket serverSocket =
new ServerSocket(Integer.parseInt(args[0]));
) {
while (true) {
Socket clientSocket = serverSocket.accept();
// Accept incoming connections
// Start a service thread
Thread.ofVirtual().start(() -> {
try (
PrintWriter out =
new PrintWriter(clientSocket.getOutputStream(), true);
BufferedReader in = new BufferedReader(
new InputStreamReader(clientSocket.getInputStream()));
) {
String inputLine;
while ((inputLine = in.readLine()) != null) {
System.out.println(inputLine);
out.println(inputLine);
}
} catch (IOException e) {
e.printStackTrace();
}
});
}
} catch (IOException e) {
System.out.println("Exception caught when trying to listen on port "
+ portNumber + " or listening for a connection");
System.out.println(e.getMessage());
}
}
}
public class EchoClient {
public static void main(String[] args) throws IOException {
if (args.length != 2) {
System.err.println(
"Usage: java EchoClient <hostname> <port>");
System.exit(1);
}
String hostName = args[0];
int portNumber = Integer.parseInt(args[1]);
try (
Socket echoSocket = new Socket(hostName, portNumber);
PrintWriter out =
new PrintWriter(echoSocket.getOutputStream(), true);
BufferedReader in =
new BufferedReader(
new InputStreamReader(echoSocket.getInputStream()));
) {
BufferedReader stdIn =
new BufferedReader(
new InputStreamReader(System.in));
String userInput;
while ((userInput = stdIn.readLine()) != null) {
out.println(userInput);
System.out.println("echo: " + in.readLine());
if (userInput.equals("bye")) break;
}
} catch (UnknownHostException e) {
System.err.println("Don't know about host " + hostName);
System.exit(1);
} catch (IOException e) {
System.err.println("Couldn't get I/O for the connection to " +
hostName);
System.exit(1);
}
}
}
调度虚拟线程和固定虚拟线程
操作系统安排平台线程何时运行。但是,Java运行时会安排虚拟线程何时运行。当Java运行时调度虚拟线程时,它将虚拟线程分配或装载到平台线程上,然后操作系统照常调度该平台线程。这个平台线程被称为载体。在运行一些代码后,虚拟线程可以从其承载器中卸载。这种情况通常发生在虚拟线程执行阻塞I/O操作时。虚拟线程从其载体上卸载后,载体是空闲的,这意味着Java运行时调度程序可以在其上装载不同的虚拟线程。
当虚拟线程固定在其承载器上时,在阻塞操作期间无法卸载它。虚拟线程在以下情况下被固定:
- 虚拟线程在同步块或方法内运行代码
- 虚拟线程运行本机方法或外部函数(请参阅外部函数和内存API)
固定不会使应用程序出错,但可能会阻碍其可扩展性。通过修改频繁运行的同步块或方法,并使用java.util.concurrent.locks保护潜在的长I/O操作,尝试避免频繁和长期的固定。重新输入锁定。
调试虚拟线程
虚拟线程是静态线程;调试器可以像平台线程一样逐步完成它们。JDK Flight Recorder和jcmd工具具有其他功能,可以帮助您观察应用程序中的虚拟线程。
话题
- JDK虚拟线程的飞行记录器事件
- 查看jcmd线程转储中的虚拟线程
JDK虚拟线程的飞行记录器事件
JDK飞行记录器(JFR)可以发出以下与虚拟线程相关的事件:
- jdk.VirtualThreadStart和jdk。VirtualThreadEnd指示虚拟线程何时开始和结束。默认情况下,这些事件处于禁用状态。
dk.VirtualThreadPinned
表示虚拟线程被固定(其承载线程未被释放)的时间超过阈值持续时间。此事件默认启用,阈值为20ms。- jdk.VirtualThreadSubmitFailed表示启动或取消标记虚拟线程失败,可能是由于资源问题。停放虚拟线程会释放底层承载线程来执行其他工作,而取消标记虚拟线程则会调度它继续执行。默认情况下会启用此事件。
使用按请求线程样式的阻塞I/O API编写简单的同步代码
虚拟线程可以显著提高以每请求线程方式编写的服务器的吞吐量,而不是延迟。在这种风格中,服务器将一个线程专门用于处理每个传入请求的整个持续时间。它至少专用一个线程,因为在处理单个请求时,您可能希望使用更多的线程来同时执行一些任务。
阻塞一个平台线程是昂贵的,因为它保留了线程——一种相对稀缺的资源——而它没有做太多有意义的工作。因为虚拟线程可能很多,所以阻塞它们是廉价的,也是受鼓励的。因此,您应该以直接的同步风格编写代码,并使用阻塞I/O API。
例如,以下代码以非阻塞异步风格编写,不会从虚拟线程中获得太多好处。
CompletableFuture.supplyAsync(info::getUrl, pool)
.thenCompose(url -> getBodyAsync(url, HttpResponse.BodyHandlers.ofString()))
.thenApply(info::findImage)
.thenCompose(url -> getBodyAsync(url, HttpResponse.BodyHandlers.ofByteArray()))
.thenApply(info::setImageData)
.thenAccept(this::process)
.exceptionally(t -> { t.printStackTrace(); return null; });
另一方面,以下代码以同步风格编写,并使用简单的阻塞IO,将受益匪浅:
try {
String page = getBody(info.getUrl(), HttpResponse.BodyHandlers.ofString());
String imageUrl = info.findImage(page);
byte[] data = getBody(imageUrl, HttpResponse.BodyHandlers.ofByteArray());
info.setImageData(data);
process(info);
} catch (Exception ex) {
t.printStackTrace();
}
这样的代码也更容易在调试器中调试、在探查器中配置文件或使用线程转储进行观察。要观察虚拟线程,请使用jcmd命令创建一个线程转储:
jcmd <pid> Thread.dump_to_file -format=json <file>
以这种风格编写的堆栈越多,虚拟线程的性能和可观察性就越好。以其他风格编写的程序或框架,如果不为每个任务指定一个线程,就不应该期望从虚拟线程中获得显著的好处。避免将同步、阻塞代码与异步框架混合使用。
将每个并发任务表示为一个虚拟线程;从不池化虚拟线程
关于虚拟线程,最难内化的是,尽管它们与平台线程具有相同的行为,但它们不应该表示相同的程序概念。
平台线程是稀缺的,因此是一种宝贵的资源。需要管理宝贵的资源,而管理平台线程的最常见方式是使用线程池。然后您需要回答的一个问题是,池中应该有多少个线程?
但虚拟线程是丰富的,因此每个线程都不应该代表一些共享的、池化的资源,而是一个任务。线程从托管资源变成应用程序域对象。我们应该有多少虚拟线程的问题变得显而易见,就像我们应该使用多少字符串在内存中存储一组用户名的问题一样:虚拟线程的数量总是等于应用程序中并发任务的数量。
将n个平台线程转换为n个虚拟线程几乎没有好处;相反,需要转换的是任务。
要将每个应用程序任务表示为线程,请不要像下面的示例那样使用共享线程池执行器:
Future<ResultA> f1 = sharedThreadPoolExecutor.submit(task1);
Future<ResultB> f2 = sharedThreadPoolExecutor.submit(task2);
// ... use futures
相反,使用虚拟线程执行器,如以下示例所示:
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
Future<ResultA> f1 = executor.submit(task1);
Future<ResultB> f2 = executor.submit(task2);
// ... use futures
}
代码仍然使用ExecutorService,但从Executors.newVirtualThreadPerTaskExecutor()返回的代码没有使用线程池。相反,它为每个提交的任务创建一个新的虚拟线程。
此外,ExecutorService本身是轻量级的,我们可以创建一个新的,就像处理任何简单的对象一样。这使我们能够依赖于新添加的ExecutorService.close()方法和try-with-resources构造。在try块结束时隐式调用的close方法将自动等待提交给ExecutorService的所有任务(即ExecutorServices派生的所有虚拟线程)终止。
对于输出场景,这是一个特别有用的模式,在输出场景中,您希望同时执行对不同服务的多个传出调用,如以下示例所示:
void handle(Request request, Response response) {
var url1 = ...
var url2 = ...
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
var future1 = executor.submit(() -> fetchURL(url1));
var future2 = executor.submit(() -> fetchURL(url2));
response.send(future1.get() + future2.get());
} catch (ExecutionException | InterruptedException e) {
response.fail(e);
}
}
String fetchURL(URL url) throws IOException {
try (var in = url.openStream()) {
return new String(in.readAllBytes(), StandardCharsets.UTF_8);
}
}
您应该创建一个新的虚拟线程,如上所示,用于即使是小的、短暂的并发任务。
为了获得更多关于编写输出模式和其他具有更好可观察性的常见并发模式的帮助,请使用结构化并发。
根据经验,如果您的应用程序从来没有10000个或更多的虚拟线程,那么它就不太可能从虚拟线程中受益。要么它的负载太轻,需要更好的吞吐量,要么您没有向虚拟线程表示足够多的任务。
使用信号量限制并发
有时需要限制某个操作的并发性。例如,某些外部服务可能无法处理超过十个并发请求。由于平台线程是一种宝贵的资源,通常在池中进行管理,线程池变得如此普遍,以至于它们被用于限制并发性,如以下示例所示:
ExecutorService es = Executors.newFixedThreadPool(10);
...
Result foo() {
try {
var fut = es.submit(() -> callLimitedService());
return f.get();
} catch (...) { ... }
}
此示例确保对有限服务最多有十个并发请求。
但限制并发只是线程池操作的副作用。池是为了共享稀缺资源而设计的,虚拟线程并不稀缺,因此永远不应该被池化!
在使用虚拟线程时,如果您想限制访问某些服务的并发性,则应该使用专门为此目的设计的构造:Semaphore类。以下示例演示了此类:
Semaphore sem = new Semaphore(10);
...
Result foo() {
sem.acquire();
try {
return callLimitedService();
} finally {
sem.release();
}
}
碰巧调用foo的线程将被抑制,也就是说,被阻塞,这样一次只有十个线程可以取得进展,而其他线程将不受阻碍地进行业务。
简单地用信号量阻塞一些虚拟线程似乎与将任务提交到固定线程池有很大不同,但事实并非如此。将任务提交给线程池会使它们排队等待稍后执行,但信号量在内部(或任何其他阻塞同步结构)创建一个被阻塞的线程队列,该队列反映了等待池线程执行它们的任务队列。由于虚拟线程是任务,因此生成的结构是等效的:
尽管您可以将平台线程池视为处理从队列中提取的任务的工作线程,将虚拟线程视为任务本身,直到它们可以继续,但计算机中的底层表示实际上是相同的。识别排队任务和阻塞线程之间的等效性将有助于充分利用虚拟线程。
数据库连接池本身就是一个信号灯。限制为十个连接的连接池将阻止第十一个线程尝试获取连接。不需要在连接池的顶部添加额外的信号量。
不要在线程本地变量中缓存昂贵的可重用对象
虚拟线程和平台线程一样支持线程本地变量。有关详细信息,请参阅线程本地变量(thread local variables)。通常,线程局部变量用于将一些特定于上下文的信息与当前运行的代码相关联,例如当前事务和用户ID。这种线程局部变量的使用对于虚拟线程来说是完全合理的。但是,请考虑使用更安全、更高效的作用域值。有关详细信息,请参阅作用域值。
线程局部变量的另一种用法与虚拟线程根本不同:缓存可重用对象。这些对象的创建成本通常很高(并且会消耗大量内存),是可变的,并且不是线程安全的。它们被缓存在线程局部变量中,以减少它们被实例化的次数和内存中的实例数量,但它们会被线程上不同时间运行的多个任务重用。
例如,SimpleDateFormat的实例的创建成本很高,而且不是线程安全的。出现的一种模式是将这样的实例缓存在ThreadLocal中,如以下示例所示:
static final ThreadLocal<SimpleDateFormat> cachedFormatter =
ThreadLocal.withInitial(SimpleDateFormat::new);
void foo() {
...
cachedFormatter.get().format(...);
...
}
只有当线程(以及缓存在线程本地中的昂贵对象)被多个任务共享和重用时,这种缓存才有帮助,就像平台线程被池化时一样。许多任务在线程池中运行时可能会调用foo,但由于线程池只包含几个线程,因此对象只会实例化几次——每个池线程一次——缓存并重用。
然而,虚拟线程从不被池化,也从不被不相关的任务重用。因为每个任务都有自己的虚拟线程,所以来自不同任务的每次对foo的调用都会触发新SimpleDateFormat的实例化。此外,由于可能有大量虚拟线程同时运行,因此昂贵的对象可能会消耗相当多的内存。这些结果与线程本地缓存所要实现的结果正好相反。
没有单一的通用替代方案可供选择,但在SimpleDateFormat的情况下,您应该将其替换为DateTimeFormatter。DateTimeFormatter是不可变的,因此所有线程都可以共享一个实例:
static final DateTimeFormatter formatter = DateTimeFormatter….;
void foo() {
...
formatter.format(...);
...
}
请注意,使用线程局部变量缓存共享的昂贵对象有时是由异步框架在幕后完成的,因为异步框架隐含地假设它们由极少数池线程使用。这就是为什么混合虚拟线程和异步框架不是一个好主意的原因之一:对方法的调用可能会导致在线程本地变量中实例化旨在缓存和共享的代价高昂的对象。
避免长时间和频繁的固定
当前虚拟线程实现的一个限制是,在同步块或方法内部执行阻塞操作会导致JDK的虚拟线程调度程序阻塞宝贵的操作系统线程,而如果阻塞操作在同步块和方法之外执行,则不会阻塞。我们称这种情况为“钉扎”。如果阻塞操作是长期且频繁的,则固定可能会对服务器的吞吐量产生不利影响。保护短暂的操作,如内存中的操作,或具有同步块或方法的不频繁操作,应该不会产生不利影响。
为了检测可能有害的钉扎实例,(JDK飞行记录器(JFR)会发出JDK。VirtualThreadPined线程当锁定了阻塞操作时;默认情况下,当操作耗时超过20ms时,会启用此事件。
或者,可以使用系统属性jdk.tracePinnedThreads在线程被固定时阻塞时发出堆栈跟踪。使用选项Djdk.tracePinnedThreads=full运行时,当线程在固定时发生阻塞时,将打印完整的堆栈跟踪,突出显示本地帧和持有监视器的帧。使用选项Djdk.tracePinnedThreads=short运行会将输出仅限于有问题的帧。
如果这些机制检测到固定既长时间又频繁的位置,请在这些特定位置使用synchronized with ReentrantLock来替换(同样,在保护短时间或不频繁操作的位置,无需替换synchronized)。以下是同步化块的长期频繁使用示例。
synchronized(lockObj) {
frequentIO();
}
lock.lock();
try {
frequentIO();
} finally {
lock.unlock();
}