资讯动态

Java异常处理最佳实践:从异常体系到线上排查的完整指南

发布时间:2026/10/21 9:02:08 来源:尧图企业网站定制
写 Java 的人几乎每天都在跟异常处理打交道。不过说实话大多数项目里异常处理这块的代码质量往往是整个工程里最容易被忽视的部分。我做过不少代码评审发现一个规律业务逻辑写得再花哨只要异常处理随意系统上线之后就是个定时炸弹。“Java 中的异常处理”听起来像个基础话题实际上牵扯到异常体系设计、运行时机制、工程规范、线上排查四个层面每一层都有太多值得掰开揉碎讲的东西。这篇文章就把我这些年踩过的坑、总结出来的经验从头到尾理一遍。适合刚学 Java 的初学者同样适合写了几年代码但总觉得异常处理差点意思的开发同学。1. 先搞清楚 Java 的异常体系再谈处理1.1 Throwable 下面的两大分支Error 和 ExceptionJava 把所有可抛出的对象统一放在Throwable这个父类下面向下分成两条主干Error和Exception。这个区分不是随便画的它代表了两类性质完全不同的问题。Error表示 JVM 层面的严重故障比如OutOfMemoryError、StackOverflowError。这类错误发生的时候程序其实已经处于很不健康的状态了内存耗尽、调用栈爆炸你再怎么 catch 也救不回来。很多新手一上来就写catch (Exception e)甚至有人图省事写catch (Throwable t)把Error也揽进来。我见过一个极端例子某服务内存已经持续告警代码里却把OutOfMemoryError捕获之后继续往下执行业务逻辑结果整个节点彻底卡死连重启脚本都执行不了。正确思路是Error交给 JVM 和运维层面去处理程序代码里只处理Exception这一支。Exception又分两类编译期强制检查的受检异常checked exception以及继承自RuntimeException的非受检异常unchecked exception。受检异常是 Java 跟其他语言很不一样的设计编译器会强制要求你在方法签名上声明throws或者在方法体里 try-catch否则根本编译不过。这个设计本意是好的调用方不用翻文档也能知道某个操作可能会失败比如读取文件、建立网络连接这类操作天然带有失败概率编译器逼着你提前考虑失败分支。但硬币的另一面是如果团队协作里没人想吃这套“强制约束”受检异常就会变成灾难。最常见的两种变形一是方法签名一路throws Exception从底层甩到上层异常信息层层丢失二是 catch 住之后打个日志就吞掉相当于告诉调用方“没事一切都好”实际上下游早就挂了。这两种写法我都见过太多后面会专门讲怎么避免。1.2 受检异常与非受检异常的选择标准关于什么时候用受检异常、什么时候用非受检异常我的经验可以浓缩成一句话调用方有责任去处理的、并且有办法恢复的异常用受检异常程序 bug 或者根本无法恢复的异常用非受检异常。举个例子读取配置文件、调用外部接口这类操作失败之后调用方至少可以做重试、可以走降级策略这时候用受检异常比较合理。反过来方法入参为null导致空指针、数组越界、非法参数这些基本上就是代码写错了应该让异常尽快抛出来让开发者在开发阶段和测试阶段就发现问题而不是层层兜住、到最后线上才爆炸。还有一个现实中很常见的场景很多人写着写着发现受检异常到处都在throws接口签名变得又长又丑干脆直接把Exception包了一层转化成RuntimeException。这个思路本身没问题很多主流框架也是这么干的但关键在于转化的时候必须保留原始异常链千万别new RuntimeException()之后把原来的 cause 弄丢了。关于异常链后面会有专门的小节来展开。2. 语法机制里那些容易踩的细节2.1 try-catch-finally 的执行顺序与返回值陷阱异常处理的基本语法都很熟了但执行顺序里有个非常经典的坑finally块一定会执行不管 try 里有没有异常、有没有return。这句话背起来简单落到代码里却经常出问题。先看一个最简单的例子。下面的方法执行完之后控制台上一定会先打印finally然后才返回try。return语句先被触达但它要等待finally块执行完才真正返回public static String test() { try { return try; } finally { System.out.println(finally); } }真正危险的是finally块里也写了return。一旦finally里有return它会直接覆盖 try 里的返回值public static String test2() { try { return try; } finally { return finally; // 这里直接把 try 里的返回值覆盖掉了 } }上面这个方法最终返回的是finally不是try。更可怕的是如果 try 里抛出了异常但finally里有个return那么这个异常会被直接吞掉外部完全感知不到。我在代码评审里只要看到finally块里出现return基本都会要求改掉。另外catch块的顺序也需要注意。Java 在匹配异常类型时是按 catch 块的书写顺序从上到下匹配的所以子类异常必须写在父类异常前面否则编译器直接报错。比如catch (IOException e)必须写在catch (Exception e)之前。实际写代码时尽量不要用过于宽泛的catch (Exception e)兜底这会把所有类型的异常混在一起定位问题时要猜半天。try { // 业务代码 } catch (FileNotFoundException e) { // 专门处理文件不存在的情况 } catch (IOException e) { // 处理其他 IO 问题 }2.2 try-with-resources 背后的关闭逻辑Java 7 引入的 try-with-resources 是我最推荐使用的资源管理方式。它的写法和普通 try 块不同括号里声明的资源必须是AutoCloseable接口的实现类。JVM 会自动帮你在代码执行完后调用close()try (FileInputStream in new FileInputStream(a.txt)) { // 读取文件 }很多开发者只是把它当成“少写几行 finally 的语法糖”但实际上它比手动写 finally 可靠得多。原因在于 try-with-resources 底层对异常的处理做了一个非常精细的设计如果 try 块内部抛出了异常 A而在自动调用close()时又抛出了异常 B那么 B 不会覆盖 A而是被附加到 A 的 suppressed 列表里A 仍然作为主异常抛给调用方。你可以通过Throwable.getSuppressed()拿到被压制的异常。而如果用传统写法FileInputStream in null; try { in new FileInputStream(a.txt); // 读取文件 } finally { if (in ! null) { in.close(); } }这里有一个隐蔽的问题如果 try 块里抛了异常 Afinally里close()又抛了异常 B那么 B 会直接把 A 覆盖掉。等你去线上排查时看到的是一堆莫名其妙的关闭失败异常真正的业务异常反而看不到。这个坑我在旧项目里踩过不止一次后来团队直接把资源管理规范定为所有实现AutoCloseable的资源一律使用 try-with-resources。2.3 多异常捕获与异常链的保留Java 7 还支持了 multi-catch可以在一个 catch 块里捕获多种互不相关的异常类型try { // 业务代码 } catch (IOException | SQLException e) { logger.error(数据读写失败, e); }需要注意multi-catch 里的异常类型不能有继承关系比如catch (FileNotFoundException | IOException e)就编译不过因为FileNotFoundException是IOException的子类写在一起没有意义Java 编译器会直接拒绝。异常链的保留是另一个容易出问题的地方。很多人写代码时习惯手动构造新异常但丢掉了原异常try { // 业务代码 } catch (IOException e) { // 错误示范原始异常信息完全丢失 throw new BizException(文件读取失败); }正确做法是把原始异常作为 cause 传进去try { // 业务代码 } catch (IOException e) { logger.error(文件读取失败, e); throw new BizException(文件读取失败, e); }这样新异常对象内部会持有原始异常的引用。线上排查时你只要顺着异常堆栈往下找Caused by就能一路追到真正的根因。如果每个人都把 cause 丟掉排查链路就断了。3. 异常处理的设计原则与最佳实践3.1 别吞异常也别重复打日志我在代码评审里见到最多的一个问题就是吞异常。典型形态是这样的try { // 业务代码 } catch (Exception e) { // 什么也不做 }这种代码的危害不用多说系统出错后一点痕迹都没有线上排查全是盲人摸象。稍微好一点的写法是打印日志但日志也不是随便打的有几个细节值得注意。第一日志里要带上下文信息。只打一个异常对象是可读的但如果你在循环里处理一批订单最好把当前处理的订单号、业务类型也打进去catch (Exception e) { logger.error(处理订单失败, orderId{}, orderId, e); }这样线上搜日志的时候能直接按照订单号把那次失败的完整上下文捞出来。以前见过一个老系统日志里只有处理失败四个字连一个异常堆栈都没有出事之后运维要从几万条日志里猜是哪一笔业务那个酸爽只有经历过的人懂。第二不要在每一层都打印日志。一个请求从 Controller 到 Service 再到 DAO每一层都 try-catch 再打印一遍日志最终结果是同一个异常在日志文件里堆了三五条一模一样的记录产生大量噪音还占据了磁盘空间。我的习惯是异常只在边界处打印一次中间层要么原样抛出要么包装成合适的业务异常往上抛由最外层统一记录。3.2 自定义异常从业务场景出发自定义异常是现代业务系统里绕不开的话题。直接往外抛 JDK 自带的异常类型往往表达力不足。比如用户下单时订单不存在抛一个NullPointerException就很莫名其妙接收方完全不知道系统到底哪里出了问题。自定义异常的设计有几个原则。命名上直接用“领域 异常类型”的格式比如OrderNotExistException、PaymentTimeoutException看名字就知道是哪个环节、什么问题。继承关系上如果你的系统里对异常有统一分类可以做一个基础的业务异常类其他业务异常都继承它public class OrderNotExistException extends RuntimeException { private final String orderId; public OrderNotExistException(String orderId) { super(订单不存在: orderId); this.orderId orderId; } public String getOrderId() { return orderId; } }把关键业务标识写进异常对象本身配合异常消息一起输出排查效率会高非常多。另外构造自定义异常时一定要提供带 cause 参数的构造函数不然包装原始异常时又会出现上一节说的“断链”问题public class OrderNotExistException extends RuntimeException { public OrderNotExistException(String message) { super(message); } public OrderNotExistException(String message, Throwable cause) { super(message, cause); } }3.3 顶层统一处理让异常在边界收敛一个健壮的应用不应该在每一层都处理异常而应该让异常在入口处统一收敛。常见的做法是在 Web 应用的请求入口加一层统一异常处理器业务层抛出的各种异常在这里统一被捕获、记录日志、转换成对客户端友好的错误响应。统一处理的好处很明显第一业务代码里减少大量散落的 try-catch代码更干净第二客户端拿到的错误结构是统一的比如固定的code、message、requestId字段前端解析起来很舒服第三日志记录集中在同一个地方避免重复打印。这里要特别提醒统一异常处理器不能只是一层光秃秃的兜底它应该对不同异常类型做分级处理。业务自定义异常可以直接返回对应的业务错误码参数校验异常返回参数错误提示完全未知的异常则记录完整堆栈并返回一个通用的“系统内部错误”。未知异常如果直接透出很容易把内部实现细节暴露给前端既不安全也容易造成误导。4. 线上排查与性能优化思路4.1 异常的性能开销比你想象的大异常处理不是免费的午餐。JVM 在构造异常对象时会调用fillInStackTrace()填充当前线程的调用栈快照这个过程开销不小。如果把异常抛在一个高频循环里性能会被肉眼可见地拖垮。有一种很典型的“用异常见不得人”的代码用异常来控制循环结束。比如遍历取值时靠catch (NoSuchElementException e)来判断数据取完了。这个写法能跑但每一次抛出异常JVM 都要做栈快照循环几万次就是几万次栈快照整段耗时可能比正常判断慢上好几个数量级。正确做法是在循环体里先做非空判断而不是依赖异常干这种脏活。另外异常密集爆发的场景下即时日志刷屏也会加剧性能问题。如果某个接口在高并发下疯狂打印异常堆栈磁盘 I/O 会先吃紧然后反过来拖垮应用本身。线上治理的思路是对高频路径做降噪处理一些已知的、可预期的失败场景能用返回值或状态码表达的就不要抛异常真正处理不了的再交给异常机制。4.2 异常丢失与抑制异常前面提到的finally里抛新异常是覆盖主异常的一种方式try-with-resources 的抑制异常是另一种。用getSuppressed()能看到被压制的异常这本身是方便的但如果你不知道这个机制存在排查问题时可能会困惑为什么堆栈顶部是一个不太相关的关闭异常真正的业务异常却不见踪影。还有一种不易察觉的异常丢失方式日志只打了异常消息而不打完整堆栈。logger.error(处理失败: {}, e.getMessage()); // 错误示范e.getMessage()很多时候只是一句null或者很短的描述没有异常类型、没有调用栈根本没法定位。正确姿势是直接把异常对象作为最后一个参数传给日志框架比如logger.error(处理失败, e)日志框架会自动输出完整堆栈。4.3 一套实用的排查套路线上出现异常告警时我的排查顺序基本是固定的。第一步先看异常堆栈的顶层确认异常类型和第一条堆栈信息这决定了问题的初步方向是参数问题、资源问题还是业务逻辑问题。第二步顺着堆栈往下找Caused by这是真正的根因。很多时候最上面几层只是包装壳真正的底层原因藏在最后一个Caused by里。第三步结合上下文信息定位具体业务。异常堆栈能告诉你代码位置但告诉不了你是哪个用户、哪笔订单。所以设计阶段的业务上下文就非常关键这也是我在前面反复强调“日志带上订单号、用户ID”的原因。有 traceId 的系统就更方便了直接通过请求链路把一批日志都捞出来从上到下还原一次完整的调用过程。最后提一个和日志相关的性能平衡点全链路打印完整堆栈确实费点 I/O但为了那一点性能牺牲掉堆栈信息在线上排查时是得不偿失的。正常业务异常按完整堆栈记录只有那些预期内的高频异常可以降级成简略日志。我个人在多次线上故障中最大的体会是异常处理不是一个单纯的语法问题它是一套贯穿编码期、上线前、故障中的完整设计。代码评审时多花两分钟看看 catch 块里到底做了什么、日志里有没有带上下文、异常链有没有断省下来的可能是未来某天凌晨的半个小时抢救时间。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑