线上故障排查到深夜打开日志滚动条看到一个被吞得干干净净的异常现场——catch块里连一行打印都没有。这种瞬间的无力感相信每个Java程序员都经历过。团队Java项目里业务逻辑往往写得花团锦簇而异常处理却经常沦为代码里的边角料。大家默认“try一下就不崩了”可真正的灾难恰恰藏在这些被忽略的细节里。要说的头号问题是空catch块。一个方法内部捕获了所有异常却什么都不做看起来程序“稳定”了实际上状态可能已经错乱。比如库存扣减失败后异常被吞掉代码继续向下执行订单生成成功但库存没有扣减。等到对账时才发现差值谁来背锅一个空catch块不是容错而是给未来埋下的定时炸弹。团队协作中更隐蔽的是那种写了logger.error(e)却把异常吞掉的做法——日志打印了但方法正常返回调用方丝毫不知道自己的请求实际失败了。如果你不能通过返回值或抛出异常告知调用方那么这个日志就只是一条自我安慰的墓志铭。与之相对的另一种极端是既打印日志又抛出异常。logger.error(发生错误, e); throw e;这看似严谨却会在多层调用中产生大量重复日志。排查时盯着一串相同堆栈的日志以为系统崩了三次其实只是同一场事故在每一层都被广播了一遍。日志和抛出只能二选一否则排查者会看到同一个错误被戏精般地演了两遍。正确的姿势是在最上层做日志拦截在业务层只负责抛出携带足够上下文的异常让最外层统一记录一次完整日志。再往下看异常类型的设计也常常被团队忽略。很多人习惯catch (Exception e)一把抓然后根据e.getMessage()里是否包含某个关键词来决定是否重试。这种做法极度脆弱用Exception兜底是把所有敌人当成同一个靶子最后必然误伤友军。比如数据库连接池超时抛出TimeoutException业务校验异常是IllegalArgumentException两者都被捕获后会进入同一套处理逻辑导致一个本该快速失败的重试操作把整个线程池拖垮。团队里应该有明确的异常分类约定可恢复异常、不可恢复异常、业务异常、系统异常各自有独特的类型和响应策略。当异常链断成残片异常链的断裂是另一个容易被忽略的坑。有些人在catch块里直接throw new RuntimeException(出错了)忘了把原始异常作为cause传进去。这样在日志中只能看到“出错了”三个字底层是SQL错误还是网络抖动完全无从知晓。丢失cause的异常就像没有目录的悬案卷宗再多的线索也串不起来。团队项目里一定要使用throw new CustomException(message, e)这种保留cause的写法否则后续排查等于大海捞针。同样在finally块中清理资源时如果发生异常也可能覆盖掉前面抛出的原始异常这时可以用Suppressed异常机制或者干脆把finally里的异常也认真处理掉。资源关闭也是异常处理的重灾区。很多人以为用了try-with-resources就万事大吉却忽略了关闭后可能抛出的异常。比如BufferedWriter.close()在缓冲区刷新失败时抛出的IOException会被隐藏在try块的出口处。资源关闭失败时你连知道的资格都没有——默认情况下它会被压制到几乎不可见。所以如果关闭异常可能影响结果需要显式捕获或通过addSuppressed处理。另一个常见误区是在finally中手动关闭资源时关闭的代码本身抛了异常会直接跳出去导致原始异常丢失。在finally里写return等于亲手销毁事故现场在finally里抛异常等于在事故现场又引爆了一颗炸弹。异常信息里的“大冤种”和“背锅侠”我们经常看到这样的错误日志java.lang.NullPointerException at com.example.OrderService.process(OrderService.java:42)。只有堆栈没有业务上下文。这意味着你需要去翻代码行号再猜当时是哪一笔订单、哪个用户。没有上下文的异常信息只是一张废纸上的乱码。团队里的统一异常包装应当携带至少一个关联ID比如requestId、orderId或userId。这些信息应该在捕获处打日志时附带而不是靠运维去脑补。可以定义一个基类BusinessException里面包含errorCode和context映射让异常自己开口说话。受检异常Checked Exception的使用在团队中两极分化严重。要么整个项目都是throws Exception要么所有异常都被包装成RuntimeException。受检异常一旦过度膨胀就会变成团队协作里的公共厕所——谁都可以扔一个进去谁都不愿意擦。正确的态度是只有调用方能够合理恢复的异常才受检比如文件不存在、网络超时如果调用方根本无从恢复比如参数为空就应该抛IllegalArgumentException这类非受检异常。很多老项目里一个方法声明抛5个受检异常调用方不得不层层try-catch最后catch里写的都是throw new RuntimeException(e)。这本身就是设计失败的信号而不是团队执行力的问题。并发与事务中的暗流多线程环境下的异常处理是更多团队的盲区。ExecutorService.submit()返回的Future里如果任务抛了异常get()时会重新抛出但很多人调用get()时只catch了InterruptedException和ExecutionException却忘了处理CancellationException或者干脆把InterruptedException吞掉了。吞掉中断异常就是掐死了线程的求救信号。线程池里的线程如果因为业务异常而终止线程池会新建线程替代但异常根本不会冒泡到调用方最后表现为某个异步任务“消失”了数据缺了一块排查时天昏地暗。所以异步任务内部必须先捕获异常、记录完整上下文再决定是否向上传递。事务回滚是另一个隐藏雷区。Spring声明式事务默认只对RuntimeException回滚受检异常默认不会触发回滚。很多人写的服务方法抛出自定义受检异常以为数据库会自己回滚结果事务悄悄提交了半截数据。事务回滚不认你的自定义异常除非你告诉它真相——或者干脆用运行时异常。另一个细节是在catch块中处理完异常后没有重新抛出也会让Transactional失效。事务方法的自调用问题更不必说但异常处理上任何被吞掉或未传播的异常都会让事务管理形同虚设。重试机制在分布式系统中很常见但异常处理不当会让重试变成灾难。比如在catch到数据库死锁后立即重试10次没有退避策略在高并发下会加剧锁竞争。无脑重试的异常处理本质上是一场自我攻击的DDoS。正确的模式是区分可重试异常如网络超时、乐观锁冲突和不可重试异常如参数错误并且采用指数退避加上随机抖动。团队里还应该统一重试的出口避免每个成员自行写for循环重试那样一旦策略调整就要改几十处。另一个容易被嘲笑的“勇士行为”是捕获OutOfMemoryError或StackOverflowError。别去接Error这个大神的盘你接不住——它通常意味着JVM已经处于不稳定状态继续执行只会让情况更糟。在OOM后试图清理内存继续跑往往适得其反。正确做法是让JVM崩溃、快速释放资源由上层运维重启而不是在一个半死不活的应用里勉强苟活。别在性能敏感路径上滥用异常这是团队里更少被讨论的细节。Java异常创建时要填充堆栈成本很高高频调用路径上一个普通的if能解决的问题却用throw new IllegalArgumentException加catch来控制流程会让吞吐量肉眼可见地下降。把异常当流程控制相当于用火箭筒打蚊子——效果很震撼但成本也很震撼。代码审查时如果发现try-catch嵌套在循环内部且这个循环可能每秒跑上万次就应当考虑先用条件判断规避。异常处理的优雅并不意味着到处抛异常恰恰相反优雅的异常处理是“大部分时候根本不需要异常”。还有一个现代Java的尴尬Lambda表达式里的异常处理。java.util.function.Function的apply()方法不声明受检异常导致很多人在Lambda体内try-catch后吞掉或转换成UncheckedIOException但团队里很少有人注意到CompletableFuture链上异常会在回调里被静默处理。Lambda中的异常比普通代码更容易“逃跑”得无影无踪。比如exceptionally回调里如果自己抛出新异常会覆盖原异常whenComplete里如果发生异常会直接影响后续的join结果。建议在异步链路里统一使用handle或exceptionally并且始终保留传入的Throwable作为新异常的cause。团队协作的软问题往往比技术缺陷更隐蔽。项目里经常有“谁的代码谁负责”的固执但异常处理策略必须是全队统一的。代码审查时大家热衷于讨论算法复杂度却很少有人问一句这个catch为什么吞了异常这个日志为什么没有订单ID异常处理的代码审查比业务逻辑更值得较真。因为业务逻辑错了最多功能不对异常处理错了整个系统的可维护性、可观测性都跟着垮掉。建议团队建立异常处理检查清单不允许空catch、不允许打印后抛出、不允许丢失cause、不允许在finally里return、所有对外异常必须带上下文、可重试异常必须声明等。把这些条款写进代码规范并在MR评论里指名道姓地指出来。只有当你把异常当回事它才会在失控时对你网开一面。