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

文章详情

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

Java异常处理全解析:从try-catch到全局异常处理器

Java异常处理全解析:从try-catch到全局异常处理器 1. 异常是什么先搞懂它到底在解决什么问题老实说Java 里有很多概念被讲得太玄异常就是其中之一。很多初学者一看到Exception就慌觉得这是某种神秘的报错机制甚至把异常和“程序出 bug”画等号。其实换个角度想异常更像是一套预先设计好的、有规范的错误上报和处理协议。你写程序本质上是在指挥一堆对象协作。只要有人参与协作就一定会出现“意外”参数传错了、文件不存在、网络断开了、除数为零了。这些事不是“bug”而是真实世界里的常态。Java 在设计之初就把“出错”当成一等公民来处理不靠返回值约定也不靠全局变量传递错误码而是用一套统一的异常体系把错误“抛”出来再由调用方决定怎么处理。这套机制最大的价值在于它把“发生错误”和“如何处理错误”强行分开了让业务代码和容错代码不至于纠缠成一团。适合谁来学不只是刚入门的新手哪怕你已经写了两年 Java回头把异常机制重新梳理一遍往往也能发现很多以前靠try-catch糊弄过去的隐患。这篇文章我会从异常体系结构、处理语法、实际踩坑、性能影响这几个维度展开把我这些年调试异常相关问题的经验全部倒出来尽量做到你看完就能直接用。先记住一句话异常不是用来“吞”的是用来“解决”的。如果你现在的代码里到处是空的catch块那这篇文章你一定要读完。2. 异常体系结构拆解Throwable 是根Error 与 Exception 是两条完全不同的路2.1 从 Throwable 往下看体系并不复杂Java 异常体系的最顶层是一个类Throwable它不是一个接口是实实在在的类。所有可以被throw或catch的东西都必须是Throwable的子类。你把throw关键词后面跟一个String编译会直接报错就是因为 String 不是Throwable的子孙。Throwable有两个直接子类ErrorException很多人只关注Exception却忽略了Error。这两者的语义完全不同处理方式也大相径庭。Error表示的是JVM层面的、程序通常无法恢复的严重问题比如OutOfMemoryError、StackOverflowError、NoClassDefFoundError。这类问题的共同点是不是你的代码逻辑错了而是运行环境或资源本身出了问题。你 catch 住OutOfMemoryError意义也不大因为 JVM 可能已经处于不健康状态即使 catch 住后续能否继续运行也是个未知数。所以对于Error最好的策略通常是不捕获或者只在极少数需要记录现场的场景下捕获捕获之后立刻重启、退出做兜底。Exception才是我们日常编程中重点关注的对象。它还能继续往下分Throwable ├── Error └── Exception ├── RuntimeException非受检异常/运行时异常 └── 其他 Exception受检异常/检查型异常这里出现了一个非常重要的概念受检异常Checked Exception和运行时异常RuntimeException / Unchecked Exception。2.2 受检异常与运行时异常Java 里最经典的强制 vs 可选受检异常是编译器会强制你处理的异常类型。你在方法里调用了某个声明了throws IOException的 API比如FileInputStream的构造函数编译器就会逼你要么用try-catch把它包起来要么在方法签名上继续声明throws IOException。如果你什么都不做编译直接失败。这是 Java 设计哲学里非常独特的一点编译期就把可能出错的环节标记出来让你必须给出一个交代。运行时异常则不需要强制处理。它继承了RuntimeException包括NullPointerException、IndexOutOfBoundsException、ArithmeticException、ClassCastException等等。这些异常通常是由程序逻辑缺陷导致的理论上可以通过正确的编码避免。编译器默认不检查它们你抛不抛、catch 不 catch 都是自由的。但这不代表你可以无视它们恰恰相反生产环境里线上跑出来最多的就是这些运行时异常。有人可能会问为什么要有这种区分为什么不全部强制处理我的理解是受检异常是对“外部不可控因素”的防御比如文件读取、网络请求、数据库连接这些操作的结果不取决于你一个人的代码质量再牛的程序员也没法保证网络永远不断所以编译器要求你处理。而运行时异常针对的是“你自己的逻辑漏洞”这种问题你就算强制处理最多也只是把问题往后拖不如让它尽早暴露逼你修代码。这个设计初衷是好的但在实际项目中受检异常往往被滥用导致代码里到处是恶心的throws Exception这一点我们后面会聊。2.3 自定义异常什么时候需要怎么设计才合理Java 里内置的异常类已经覆盖了绝大多数场景但实际业务中我们还是经常需要自定义异常。为什么呢因为内置异常表达的是技术的“异常”而不是业务的“异常”。比如用户发起支付余额不足这时候你抛一个IllegalArgumentException虽然语义沾边但调用方根本没法区分“参数不合法”到底是余额不足还是风控拦截。所以合理的做法是自定义一个业务异常类public class BizException extends RuntimeException { private final int errorCode; public BizException(int errorCode, String message) { super(message); this.errorCode errorCode; } public int getErrorCode() { return errorCode; } }这里我特意继承了RuntimeException而不是直接继承Exception。原因很简单业务异常往往不是“必须处理”的外部故障而是业务规则校验失败如果继承受检异常那所有上层方法都得被迫声明 throws会让代码变得非常啰嗦。当然这个选择也有反面声音。在传统的 Service 层事务管理场景中有经验的开发者会告诉你自定义异常继承RuntimeException才能触发事务回滚这是 Spring 事务默认机制决定的如果你继承的是受检Exception事务不会自动回滚。这个知识点很隐蔽很多项目出过大事故才意识到。自定义异常的设计还有两个细节需要注意构造方法要留全除了带消息的构造最好提供带cause参数的构造这样才能把底层异常链完整保留下来。错误码和消息要规范建议在异常类里定义常量或枚举避免到处散落魔法数字。3. 处理异常的语法机制try-catch-finally 与 try-with-resources 的正确姿势3.1 try-catch-finally 的执行顺序比你想象的更容易出错先看一个最经典的代码段public static String test() { try { System.out.println(try); return success; } finally { System.out.println(finally); } }这个方法的返回值是什么很多人会脱口而出先打印try再打印finally最后返回success。实际上确实如此但如果你是抱着“finally 里的代码一定在 return 之后执行”的理解那就错了。准确的说法是return表达式的结果会先被计算出来然后在方法返回之前执行finally块最终把之前算好的结果返回。如果finally里也有return那会发生什么呢public static String test() { try { return try; } finally { return finally; } }结果返回的是finally。因为finally里的return会直接覆盖掉try里的返回值。这种代码是极其恶劣的因为它会让调用方完全摸不清你的逻辑。我个人强烈建议永远不要在finally中写return也不要在finally中写throw除非你非常清楚自己在做什么。否则排查问题时你会发现程序的走向完全被一个测试中毫不起眼的finally劫持了。再来看catch的执行顺序。多个catch块并不是随意的JVM 会按照你书写的顺序从上往下匹配一旦某个catch匹配成功后续的catch就不再执行。所以你必须把最具体的异常类型写在前面最宽泛的写在最后面。否则你把Exception写在第一个后面再写NullPointerException编译器会直接报错exception has already been caught。我之前见过一个项目犯这种错误排查了半天最后发现就是 catch 顺序写反了子类异常永远不生效。3.2 不要滥用异常try-catch 不是万能药也不是性能毒药关于异常对性能的影响网上说法两极分化。有人说 Java 异常性能极差绝对不能多用也有人说现代 JVM 已经优化了异常随便用。这两种说法都不太准确。Java 异常的性能开销主要在异常对象的创建和栈轨迹填充这个环节。每次new一个异常对象并且调用fillInStackTrace()去采集当前的调用栈这个动作是昂贵的。但这种开销在正常处理流程中除了解析栈帧和内存分配之外并没有我们想象中那么大真正影响性能的是用异常来控制正常流程逻辑的方式。比如用try-catch包裹一个for循环循环 100 万次每次迭代内部抛一个异常那性能就非常糟糕。每一次抛异常都是一次完整的异常分派流程包括栈收集、异常表匹配、异常对象生命周期管理这远不如用一个if判断来解决。正确的原则是异常应该用于“异常情况”而不是“正常流程的分支条件”。比如判断一个字符串能否转成数字你完全可以先用正则或者Character.isDigit去做校验再决定要不要调用解析方法。但如果你非要用NumberFormatException来捕获非法格式那就等于把每次可能失败的解析都当做一次完整异常处理来跑用户量大时系统性能会被明显拖累。经验之谈如果你发现某个接口的 QPS 很高而且代码里频繁在正常业务路径上抛异常请先重构这段逻辑。性能优化优先级里消灭本就是不必要的异常往往比改垃圾收集参数更有效。3.3 try-with-resources你必须养成的资源管理习惯Java 7 引入了 try-with-resources 语法它解决了一个长期痛点资源关闭。以前处理文件流、数据库连接时你需要写一长串的try-catch-finally并且在finally里小心翼翼地关流还得处理close()本身抛出的异常。代码丑不说漏关资源的风险还特别大。有了 try-with-resources 之后只需要这样写try (FileInputStream fis new FileInputStream(data.txt); BufferedReader reader new BufferedReader(new InputStreamReader(fis))) { // 处理业务 } catch (IOException e) { log.error(读取文件失败, e); }使用这个语法有个硬性要求资源类必须实现AutoCloseable接口。Java 标准库里的流、连接类基本都实现了你如果自定义了一个需要关闭的资源类记得也去实现它。每个实现了AutoCloseable的资源在 try 代码块结束后都会按照声明顺序的逆序自动关闭。这个顺序很关键比如上面的例子中reader会先关闭然后fis再关闭这正好符合常规资源的依赖关系。还有一个很多人不知道的细节在 try-with-resources 中如果 try 块内的业务代码抛出异常A然后在自动关闭资源时又抛出了异常B最终向外抛出的是A而B会被抑制suppressed同时会保存在异常对象内部可以通过getSuppressed()查看。这在诊断问题时非常有用因为资源关闭失败往往和主异常有因果关系。早期 Java 7 之前这种场景你只能看到 B主异常 A 直接丢失调试起来让人想摔键盘。现在这个问题解决了但如果你用的是旧写法这个问题会一直存在。4. throw 与 throws一个面向动作一个面向声明4.1 throw 怎么用才算规范throw关键字用于手动抛出一个异常对象它后面必须跟一个Throwable的实例。比如说参数校验失败可以这样写public void deposit(BigDecimal amount) { if (amount null || amount.compareTo(BigDecimal.ZERO) 0) { throw new IllegalArgumentException(存款金额必须为正数); } // 正常处理 }这种主动抛出异常的写法本质上是在定义方法的“前置条件”。Java 没有内置的断言语法Java 的assert并不能在运行时可靠开启所以在方法入口做参数校验并抛出带有明确信息的异常是最常见的编码规范。阿里巴巴开发手册里也有类似建议参数校验失败应该抛出特定异常而不是打印日志后继续执行。这里我要特别强调一点不要使用throw null也不要在需要异常对象的地方传 null。throw null在 Java 中实际上是会抛出一个NullPointerException因为 JVM 拿到 null 之后没法完成 throw 操作只好抛一个 NPE。这种代码主观上想抛异常客观上抛出的是另一个异常行为非常诡异排查起来极度浪费时间。还有一个规范抛异常的时机越早越好。不要把校验逻辑憋到业务执行到一半再抛。如果一个方法的入口处就可以判断参数非法就不要等到方法内部调用了两个 DAO 之后才想起来抛异常。早点抛能减少不必要的计算也能让异常信息更贴近出错的源头。4.2 throws 声明的粒度与接口设计哲学方法签名上的throws是在告诉调用方“本项目执行时可能抛出这些异常你自己看着办。”这是受检异常时代的核心约定。但对于现代大型项目我个人的推荐是底层基础设施类方法比如 DAO、远程调用封装可以声明具体的受检异常因为调用方需要感知这些故障。业务 Service 层方法尽量不直接漏出底层受检异常而是捕获之后转换成自定义的BizException或RuntimeException抛出。这样可以避免受检异常像病毒一样在调用链中层层上传污染整个 Service 接口签名。对外提供 REST 接口的 Controller 层一般不建议在方法签名上写throws Exception因为框架层通常已经有全局异常处理器你可以在全局处理器里统一处理业务异常并转换成 HTTP 状态码和错误响应体。另外要特别提醒不要声明一个超级宽松的throws Exception。这等于告诉调用方“所有异常我都可能有你随意处理吧”看起来灵活实际上毁掉了编译器的检查意义也让接口的契约形同虚设。好的异常设计是“窄”的能声明throws IOException就绝不声明throws Exception能只声明一种异常就别罗列七八种。从接口设计角度讲异常也是接口契约的一部分。调用方在决定怎么 catch 的时候依赖的就是你的throws声明。如果声明太宽调用方只能 catch 一个巨大的Exception然后再用instanceof去区分这会让代码变得极其丑陋也在无形中把错误处理的责任推给了所有下游。很多团队实际开发中索性选择全部转成运行时异常这虽然失去了编译期的强制提醒但换来的是代码简洁和全局异常处理的一致化。这种取舍没有绝对的对错关键在于团队共识和对调用方的约束是否清晰。5. 异常链与日志记录排查故障的核心场景5.1 为什么异常堆栈不能丢失异常对象里最有价值的就是堆栈轨迹它精确记录了异常发生时的调用链路。我们排查一个线上问题90% 的时间都在看堆栈。所以异常处理和日志记录的第一条铁律就是永远不要丢失异常堆栈。哪些操作会丢失堆栈呢只记录e.getMessage()忽略了e.printStackTrace()或 log.error 的完整堆栈参数。getMessage()通常只是一句简短描述根本看不出问题出在哪个类哪一行。捕获后直接溜走什么都不打印。手动new Exception()并把原始异常当作message拼接而不是传入cause。来看一个正确写法try { // 可能抛异常的代码 } catch (IOException e) { throw new BizException(500, 文件处理失败, e); }这里第三个参数e就是作为cause传入的。这样在最终打印BizException的堆栈时root cause 链路会完整保留你一眼就能看到最初是IOException从哪一行抛出来的。如果只是写成throw new BizException(500, 文件处理失败 e.getMessage())那原始异常的堆栈就完全丢失了只剩下一句话排查问题的难度直接翻倍。5.2 捕获异常的粒度与处理策略我见过的坏代码里比较典型的一种是把一个大方法的所有操作都塞到一个try-catch (Exception e)里。这种写法带来的问题很多一旦发生异常你无法精确判断到底是哪一步出了问题而且所有异常都被兜住了后面的代码会继续执行如果你 catch 之后没抛出新异常导致数据处于不完整的中间状态。正确的做法是尽量缩小 try 的范围让 try 只包裹可能抛异常的代码并且针对不同的操作类型分开捕获。比如一个方法里既做了网络请求又做了文件持久化那你就应该两个分别 try-catch分别记录日志并做相应的兜底处理。如果两个操作共享一个 try要么异常覆盖面太宽要么处理逻辑只能取交集。还有一个策略问题是捕获之后是“吞掉”还是“抛出”这里提供一个我常用的决策思路场景推荐策略程序可以降级处理异常不影响主流程捕获后记录日志继续执行主流程异常导致后续操作没有意义捕获后立即抛出异常中断执行异常发生在任务提交阶段重试成本低捕获后重试 1-2 次仍失败则抛异常异常仅仅用来满足编译器检查重新设计代码避免这种异常场景5.3 日志里不要出现的危险操作如果你往日志系统里写异常有几个细节一定要重视不要把Exception对象直接跟字符串拼接比如log.info(error: e)这样只会调用它的toString()同样会丢失完整堆栈。不要频繁打印异常堆栈。如果一个方法被高频调用而且异常也在高频出现日志系统会被无限刷屏磁盘 IO 直接被打满严重时反而引发新的问题。为了避免这种情况可以考虑给异常日志做抽样或限流。不要在日志中记录敏感信息。异常消息里很可能包含 SQL 语句、请求参数、Redis key 等如果这些参数里有密码、身份证号直接打出来就是安全事故。一个我在生产环境学习到的教训曾经有段代码在 catch 中打印异常堆栈后来排查问题时发现日志量比正常业务日志还大导致真正的业务日志被大量淹没反而延误了问题定位。后来把异常日志改成按分钟聚合计数只在首次出现时打印完整堆栈之后只打印次数。这在复杂系统中值得借鉴。6. 受检异常的三方库困境Monadic 异常处理与使用建议6.1 当受检异常遇上函数式编程Java 8 引入了Stream和Optional函数式风格逐步流行起来。但受检异常和函数式接口之间有一个公开的敌意Function、Consumer等函数式接口的方法声明中并不允许抛出受检异常。你能写出这样的代码吗Files.list(Paths.get(dir)) .map(path - new String(Files.readAllBytes(path))) // 编译错误这行代码无法编译因为Files.readAllBytes(path)抛出了IOException而map的参数Function的apply方法并不声明throws IOException。为了解决这个问题大家想了很多招写一个包装方法用try-catch捕获受检异常再throw new UncheckedIOException(e)。在Consumer匿名实现里捕获异常自行处理。用第三方库比如某些 OSS 库提供了可以声明throws Exception的函数式接口。这里我推荐的做法是采用包装模式把受检异常转换为对应的非受检异常再抛出。比如文件读取最适合的包装异常就是UncheckedIOExceptionJava 标准库已经提供了这个类不要自己造。Path path Paths.get(data.txt); try { ListString lines Files.readAllLines(path); lines.stream().forEach(System.out::println); } catch (IOException e) { throw new UncheckedIOException(e); }这样既保持了 Stream 的流畅性又保留了异常链。写出的代码比在map内部写try-catch干净得多也更容易做统一处理。6.2 Optional 不能替代 try-catch顺带提一句Optional不能替代异常处理。有些同学试图用Optional.ofNullable或者Optional.empty来表示“方法执行失败”但 Optional 只能表达“值不存在”无法携带失败原因。你的调用方拿到Optional.empty()后只知道没结果不知道是输入非法、系统超时还是磁盘损坏只能猜。这个时候不要硬用 Optional直接抛异常把失败原因表达清楚才是对调用方负责。6.3 使用第三方库时如何处理受检异常侵袭真实项目中你不太可能只用 JDK 自带 API。数据库操作、消息队列、HTTP 客户端很多框架对外抛出的是受检异常或者干脆是统一异常。比如某些老牌 JDBC 操作抛SQLException这个类非常烦人它被强制要求捕获了很多年。spring 的JdbcTemplate之所以受欢迎一个重要原因就是它把所有SQLException转换成了DataAccessException这种运行时异常让业务代码轻松了很多。当你要封装自己的基础组件时我建议借鉴这种思路底层负责捕获受检异常转换成团队约定的运行时异常。抛出时可以附加 context 信息比如表名、操作类型、SQL 语句脱敏后方便上层排查。不要在每一层都 catch 一遍然后 transform 一遍那样会导致异常链越来越长却没有任何新增信息。理想的情况是底层转换一次上层全靠全局处理器收敛。7. 实战案例从空指针 NPE 到全局异常处理器的落地7.1 一个典型的空指针排查过程NullPointerException大概是 Java 里出现频率最高的异常。很多人刚入行时总以为 NPE 只是因为某个对象忘了判空其实 NPE 的根源往往是职责不清一个方法到底允不允许返回 null调用方拿到 null 之后应该怎么处理这些在代码里完全没有约定。我举一个真实场景已脱敏某服务调用了另一个内部服务的方法对方返回了一个 list正常情况下 list 是空列表[]但在某些边界情况下上游会返回 null。服务代码里直接写了list.size()于是线上时不时出现 NPE。最初大家以为是上游 bug要求上游修复上游说标准响应不应该为 null是框架序列化时放宽了两边扯了很长时间。最后在本方代码里做的防御性处理if (list null) list Collections.emptyList();然后本地兜底记录日志。这个案例说明异常处理不仅仅是 catch 的问题更是设计约定的问题。你无法控制外部系统的不规范行为但你在自己的边界上要做好防御。防御性处理和过度防御之间要有分寸。如果每个方法入口都对参数做全套判空判断代码会非常臃肿。通常的原则是外部边界Controller 入参、RPC 返回必须防御。内部私有方法之间可以依赖团队约定不做过量校验。关键路径上的方法建议用Objects.requireNonNull在引入参数时显式声明“不允许为空”。Objects.requireNonNull这个东西很多人没用好。它接收一个对象如果为 null 则抛出NullPointerException但关键是你可以传入自定义的提示信息public void process(User user) { Objects.requireNonNull(user, user 不能为空); ... }这样抛出 NPE 时异常消息明确告诉你哪个参数为空比你空手抛一个 NPE 强得多。7.2 全局异常处理器让 Controller 层彻底瘦身现代 Java Web 开发中我们通常用统一的异常处理器来收敛错误响应。以 Spring Boot 为例可以基于RestControllerAdvice实现。这样一个类的职责就是“把不同异常映射为对应的 HTTP 状态和错误码”业务代码里再也不需要到处写try-catch返回自定义错误对象。我的习惯是做三层分类业务异常BizException统一返回 HTTP 400/200业务错误码message 可以直接透出给前端。参数校验异常MethodArgumentNotValidException、ConstraintViolationException提取字段错误信息返回结构化的参数错误明细。未预期的Exception和Error记录完整堆栈返回 HTTP 500 和通用错误提示不对用户暴露内部细节。这里有一个很容易忽略的细节全局异常处理器里一定不要返回e.getMessage()给前端。很多底层异常消息里带了类名、SQL、甚至文件路径直接透出是安全风险。同时你还需要确保异常处理器自身不会因为序列化对象出了问题而再次抛出新的异常所以返回的 ErrorResponse 类要尽量简单、易于序列化。7.3 事务与异常回滚条件的微妙之处Java 异常处理与数据库事务深度绑定。Spring 事务的默认回滚规则是只在抛出运行时异常RuntimeException或Error时回滚对于受检异常默认不触发回滚。这是一个很多人一知半解、从而踩坑的机制。假设你的 Service 方法标注了Transactional某段代码抛了一个自定义的受检异常OrderBizException继承Exception如果不在Transactional上指定rollbackFor事务不会回滚。数据已经写了半截异常已经抛出你以为事务回滚了实际上没有。线上就会出现“报错了但数据多了”的灵异事件。正确的做法是Transactional(rollbackFor Exception.class) public void createOrder(OrderDTO dto) throws Exception { // ... }或者更优雅一点让所有业务异常都继承RuntimeException从而能触发默认回滚。我个人的建议是后者优先因为它不需要每次声明事务注解时都记住rollbackFor。如果你的团队必须用受检异常那么请在类级别统一配置 rollbackFor。还要注意一点事务回滚只对RuntimeException生效的前提是异常被传播出事务方法。如果你在方法内部把异常 catch 住了事务自然不会回滚。换句话说通过全局异常处理器处理业务异常时事务回调已经走到了提交阶段。所以不要在事务方法内自行“吞”异常否则事务边界形同虚设。8. 异常处理中的性能考量不要为了优雅牺牲吞吐量8.1 异常对象的生成成本前面已经提过异常对象的创建成本主要来自堆栈填充。这里我想再深入一点。fillInStackTrace()会遍历 JVM 的调用栈逐帧记录类名、方法名、文件名、行号并且存储在一个数组里。异常越是在调用链深处抛出这个遍历成本就越高。如果一个接口在深层被高频调用每抛一次异常可能要多耗费几十微秒甚至更多在百万级 QPS 的场景下这就是不可接受的延迟损耗。所以高性能路径上有一个古老而有效的优化方式复用异常对象。因为异常对象的堆栈是创建时快照的你复用同一个异常实例时堆栈信息永远是第一次创建时的。这对“统一抛令牌失效异常”这种场景其实影响不大因为堆栈中记录的位置与真实触发点已经无关了所以这种做法只适合那些不需要堆栈信息、只用于逻辑跳转的特殊场景不是很常见一般也不推荐。更常见且合理的优化是把可能频繁触发的错误用普通返回值或枚举表达而不是用异常表达。比如用户请求里如果携带的 token 无效这种属于预期内的业务分支不应该抛异常而是应该返回一个“未认证”的状态码。只有真正的系统故障——数据库崩了、消息发不出去——才值得走异常链路。8.2 catch 快而不准 vs 准而不快有人为了性能写了一个超大 catch 块把所有异常都捕获住然后根据异常类型在内部用if-else分支再处理。这在性能上也许和多个 catch 块差不多JVM 的异常匹配是通过异常表完成的时间成本不高但从可读性上讲非常糟糕。正确的做法仍然是写多个精确的 catch 块JVM 会为每个 try 块维护一个异常表在处理时会根据异常类型逐条匹配这种分派几乎是常数级开销并不会因为你多写了两个 catch 而变慢。真正影响性能的是你如何根据异常类型执行后续逻辑。如果某个 catch 块内部要做大量重试或反射调用那性能问题就会来自业务处理而不是异常机制本身。8.3 一个易被忽视的耗时点打印堆栈当你把异常写入日志文件时如果日志是同步写盘并且每个异常都输出完整堆栈在高并发场景下磁盘 IO 会成为瓶颈。我见过一个线上事故某接口偶发异常大家都觉得不至于影响系统结果日志量暴增日志文件被写爆导致系统频繁 GC 甚至响应超时。最后是通过日志异步化和异常信息聚合才缓解的。另一个性能点是幂等重试和退避结合。如果捕获到远程调用超时异常立刻原地重试三次往往没有意义因为服务端可能还处于过载状态。建议加一个简单的指数退避第一次等 100ms第二次等 200ms第三次等 400ms重试前还可以做快速熔断。这个策略在异常处理中属于“重试策略”但很多开发者是在 catch 块里实现的所以也要归入异常处理的范畴。9. 常见异常类型速查与避坑清单为了便于大家日常查漏补缺我将高频出现的异常类型整理成表并附上关键的定位思路。异常类型常见原因定位思路NullPointerException对象未初始化、外部系统返回 null、参数未判空看堆栈中哪个方法哪一行确认调用链中哪个对象可能为 null优先在边界处加防御性判空IndexOutOfBoundsException访问数组、List 时下标越界检查集合大小和索引来源注意多线程场景下的ConcurrentModificationException易与其混同ClassCastException强转错误检查泛型是否擦除后被错误赋值避免无脑(ListString) obj转换IllegalArgumentException参数不合法在方法入口对参数做显式校验并抛出此异常IllegalStateException当前状态不支持某操作比如对象未初始化就调用一般表示代码顺序错误UnsupportedOperationException调用了不支持的操作多发生于Arrays.asList返回的固定长度 list 调用add/remove时ArithmeticException除数为零数学运算前检查分母注意整数除法的边界IOException文件读写、网络 IO 失败检查文件路径、权限、网络连通性注意处理的颗粒度SQLException数据库操作失败连接是否释放、SQL 语法是否错误、字段类型是否匹配OutOfMemoryErrorJVM 内存不足分析堆转储文件排查是否有大对象或泄漏StackOverflowError递归调用太深检查是否缺少终止条件或者方法调用链过深这里要特别讲一下ClassCastException在泛型擦除中的坑。比如你有一个List?内部实际存的是Integer但你强转成ListString赋值时不会立刻报错只有在取元素时才会抛ClassCastException。这是典型的类型擦除导致“延迟暴露”问题调试时容易让人困惑。更可怕的是有些代码在列表为空时操作正常一旦列表有元素才炸相当隐蔽。所以强转集合要极其谨慎能使用泛型安全地重建集合就不要直接整型强转。10. 异常设计的最后一块拼图团队规范与代码评审聊了这么多底层机制和实战细节最后想说的是异常机制用得好不好很大程度是团队规范问题。代码评审中我发现异常相关的坏味道其实很有规律下面总结几条我常用的评审清单直接用于 Code Review 会非常方便。禁止空 catch 块捕获了异常却什么都不做等于谋杀现场的证据。至少要打一行日志。禁止 catch 后吞异常再返回 null比如某个方法内部catch (IOException e) { return null; }调用方只会拿到一个 null完全不知道失败原因。宁可把异常包装后抛到上层。禁止 catch(Throwable)Throwable包含Error大多数业务代码都不应捕获Error否则系统级故障会被误吞。禁止在循环体内捕获大范围异常如果要包循环尽量包住整个循环或者在循环内部只捕获非常明确的异常避免一次异常导致循环提前终止却不易察觉。禁止 finally 中写 return / throw这一点前面强调过破坏返回语义。业务异常要有统一的错误码体系如果每个模块各自定义自己的异常类错误码不统一到前端就会变成一锅粥。在给新人定规范时我会这样建议先学会捕获异常然后学会传递异常最后要学会控制和限制异常。所谓“控制”就是明确哪些场景走异常哪些场景不该走异常。所谓“限制”就是不要让异常跨多个系统满天飞而是统一在边界出口处作转换。能做到这几点你的异常处理水平已经超过绝大多数开发者了。11. 异常处理实践中的心得体会文章讲到这里基本原理和实操技巧都覆盖得差不多了。最后分享几个我个人在实际项目中养成的习惯也许对你有参考价值。第一我写每个方法之前会先在脑子里过一遍“这个方法可能因为什么原因失败”。如果可能的原因超过两种我大概率会为它们定义不同的异常类型或错误码然后再动手写代码。这比写完再补 try-catch 要高效得多。第二我极其重视异常信息本身的可读性。任何自定义异常的 message 都要像给用户写提示语一样包含“发生了什么 关键参数 可能的影响”。比如抛异常时用户ID123 绑定银行卡失败原因银行卡号格式错误。这样的消息打进日志值班同学看见就能立刻定位不需要再翻半天代码。第三我会给线上接口设置一个“异常率告警”。当某接口的运行时异常比例超过阈值时报警比等用户投诉再去看日志要主动得多。很多异常不会立刻导致功能全挂但频繁出现的异常往往是功能劣化的前兆。最后一个小技巧在测试环境尽量开启-XX:-OmitStackTraceInFastThrow参数。JVM 有个隐藏优化同一个异常类型在热点路径重复抛出多次后为了性能会不再填充堆栈直接抛“冷异常”此时日志看到的堆栈只有一行极其不利于排查。加上这个参数可以把完整堆栈保留下来虽然性能会略降但测试环境完全没问题。线上如果你非常依赖异常定位也可以考虑开启我见过许多生产事故因为缺失堆栈导致排查耗时翻倍最后都是靠加参数出栈信息解决的。异常处理不是什么高深武功但它属于“平时不显山露水、出事时才见真章”的能力。希望你读完这篇文章之后不只是会写 try-catch而是能设计出更清晰、更健壮的异常体系。有问题欢迎在评论区一起聊我会挑典型的场景继续补充。
返回列表