这是一段近 30 年的进化之路。直到 Java 21 推出的虚拟线程,才让 Java 中的多任务处理变得轻松自如。要理解这一突破性的改变有多重要,我们得回顾下 Java 这些年来是如何一步步尝试解决"如何在等待时做其他事情"这个看似简单却棘手的问题。
Java 1 时代
1995 年,Java 1.0 的发布是编程界的一个重要里程碑。作为一门强类型的、面向对象的编程语言,它不仅继承了 C 语言的语法特点,还带来了许多革新,其中最引人注目的就是简单易用的线程机制。Thread 类让开发者可以轻松创建在主线程之外运行的代码。这些线程其实就是对操作系统线程(也叫内核线程)的包装。你只需实现一个 Runnable 接口就能定义要执行的任务,剩下的线程管理工作都交给 Java 来处理。这样一来,同时处理多个任务看起来变得很容易。来看个简单的例子:
package example.java1;
public class Simple {
public void threadExample() {
Runnable r = new Runnable (){
public void run() {
System.out.println("do something in background");;
}
};
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("Result from the thread: " + s);
}
}
Runnable 实现描述了一个长时间运行的进程,这里通过调用 sleep() 来模拟。主线程生成一个后台线程并启动它来执行工作。主线程(希望)能够在后台线程忙于执行单独工作时执行有用的工作,太棒了!然而,在某个时刻,主线程必须在继续之前获得后台线程的结果,并且可能别无选择,只能等待结果准备就绪。
注意代码中的"//阻塞!"注释。后台线程在等待结果时被阻塞。这种阻塞的真实示例发生在调用数据库、外部 Web 服务等时。在阻塞时,与线程相关的所有资源实际上都被卡住等待,无法做任何有用的工作。主线程在等待后台线程时被阻塞,无法做任何有用的工作。这里使用同步块来安全地访问多个线程使用的变量,在这种情况下有点过度,但有助于说明其他阻塞点。与生成单独的操作系统级进程相比,这里看到的平台线程相对轻量级,但大量的线程可能会消耗大量 JVM 资源。上面的线程大部分时间都在等待要做的事情。
在这个例子中,敌人是阻塞,通过 sleep() 方法模拟。多线程提供的不完美解决方案并没有解决问题,它只是将阻塞停在后台线程中。最终,当协调活动时,线程会相互阻塞,这里在 join() 和同步块中可以看到。无论何时发生阻塞都是对资源的可怕浪费。多年来,Java 将阻塞视为一个无法解决的障碍,使用不完美的、资源密集型的多线程解决方案来解决它。
在这种背景下,Java 开发人员面临的一个难题是,像 JavaScript 这样的单线程脚本语言如何在某些任务上使用更少的资源而优于多线程编译的 Java 代码。考虑这个 2000 年的 JavaScript 代码,它使用其内存的一小部分实现了与前面示例相同的结果:
var result;
// 使用内联回调模拟长时间运行的阻塞进程的函数
function longRunningProcess(callback) {
setTimeout(() => {
callback("Hello World!");
}, 2000); // 阻塞!
}
// 使用内联回调调用函数
longRunningProcess((data) => {
result = data;
// 做其他工作...
// 使用结果
console.log("Result from the inline callback:", result);
});
JavaScript 的单线程事件循环和回调机制允许它在等待时释放资源。这种方法的缺点是所谓的"回调地狱",其中嵌套的回调会导致代码难以阅读和维护。然而,这种方法确实展示了一种不同的思维方式,即在等待时释放资源。
Java 5 - Future
Java 5 (2004) 引入了 Future 接口,这是一个重要的进步。Future 代表了一个尚未完成的计算的结果。它提供了一种更优雅的方式来处理后台线程的结果。考虑这个使用 Future 的示例:
package example.java5;
import java.util.concurrent.*;
public class FutureExample {
private static String longRunningProcess() {
try { TimeUnit.SECONDS.sleep(2); } // 阻塞!
catch (InterruptedException e) {}
return "Hello World!";
}
public static void main(String[] args) {
// 创建一个线程池:
ExecutorService executor = Executors.newSingleThreadExecutor();
// 提交任务并获取 Future:
Future<String> future = executor.submit(() -> longRunningProcess());
// 做其他工作...
try {
// 获取结果 (等待如果需要):
String result = future.get(); // 阻塞!
System.out.println("Result from the future: " + result);
} catch (Exception e) {
e.printStackTrace();
}
executor.shutdown();
}
}
Future 接口提供了一种更优雅的方式来处理后台任务的结果。它消除了对共享变量和同步块的需求。然而,Future 仍然没有解决基本问题 - 阻塞。在某个时刻,主线程必须调用 get() 并等待结果。
Java 8 - CompletableFuture
Java 8 (2014) 引入了 CompletableFuture,这是对 Future 的重大改进。CompletableFuture 允许我们以声明式的方式描述异步操作的链。考虑这个例子:
package example.java8;
import java.util.concurrent.*;
public class CompletableFutureExample {
private static String longRunningProcess() {
try { TimeUnit.SECONDS.sleep(2); } // 阻塞!
catch (InterruptedException e) {}
return "Hello World!";
}
public static void main(String[] args) {
CompletableFuture<String> future = CompletableFuture
.supplyAsync(() -> longRunningProcess())
.thenApply(s -> s.toUpperCase())
.thenApply(s -> "Result from the completable future: " + s);
// 做其他工作...
try {
String result = future.get(); // 阻塞!
System.out.println(result);
} catch (Exception e) {
e.printStackTrace();
}
}
}
CompletableFuture 提供了一种更优雅的方式来描述异步操作。然而,它仍然没有解决基本问题 - 阻塞。在某个时刻,主线程必须调用 get() 并等待结果。
Java 21 - 虚拟线程
终于,在 2023 年,Java 21 引入了虚拟线程。虚拟线程是平台线程的轻量级替代品。它们由 JVM 而不是操作系统管理,这使得它们非常便宜。考虑这个使用虚拟线程的例子:
package example.java21;
import java.util.concurrent.*;
public class VirtualThreadExample {
private static String longRunningProcess() {
try { TimeUnit.SECONDS.sleep(2); } // 挂起!
catch (InterruptedException e) {}
return "Hello World!";
}
public static void main(String[] args) {
try {
String result = Thread.startVirtualThread(() -> {
return longRunningProcess();
}).join(); // 挂起!
System.out.println("Result from virtual thread: " + result);
} catch (Exception e) {
e.printStackTrace();
}
}
}
这个例子看起来很像最初的线程示例,但有一个关键的区别。注意我使用了"挂起"而不是"阻塞"这个词。当虚拟线程遇到可能阻塞的操作时,它会挂起自己并将其平台线程返回到公共池中。当阻塞操作完成时,虚拟线程会恢复到可用的平台线程上。这种方法允许少量平台线程支持大量虚拟线程,从而大大减少资源使用。
结论
Java 用了将近 30 年的时间,终于找到了优雅的解决方案。这漫长的进化过程告诉我们,在编程世界里,完美的解决方案往往需要时间来打磨。虚拟线程的出现,不仅让 Java 开发者能够写出简洁高效的并发代码,更是展示了技术演进的必然规律。
这个故事告诉我们:好的解决方案可能需要漫长的等待。在此期间,我们要学会与不完美共处,但同时也要保持开放的心态,在更好的方案出现时及时拥抱变化。
文章评论