引言
你是不是也曾在开发中,觉得 CompletableFuture
这类异步编程的工具能让你高效、优雅地处理并发任务,从而避免线程阻塞,提升系统响应速度?相信很多开发者都曾有过这种理想主义的想法,认为异步编程不仅能优化性能,还能让代码变得简洁优雅。但在实际项目中,有时我们在过度依赖 CompletableFuture
或类似异步工具时,往往忽略了它们在某些边缘场景下的潜在风险和问题,最终却为此付出了惨重的代价。
今天,我想和大家分享一个故事,一个关于我在生产环境中因为使用 CompletableFuture
而引发线上事故的故事。事故的发生不仅导致了系统的严重崩溃,还让我背上了“C 绩效”,这对我来说,无疑是一次刻骨铭心的教训。
那是一个忙碌的周五下午,夕阳即将西下,我正在望着窗外思考,准备对即将上线的版本进行最后的调试和测试。我们的一款后台服务系统需要处理大量的并发 I/O 请求,这些请求大多是外部系统的 API 调用和文件处理任务。为了提升响应速度,我们决定采用 CompletableFuture
来优化这些异步任务,尤其是在涉及外部接口调用和数据库查询时,让主线程能够并发执行,而不是等待每个操作完成后再继续处理。
我们的理想设计
我们设计了一个异步任务调度系统,通过 CompletableFuture
来分发多个异步操作,并用 thenRun
和 thenAccept
进行任务的链式处理。代码看起来很简洁,所有异步操作都不会阻塞主线程,理论上应该能大大提高系统的处理效率。
CompletableFuture<Void> task = CompletableFuture.runAsync(() -> {
// 执行文件读取任务
readFileAndProcessData();
}).thenRun(() -> {
// 执行外部接口调用
callExternalApi();
}).thenAccept(result -> {
// 处理外部接口返回结果
saveResultToDatabase(result);
});
万象更新,悲剧上演
那时候,我和我的同事们觉得这个实现极为理想,代码优雅且效率高,异步任务的执行并不会阻塞主线程。系统的响应时间和处理速度都得到了显著的提升,一切似乎都在朝着正确的方向发展。
然而,问题出在了一个细节上:我并没有深入思考 CompletableFuture
在特定场景下的行为,尤其是主线程退出时异步线程的生命周期问题。在当时的设计中,我们没有使用线程池,而是直接通过 CompletableFuture.runAsync()
启动了异步任务。由于没有显式的线程管理,所有异步线程默认是用户线程。
不幸的时刻终于到来了。
上线当天,随着流量的逐渐增加,系统突然出现了无法预料的崩溃,API 响应变得异常缓慢,部分任务卡住,甚至有些请求超时未能返回。这时候,我开始排查问题,发现问题出现在我们的异步任务处理上。
事情的根本原因
问题的根本原因在于,主线程退出时,异步线程被强制中断了。由于我没有对异步任务进行适当的生命周期管理,主线程在完成初步的任务后直接退出,导致与主线程相关联的所有异步线程也被强制终止。此时,尽管异步线程还在进行数据处理和外部 API 调用,但由于主线程的退出,所有异步操作都被中断,导致了未完成的任务丢失,数据处理中断,API 请求未能完成。
我没有充分理解 CompletableFuture
和线程池管理的关键区别,特别是线程池管理下,异步任务的生命周期不受主线程的影响,而直接使用 CompletableFuture.runAsync()
启动的任务依赖于主线程的生命周期,导致了这一严重的设计失误。
1. 异步线程与主线程的生命周期关系
1.1 Java 中的线程类型
在理解主线程与异步线程的关系之前,我们首先需要了解 Java 中线程的基本分类。Java 中的线程主要分为两种类型:用户线程(User Thread)和守护线程(Daemon Thread)。线程的类型直接决定了它的生命周期。如果主线程是用户线程,它会等待所有用户线程执行完毕后才退出。如果主线程退出时仍有活跃的用户线程,JVM 会阻止进程结束,直到这些线程执行完成。
- 用户线程(User Thread)
用户线程是默认的线程类型,所有普通线程都属于用户线程。用户线程的生命周期由 JVM 管理,只要有一个用户线程在运行,JVM 就不会退出。用户线程的生命周期通常会持续到线程执行完毕或被显式地中断。当所有用户线程完成时,JVM 会终止进程并退出。
- 守护线程(Daemon Thread)
守护线程是由开发者手动设置的线程类型。与用户线程不同,守护线程的生命周期依赖于非守护线程。当所有非守护线程(即用户线程)执行完毕时,JVM 会自动退出并终止所有守护线程。守护线程的退出并不会影响 JVM 的退出,因此它通常用于执行一些后台任务,如垃圾回收、定时任务等。
1.2 主线程退出时,异步线程的行为
在大多数 Java 程序中,主线程通常会启动一些后台任务,如 I/O 操作、网络请求等。这些任务可能会通过异步线程来执行,避免阻塞主线程。比如,我们可以使用 CompletableFuture
来启动异步任务,让主线程继续执行其他操作,而不被阻塞。
然而,问题随之而来:如果主线程结束时,异步线程是否会继续运行?
默认情况下,在没有线程池管理的情况下,Java 启动的异步线程会被视为用户线程,而不是守护线程。这意味着,如果主线程退出时,JVM 会检查是否还有其他活跃的用户线程。如果没有活跃的用户线程,JVM 会终止进程,强制终止所有用户线程,包括异步线程。
这种行为在某些情况下会导致异步任务的中断或丢失,尤其是在异步线程需要较长时间执行的情况下,主线程退出后,异步线程的生命周期会受到影响,从而导致任务没有被正确完成。
1.3 问题展示:主线程退出,异步线程也退出
例如,下面的代码中使用 CompletableFuture.runAsync()
启动了一个异步线程来监听 USB 设备,而主线程在异步线程执行期间退出了:
import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
public class UsbListenerAsyncTest {
private static final Set<String> knownDevices = new HashSet<>();
public static void main(String[] args) throws InterruptedException {
String configValue = "someConfig";
CompletableFuture<Void> usbDetectionFuture = CompletableFuture.runAsync(() -> startUsbListening(configValue))
.thenRun(() -> System.out.println("监听任务完成"));
usbDetectionFuture.join(); // 阻塞主线程直到异步任务完成
System.out.println("主线程退出,但异步任务仍在后台运行...");
}
public static void startUsbListening(String configValue) {
System.out.println("初始化 startUsbListening");
while (true) {
System.out.println("获取设备列表中...");
try {
Thread.sleep(2000); // 每 2 秒检测一次设备变化
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
}
-
如果主线程调用了
join()
,主线程会显式地等待异步任务完成。在此期间,主线程仍然是活跃的用户线程,JVM 不会触发进程退出,也不会中断异步线程。因此,异步任务会正常执行并完成。 -
如果主线程未调用
join()
或其他显式阻塞操作,且未使用线程池管理异步线程,主线程很可能会提前退出。此时,若主线程是最后一个活跃的用户线程,JVM 会认为程序可以结束,从而导致异步线程被强制中断。
通过下面的两种截图,我们会发现,如果不进行阻塞操作,当前主线程退出的情况,异步线程也会退出。
1.4 原因分析
这是因为 Java 默认将所有线程(包括由主线程启动的异步线程)都视作用户线程。在没有线程池管理的情况下,当主线程退出时,如果没有其他活跃的用户线程,JVM 会检测到这是最后一个活跃的用户线程,因此会自动终止所有其他用户线程,包括异步线程。
2. 如何确保异步线程在主线程退出后继续执行
虽然主线程退出时会导致异步线程的终止,但 Java 提供了多种方法来确保异步线程能够在主线程退出后继续执行。
2.1 使用守护线程
如果你希望异步线程在主线程退出后继续运行,可以将异步线程设置为守护线程。守护线程的生命周期不依赖于主线程,只要有用户线程存在,守护线程就会继续执行。Thread.currentThread().setDaemon(true) 设置该线程为守护线程。当主线程结束时,如果没有其他非守护线程在运行,JVM 会自动结束所有守护线程。守护线程的生命周期是依赖于非守护线程的,主线程退出时不会影响守护线程的执行。
CompletableFuture.runAsync(() -> {
Thread.currentThread().setDaemon(true); // 设置当前线程为守护线程
try {
Thread.sleep(5000);
System.out.println("异步任务完成");
} catch (InterruptedException e) {
e.printStackTrace();
}
});
2.2 使用线程池(ExecutorService)
使用线程池可以让你更细致地控制线程的生命周期。线程池中的线程不受主线程的控制,它们会根据任务的完成情况独立退出。我们通过 ExecutorService 来执行异步任务,这样异步任务将在独立线程池中执行,并且主线程退出时不会影响异步任务的执行。主线程会直接退出,并且不会调用 join() 等方法阻塞,确保异步任务继续运行。主线程的退出不影响异步线程的生命周期,因为它们是在不同的线程池中执行的。当不再需要执行异步任务时,可以调用 shutdownNow() 来停止线程池中的所有线程。
import java.util.concurrent.*;
public class AsyncTestWithExecutor {
private static ExecutorService executorService = Executors.newSingleThreadExecutor(); // 使用线程池
public static void main(String[] args) throws InterruptedException {
CompletableFuture<Void> usbDetectionFuture = CompletableFuture.runAsync(() -> startUsbListening("someConfig"), executorService)
.thenRun(() -> System.out.println("USB 监听任务完成"));
System.out.println("主线程退出,但异步任务仍在后台运行...");
Thread.sleep(5000); // 模拟主线程退出
executorService.shutdownNow(); // 停止线程池
}
public static void startUsbListening(String configValue) {
System.out.println("初始化 startUsbListening");
while (true) {
System.out.println("获取设备列表中...");
try {
Thread.sleep(2000); // 每 2 秒检测一次设备变化
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
}
2.3 显式阻塞主线程
除了使用守护线程和线程池之外,另一种简单的方法是显式地阻塞主线程,直到异步任务执行完成。通过 join()
等方法,可以确保主线程等待所有异步任务完成后再退出。在这种情况下,主线程会阻塞在 join() 方法上,直到异步任务执行完成后才会退出。这种方式比较简单,但可能会降低程序的并发性,因此在需要高效并发的场景下不建议使用。
CompletableFuture<Void> usbDetectionFuture = CompletableFuture.runAsync(() -> {
try {
Thread.sleep(5000);
System.out.println("异步任务完成");
} catch (InterruptedException e) {
e.printStackTrace();
}
});
usbDetectionFuture.join(); // 阻塞主线程,等待异步任务完成
教训与反思
这次事故后,我花了大量时间反思。首先,我意识到对于异步编程,尤其是 CompletableFuture
等并发工具的使用,我们必须理解其背后的线程管理机制,特别是在主线程退出时,异步线程的生命周期是如何受到影响的。其次,不管是使用守护线程、线程池,还是显式阻塞主线程,只有确保异步任务能够在主线程退出后继续执行,才是保证系统稳定运行的关键。
从此以后,我在使用 CompletableFuture
或其他异步工具时,学会了更加谨慎地管理线程的生命周期,避免直接依赖于主线程的退出。在线上环境中,任何看似简单的并发操作,都需要通过线程池进行细粒度的控制,确保任务的正确执行。
在 Java 中,当主线程退出时,若没有其他活跃的用户线程,JVM 会终止所有非守护线程(包括异步线程)。为了解决这一问题,可以通过以下方法确保异步线程在主线程退出后继续执行:
- 将异步线程设置为守护线程;
- 使用线程池(
ExecutorService
)管理异步任务; - 显式阻塞主线程,等待所有异步任务完成。
这些方法各有优缺点,开发者可以根据具体的应用场景选择合适的方案。在现代的高并发系统中,使用线程池通常是最推荐的做法,它能帮助开发者更好地控制线程的生命周期,避免线程资源浪费并提高程序的并发能力。
标签:异步,主线,品怨种,任务,线程,写线,退出,CompletableFuture From: https://www.cnblogs.com/bu-huo/p/18597044