多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

Java异常处理全解析:从Throwable到自定义异常体系

Java异常处理全解析:从Throwable到自定义异常体系 1. 异常体系全景图先搞清楚“谁是谁”1.1 一句话看懂 Java 异常家族树刚入行那会儿我面对异常也是一头雾水程序崩了就往上抛红字复制粘贴个try-catch就算处理了至于那个Exception到底从哪里来、往哪里去根本说不清楚。后来被骨干带着读源码、啃 JLSJava Language Specification才算把这张家族树刻进脑子里。Java 把所有“程序运行中可能出现的非正常情况”统一定义成一个根类Throwable。它下面直接分出两个分支Exception和Error。Exception是“我还能抢救一下”的问题比如文件没找到、网络超时、参数不合法。这些问题理论上可以通过代码逻辑去避免、捕获或者恢复。Error是“这局基本没救了”的问题比如OutOfMemoryError内存溢出、StackOverflowError栈溢出。这类问题通常是 JVM 本身或底层资源出了致命状况代码层面基本做不了什么有意义的恢复。再往下Exception又分成两支RuntimeException运行时异常也叫非受检异常unchecked exception。NullPointerException、ArrayIndexOutOfBoundsException、IllegalArgumentException都属于这一类。编译器不会强制你捕获你完全可以不写try-catch直接让程序崩掉——但我不建议这么干。非RuntimeException的其他异常也就是受检异常checked exception。典型的有IOException、SQLException、ClassNotFoundException。编译器会强制要求你处理要么用try-catch包住要么在方法签名上throws往外抛。这个区分是整个 Java 异常体系的基石。很多刚学 Java 的同学搞不明白“为什么有的异常必须 catch有的不用”根源就在于没分清这两类。1.2 受检异常与非受检异常编译器的“双重标准”受检异常是 Java 和其他很多语言比如 C、Python不太一样的地方。Java 设计者认为某些异常是“可预期的外部环境问题”比如读取一个文件文件可能不存在连接数据库数据库可能连不上。这些情况既然能预判程序员就应该在代码里显式处理。所以编译器在你写出下面这样的代码时会直接报错逼你处理FileInputStream in new FileInputStream(config.properties); // 编译报错Unhandled exception: java.io.FileNotFoundException而RuntimeException体系下的异常设计者认为它们大多源于代码本身的逻辑漏洞比如空指针、数组越界、类型转换错误“你写对代码就不会有这个问题”所以不强制在编译期处理。结果就是很多初学者养成了坏习惯只处理编译器逼着处理的受检异常对NullPointerException这类运行时异常视而不见。我的观点是不要把“编译器不强制”当成“可以不处理”。受检异常解决的是“外部世界可能出错”的问题非受检异常解决的是“你的代码本身可能出错”的问题。后者往往危害更大因为它代表的是你代码里的 bug而不是环境的不配合。1.3 经常被忽略的 Error你基本不需要 catch 它我见过有人写代码catch (Throwable)试图“把所有问题都兜住”。这属于典型的反面教材。Error分支下的问题比如OutOfMemoryError你 catch 住之后能做什么内存都溢出了你连创建异常对象本身都可能触发新的内存申请失败更别提去恢复什么了。正确的态度是Error不需要主动捕获也不应该捕获。让它抛出来让监控系统记下来让进程重启或者扩容去解决。你在代码里 catch 一个OutOfMemoryError除了把日志打得更乱之外没有任何正面作用。提示永远不要写catch (Throwable t)这种代码。它把Exception和Error混在一起等于告诉 JVM“我什么都能处理”但实际上你什么都处理不了。这条规则我在 code review 里看到一次就驳一次。2. 五个关键字的正确打开方式2.1 try-catch捕获范围越小越好try-catch是大家最熟悉的异常处理方式但“会用”和“用对”是两码事。来看一个最常见的误区try { // 一大段逻辑包含了读取配置、处理业务、写日志、调接口 } catch (Exception e) { log.error(出错了); }这段代码的问题在于catch 到的Exception太过笼统既不知道是哪一行出了错也不知道是什么类型的错。而且把大段代码塞进一个 try 块会显著增加代码的耦合度。更好的做法是让 try 块尽量只包裹“可能会抛异常的那一小段代码”并且区分异常类型try { FileInputStream in new FileInputStream(config.properties); } catch (FileNotFoundException e) { log.error(配置文件不存在请检查部署环境, e); // 这里可以做降级处理比如用默认配置 }这样有几个好处第一定位问题快。看到日志里FileNotFoundException字段的堆栈立刻知道是文件读取这段出的问题而不是在一大段逻辑里找。第二你可以在 catch 里做针对性的降级或补偿逻辑比如读取配置失败时回退到默认配置——这种细粒度 catch 才能写出健壮的业务代码。2.2 finally它的执行时机没你想的那么简单finally块用来做“不管是否异常都必须执行”的事最常见的就是释放资源关闭文件流、关闭数据库连接、释放锁。但网上很多教程会把finally的执行时机讲得过于绝对导致新手踩坑。来看下面这个例子try { System.exit(1); } finally { System.out.println(finally 执行了吗?); }答案是不会执行。System.exit直接终止 JVMfinally来不及跑。还有两种情况需要注意在try或catch中执行了returnfinally仍然会执行而且它的return会覆盖前面return的值。在try或catch中抛出了异常finally仍然会执行但如果在finally里又抛了一个新异常新异常会覆盖原始异常。最后这一点尤其坑。早期版本的 Java 中如果try块抛出一个IOException接下来finally里关流时又抛出一个RuntimeException那么调用方只能看到RuntimeException原始IOException直接丢失。这在排查线上问题时非常致命——你明明处理的是 IO 故障看到的却是莫名的运行时异常。这也是 Java 7 推出try-with-resources的核心动机之一我会在后面单独讲。2.3 throw 与 throws抛出异常的两种姿势这两个关键字名字像作用却完全不同throw用在方法内部表示“我主动创建一个异常对象并把它抛出去”后面跟的是一个异常实例。throws用在方法签名上表示“我这个方法可能抛出某些异常请调用方处理”后面跟的是异常类型列表。看个实际的用法public void checkAge(int age) throws IllegalArgumentException { if (age 0) { throw new IllegalArgumentException(年龄不能为负数: age); } // 正常业务逻辑 }throw的常见使用场景是代码的入参不合法或者业务状态不满足前置条件你需要主动终止流程并告诉调用方原因。throws则是把异常处理的责任转移给调用方。这里有个很实际的经验什么时候用throw而不是返回一个错误码答案很简单——当你想强制调用方知晓并处理这个错误时。如果用返回布尔值或错误码调用方一不小心就忽略返回值错误就被静默吞掉了。异常机制则是“要么处理要么继续往上抛”从语义上保证了错误不会被轻易忽略。2.4 try-with-resources资源释放的正确姿势Java 7 之后引入的try-with-resources是我个人最喜欢的改进之一。它解决了两大痛点一是代码冗长二是 finally 中再次抛异常会掩盖原始异常。写法是这样的try (FileInputStream in new FileInputStream(config.properties); FileOutputStream out new FileOutputStream(output.bin)) { // 读写逻辑 } catch (IOException e) { log.error(读写失败, e); }前提条件是资源类必须实现AutoCloseable或Closeable接口。进入 try 块后无论是正常结束还是抛异常JVM 都会自动调用资源的close()方法而且关闭顺序是逆序的跟资源创建顺序相反——这符合资源依赖的一般直觉。更重要的是当try块内的异常和close()异常同时发生时Java 会保留原始异常并把close()抛出的异常作为“被抑制的异常”suppressed exceptions附加在后面。排查问题时你能看到完整链路而不会像旧式finally那样把根因丢掉。提示如果你的代码还在用finally手动关流并且项目最低支持 Java 7 以上建议立刻改用try-with-resources。一行代码的改动换来的却是日志可读性和健壮性的质变。3. 异常处理的工程实践选型与设计3.1 受检异常还是非受检异常这是个战略问题很多团队在异常设计上长期摇摆什么时候自定义受检异常什么时候用RuntimeException我经历过几个项目总结出来的原则是看这个异常“是否能通过调用方的正确使用来避免”。如果能避免属于调用方的责任那就用非受检异常。最典型的例子是参数校验你传了一个null给一个明确不允许null的方法这是调用方的错用IllegalArgumentException这类运行时异常合适。如果不能避免属于外部环境或不可控因素那就用受检异常。例如调外部接口失败、读文件失败这类问题即使调用方代码写得再正确也可能发生应该让调用方意识到并显式处理。但这里必须提醒一句受检异常用起来有成本。如果设计不当会出现“异常传染”——底层一个方法声明throws Exception上层所有方法都得跟着声明接口签名变得臃肿。我见过不少项目把异常逐层往上传最后 Controller 层不得不加一大串 throws非常难看。所以现在的工程实践趋势是业务异常统一使用非受检异常受检异常只留给基础设施层比如 IO、网络、数据库操作。原因很简单业务异常如“余额不足”“订单已取消”本质上是可以避免或需要业务规则兜底的用受检异常会污染每一层的签名。3.2 异常粒度一个方法里要不要区分多种异常有些人喜欢一个 try 块里 catch 十几种异常然后每个 catch 里写差不多一样的日志。这种“伪粒度”没有意义。异常粒度的真正价值在于不同异常需要做不同的事。try { orderService.pay(orderId, amount); } catch (InsufficientBalanceException e) { log.warn(余额不足订单 {} 支付取消, orderId); return Result.fail(余额不足); } catch (OrderNotFoundException e) { log.error(订单 {} 不存在需要人工核对, orderId); return Result.fail(订单不存在); }这种分开 catch 是有价值的因为每个异常的业务处理路径不同——一个只需要提示用户一个需要走人工核对流程。反过来如果两个异常的处理方式完全一样那就没必要分开用一个多异常捕获catch (AException | BException e)反而更清爽。还有一个容易忽略的细节catch 的顺序。多个 catch 之间存在上下层级关系时子类异常必须写在父类异常前面否则编译器直接报错。这也是很多初学者在 IDE 里按了编译才发现的坑。3.3 异常传递与日志记录别把异常“捂”在手里开发中最常见也最让人头疼的反模式是“打日志后吞异常”。长这样try { // 业务逻辑 } catch (Exception e) { log.error(出错了, e); }表面上看这个异常被记录了没有泄漏。但如果你把这段代码放在一个被上层调用很多次的底层方法里会出现两个问题第一日志重复。底层打一遍上层又打一遍日志系统里充斥着重复的堆栈真正的关键信息被淹没。第二调用方以为“反正你处理了”于是没有做任何补偿操作异常虽然被记录但业务流程已经中断该回滚的没回滚该提示用户的没提示。我认为更合理的模式是在合适的地方统一处理而不是每一层都打日志。具体来说底层方法抛出异常时不要随意 catch让异常携带完整堆栈向上传递。在一个全局边界比如 Web 项目的 ControllerAdvice或消息队列的消费者封装层统一捕获、统一记录日志、统一返回给调用方。如果需要在某一层做上下文补充可以捕获后包装成新异常再抛出但一定要保留原始异常作为 cause。try { userRepository.save(user); } catch (DataAccessException e) { throw new BusinessException(用户保存失败, e); }这里的e作为新异常构造器的第二个参数传入就形成了异常链。后面排查时既能从BusinessException看出业务语义又能通过getCause()一路追到底层的DataAccessException根因。3.4 异常是有成本的异常不是流程控制工具这一点我要专门拿出来强调。异常机制的底层开销远高于普通的 if-else 判断。当 JVM 抛出异常时需要填写堆栈轨迹、创建异常对象、在方法调用栈上逐层搜索匹配的 catch 块——这个过程比执行一次普通方法调用慢很多。我之前看过一段代码在循环里用异常做“终止条件”控制循环跳出for (int i 0; i n; i) { try { // 某种查找逻辑找不到就抛异常 } catch (NotFoundException e) { break; } }这个性能损耗在 n 很大时非常明显。异常机制是给“意外情况”设计的不是给“正常业务分支”设计的。流程控制该用 if-else 就用 if-else该返回状态就返回状态。只有真正的异常场景才应该走异常通路。4. 自定义异常实操搭建一套业务异常体系4.1 自定义异常类的设计要点大多数 Java 项目最后都会沉淀出一套自己的异常体系。自定义异常核心要解决三个问题异常类型语义清晰、携带的业务上下文足够、便于统一处理。设计自定义异常时我建议遵循以下几条原则继承哪个父类要想清楚。业务异常通常继承RuntimeException避免污染每层方法签名基础设施相关异常可以继承受检异常比如自己封装HttpInvokeException继承Exception。至少提供两个构造函数一个是带错误消息的一个是带消息加Throwable cause的。后面这个极其关键没有它就无法保留异常链。在消息里带上业务标识比如订单号、用户 ID、商品 ID。这样拿到异常消息就能直接定位业务对象而不需要再去翻参数明细。4.2 一个可落地的业务异常 Demo我举个实际项目的例子。一个电商后端项目我们会先定义一个顶层业务异常再按业务域拆出子类。public class BizException extends RuntimeException { private final int errorCode; public BizException(String message) { super(message); this.errorCode 500; } public BizException(int errorCode, String message) { super(message); this.errorCode errorCode; } public BizException(int errorCode, String message, Throwable cause) { super(message, cause); this.errorCode errorCode; } public int getErrorCode() { return errorCode; } }再定义业务子类public class OrderNotFoundException extends BizException { public OrderNotFoundException(Long orderId) { super(404, 订单不存在, orderId orderId); } public OrderNotFoundException(Long orderId, Throwable cause) { super(404, 订单不存在, orderId orderId, cause); } }使用时的效果public Order getOrder(Long orderId) { Order order orderMapper.selectById(orderId); if (order null) { throw new OrderNotFoundException(orderId); } return order; }这里有个很爽的点调用方只需要捕获一个BizException就能拿errorCode做统一处理而OrderNotFoundException自带的构造器已经强制你把订单号拼进消息里排查问题几乎不需要再翻上下文。4.3 全局异常处理的配合思路自定义异常体系要和全局处理器配合才有真正的威力。如果是 Web 项目通常会在 Controller 层加一个全局异常处理器类似 Spring 的RestControllerAdvice思路但就算不用 Spring用过滤器或 AOP 也能实现同样的效果。核心思想是业务代码只管抛不管接。业务方法里发现订单不存在就throw new OrderNotFoundException(orderId)不用想着如何返回给前端全局异常处理器统一负责把BizException转成对应的 HTTP 状态码和响应体。这样分层的收益是业务代码非常干净没有满屏的 try-catch异常语义统一新增异常类型时只需定义异常类自身全局处理逻辑不用频繁改动。5. 真实开发中的坑与排查技巧5.1 常见反模式速查表我把 review 里经常出现的异常处理反模式整理成了表格每一条都是我见过线上事故或反复扯皮的来源反模式问题表现正确做法吞异常catch 后什么都不做或只打 log 不抛出要么处理要么抛出切不可静默吸收用异常控制流程循环内频繁抛出捕获异常来 break用状态码、返回值或正常条件判断catch 后 return null调用方拿到 null 继续 NPE根因被藏匿抛出带上下文的异常或返回 Optional打印堆栈后又抛新异常日志重复异常链断裂构造新异常时传入原异常作为 cause捕获 Throwable连同 OutOfMemoryError 一起捕获毫无意义只捕获你能恢复或至少要兜底的异常类型方法签名 throws Exception签名过于宽泛调用方无法精准处理声明具体的异常类型5.2 两个真实场景的排查记录场景一接口偶发 500排查半天没头绪。后来看日志发现底层一个方法在catch (Exception e)里只打了一行log.error(error)连堆栈都没带。异常信息的关键部分全部丢失只能通过重启接口复现来猜测问题。后来让开发把日志改成log.error(error, e)并去掉多余的 catch第二天就定位到了是连接池被打满导致的超时。场景二一个批量导入功能数据量一大就处理失败。日志里报的是ClassCastException但追代码根本看不到哪里做了强制类型转换。后来把异常链完整提取出来才发现在某个工具类的方法里编译器自动做了泛型擦除后的强制转换是因为传入的集合元素类型不匹配。如果不是把原始异常链完整打出来这种隐藏在泛型操作里的类型问题很难一眼看到。这两个场景给我的共同教训是异常信息里最值钱的不是异常类型而是完整的堆栈轨迹和上下文标识。任何会“精简”异常信息的包装都应该重新考虑。5.3 调试异常的实用技巧第一时间看堆栈最深层。异常堆栈从顶往下看第一行指明异常类型和消息下面跟着的是调用链。真正的根因通常在堆栈的最底部附近Caused by链的最后不要盯着第一行就下结论。善用getSuppressed()。如果使用try-with-resources关闭资源时抛出的异常会作为被抑制异常附加在原始异常上。排查时如果只看主异常而忽略 suppressed 异常可能会错过资源关闭环节的真实故障。起一个全局懒人配置在本地开发环境可以给项目配置一个统一异常打印的拦截器把未捕获异常完整记录到单独文件里。这样运行时不需要到处打断点光看异常文件就能盘出大部分问题。代码评审时养成习惯看 catch 块就三个问题——e有没有传给日志日志里有没有业务上下文订单号、用户 IDcatch 之后是继续抛还是停了三个问题都回答清楚异常处理通常就没大问题。最后再分享一个小技巧我以前在项目里定的死规矩是任何 catch 块中不允许出现“只打消息不打堆栈”的写法log.error(xxx, e)里的e必须带上。这条规矩听着简单但它真的能帮你省掉大量排查时间。异常处理不是为了写出“看起来很稳”的代码而是为了让问题在发生时可以被最快速、最准确定位——这才是工程意义上的健壮性。
返回列表