在学习和使用 Java 的 CompletableFuture 进行异步编程时,很多开发者都可能遇到一个令人困惑的场景:在 main 方法中满怀信心地提交了一个耗时任务,结果程序一闪而过,预期的异步结果却迟迟没有出现。这背后究竟隐藏着什么秘密?

本文将从一个典型的“踩坑”案例出发,层层深入,为您揭示 CompletableFuture 与 JVM 线程模型之间的微妙关系,并最终给出一套健壮的解决方案。

现象:一闪而过的异步任务

让我们从一个常见的场景开始:异步查询用户信息,然后根据结果发送短信。

场景描述:

  1. 异步执行一个模拟的耗时操作(比如查询用户手机号,耗时3秒)。
  2. 操作成功后,获取结果(手机号)。
  3. 使用该结果执行下一步操作(发送短信)。

初始代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;

public class AsyncProblemDemo {

public static void main(String[] args) throws InterruptedException {
System.out.println("主线程:启动应用程序...");
scene_runAsync();
System.out.println("主线程:应用程序即将退出。");
}

private static void scene_runAsync() throws InterruptedException {
CompletableFuture.supplyAsync(() -> {
// 1. 模拟一个耗时3秒的HTTP请求
System.out.printf("线程 [%s] 开始查询用户手机号...%n", Thread.currentThread().getName());
try {
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedException e) {
throw new IllegalStateException(e);
}
System.out.printf("线程 [%s] 成功查询到手机号!%n", Thread.currentThread().getName());
return "138****1234";
})
.thenAccept(phone -> {
// 2. 拿到手机号后,发送短信
System.out.printf("线程 [%s] 正在给用户 [%s] 发送短信...%n", Thread.currentThread().getName(), phone);
});

// 为了观察,让主线程稍微等一下
TimeUnit.SECONDS.sleep(1);
System.out.println("主线程:已提交异步任务,继续执行其他事情...");
}
}

预期输出:
我们期望看到线程开始查询、查询成功、发送短信等一系列日志。

实际输出:

1
2
3
4
5
6
主线程:启动应用程序...
线程 [ForkJoinPool.commonPool-worker-9] 开始查询用户手机号...
主线程:已提交异步任务,继续执行其他事情...
主线程:应用程序即将退出。

Process finished with exit code 0

程序在打印出“开始查询”后,主线程稍作等待就退出了。耗时3秒的任务和后续的发送短信操作,如同石沉大海,杳无音信。

真相:守护线程(Daemon Thread)的“锅”

问题出在 CompletableFuture 默认使用的线程池上。当我们不指定 Executor 时,supplyAsync 和其他类似方法会使用 ForkJoinPool.commonPool() 这个公共线程池。而这个线程池有一个至关重要的特性:它内部的线程都是守护线程(Daemon Thread)

什么是守护线程?

我们可以将 JVM 中的线程分为两类:

  1. 非守护线程 (Non-Daemon Thread):也叫用户线程,比如我们程序的 main 线程。它们是程序的主体,只要有任何一个非守护线程还在运行,JVM 就不会退出。可以把它想象成公司的“正式员工”,只要还有一个员工没下班,公司大楼(JVM)就不能关门。

  2. 守护线程 (Daemon Thread):它是一种在后台提供服务的线程,例如 Java 的垃圾回收(GC)线程。它的使命是为其他非守护线程服务。当所有非守护线程都结束后,JVM 会立即关闭,并粗暴地终止所有仍在运行的守护线程,无论它们的工作是否完成。可以把它想象成公司的“保洁员”,当所有员工都下班后,大楼就会关门,没人会关心保洁工作是否做完。

回到我们的案例,执行流程是这样的:

  1. main 线程(非守护)启动。
  2. supplyAsync 将一个耗时3秒的任务,提交给了 ForkJoinPool 中的一个守护线程
  3. main 线程继续执行,休眠1秒后,打印日志,然后执行完毕。
  4. 此时,JVM 环顾四周,发现唯一的非守护线程 main 已经结束了。
  5. JVM 决定立即关闭,那个正在努力工作的守护线程被无情地中断。

这就是我们任务“神秘失踪”的根本原因。

新的尝试:换用自定义线程池

有些同学可能会想:“既然是默认线程池的问题,那我换一个自定义的行不行?”

说做就做,我们用 Executors.newFixedThreadPool 创建一个自己的线程池。

修改后的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

public class AsyncProblemDemo2 {

// 自定义一个线程池
private static final ExecutorService bizExecutor = Executors.newFixedThreadPool(10, r -> {
Thread thread = new Thread(r);
thread.setName("业务线程-" + thread.getId());
return thread;
});

public static void main(String[] args) throws InterruptedException {
System.out.println("主线程:启动应用程序...");
scene_runAsync_custom_pool();
System.out.println("主线程:应用程序即将退出。");
}

private static void scene_runAsync_custom_pool() throws InterruptedException {
CompletableFuture.supplyAsync(() -> {
System.out.printf("线程 [%s] 开始查询用户手机号...%n", Thread.currentThread().getName());
try {
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedException e) {
throw new IllegalStateException(e);
}
System.out.printf("线程 [%s] 成功查询到手机号!%n", Thread.currentThread().getName());
return "138****1234";
}, bizExecutor) // <-- 注意:传入了自定义线程池
.thenAccept(phone -> {
System.out.printf("线程 [%s] 正在给用户 [%s] 发送短信...%n", Thread.currentThread().getName(), phone);
});

TimeUnit.SECONDS.sleep(1);
System.out.println("主线程:已提交异步任务,继续执行其他事情...");
}
}

这次的输出:

1
2
3
4
5
6
7
8
9
主线程:启动应用程序...
线程 [业务线程-21] 开始查询用户手机号...
主线程:已提交异步任务,继续执行其他事情...
主线程:应用程序即将退出。
(等待2秒后)
线程 [业务线程-21] 成功查询到手机号!
线程 [业务线程-21] 正在给用户 [138****1234] 发送短信...

(程序并未退出,光标一直在闪烁)

这次结果喜忧参半:

  • 喜: 异步任务成功执行完毕了!
  • 忧: 程序为什么执行完后,卡住了不退出?

再探真相:非守护线程的“坚守”

原因与上次恰恰相反。Executors 工具类创建的线程池,默认情况下,其内部的线程都是非守护线程

main 线程结束后,JVM 再次检查,发现 bizExecutor 线程池里还有存活的非守护线程(它们在完成任务后,会继续等待下一个任务,而不是销毁)。根据“只要还有一个正式员工没下班,公司就不能关门”的原则,JVM 必须耐心地等待这些线程结束。

而我们没有告诉线程池何时关闭,所以它们会永远“坚守岗位”,程序自然也就无法退出了。

终极方案:优雅地等待与关闭

现在我们已经掌握了问题的本质,可以给出两套针对不同场景的终极解决方案。

方案一:在主程序中等待任务完成 (join)

当我们没有使用自定义线程池,而是使用默认的ForkJoinPool.commonPool()公共线程池,线程池中的线程都是守护线程

如果你是在一个会自然结束的简单应用(如 main 方法)中测试或运行异步任务,并且希望看到它的结果,最简单直接的方法就是让主线程阻塞等待。

CompletableFuturejoin() 方法就是为此而生。它会阻塞当前线程,直到 CompletableFuture 计算完成。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// ... 省略其他代码 ...
private static void scene_runAsync_with_join() {
CompletableFuture<Void> future = CompletableFuture.supplyAsync(() -> {
System.out.printf("线程 [%s] 开始查询用户手机号...%n", Thread.currentThread().getName());
try {
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedException e) {
throw new IllegalStateException(e);
}
System.out.printf("线程 [%s] 成功查询到手机号!%n", Thread.currentThread().getName());
return "138****1234";
})
.thenAccept(phone -> {
System.out.printf("线程 [%s] 正在给用户 [%s] 发送短信...%n", Thread.currentThread().getName(), phone);
});

System.out.println("主线程:任务已提交,现在开始等待结果...");
// 调用join(),主线程会在此阻塞,直到异步任务链全部完成
future.join();
System.out.println("主线程:检测到异步任务完成,可以安全退出了。");
}

这种方式简单明了,非常适合测试和简单的命令行应用。

方案二:优雅地关闭自定义线程池 (shutdown)

当我们使用自定义的线程池,线程池中的线程都是非守护线程

当你使用自定义线程池时,你就是这个池子的管理者。“谁创建,谁关闭” 是一个必须遵守的黄金法则。这不仅能让程序正常退出,也是为了及时释放资源。

最佳实践是使用 try-finally 结构,确保线程池的 shutdown() 方法总能被执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// ... 省略其他代码 ...
public static void main(String[] args) {
System.out.println("主线程:启动应用程序...");
try {
scene_runAsync_custom_pool_and_shutdown();
} finally {
System.out.println("主线程:准备关闭线程池...");
// shutdown() 会平滑关闭:不再接受新任务,但会等待已提交任务执行完毕
bizExecutor.shutdown();
System.out.println("主线程:线程池已发出关闭指令。");
}
}

private static void scene_runAsync_custom_pool_and_shutdown() {
// 为了确保main方法在关闭线程池前能让任务跑完,我们同样可以使用join
CompletableFuture<Void> future = CompletableFuture.supplyAsync(() -> {
// ... 异步任务逻辑 ...
}, bizExecutor)
.thenAccept(phone -> {
// ... 后续消费逻辑 ...
});

// 阻塞等待,确保异步任务在shutdown之前完成
future.join();
}

这样,程序既能保证异步任务的完整执行,也能在结束后干净利落地退出。

总结

通过这次“踩坑”之旅,我们深入了解了 CompletableFuture 背后的线程机制,并总结出以下关键点:

  1. 默认用守护CompletableFuture 默认使用 ForkJoinPool.commonPool(),它由守护线程构成,不会阻止 JVM 退出。
  2. 自定义是非守护:通过 Executors 创建的线程池,默认由非守护线程构成,会阻止 JVM 退出。
  3. 等待用 join():在需要等待异步结果的场景(如测试、简单应用),使用 future.join() 是最直接的方式。
  4. 关闭用 shutdown():只要是手动创建的 ExecutorService,务必在程序退出前调用 shutdown() 方法进行优雅关闭。

掌握了守护线程与非守护线程的区别,你就能在异步编程的世界里游刃有余,写出更加健壮、可控的代码。