写 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 块里抛了异常 A,finally里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 块里到底做了什么、日志里有没有带上下文、异常链有没有断,省下来的可能是未来某天凌晨的半个小时抢救时间。
写 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 块里抛了异常 A,finally里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 块里到底做了什么、日志里有没有带上下文、异常链有没有断,省下来的可能是未来某天凌晨的半个小时抢救时间。