花了将近30年的时间。Java 1.21 对虚拟线程的引入最终将使 Java 中的多任务处理变得几乎毫不费力。为了充分理解它们的革命性,看看 Java 多年来提供的各种不完美的解决方案是有帮助的,以解决“在我们等待其他事情的同时做有用的工作”的问题。
Java 1
1 年 Java 版本 1995 的推出是非凡的。一种强类型、面向对象、类似 C 的语法语言,提供了许多功能,包括易于使用的线程。Thread 类表示一个对象,该对象将在与主执行线程不同的线程中运行所选代码。Thread 对象本身是实际操作系统级线程的包装器,称为平台线程,也称为内核线程。要执行的逻辑是通过实现 Runnable 接口来描述的。Java 负责启动和管理这个单独线程的所有复杂性。现在,同时执行多项任务几乎是微不足道的,或者看起来是这样。请看以下示例:
package example.java1;
public class Simple {
public void threadExample() {
Runnable r = new Runnable (){
public void run() {
System.out.println("在后台做某事");;
}
};
Thread t = new Thread(r);
t.start();
}
}
主线程描述了 Runnable 接口的实现,在本例中,该接口正在执行琐碎的工作。第二个线程,我们称之为后台线程,是使用此 Runnable 实例化的。start() 方法命令后台线程工作,而主线程恢复它需要做的任何工作。
非常好!但是,第一代 Java 开发人员面临的一个直接挑战是如何将值从后台线程传递回主线程。考虑这个例子,我们希望在后台执行一个长时间运行的阻塞进程,然后稍后在主线程中使用其返回值:
package example.java1;
import java.util.concurrent.TimeUnit;
public class PassingValues {
//线程之间共享的变量 :
private static String result;
// 模拟延迟的实用方法:
private static void delay(int i) {
try { TimeUnit.SECONDS.sleep(i); }
catch (InterruptedException e) {}
}
// 创建更新共享变量的Runnable实例
private static Runnable workToDo = new Runnable (){
public void run() {
String s = longRunningProcess();
synchronized (this) {
result = s; // 阻止!安全地更新共享变量。
}
}
// 模拟长时间运行的阻塞过程:
private String longRunningProcess() {
delay(2);
return "Hello World!";
}
};
public static void main(String[] args) {
// 创建一个线程,传递Runnable,然后启动它:
Thread thread = new Thread(workToDo);
thread.start();
// 干其它的..
// 等待线程完成:
try {
thread.join(); // 阻塞!
} catch (InterruptedException e) {
e.printStackTrace();
}
// 使用结果
String s = null;
synchronized (workToDo) {
s = result; // 阻止!安全获取共享变量
}
System.out.println("线程的结果: " + s);
}
}
Runnable 实现描述了一个长时间运行的进程,这里通过调用 sleep() 来模拟。主线程生成一个后台线程并启动它来执行工作。主线程(希望)能够在后台线程忙于做单独的工作时执行有用的工作,太棒了!但是,在某个点上,主线程必须先从后台线程获取结果,然后才能继续,并且可能没有安全的选择,只能等到结果准备就绪。
请注意代码中的 “// blocking!”注释。后台线程在等待结果时被阻止。在调用数据库、外部 Web 服务等时,会出现这种阻塞的真实示例。当被阻塞时,与线程关联的所有资源实际上都卡在等待中,无法执行任何有用的工作。主线程在等待后台线程时被阻塞,无法执行任何有用的工作。同步块用于安全地访问多个线程使用的变量,在这种情况下有点过分,但对于说明其他阻塞点很有用。与生成单独的操作系统级进程相比,此处看到的平台线程相对轻量级,但大量线程会消耗大量 JVM 资源。上面的线程大部分时间都在等待某事。
在此示例中,敌人正在阻挡,由 sleep() 方法模拟。多线程提供的不完美的解决方案并不能解决问题,它只是将阻塞停放在后台线程中。最终,线程在协调活动时会相互阻塞,这在 join() 和 synchronization 块中可以看到。每当发生阻塞时,都是对资源的可怕浪费。多年来,Java 一直将阻塞视为无法解决的障碍,使用不完善的、资源密集型的多线程解决方案来解决它。
在这种背景下,Java 开发人员面临的一个难题是,像 JavaScript 这样的单线程脚本语言如何在某些任务中胜过多线程、编译的 Java 代码,同时使用更少的资源。考虑一下这个 2000 年的 JavaScript 代码,它使用一小部分内存实现了与上一个示例相同的结果:
var result;
//用于模拟长时间运行的阻塞进程的函数
//带有内联回调
function longRunningProcess(callback) {
setTimeout(() => {
callback("Hello World!");
}, 2000); // blocking!
}
// 使用内联回调调用函数
longRunningProcess((data) => {
result = data;
// Do other work...
// Use results
console.log("内联回调的结果:", result);
});
JavaScript 在单个线程中使用事件循环。回调是函数引用,用于标识任务完成时要完成的工作。JavaScript 不会占用等待工作完成的唯一线程,而是将回调引用放在其内部消息队列上并继续工作。稍后,事件循环将异步执行回调。没有阻挡,或者更准确地说,没有阻挡惩罚;任何等待结果都不会影响执行线程。
请注意两个示例中的“longRunningProcess()”。签名不同;Java 期望在工作完成时返回一个值,JavaScript 期望在工作完成时调用回调函数。Java 演示了同步执行,JavaScript 演示了异步执行。两者都涉及等待一些工作完成,但同步会占用一个线程来执行此操作。
公平地说,如果 1) 线程大部分时间都在忙于执行有用的内存工作,2) 线程之间的协调不存在或很少,则多个 Java 线程的性能将优于 JavaScript。在许多现实世界中,情况并非如此。
还有一点需要注意的是,这里显示的代码创建了一个线程,并允许通过垃圾回收来处理它,浪费时间和内存。Java 1 没有提供任何线程池功能,因此早期的 Java 开发人员使用管理逻辑创建了自己的管理逻辑,以便从池中获取和释放线程。
总结一下 Java 1 中的线程:
- 多线程并不能解决阻塞问题,阻塞通常是主要问题。
- 多线程是资源密集型的,而不是单线程的异步模型(即 JavaScript)。
- 线程是需要代码来管理和协调的低级构造。
后来的 Java 版本将解决所有这些问题,尽管需要一段时间才能实现。Java 1.2 (1998) 和 1.3 (2000) 对线程的贡献很小,下一批改进需要等到 2004 年发布的 Java 5 (1.5)。
Java 5
Java 1.5 (2004) 引入了几个特性来改进线程管理和线程之间的工作协调,我们将重点介绍 java.util.concurrent 包中的 Future、ExecutorService 和 Callable。
ExecutorService 抽象化了执行某些内容的机制。单线程、固定大小的线程池、增长和收缩的线程池等都存在实现。Callable 类似于 Runnable,但它可以返回一个值。为了保存从 Callable 返回的值,我们需要一个特殊的对象,它可以存在于一个线程中,但保存由 Callable 填充的返回值。这是 Future 对象,它表示一个线程的 promise 来填充另一个线程的值。
package example.java5;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
public class Demo {
// 模拟延迟的实用方法:
private static void delay(int i) {
try { TimeUnit.SECONDS.sleep(i); }
catch (InterruptedException e) {}
}
// 创建一个返回结果变量的Callable
private static Callable<String> workToDo = new Callable<>() {
public String call() throws Exception {
return longRunningProcess();
};
// 模拟长时间运行的阻塞过程
private String longRunningProcess() {
delay(2); // blocking!
return "Hello World!";
}
};
public static void main(String[] args) {
// 创建一个单线程执行器服务,并提交工作:
Future<String> future =
Executors
.newSingleThreadExecutor()
.submit(workToDo);
//做其他工作。。。
//等待线程完成:
String result = "";
try {
result = future.get(); // blocking!
} catch (InterruptedException e) {
e.printStackTrace();
} catch (ExecutionException e) {
e.printStackTrace();
}
System.out.println("线程的结果: " + result);
}
}
请注意与 Java 1 的区别:
- 当后台线程需要向主线程返回一个值时,Callable 优于 Runnable。两个线程之间不再共享变量或同步。
- Executors 类包含用于创建 ExecutorService 的工厂方法。我们不需要构建自己的线程池。
- 未来代表着一个承诺的结果。get() 方法返回从另一个线程返回的结果,如果结果尚不可用,则阻塞主线程。我们甚至可以用它来取消另一个线程。
注意没有改变的事情:这里的敌人仍在阻挡。用 “// blocking!”注释的行表示它可能发生的要点、sleep() 方法模拟的长时间运行进程和 future.get() 方法,如果后台线程未完成,可能会导致阻塞。虽然 Java 5 提供了更多的语法优雅和易用性,但我们仍然使用线程作为不完美的解决方案。
事实上,Java 5 可能使情况变得更糟!通过使多线程更易于使用,越来越多的开发人员将其作为各种性能问题的答案进行探索。常识是,如果我们将单个任务分解为并行处理的许多部分,则整个任务会进行得更快。除非各种线程在大部分时间里都很忙并且几乎不需要协调,否则 Java 程序通常比其他模型消耗更多的内存资源,甚至由于上下文切换的开销而表现出更差的性能。
此外,人们越来越需要将多个工作链接在一起,在后台执行每个部分。请考虑以下不太优雅的解决方案:
package example.java5;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
public class ChainingAsync {
public static void main(String[] args) {
try {
ExecutorService svc = Executors.newSingleThreadExecutor();
Future<String> future1 = svc.submit(new WorkToDo1());
// Do other work...
String result1 = future1.get(); // blocking!
Future<String> future2 = svc.submit(new WorkToDo2(result1));
// Do other work...
String result2 = future2.get(); // blocking!
Future<String> future3 = svc.submit(new WorkToDo3(result2));
// Do other work...
String result3 = future3.get(); // blocking!
System.out.println("Result from the thread: " + result3);
} catch (Exception e) {}
}
private static class WorkToDo1 implements Callable<String> {
public String call() throws Exception {
delay(1); // blocking!
return "Hello";
}
}
private static class WorkToDo2 implements Callable<String> {
private final String prefix;
public WorkToDo2(String prefix) {
this.prefix = prefix;
}
public String call() throws Exception {
delay(1); // blocking!
return prefix + " World";
}
}
private static class WorkToDo3 implements Callable<String> {
private final String prefix;
public WorkToDo3(String prefix) {
this.prefix = prefix;
}
public String call() throws Exception {
delay(1); // blocking!
return prefix + "!!";
}
}
// 模拟延迟的实用方法:
private static void delay(int i) {
try { TimeUnit.SECONDS.sleep(i); }
catch (InterruptedException e) {}
}
}
这越来越混乱了。链接工作需要自定义可调用对象来接收和返回所需的值。此代码尝试尽可能多地进行多任务处理。它的有效性取决于在主线程中执行了多少“其他工作”。如果它微不足道,我们消耗的资源比简单地按顺序调用三个函数所需的资源要多:
package example.java5;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
public class ChainingSync {
public static void main(String[] args) {
String result1 = doThing1();
String result2 = doThing2(result1);
String result3 = doThing3(result2);
System.out.println("Result from a single thread: " + result3);
}
private static String doThing1() {
delay(1);
return "Hello";
}
private static String doThing2(String input) {
delay(1);
return input + " World";
}
private static String doThing3(String input) {
delay(1);
return input + "!!";
}
// 模拟延迟的实用方法:
private static void delay(int i) {
try { TimeUnit.SECONDS.sleep(i); }
catch (InterruptedException e) {}
}
}
即使是经验很少的程序员也可以遵循第二个示例,但它的运行资源比第一个示例少。现在考虑一个 JavaScript 等效项,但没有阻塞:
(function() {
setTimeout(function doThing1() {
const result1 = "Hello";
setTimeout(function doThing2() {
const result2 = result1 + " World";
setTimeout(function doThing3() {
const result3 = result2 + "!!";
console.log("JavaScript的结果:", result3);
}, 1000);
}, 1000);
}, 1000);
})();
(您选择使用 Java 而不是 JavaScript 是有原因的。你在这里看到的可怕的 JavaScript 语法被称为“回调地狱”。从好的方面来说,没有阻塞,只使用单个线程,而且它只消耗了前面 Java 示例的一小部分资源。
Java 需要一种更好的方法来将 Futures(承诺)从一个链接到另一个,而不会出现回调地狱,当然我们仍然需要对阻塞做一些事情。(公平地说,现代 JavaScript 使用 Promise 和 async/await 来更清楚地实现您在此处看到的逻辑,但本文是一堂历史课。
Java 7
接下来的两个 Java 版本,1.6(2006 年)和 1.7(2011 年)都没有解决这两个问题。Java 1.7 确实引入了一项名为 ForkJoinPool 的新改进。这个新功能提供了一种将一组工作划分为多个块以进行并行处理的方法。ForkJoinPool 与 RecursiveTask 一起可以很容易地放下大量要执行的工作,例如在 Collection 中,并将其递归分解为越来越小的块,以便由不同的线程进行并行处理(分叉),然后收集所有已完成工作的结果(联接)。
虽然这本身很有趣,但这一发展的副产品是引入了一种工作窃取算法,其中 ForkJoinPool 中的空闲线程可以从其他繁忙的线程中“窃取”任务。ForkJoinPool 是一般用途的一个很好的线程池选项,但它对我们解决阻塞问题没有帮助,因为在这种情况下,阻塞的线程不被视为“空闲”。有趣的是,工作窃取的概念预示着一个概念,即一个被阻塞的线程可能会被释放来做其他工作,这是 Java 21 虚拟线程解决方案的本质。
Java 8
Java 1.8 (2014) 是一个 BIG 版本。在多任务处理方面,新增的明星是 CompleteableFuture,这是对 Java 1.5 的 Future 的一大改进。为了快速轻松地在单独的线程中运行工作,CompleteableFuture 类提供了许多“*Async”方法,这些方法在 ForkJoinPool(默认情况下)提供的另一个线程中执行所需的代码。调用方只需直接调用该方法,而无需摸索 Threads 或 ExecutorService。回调方法可以注册异步工作完成时要执行的操作。最后,Lambda 语法减少了 Java 5 示例中繁重的样板文件:
package example.java8;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
public class Demo {
// 模拟延迟的实用方法
private static void delay(int i) {
try { TimeUnit.SECONDS.sleep(i); }
catch (InterruptedException e) {}
}
// 模拟长时间运行的阻塞过程
private static CompletableFuture<String> longRunningProcess() {
return CompletableFuture.supplyAsync(() -> {
delay(2); // blocking!
return "Hello World!";
});
}
public static void main(String[] args) {
//创建 CompletableFuture
longRunningProcess().thenAccept(result -> {
System.out.println(" CompletableFuture结果: " + result);
});
//做其他工作。。。
//引入延迟以防止JVM
//禁止在工作完成前退出。
delay(3);
}
}
通知:
- 调用线程不再需要摸索 Executors 或 ExecutorService。
- 主线程不会阻塞等待异步执行完成。相反,要完成的工作在回调中注册。
- 在实际应用程序中不需要最终的 delay()。它在这里仅用于防止主非守护程序线程在演示守护程序线程中的回调之前退出。
上面显示的回调允许将多个操作链接在一起。CompleteableFuture 支持流畅的接口,允许异步工作流的组合;异步操作的管道,每个操作在前一个操作完成后执行。提供了许多方法,这些方法接受 Suppliers、Consumers、Functions 和其他接口,这些接口可以替换为 Lambda 表达式。
package example.java8;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
public class ChainDemo {
// 模拟延迟的实用方法
private static void delay(int i) {
try { TimeUnit.SECONDS.sleep(i); }
catch (InterruptedException e) {}
}
private static String doThing1() {
delay(1);
return "Hello";
}
private static String doThing2(String s) {
delay(1);
return s + " World";
}
private static String doThing3(String s) {
delay(1);
return s + "!!";
}
public static void main(String[] args) {
CompletableFuture
.supplyAsync(() -> doThing1())
.thenApplyAsync(s -> doThing2(s))
.thenApplyAsync(s -> doThing3(s))
.thenAcceptAsync(System.out::println);
System.out.println("主线程继续,不受阻碍.");
//引入延迟以防止JVM
//禁止在工作完成前退出。
delay(4);
}
}
比 Java 5 的同类产品更简单、更干净!您在此处看到的每个 Fluent 方法都返回一个 CompleteableFuture。“异步”方法在 ForkJoinPool 提供的线程中异步运行。此示例建立了一个回调链,每个回调在后台线程中执行,每个回调在前一个回调完成时执行。在本例中,“主线程...”消息实际上显示在 CompleteableFuture 生成的消息之前,因为链中线程之间的上下文切换会消耗时间。
CompleteableFuture 是对早期模型的重大实用改进;更易于使用,并解决了大多数开发人员的实际问题,在后台执行长时间运行的调用,同时增加了组装操作链的能力。但是,当后台线程阻塞时,或者在我们必须等待最终结果然后返回调用方的情况下,CompletableFuture 不会做任何特别的事情。
在其他线程中要完成的多个操作的链接也使我们的调试、测试和异常处理工作更加困难。在我们自己的代码(doThing*() 方法)中发生的异常不会构成挑战。但是,如果在异步框架中引发异常,例如因为我们的一个方法返回了 null,则生成的 NullPointerException 将很难追踪。生成的堆栈跟踪可能根本不包含对代码的任何引用。
CompleteableFuture 确实提出了一个有趣的可能性:如果我们整个应用程序中的每个方法都允许在另一个线程中执行,包括对数据库、Web 服务等的调用,那会怎样?如果每个方法都返回 CompleteableFuture,则可以将整个应用程序的逻辑构造为由池中可用的任何线程完成的步骤链。如果有可能将每个阻塞调用替换为对回调的引用,那么就有可能将线程从阻塞中解放出来,并从一两个线程中获得大量的生产性工作。
为响应式编程奠定了基础......
Java 9
响应式编程建立在链接和组合 CompleteableFutures 的范式之上,但它的起源在别处。在 Java 9(2017 年)引入 java.util.concurrent.Flow 类型之前,Java 开发人员已经在使用 RxJava 和 Spring 的 Reactor。后者使用了 Reactive Streams 库,该库后来被合并到 Java 9 中。
响应式编程根本不是关于多线程的。它是关于将工作负载减少到可以异步处理的事件流。在响应式编程中,一切都可以表示为流。流包含按顺序流动的事件。例如,数据库调用的结果可以被视为事件流,即与查询匹配的行。可以将 Web 服务调用的结果视为一个流,即使它是一件事的流。这里的术语“流”不应与 Java 8 中的 java.util.stream 概念混淆;虽然在语法上有相似之处,但反应式流本质上是异步的,而 Java 8 流提供了复杂循环的替代方案,其中包含用于保存状态的变量。
反应式很难学习,所以慢慢来。我建议从 Dave Syer 出色的三部分探索和 Andre Saltz 的精彩介绍开始。Java 9 的 java.util.concurrent.Flow 类是响应式结构(Publisher 和 Subscriber)的低级定义,需要大量代码来构造一个简单的流。对于我们的旅程,让我们考虑一个 RxJava 替代方案,以替代前面所示的 CompleteableFuture 示例:
package example.java9;
import java.util.concurrent.TimeUnit;
import io.reactivex.rxjava3.core.Single;
public class RxJavaDemo {
// 模拟延迟的实用方法
private static void delay(int i) {
try { TimeUnit.SECONDS.sleep(i); }
catch (InterruptedException e) {}
}
private static String doThing1() {
delay(1);
return "Hello";
}
private static String doThing2(String input) {
delay(1);
return input + " World";
}
private static String doThing3(String input) {
delay(1);
return input + "!!";
}
public static void main(String[] args) {
Single.fromCallable(() -> doThing1())
.map(RxJavaDemo::doThing2)
.map(RxJavaDemo::doThing3)
.doAfterSuccess(System.out::println)
.subscribe(); // Subscribe to start the reactive pipeline
}
}
RxJava 有两种类型的流:Single 和 Observable。Single 专为需要单个事件的流而设计,因此适合在此处使用。我们的流从一个事件开始,其中包含从 doThing1() 方法返回的“Hello”字符串,但它是一个流。map() 函数是一个运算符。它从一个流中获取事件,对其进行处理,然后在一个新的单独流中将其发送出去。在这种情况下,doThing2() 方法只是将 “ World” 连接到传入 String 的末尾。下一个 map() 运算符接收事件,调用 doThing3(),将 “!!” 连接到传入的 String,并生成另一个输出流。doAfterSuccess() 函数通过打印到控制台来操作它在单个流中遇到的唯一事件。subscribe() 调用很关键 - 前面的代码只是定义了算法,subscribe() 调用有效地命令它开始运行。
这是另一个基于 Spring 的 Reactor 的实现。请注意相似之处:
package example.java9;
import java.util.concurrent.TimeUnit;
import reactor.core.publisher.Mono;
public class ReactorDemo {
// 模拟延迟的实用方法
private static void delay(int i) {
try { TimeUnit.SECONDS.sleep(i); }
catch (InterruptedException e) {}
}
private static String doThing1() {
delay(1);
return "Hello";
}
private static String doThing2(String input) {
delay(1);
return input + " World";
}
private static String doThing3(String input) {
delay(1);
return input + "!!";
}
public static void main(String[] args) {
Mono.fromCallable(() -> doThing1())
.map(ReactorDemo::doThing2)
.map(ReactorDemo::doThing3)
.doOnNext(System.out::println)
.subscribe();
}
}
Reactor 使用 Mono 和 Flux 而不是 RxJava 的 Single 和 Observable。我们的 Mono 从一个包含“Hello”字符串的事件开始,就像 RxJava 版本一样。map() 和 subscribe() 方法等同于它们的 RxJava 对应方法,doOnNext() 大致等同于 doAfterSuccess()。
在这两个示例中,当调用 subscribe() 时,响应式流直接在主线程中执行。流耗尽后,程序将继续执行。不需要前面示例中看到的 delay() 调用,因为所有工作都是在主线程中完成的。
让我重复一遍:在这里显示的示例中,所有工作都在主线程中完成。除了语法之外,与前面构建的 CompleteableFuture 链的本质区别在于,响应式方法通过将工作分解为将异步处理的块(事件)来运行。异步并不意味着“同时”或“在后台”,而是意味着“在时间上不是同时或并发的”(Merriam-Webster,强调我的)。所使用的执行模型比早期 Java 版本中的多线程方法更接近 JavaScript 的事件循环。虽然我们可以要求任何一个实现池化多个线程来分解事件处理,但在这些简单的示例中,这并不是必需的。请记住:这些示例中的敌人是阻塞,由 delay() / sleep() 调用表示。前面的示例试图通过多线程来解决阻塞问题,而反应式则试图通过异步事件处理来解决它。
但是这些反应式示例能解决阻塞问题吗?如果对 doThing1()、2 和 3 的调用进行阻塞调用,它们仍将阻塞整个线程,即这些示例中的主线程。此处显示的响应式方法每个都消耗一个线程而不是两个线程,因此它们对计算机资源更加节俭。我们可以使用调度程序来池化单独的线程,以便在异步事件传入时对其进行处理,但最终,在这里显示的情况下,这些单独的线程将在大多数时间被阻塞。
当然,这是一个使用 sleep() 模拟对数据库或 Web 服务的实际阻塞调用的人为示例,所有这些都是从公共静态 void main() 方法调用的。优化的示例将使用响应式 HTTP 客户端(如 WebClient)或数据库库(如 R2DBC)。它们返回响应式类型(Flux/Mono),这些类型可以流入上游对象,而上游对象本身返回响应式类型。如果我们能够使用非阻塞 IO 处理程序构建一个完美的响应式调用堆栈到现代 HTTP 服务器,我们就可以消除所有阻塞调用。如果这篇文章主要是关于响应式编程的,我会提供这样一个例子,并且足以说明它将以最少的线程使用来消除所有阻塞。
对于大多数开发人员来说,最基本的困难是以响应式编程风格思考。它需要背离易于理解的分步命令式编程。与 CompleteableFutures 链一样,响应式程序使我们的调试、测试和异常处理工作更加困难。为了获得响应式编程的好处,应用程序的整个调用堆栈必须处理响应式类型;如果我们引入单个阻塞调用,我们就消除了该模型的本质优势。在这种情况下,我们可能会取得不比 CompleteableFuture 更好的结果,并在此过程中让自己偏头痛。
但是我们已经解决了阻塞问题!...以阿司匹林为单位。总结一下我们在响应式编程方面所处的位置:
- 我们必须学会以响应式编程风格思考,而不是命令式编程。
- 应用程序的整个调用堆栈必须处理响应式类型。单个阻塞调用会威胁到整个模型。
- 调试很困难,异常处理令人困惑。
让我们回到我们真正想要的:能够长时间运行,可能阻止来自代码的调用,而不会招致阻塞线程的惩罚。理想情况下,我们想告诉 JVM,在我们等待响应时,线程可以处理其他事情。我们希望在命令式的分步指令中做到这一点,而不必为异步事件模型而苦苦挣扎,而且绝对没有我们在 JavaScript 中看到的回调语法。
现在,Java 21 虚拟线程的阶段已经设置好了......
Java 21 虚拟线程
在版本 9 之后,Java 切换到了六个月的发布节奏,所以接下来的几年过得很快。在多线程或响应式编程方面,下一个重大进步将发生在 Java 21(2023 年)引入的虚拟线程中:
虚拟线程是传统 Java 平台线程的轻量级替代方案,由操作系统级线程提供支持。与平台线程相比,它们非常轻量级,在应用程序运行时启动数千个线程是微不足道的。池化是完全没有必要的。可以在每个平台线程中运行大量虚拟线程。所有工作最终仍是在平台线程上完成的,但使用一个特殊的 Continuation 对象来交换虚拟线程在遇到阻塞调用时正在运行的虚拟线程。
与早期基于 CompleteableFuture 或 Reactive Streams 的模型相比,使用虚拟线程进行编码非常简单。让我们回到一些自 Java 1 和 5 以来我们从未见过的代码,当时我们将在简单的 Runableles 或 Callables 中定义所有工作:
package example.java21;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
public class Demo {
// 模拟延迟的实用方法
private static void delay(int i) {
try { TimeUnit.SECONDS.sleep(i); }
catch (InterruptedException e) {}
}
private static String doThing1() {
delay(1);
return "Hello";
}
private static String doThing2(String input) {
delay(1);
return input + " World";
}
private static String doThing3(String input) {
delay(1);
return input + "!!";
}
// 创建实现我们的逻辑的Runnable实例
private static Runnable myRunnable = () -> {
String result1 = doThing1();
String result2 = doThing2(result1);
String result3 = doThing3(result2);
System.out.println("结果: " + result3);
};
public static void main(String[] args) {
// 创建一个虚拟线程,传递Runnable并启动它:
Executors.newVirtualThreadPerTaskExecutor().submit(myRunnable);
//做其他工作。。。
//引入延迟以防止JVM
//禁止在工作完成前退出。
delay(4);
}
}
Java 5 中的 Executors 工厂类又回来了,它带来了一个新的工厂方法 newVirtualThreadPerTaskExecutor()。这将返回一个 ExecutorService,它提供虚拟线程而不是标准平台线程。Runnable 将在虚拟线程中执行,该虚拟线程本身在平台线程中运行。睡眠延迟之所以返回,只是因为所有工作都在守护程序线程中完成,如果主线程退出,则在我们的演示中会中断。
那么封锁呢?虚拟线程在由修改后的 ForkJoinPool 提供的平台线程中运行。称为 Continuation 的特殊内部对象管理平台线程中的多个虚拟线程。当检测到阻塞调用时,虚拟线程中的代码会调用 Continuation.yield() 以允许另一个 VirtualThread 轮流运行。yield() 进程从平台线程中卸载 VirtualThread,将其堆栈内存复制到堆内存中,然后开始运行其等待列表中的下一个虚拟线程。当操作系统处理程序发出阻塞调用结果已完成的信号时,它会调用 Continuation.run() 将此 VirtualThread 放回平台线程的等待列表中。如果此平台线程繁忙,则 ForkJoinPool 的工作窃取功能将发挥作用,任何其他可用的平台线程都可以恢复工作。通过一些内存管理消除了阻塞损失 - 效率更高。
但是 VirtualThread 如何检测阻塞呢?Java 21 几乎修改了 JDK 中可能导致阻塞的每一行代码。现在,当阻塞开始时,它们会调用 yield() 进程。这是对 JDK 的巨大更改,超过 <> 个文件是拉取请求的一部分。
结果是,我们可以在虚拟线程中运行正常的命令式代码,而完全忽略阻塞惩罚,因为它们的不良影响已被消除。虚拟线程允许底层平台线程在代码等待结果时忙于其他虚拟线程。更少的线程可以做更多的工作,就像我们在 Reactive Streams 或 JavaScript 中看到的那样,但我们不需要采用高级异步技术或回调来享受这些好处。从现在开始,这对我们如何用 Java 编写代码产生了革命性的影响,用简单的命令式编程取代了对优雅但难以掌握的技术的需求,如 CompleteableFuture 或 Reactive Streams。
虚拟线程不是灵丹妙药,并不适合所有情况。与直接在平台线程中运行相比,虚拟线程的操作负担较小,这主要是由于在发生 yield()/run() 调用时卸载和重新装载内存。使用本文前面所述的多线程或反应式技术,可以更恰当地实现不会产生阻塞的工作负载(例如长时间运行的计算)。本机代码和带有同步块的代码可以在虚拟线程中运行,但这些任务被固定到单个平台线程,从而消除了窃取工作的好处。
需要考虑的一个可能的危险是:上面示例中的代码仅在虚拟线程与平台线程中运行时才能有效运行。在整个 JDK 代码库中添加的修改仅在代码在虚拟线程中运行时生效。如果对这段代码稍作修改,例如删除 Runnable 或调用不同的 Executors 工厂方法,结果将是与我们花了 25 年时间试图避免的低效阻塞相同的结果。对于忙碌的开发人员来说,有时很难注意到这种微妙的变化。
总结一下 Java 虚拟线程:
- 在大多数实际情况下,自 Java 早期以来所经历的阻塞惩罚是可以消除的。
- 开发人员可以使用易于使用的命令式编程来读取、写入、测试、调试和处理异常。
- 在非阻塞情况下,多线程仍然是首选,例如长时间运行的计算。