原文档链接:docs.oracle.com/en/java/jav…
虚拟线程是轻量级线程,旨在减少编写、维护和调试高吞吐量并发应用程序的工作量。
有关虚拟线程的背景信息,请参见JEP 444。
线程是可以被调度的最小处理单元。它与其他这样的单元同时运行——在很大程度上是独立运行的。它是java.lang.Thread
类的一个实例。这里的线程分为两种,平台线程和虚拟线程。
什么是平台线程?
平台线程是作为操作系统线程的瘦包装器实现的。平台线程在其底层操作系统线程上运行Java代码,并且平台线程在整个生命周期内捕获其操作系统线程。因此,可用的平台线程数量受限于操作系统线程的数量。
平台线程通常有一个大的线程栈和其他由操作系统维护的资源。它们适合运行所有类型的任务,但它们可能是有限的资源。
什么是虚拟线程?
像平台线程一样,虚拟线程也是java.lang.Thread
类的实例。但是,虚拟线程并不绑定到特定的操作系统线程。虚拟线程仍然是在操作系统线程上运行代码。但是,当运行在虚拟线程中的代码调用阻塞I/O操作时,Java的运行时(Runtime)将挂起这个虚拟线程,直到可以恢复它。与挂起的虚拟线程相关联的操作系统线程现在可以自由地执行其他虚拟线程的操作。
虚拟线程的实现方式与虚拟内存类似。为了模拟大量内存,操作系统将一个大的虚拟地址空间映射到数量有限的RAM。类似地,为了模拟大量线程,Java的运行时将大量虚拟线程映射到少量操作系统线程。
与平台线程不同,虚拟线程的调用栈通常比较浅,可能仅仅只执行一个HTTP客户端调用或一个JDBC查询。尽管虚拟线程支持线程局部变量和可继承的线程局部变量,但您仍然应该仔细考虑它们的使用,因为单个JVM可能支持数百万个虚拟线程。
虚拟线程适合运行那些大部分时间被阻塞的任务,这些任务通常等待I/O操作完成。而它们并不适用于长时间运行的CPU密集型操作。
为什么使用虚拟线程
虚拟线程适用于高吞吐量并发应用程序中,特别是那些由大量需要花费时间等待的并发任务所组成的应用程序。服务器应用程序是高吞吐量应用程序的示例,因为它们通常处理执行阻塞I/O操作(如获取资源)的许多客户端请求。
虚拟线程不是更快的线程;它们运行代码的速度并不比平台线程快。它们的存在是为了提供可伸缩性(更高的吞吐量),而不是速度(更低的延迟)。
创建并运行一个虚拟线程
Thread
和Thread.Builder
API提供了创建平台线程和虚拟线程的方法。java.util.concurrent.Executors
类也定义了方法来创建一个为每个任务启动一个新的虚拟线程的ExecutorService
。
使用Thread类和Thread.Builder接口来创建一个虚拟线程
调用Thread.ofVirtual()
方法来创建一个Thread.Builder
实例用于创建虚拟线程。
下面的示例创建并启动一个打印消息的虚拟线程。它调用了join
方法来等待虚拟线程的终止。(这使得你能够在主线程终止之前看到打印的消息。)
Thread thread = Thread.ofVirtual().start(() -> System.out.println("Hello"));
thread.join();
Thread.Builder
接口让你可以使用常见的Thread
属性(如线程名称)来创建线程。Thread.Builder.OfPlatform
子接口创建平台线程,而Thread.Builder.OfVirtual
则创建虚拟线程。
下面示例使用Thread.Builder
接口创建了一个名为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.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
使用Executors.newVirtualThreadPerTaskExecutor()方法来创建并运行虚拟线程
执行器(Executor)允许您将线程的管理和创建与应用程序的其余部分分开。
下面的示例使用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的运行时调度虚拟线程时,它在平台线程上分配或挂载虚拟线程,然后操作系统像往常一样调度该平台线程。这个平台线程称为载体(carrier)。在运行一些代码后,虚拟线程可以从它的载体上进行卸载。这通常发生在虚拟线程执行阻塞I/O操作的时候。虚拟线程从它的载体上卸载后,载体是空闲的,这意味着Java运行时调度器可以在其上挂载不同的虚拟线程。
在阻塞操作期间,当虚拟线程被固定到它的载体上时,它不能被卸载。虚拟线程在以下情况下被固定:
- 虚拟线程在运行代码到
synchronized
代码块或synchronized
方法中时。 - 虚拟线程运行一个native方法或一个外部函数(见外部函数与内存API)。
固定不会使应用程序出错,但可能会影响其可伸缩性。可以通过修改频繁运行的synchronized
代码块或方法并使用java.util.concurrent.locks.ReentrantLock
保护可能长时间的I/O操作,来尝试避免频繁和长时间的固定。
调试虚拟线程
虚拟线程仍然是线程;调试器可以像平台线程一样对它们进行单步调试。JDK Flight Recorder和jcmd
工具具有额外的功能,可以帮助您观察应用程序中的虚拟线程。
JDK Flight Recorder关于虚拟线程的事件
JDK Flight Recorder(JFR)可以发出下面这些和虚拟线程相关的事件:
jdk.VirtualThreadStart
和jdk.VirtualThreadEnd
表示一个虚拟线程何时开始和结束。这些事件默认是禁用的。jdk.VirtualThreadPinned
表示一个虚拟线程被固定(并且它的载体线程没有被释放)的时间超过阈值间隔。这个事件默认启用且阈值为20ms。jdk.VirtualThreadSubmitFailed
表示启动或取消停放虚拟线程失败,可能是由于资源问题。停放一个虚拟线程会释放底层的载体线程去做其他工作,而取消停放一个虚拟线程则调度它继续运行。该事件默认开启。
可以通过JDK Mission Control
来启用jdk.VirtualThreadStart
和jdk.VirtualThreadEnd
事件,也可以通过Java Platform, Standard Edition Flight Recorder API Programmer’s Guide
中Flight Recorder Configurations所描述的一个自定义JFR配置来启用。
要打印这些事件,请运行以下命令,其中recording.jfr
是你要记录的文件名:
jfr print --events jdk.VirtualThreadStart,jdk.VirtualThreadEnd,jdk.VirtualThreadPinned,jdk.VirtualThreadSubmitFailed recording.jfr
查看jcmd线程转储中的虚拟线程
你可以创建一个纯文本或JSON格式的线程转储:
jcmd <PID> Thread.dump_to_file -format=text <file>
jcmd <PID> Thread.dump_to_file -format=json <file>
JSON格式对于接受这种格式的调试工具来说是理想的。
jcmd线程转储列出在网络I/O操作中阻塞的虚拟线程和由ExecutorService接口创建的虚拟线程。它不包括对象地址、锁、JNI统计信息、堆统计信息和其他出现在传统线程转储中的信息。
虚拟线程:采用指南
虚拟线程是由Java运行时实现而不是由操作系统实现的Java线程。虚拟线程和传统线程(我们称之为平台线程)之间的主要区别在于,我们可以很容易地在同一个Java进程中运行大量活动的虚拟线程,甚至数百万个。大量的虚拟线程赋予了它们强大的功能:通过允许服务器并发处理更多的请求,它们可以更有效地运行以每个请求一个线程的方式编写的服务器应用程序,从而实现更高的吞吐量和更少的硬件浪费。
由于虚拟线程是java.lang.Thread
的实现,并且遵循自Java SE 1.0以来指定的java.lang.Thread
的相同规则,因此开发人员不需要学习使用它们的新概念。然而,由于无法生成非常多的平台线程(多年来Java中唯一可用的线程实现),因此产生了旨在应对其高成本的编程实践。当应用于虚拟线程时,这些做法会适得其反,必须摒弃。此外,成本上的巨大差异提示了一种对于线程的新的思考方式,即这些线程可能是外来的。
本指南并不打算全面介绍虚拟线程的每一个重要细节。它只是为了提供一组介绍性的指导方针,以帮助那些希望开始使用虚拟线程的人充分利用它们。
编写简单、同步的代码,以一个线程一个请求风格来使用阻塞式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>
以这种风格编写的堆栈越多,虚拟线程的性能和可观察性就越好。用其他风格编写的程序或框架,如果没有为每个任务指定一个线程,就不应该期望从虚拟线程中获得显著的好处。避免将同步、阻塞代码与异步框架混在一起。
将每个并发任务表示为一个虚拟线程;不要共用虚拟线程
关于虚拟线程,最难内化的是,虽然它们具有与平台线程相同的行为,但它们不应该表示相同的程序概念。
平台线程是稀缺的,因此是一种宝贵的资源。需要管理宝贵的资源,管理平台线程的最常用方法是使用线程池。接下来需要回答的问题是,池中应该有多少线程?
但是虚拟线程非常多,因此每个线程不应该代表一些共享的、池化的资源,而应该代表一个任务。线程从托管资源转变为应用程序域对象(application domain objects)。我们应该有多少个虚拟线程的问题变得很明显,就像我们应该使用多少个字符串在内存中存储一组用户名的问题一样:虚拟线程的数量总是等于应用程序中并发任务的数量。
将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
的所有任务(即由ExecutorService
生成的所有虚拟线程)终止。
对于fanout场景,这是一个特别有用的模式,在这种场景中,您希望并发地向不同的服务发送请求,如下面的示例所示:
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);
}
}
您应该创建一个新的虚拟线程,如上所示,即使是小型的、短暂的并发任务也是如此。
为了在编写fanout模式和其他常见并发模式时获得更多帮助,并且具有更好的可观察性,请使用结构化并发。
根据经验,如果您的应用程序从来没有10000个或更多的虚拟线程,那么它不太可能从虚拟线程中获益。要么它的负载太轻,不需要更好的吞吐量,要么你没有将足够多的任务表示为虚拟线程。
使用信号量来限制并发
有时需要限制某个操作的并发性。例如,某些外部服务可能无法处理十个以上的并发请求。由于平台线程是通常在池中管理的宝贵资源,因此线程池已经变得如此普遍,以至于它们被用于限制并发性的目的,如下例所示:
ExecutorService es = Executors.newFixedThreadPool(10);
...
Result foo() {
try {
var fut = es.submit(() -> callLimitedService());
return f.get();
} catch (...) { ... }
}
此示例确保对有限的服务最多有10个并发请求。
但是限制并发性只是线程池操作的副作用。池被设计为共享稀缺资源,而虚拟线程并不稀缺,因此永远不应该被池化!
在使用虚拟线程时,如果希望限制访问某些服务的并发性,则应该使用专门为此目的设计的构造:Semaphore类。下面的例子演示了这个类:
Semaphore sem = new Semaphore(10);
...
Result foo() {
sem.acquire();
try {
return callLimitedService();
} finally {
sem.release();
}
}
碰巧调用foo
的线程将被限制,即阻塞,因此一次只有10个线程可以进行,而其他线程将不受阻碍地继续它们的业务。
简单地用信号量阻塞一些虚拟线程似乎与将任务提交到固定线程池有很大的不同,但事实并非如此。将任务提交到线程池使它们排队等待稍后执行,但是这个内部的信号量(或任何其他阻塞同步构造)创建了一个阻塞在它上面的线程队列,该队列反映了等待池化线程执行它们的任务队列。因为虚拟线程是任务,所以结果结构是等价的:
图14-1 比较线程池和信号量
即使您可以将平台线程池视为处理从队列中提取的任务的工作人员,也可以将虚拟线程池视为任务本身,阻塞直到它们可以继续,但计算机中的底层表示实际上是相同的。认识到排队任务和阻塞线程之间的等价性将帮助您充分利用虚拟线程。
数据库连接池本身用作信号量。限制为10个连接的连接池将阻止第11个试图获取连接的线程。不需要在连接池之上添加额外的信号量。
不要在线程局部变量中缓存昂贵的可重用对象
虚拟线程支持线程局部变量,就像平台线程一样。关于更多信息见Thread-Local Variables。通常,线程局部变量用于将一些特定于上下文的信息与当前运行的代码相关联,例如当前事务和用户ID。对于虚拟线程,使用线程局部变量是完全合理的。但是,请考虑使用更安全、更有效的作用域值(Scoped Values)。关于更多信息见Scoped Values。
线程局部变量(thread-local)的另一种用法与虚拟线程根本不同:缓存可重用对象。这些对象的创建成本通常很高(并消耗大量内存),它们是可变的,而且不是线程安全的。它们缓存在线程局部变量中,以减少实例化的次数和内存中的实例数量,但是它们被在不同时间运行在该线程上的多个任务重用。
例如,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(...);
...
}
请注意,使用线程局部变量来缓存昂贵的共享对象有时是由异步框架在幕后完成的,在它们隐含的假设下,它们被非常少量的池化线程使用。这就是为什么混合虚拟线程和异步框架不是一个好主意的原因之一:对方法的调用可能会导致在线程局部变量中实例化昂贵的对象,这些对象本来是打算被缓存和共享的。
避免长时间和频繁的固定
目前虚拟线程的具体实现的一个限制是,在synchronized
代码块或方法内部执行阻塞操作会导致JDK的虚拟线程调度器阻塞宝贵的操作系统线程,而如果阻塞操作在synchronized
代码块或方法之外完成,则不会。我们称这种情况为“固定”。如果阻塞操作既长又频繁,那么固定可能会对服务器的吞吐量产生不利影响。仅使用synchronized
保护短时间操作,比如内存操作,或者使用synchronized
代码块或方法的是不频繁操作,应该不会产生不利影响。
为了检测可能有害的固定实例,当阻塞操作被固定时JDK Flight Recorder(JFR)会发出jdk.VirtualThreadPinned
事件;默认情况下,当操作时间超过20ms时启用此事件。
或者,您可以使用系统属性jdk.tracePinnedThreads
,这样可以在被固定的线程发生阻塞时发出堆栈跟踪信息。使用-Djdk.tracePinnedThreads=full
选项运行时,当被固定的线程阻塞时,打印完整的堆栈跟踪信息,突出显示本地栈帧和持有监视器的栈帧。使用-Djdk.tracePinnedThreads=short
选项会将输出限制为有问题的栈帧。
如果这些机制检测到固定存在时间较长且频繁的地方,那么在这些特定的地方用ReentrantLock
替换synchronized
的使用(同样,不需要在synchronized
保护时间较短或不频繁的操作的地方替换掉synchronized
)。下面是一个长时间且频繁使用synchronized
代码块的例子。
synchronized(lockObj) {
frequentIO();
}
你可以把它替换为:
lock.lock();
try {
frequentIO();
} finally {
lock.unlock();
}