ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

finally为什么不等待异步任务?Java并发执行模型深度解析

finally为什么不等待异步任务?Java并发执行模型深度解析 开头写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调用方法BB在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的清理逻辑放进回调里。CompletableFutureVoid 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()一次等到计数器减到0await()的线程就会被唤醒。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是共享的不能随意关闭CountDownLatchawait / countDown多个异步任务全部完成后放行计数器归零后不可复用countDown必须在finallyThread.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()。这三个超时参数就是你从“踩坑”到“排雷”的分水岭。
返回列表