completionexception 是 completablefuture 对底层异常的包装容器,需调用 getcause() 获取原始异常;直接 catch exception 会丢失业务异常信息,日志中仅显示 completionexception 而非真实原因。

CompletionException 是什么,为什么不能直接 catch Exception
它不是你代码里主动 throw 的异常,而是 CompletableFuture 把底层真实异常“包了一层”后抛出来的容器。你写 supplyAsync(() -> { throw new RuntimeException("boom"); }),最终拿到的几乎总是 CompletionException,而它的 getCause() 才是那个 "boom"。
常见错误现象:用 catch (Exception e) 捕获了,但没调用 e.getCause(),结果日志里只看到 java.util.concurrent.CompletionException,根本看不出业务逻辑哪崩了。
- 所有由
CompletableFuture自动包装的异常(包括RuntimeException、IOException等)都会变成CompletionException或其子类ExecutionException(仅在get()阻塞调用时出现) -
handle()和whenComplete()回调里收到的Throwable参数,也可能是CompletionException,别急着 log,先if (t instanceof CompletionException) t = t.getCause(); - 不要在
exceptionally()里再 throw 新异常——它只接受Function<throwable t></throwable>,返回值才是 fallback 结果;throw 会导致链式中断
exceptionally() vs handle():该用哪个处理异常
exceptionally() 只在上游发生异常时触发,且必须返回和原始 CompletableFuture 相同类型的值;handle() 则无论成功失败都执行,参数是 (result, throwable),更灵活但也更容易写错分支逻辑。
使用场景:想兜底一个默认值(比如查缓存失败就查 DB),用 exceptionally() 更干净;想统一记录耗时+异常+结果,或者需要根据异常类型做不同 fallback,选 handle()。
立即学习“Java免费学习笔记(深入)”;
-
exceptionally()接收单个Throwable参数,返回值类型必须匹配原始 future 的泛型,例如CompletableFuture<string></string>的exceptionally()必须返回String -
handle()的 lambda 两个参数:成功时result非 null、throwable为 null;失败时result为 null、throwable非 null——注意判空,别直接 toString() - 如果在
handle()里对throwable做了处理但没 re-throw,future 就算“恢复成功”,下游thenApply()会照常执行;这点和exceptionally()行为不同
链式调用中异常传播的隐式规则
异常不会自动“穿透”整个链——它只停留在第一个没被处理的 stage。比如 a.thenApply(...).thenApply(...).exceptionally(...),只有最末尾的 exceptionally() 能捕获前面任意 stage 抛出的异常;中间任何一个 thenApply() 出错,后续 thenApply() 就不会执行,但也不会报错,future 状态直接变成 completed exceptionally。
性能影响:频繁用 get() 强制阻塞获取结果,会把异步异常转成 ExecutionException,丢失原始栈帧信息,且阻塞线程,不推荐。
- 每个
thenXXX()stage 都是独立的异常边界;前一个 stage 的异常不会自动传给下一个thenApply(),除非你显式用handle()或exceptionally()处理 -
thenCompose()如果返回的 future 本身异常,这个异常会“扁平化”进当前链,而不是包成两层CompletionException - 用
completeExceptionally(new RuntimeException())手动完成 future 时,传入的异常会被原样保留,不会被二次包装——这点和异步执行不同
日志和监控时容易漏掉的关键点
很多团队只记录 CompletionException.toString(),结果告警里全是 “CompletionException: null”,因为没打 getCause()。更隐蔽的是,有些异常在 forkJoinPool 中被吞掉栈帧,或者被 CompletableFuture.delayedExecutor() 包装后难以定位源头。
- 所有日志输出
Throwable前,加一行if (t instanceof CompletionException) t = t.getCause();,再 log - 监控指标别只看
isDone()和isCompletedExceptionally(),要结合getNow(null)+completeExceptionally()的调用点做埋点 - 单元测试里模拟异常,别只用
when(mock).thenReturn(CompletableFuture.failedFuture(new RuntimeException())),要测真实异步路径:用supplyAsync(() -> { throw ... })触发包装逻辑
异常处理真正难的不是语法,是得时刻记住:CompletableFuture 不是“把代码变快”,而是把异常流转变成了显式的数据流——漏掉一次 getCause(),就可能让线上问题多排查两小时。









