开头
写Java这么多年,try-catch-finally大概是背得最熟的几行代码之一。但真到生产环境里,很多人栽在“finally不等异步”这个细节上。你辛辛苦苦在try里提交了一个异步任务,想在finally里把线程池关掉、把数据库连接释放、把状态位复位,结果日志打出来的顺序完全乱套——异步代码还没跑完,finally已经执行完了,甚至资源都被回收了。这不是个例,而是对Java执行模型理解不够深时最容易踩的坑。
这篇文章我打算把这个点彻底聊透:try-catch-finally到底保证了什么,异步任务在JVM里是怎么调度的,为什么finally不等待异步线程,以及如果你确实需要“等异步执行完再收尾”,有哪些靠谱的解法。顺带会整理几个面试里高频的变体题,比如finally里return、finally里抛异常、异步任务里的异常被谁捕获,这些在“Java面试题”“Java八股文”里经常出现,但很少有人真正讲清楚底层原因。如果你也在用ExecutorService、CompletableFuture或者各种线程池做异步处理,建议认真看完。
1. 从一段踩坑代码说起:finally里写异步到底发生了什么
1.1 现象还原:日志顺序乱套
先看一段非常典型的错误示范。假设我们要往消息队列里发一条异步消息,发送前先记录开始时间,finally里做清理工作。
ExecutorService executor = Executors.newFixedThreadPool(2); try { System.out.println("1. 开始处理业务"); executor.submit(() -> { // 模拟异步任务耗时 Thread.sleep(2000); System.out.println("3. 异步任务执行完成"); }); System.out.println("2. try块结束"); } catch (Exception e) { e.printStackTrace(); } finally { System.out.println("4. finally执行清理"); executor.shutdown(); }运行这段代码,你大概率会看到这样的输出:
1. 开始处理业务 2. try块结束 4. finally执行清理 3. 异步任务执行完成注意,“4. finally执行清理”抢在了“3. 异步任务执行完成”前面。更危险的是,如果finally里调用了executor.shutdown(),那么线程池会被标记为关闭状态,此时异步任务虽然已经提交了,但可能还没开始执行就被拒绝,或者执行到一半被迫中断——这会导致数据丢失、状态不一致,严重的还会出现线上事故。
很多人第一次看到这个结果会很困惑:明明我先把任务提交到线程池了,为什么finally不等等它?这里的关键在于,你提交任务这个动作本身是同步的,但任务的执行是异步的。换句话说,executor.submit()方法只是把任务“丢”给了线程池,然后立刻返回,至于任务什么时候真正跑、跑多久,完全不由当前线程控制。finally块属于当前线程的代码,当然不会去等待另一个线程完成工作。
1.2 为什么finally不等异步?——先理解try-catch-finally的本质
要弄明白这个问题,得先回到JVM执行模型的最底层。try-catch-finally是Java语言层面提供的异常处理结构,它保证的是在当前线程、当前方法调用栈内的控制流顺序。try块里如果抛出了异常,会被对应的catch块捕获,无论是否捕获,finally块都会在当前线程继续执行后续的收尾动作。这一切都发生在同一个线程的栈帧里。
而异步执行,意味着代码运行在另一个线程上。你在try里调用executor.submit(),本质上是把Runnable对象的引用传递给了另一个线程,然后当前线程继续往后走。当前线程的finally块和执行异步任务的线程之间,唯一的联系就是共享的线程池和任务对象,二者在时间线上是并发的。
这里可以打个生活化的比方。你去餐厅点餐,你把菜单给服务员(提交任务),然后服务员把菜单传到后厨(线程池),厨师开始做菜(异步执行)。但你在点完菜之后不会傻站在窗口等着,你会回到座位上,继续喝水、看手机(当前线程继续执行)。如果你在点完菜之后就立刻找服务员要求“把厨房打扫干净”(finally收尾),那菜还没做好呢,也只能等真正出锅了再上。你的座位(当前线程)和厨房(后台线程)是两个独立的空间,你在座位上做的事不会等厨房里的进度。
理解了这一点,你就能看穿很多“Java面试题”里的套路。面试官问你“finally里的代码一定会执行吗”,答案不是简单的“一定”,因为在异步场景下,finally只能保证当前线程的代码块结束时会执行,但绝不保证异步任务的时机。如果我们在try中提交了一个异步任务,然后finally里关闭了线程池,那么这个异步任务可能根本执行不到,或者在执行中被中断。这不是语法上的问题,而是并发模型下的必然结果。
2. 异步执行的“等”与“不等”:线程模型决定一切
2.1 同步调用 vs 异步提交:谁在跑你的代码?
要彻底理解“finally不等异步”,必须区分两个概念:同步调用和异步提交。
同步调用最简单。方法A调用方法B,B在A的线程栈上执行,A必须等B返回后才能继续。这时候你在try里调用一个同步方法,finally只有等它跑完才会执行,控制流是确定的。比如下面这段代码:
try { doSomething(); // 同步执行,跑完才会走finally } finally { cleanup(); }doSomething()里哪怕sleep了10秒,也要等它结束了,finally里的cleanup()才会执行。这是同步模型的确定性。
但异步提交不一样。你用executor.submit()、new Thread().start()、CompletableFuture.runAsync()等方式发起任务时,JVM会创建或调度一个新的线程来执行你的代码逻辑,而当前线程立刻返回,继续往下执行。此时你的程序分成了两条执行流:
- 当前线程:执行try里剩下的代码,然后进入finally,执行清理动作。
- 异步线程:被线程池调度后,在某个时间点开始执行你提交的任务。
这两条执行流互不等待,唯一的同步点是你主动去“等”,比如调用Future.get()、join()、await()等阻塞方法。如果你什么等待动作都不做,那finally想等都等不到,因为它根本不知道异步任务什么时候结束。用一句话概括:同步调用保证的是同一个线程内的顺序,异步提交只是把任务交给另一个线程,当前线程不会自动等待。
2.2 finally的职责边界:它只负责同步代码块的收尾
聊到这里就可以给finally的职责做一个精准的定位了:finally是当前线程进入try块之后、退出这个try-catch-finally结构之前,保证一定会执行的一段清理代码。它管的是“当前线程的资源释放”“当前线程的状态复位”,它管不了别的线程正在做的事情。
最典型的误用就是“在finally里关闭整个线程池”。很多开发者觉得,线程池是资源,资源就该在finally里释放,这个想法本身没错。但问题是,关闭线程池和等待池内任务完成是两回事。executor.shutdown()并不会等待已经提交的任务全部执行完毕,它只是禁止继续提交新任务,已经提交的任务还会尽力执行完;而executor.shutdownNow()则更激进,会尝试中断正在执行的任务。无论哪种,都不是在等异步任务执行完。
更准确的做法是:在finally里调用executor.awaitTermination(timeout, unit),这个方法才会真正阻塞当前线程,直到所有任务完成、或超时、或当前线程被中断。但注意,awaitTermination本身又是在finally里同步等待了,如果异步任务执行时间很长,你的当前线程也会被拖住,这又是一个新的问题——线程阻塞与异步初衷的矛盾。这个矛盾后面我专门展开讲。
先记住一条边界:finally是当前线程的兜底,不是所有任务的兜底。如果你希望异步任务和当前线程的生命周期耦合,就必须显式地建立同步关系,而不是指望JVM在语义上替你等。Java的设计者从一开始就没打算让finally去感知其他线程的状态,这也符合语言最小化原则——如果不显式控制并发,就不应该产生隐式的阻塞行为。
3. 想等异步执行完再走finally?这几招实测有效
3.1 方案一:Future.get() 阻塞等待
如果你用的是ExecutorService.submit(),会返回一个Future对象。在finally之前,可以调用future.get()来阻塞当前线程,直到任务执行完成或抛出异常。这是最直接、最原生的等待方式。
ExecutorService executor = Executors.newFixedThreadPool(2); Future<?> future = null; try { System.out.println("1. 开始处理业务"); future = executor.submit(() -> { try { Thread.sleep(2000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } System.out.println("3. 异步任务执行完成"); }); // 关键:等异步任务执行完 future.get(3, TimeUnit.SECONDS); System.out.println("2. try块等待结束"); } catch (Exception e) { // 注意:ExecutionException、TimeoutException、InterruptedException都在这里 e.printStackTrace(); } finally { System.out.println("4. finally执行清理"); executor.shutdown(); }这个方案背后的原理很简单:future.get()是当前线程主动进入阻塞状态,等异步线程把结果写回Future对象后,当前线程被唤醒。这里有几个容易踩的坑,我一个个说明。
第一,future.get()有两个重载:一个是不带参数,无限期等待;另一个是带超时时间,比如future.get(3, TimeUnit.SECONDS)。建议生产环境永远用带超时的版本,不然异步任务要是因为死循环、网络卡死一直不返回,你的当前线程会无限期阻塞,比异步不等待更可怕。第二,get()抛出的异常类型不一样:任务执行过程中抛出的业务异常会被包装成ExecutionException;超时抛出TimeoutException;当前线程被中断抛出InterruptedException。你得分别处理,至少catch住Exception,并做好超时后的善后逻辑(比如取消任务)。第三,InterruptedException不能吞掉,应该重新设置中断标志,否则线程的状态会出问题。
这个方案适合“一个任务、一个结果”的场景。如果你提交了多个任务,就得用多个Future,或者考虑下面的方案。
3.2 方案二:CompletableFuture 回调编排
CompletableFuture算是现在Java里最灵活的异步工具,它支持回调编排,可以在任务完成时自动触发后续动作,不需要你手动阻塞等待。如果你希望在异步任务跑完之后,再执行finally里的清理工作,可以把finally的清理逻辑放进回调里。
CompletableFuture<Void> future = CompletableFuture.runAsync(() -> { System.out.println("3. 异步任务执行完成"); }); try { System.out.println("1. 开始处理业务"); // 等待异步任务完成,再执行后续 future.thenRun(() -> System.out.println("2. 异步任务后续动作")); // 这里如果想要阻塞,可以future.join() } catch (Exception e) { e.printStackTrace(); } finally { // 注意:如果上面没有join,这里还是会先执行 System.out.println("4. finally执行清理"); }这里有一个特别容易混淆的点:CompletableFuture.runAsync()默认使用ForkJoinPool.commonPool()来执行任务,而commonPool是全局共享的。如果你在finally里关闭了commonPool,整个应用的其他地方都会受影响,所以千万别这么干。正确的做法是,用future.join()或future.get()阻塞等待任务完成,再进入finally;或者压根不用finally,而是在回调链的最后处理清理逻辑。
回调式写法的核心思路是把“下一步”依赖“上一步”的关系,从同步的“等待”变成异步的“通知”。这在响应式编程里叫事件驱动。比如:
CompletableFuture.supplyAsync(() -> { // 异步任务,返回结果 return "result"; }).thenApply(result -> { // 处理结果 return result + " processed"; }).whenComplete((res, ex) -> { // 无论成功失败,都执行清理 System.out.println("清理工作"); });这里whenComplete就是在异步任务链结束时自动触发的回调,完全绕开了当前线程的finally。对于“希望异步完成后再执行清理”的需求,这是更地道的Java8及以后的写法。但也要注意,如果你的主线程需要清理异步线程的资源(比如线程池),还是得在finally里等待任务结束,不能指望回调帮你做线程池的管理。
3.3 方案三:CountDownLatch 手动门闩
CountDownLatch是java.util.concurrent里的同步工具,特别适合“让多个任务完成后,再放行当前线程”的场景。它的原理说穿了很简单:初始化一个计数器,每有一个任务完成就countDown()一次,等到计数器减到0,await()的线程就会被唤醒。
ExecutorService executor = Executors.newFixedThreadPool(3); CountDownLatch latch = new CountDownLatch(3); try { for (int i = 0; i < 3; i++) { final int index = i; executor.submit(() -> { try { Thread.sleep(1000 * (index + 1)); System.out.println("任务" + index + " 完成"); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { // 注意:无论任务成功失败,都要减少计数,否则会死等 latch.countDown(); } }); } // 主线程等待所有任务完成 if (latch.await(5, TimeUnit.SECONDS)) { System.out.println("所有任务执行完成"); } else { System.out.println("等待超时,部分任务未完成"); } } catch (Exception e) { e.printStackTrace(); } finally { executor.shutdownNow(); System.out.println("finally执行清理"); }这个方案有几个关键点。
第一,latch.countDown()必须在异步任务里放到finally中,不管是正常完成还是异常退出,都要把计数减掉,否则主线程的latch.await()一直等不到0,就直接死锁了。很多初学者只想到countDown放末尾,但任务抛异常就会跳过countDown,造成latch永远不为0。第二,await()同样建议带超时,而且返回值为boolean,你可以根据返回值判断是否有任务没完成,再做对应的补偿处理。第三,CountDownLatch是一次性的,用完不能重置;如果你需要重复等待多个批次任务,可以考虑CyclicBarrier,不过那个语义不一样,适合各线程互相等待的场景。
3.4 方案四:Thread.join() 只适用于你自己new的线程
如果你的异步代码就是用new Thread().start()启动的,那么最简单直接的方式是调用thread.join(),让当前线程等待指定线程死亡。
Thread worker = new Thread(() -> { System.out.println("异步任务开始"); try { Thread.sleep(2000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } System.out.println("异步任务结束"); }); try { worker.start(); worker.join(3000); // 等待worker线程结束,最多等3秒 System.out.println("主线程继续"); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { System.out.println("finally清理"); }join()的实现原理是:当前线程进入等待状态,直到目标线程的run()方法执行完并退出后,JVM会调用notifyAll()唤醒正在join等待的线程。这种方案只适用于你能拿到Thread对象引用的情况。如果你用的是线程池,任务被提交后你无法获得具体线程对象的引用,所以没法join。
这里还要强调一个容易踩的坑:join()会让当前线程阻塞,如果worker线程里又去join另一个线程,就可能形成死锁。另外,join()在等待期间无法被中断响应吗?其实可以的,如果当前线程在join过程中被其他线程interrupt(),会抛出InterruptedException,但目标线程并不会因此停止运行。所以join的本质是“当前线程陪着等”,不是“控制目标线程”。
为了直观对比,我整理一个表格,把常见的方案列出来,方便你针对不同场景做选型:
| 方案 | 核心API | 适用场景 | 缺点 / 注意事项 |
|---|---|---|---|
| Future.get() | ExecutorService.submit()返回的Future | 单个异步任务,需要结果或等待完成 | 必须处理TimeoutException,防止无限阻塞 |
| CompletableFuture回调 | thenApply / whenComplete / join | 异步编排、链式处理 | commonPool是共享的,不能随意关闭 |
| CountDownLatch | await / countDown | 多个异步任务,全部完成后放行 | 计数器归零后不可复用,countDown必须在finally |
| Thread.join() | thread.join(timeout) | 只用new Thread起的简单线程 | 拿不到线程引用时失效,等待太久会阻塞业务 |
4. 生产环境里的真实坑与排查技巧
4.1 资源提前释放引发的故障
我见过一个印象特别深的线上事故。某个服务在处理订单支付回调时,代码里用线程池异步发了几个通知任务,然后在finally里关闭了数据库连接池。结果通知任务需要在执行过程中查数据库,连接池被关了之后,任务里一拿连接就抛异常,导致通知发不出去,用户没收到支付成功提醒。排查到最后,发现根本不是业务逻辑的问题,而是finally把数据库连接池提前关掉了,异步线程想用但用不了。
这种问题的本质,还是“资源生命周期”和“任务生命周期”没有对齐。一个资源到底该在什么时候释放,必须由所有使用它的执行流共同决定。你可以在finally里关闭线程池,但线程池中的任务可能还在跑;你可以在finally里关闭数据库连接池,但异步任务可能还需要连接。如果异步任务依赖的资源被提前释放,轻则异常,重则数据不一致。比如支付通知失败需要重试,但任务已经被中断了,重试机制也补不回来。
所以我在设计异步任务时,会遵循一个原则:资源的释放责任,一定要和最后使用这个资源的执行流绑定。如果任务在异步线程里跑,资源就应该由异步任务的finally来释放,而不是由提交任务的主线程finally来释放。主线程的finally只负责清理主线程自己占用的资源,比如HttpClient连接、本地临时文件等。这一点,你在写代码之前就要想清楚。
4.2 排查思路:从日志时间戳反推执行序
如果你已经遇到了“finally先执行了,异步任务后执行完”的情况,怎么快速定位和确认?最直接的办法是看日志时间戳。分布式链路追踪也好,普通logback日志也好,只要开了毫秒级时间戳,就能看到当前线程和异步线程的先后顺序。
我常用的排查套路是这样的:第一步,在try块开始、异步任务提交前、finally块、异步任务内部,各打一条带线程名的日志。比如这样:
System.out.println("[" + Thread.currentThread().getName() + "] try开始"); executor.submit(() -> { System.out.println("[" + Thread.currentThread().getName() + "] 异步任务开始"); // 业务逻辑 System.out.println("[" + Thread.currentThread().getName() + "] 异步任务结束"); }); System.out.println("[" + Thread.currentThread().getName() + "] finally执行");日志里会看到主线程的名字比如“main”,异步线程的名字比如“pool-1-thread-1”。如果时间线是:main的finally时间戳早于pool-1-thread-1的任务结束时间戳,那基本就能确定是“finally没有等待异步任务”的问题。
第二步,检查有没有隐式的等待动作。比如你在try里调用了future.get(),然后在finally里又做清理,这时候日志顺序应该是正常的。如果顺序还是乱的,那就要看是不是你自己写的服务里还有其他线程在竞争,或者用了async注解但实际执行器配错了。
还有一种隐蔽的情况:异步线程执行得特别快,快到你根本没注意到它比finally先跑完。这时候日志顺序看起来是正常的,但代码结构上依然存在竞态条件,只是碰巧没触发。这种隐患比直接乱序更可怕,因为它在低负载时测不出来,高负载并发一上来就出问题。所以排查的时候,不要只看一次日志顺序,要多压测几轮,尤其是在线程池核心线程数不够、任务排队的情况下,更容易暴露出顺序问题。
4.3 面试官最爱问的变体:finally里return和异步
作为Java面试的常客,try-catch-finally的变体题我已经被问过好几轮了。把这些题目整理出来,基本能覆盖面试官的各种套路。
第一道题:try块里有return语句,finally块会执行吗?答案是会。Java在字节码层面保证了finally里的代码一定会在return之前执行,如果finally里有return,那么finally的return会覆盖try里的return。比如:
public static int test() { try { return 1; } finally { return 2; } } // 返回2这里有个经典的坑:如果finally里没有return,但修改了返回变量,返回值不会受影响,因为return的值在进入finally之前就已经确定了。只有finally里return才能改变结果。
第二道题:异步任务里的异常,finally能捕获吗?答案是不能。比如你在try里提交了一个异步任务,任务内部抛出了异常,这个异常会被Future封装,由Future.get()在调用时抛出ExecutionException。如果用的是execute()而不是submit(),异常会被线程池的UncaughtExceptionHandler处理,不会被当前线程的try-catch捕获。所以你在主线程try外面写的catch(Exception e),对异步任务里的异常完全无效。
第三道题:try里有一个异步任务,finally里调用shutdownNow(),异步任务会被中断吗?答案是会尝试中断,但不保证成功。shutdownNow()会对线程池里正在执行的任务调用Thread.interrupt(),如果任务里没有响应中断(比如没有检查InterruptedException),或者正在执行阻塞I/O且不可中断,任务还是会继续跑。而且如果任务在执行中断前已经修改了共享数据,接着finally里又做了其他清理,就可能造成数据不一致。这也是“Java怎么保证数据一致性”这个热词背后常被问到的并发问题。
第四道题也是我最喜欢问别人的:如何在finally里安全地等待异步任务执行完而不阻塞太久?标准答案就是用前面提到的Future.get(timeout)、CountDownLatch.await(timeout)之类的超时等待。但关键是你要理解,这些等待手段本身是同步阻塞的,一旦用了它们,你的“异步”实际上就变成了“同步等待”。这并不丢人,很多时候业务就需要这种同步保证,比如你在一个请求处理链路中需要异步任务的最终结果才能返回响应。真正需要避免的是那些“既不要结果,又不希望主线程等待,还要在finally里关闭线程池”的矛盾需求,这种需求必须靠调用方重新设计。
5. 把“finally不等异步”背后的并发模型再往深挖一层
5.1 线程、栈帧与执行流的独立性
Java程序运行本质上是多线程并发执行。每个线程有自己独立的程序计数器、线程栈和局部变量,共享的只有堆内存和静态字段。try-catch-finally是包裹在一个方法调用栈上的同步控制结构,它的执行流完全属于当前线程。异步任务被提交后,它的执行流属于线程池里的某个工作线程,这个线程有自己的栈,有自己的异常处理链。
所以,当你在try块中创建了一个Runnable并提交给线程池,JVM实际上是创建了一个新的Task对象,然后把它放入线程池的任务队列。工作线程从队列中取出这个Task,在自己的栈帧上执行run()方法。你在当前线程写下的finally块,和那个工作线程之间没有任何栈层面的关系。如果强行要让finally等到异步任务完成,就需要在工作线程的执行结果和当前线程之间建立一条“通信链路”——这恰好就是Future、CompletableFuture、CountDownLatch这些工具做的事情。
理解了这个底层模型,你就明白为什么“finally不等待异步”不是一个JDK的缺陷,也不是什么应该被修复的行为,而是线程模型本身决定的。Java语言规范从来没有说过finally要等待其他线程。相反,如果你希望当前线程执行到finally时,某些异步任务已经完成,你必须显式地等待,这是并发编程的基本修养。
5.2 阻塞与异步的权衡:等还是不等,这是个设计问题
实际项目中,我经常看到一些代码在try里用线程池做异步,然后又在finally里调用future.get()来等结果。这种写法虽然能保证finally在任务结束后执行,但本质上已经失去了异步的收益——主线程还是被阻塞了。那为什么还要用线程池?可能因为代码是从同步版本改过来的,改了一半;或者为了复用线程池的线程管理能力。
这里想给你一个设计建议:先把目标和手段分开。如果你的目标是“主线程等异步任务完成后再走finally”,那本质上就是同步等待,不需要用Future.get()那么迂回,直接用同步调用不就行了?如果你的目标是“不让主线程等待,异步任务自己去跑”,那就不应该期待finally去等它,资源的清理应该放在异步任务自己的回调里。
不要两头摇摆。你如果既想让主线程立即返回,又想让所有异步任务在finally之前完成,这在单线程的finally语义下是做不到的,除非把所有异步任务在提交时就被同步执行掉(比如用CallerRunsPolicy拒绝策略),但这不是标准解法,只是把异步变成同步的另一种形式。
聊到这里,你可以发现“finally不等异步”不仅仅是一个语法细节,而是对“同步控制流”和“异步执行流”两种并发模型理解的试金石。我在实际面试中问过很多人,十有七八都卡在回答“finally一定会执行”上,但说不出“finally只保证当前线程控制流”这个层次。如果能把这一层讲清楚,面试官对你的好感会明显不一样。
5.3 数据一致性视角下的finally与异步
很多人问Java怎么保证数据一致性,其实在try-catch-finally和异步任务混用的场景里,最容易出数据一致性问题的,就是“状态提交”和“状态校验”之间的时序错乱。举个例子,你有一个订单状态字段,初始是“待支付”,支付成功后要改成“已支付”。如果你在一个异步任务里更新状态,而主线程在finally里又做了另一个状态的修改,两个线程没有同步关系,就可能出现一边改成“已支付”,另一边又把状态覆盖成“已取消”,最终落库的数据不符合预期。
想要保证一致性,常见的手段是加锁、使用具备原子性的数据结构,或者用事务。但事务有一个前提:数据库事务的边界必须包含你所有的写操作。如果部分写在主线程、部分写在异步线程,你需要把整个异步任务的执行纳入到同一个事务里,通常做法是把异步任务改成同步执行,或者使用支持跨线程事务传播的框架。这里不展开,但你要记住,finally不等异步导致的最直接后果就是写操作的乱序,这比日志乱序严重得多。
我在实操中总结了一条经验:凡是涉及数据一致性要求的异步任务,一定不要用“提交后不管”的写法,至少要确保任务执行完成的信号能被主线程感知到,比如用Future.get()或CountDownLatch阻塞确认后再离开try块。虽然这样损失了一些性能,但一致性优先,性能可以在没有并发风险后重新优化。
最后再分享一个小技巧
如果你现在正在写一个需要用线程池处理异步任务的接口,我建议你把线程池的生命周期和Spring容器或应用生命周期绑定,不要在每次请求的try-finally里关闭线程池。单独的临时线程池在Web应用里非常容易造成资源碎片化,反复关闭、创建线程池的成本很高。更好的做法是在启动时创建一个全局线程池,在应用关闭时统一shutdown,异步任务只负责提交,不负责生命周期。
我在实际项目里踩过不少次“finally里shutdown线程池”的坑,后来定了两条规矩:第一,全局线程池绝不放在业务代码的finally里关闭;第二,每个异步任务内部自己负责清理它创建的资源,主线程的finally只清理主线程的资源。这样之后,类似“finally先执行导致异步任务失败”的问题,基本就从根上消失了。
如果你想把这篇文章里写的“等待异步任务完成再执行finally”的几种方案应用到自己的代码里,记住三个数字:Future.get()用超时时间,CountDownLatch.await()用超时时间,线程池关闭后一定配合awaitTermination()。这三个超时参数,就是你从“踩坑”到“排雷”的分水岭。