在学习和使用 Java 的 CompletableFuture 进行异步编程时,很多开发者都可能遇到一个令人困惑的场景:在 main 方法中满怀信心地提交了一个耗时任务,结果程序一闪而过,预期的异步结果却迟迟没有出现。这背后究竟隐藏着什么秘密?
本文将从一个典型的“踩坑”案例出发,层层深入,为您揭示 CompletableFuture 与 JVM 线程模型之间的微妙关系,并最终给出一套健壮的解决方案。
现象:一闪而过的异步任务
让我们从一个常见的场景开始:异步查询用户信息,然后根据结果发送短信。
场景描述:
- 异步执行一个模拟的耗时操作(比如查询用户手机号,耗时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(() -> { 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); });
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 中的线程分为两类:
非守护线程 (Non-Daemon Thread):也叫用户线程,比如我们程序的 main 线程。它们是程序的主体,只要有任何一个非守护线程还在运行,JVM 就不会退出。可以把它想象成公司的“正式员工”,只要还有一个员工没下班,公司大楼(JVM)就不能关门。
守护线程 (Daemon Thread):它是一种在后台提供服务的线程,例如 Java 的垃圾回收(GC)线程。它的使命是为其他非守护线程服务。当所有非守护线程都结束后,JVM 会立即关闭,并粗暴地终止所有仍在运行的守护线程,无论它们的工作是否完成。可以把它想象成公司的“保洁员”,当所有员工都下班后,大楼就会关门,没人会关心保洁工作是否做完。
回到我们的案例,执行流程是这样的:
main 线程(非守护)启动。
supplyAsync 将一个耗时3秒的任务,提交给了 ForkJoinPool 中的一个守护线程。
main 线程继续执行,休眠1秒后,打印日志,然后执行完毕。
- 此时,JVM 环顾四周,发现唯一的非守护线程
main 已经结束了。
- 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 方法)中测试或运行异步任务,并且希望看到它的结果,最简单直接的方法就是让主线程阻塞等待。
CompletableFuture 的 join() 方法就是为此而生。它会阻塞当前线程,直到 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("主线程:任务已提交,现在开始等待结果..."); 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("主线程:准备关闭线程池..."); bizExecutor.shutdown(); System.out.println("主线程:线程池已发出关闭指令。"); } }
private static void scene_runAsync_custom_pool_and_shutdown() { CompletableFuture<Void> future = CompletableFuture.supplyAsync(() -> { }, bizExecutor) .thenAccept(phone -> { });
future.join(); }
|
这样,程序既能保证异步任务的完整执行,也能在结束后干净利落地退出。
总结
通过这次“踩坑”之旅,我们深入了解了 CompletableFuture 背后的线程机制,并总结出以下关键点:
- 默认用守护:
CompletableFuture 默认使用 ForkJoinPool.commonPool(),它由守护线程构成,不会阻止 JVM 退出。
- 自定义是非守护:通过
Executors 创建的线程池,默认由非守护线程构成,会阻止 JVM 退出。
- 等待用
join():在需要等待异步结果的场景(如测试、简单应用),使用 future.join() 是最直接的方式。
- 关闭用
shutdown():只要是手动创建的 ExecutorService,务必在程序退出前调用 shutdown() 方法进行优雅关闭。
掌握了守护线程与非守护线程的区别,你就能在异步编程的世界里游刃有余,写出更加健壮、可控的代码。