ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Java异常体系详解:从Throwable到自定义业务异常设计

Java异常体系详解:从Throwable到自定义业务异常设计 做 Java 开发这些年几乎每天都在和异常打交道。NullPointerException、ArrayIndexOutOfBoundsException、IllegalArgumentException……这些名字对 Java 程序员来说太熟了。但真被问到“Throwable 和 Exception 是什么关系”“受检异常和非受检异常怎么区分”“自定义业务异常到底该继承谁”能讲清楚的人其实没那么多。这篇文章就把 Java 异常这条线从根上捋一遍先看 Throwable 家族图谱再讲 try-catch-finally 这些处理机制最后落地到一套可复用的自定义业务异常设计。新手能把它当成入门扫盲准备面试的可以对着高频题自测已经在写业务代码的也能拿最后一节的设计直接改到项目里。1. Throwable 家族一切异常的根1.1 Error 和 Exception两类问题的分界线Java 里所有异常相关的东西最终都挂在Throwable这个类下面。Throwable在java.lang包里它有两个直接子类Error和Exception。这个结构是所有异常知识的起点也是面试官最喜欢问的“请描述一下 Java 异常体系”的标准答案框架。Error表示 JVM 层面的严重问题常见的有OutOfMemoryError内存溢出、StackOverflowError栈溢出、NoClassDefFoundError类找不到。这类问题一旦出现程序基本处于“救不回来”的状态你正常业务代码里不应该去捕获它因为捕获了也没法恢复。比如堆内存已经满了你 catch 住OOM又能怎样再申请内存还是会抛强行处理反而会掩盖系统的真实状态。Exception则是程序运行过程中可以恢复、可以处理的问题。比如配置文件不存在、网络连接超时、参数格式不对这些都是Exception范畴。平时我们写的try-catch就是围绕Exception展开的。还有一个容易忽略的细节Throwable本身也是可以被catch的但不建议在业务代码里catch (Throwable t)因为这会连Error一起抓住等于把线程死亡的信号吞掉后续排查会非常痛苦。1.2 受检异常与非受检异常编译器的两条规则Exception下面又分了两派。一派是“受检异常”checked exception直接继承Exception但不继承RuntimeException比如IOException、SQLException。另一派是“非受检异常”unchecked exception继承RuntimeException比如NullPointerException、IllegalArgumentException。受检和非受检的核心区别就一句话编译器管不管。受检异常要求你必须在方法里try-catch或者在方法签名上用throws声明否则编译直接不过。非受检异常则没有这个约束你可以完全不处理编译器也不拦你。我见过很多刚入门的朋友不理解为什么要有受检异常总觉得是编译器在找麻烦。实际上受检异常的设计初衷是为了强制程序员处理那些“可以预期但不一定能避免”的外部问题。比如你去读一个文件文件可能不存在这是程序之外的环境因素你必须想想这个情况怎么办。所以受检异常更像是一种“流程契约”。但后来大家也发现受检异常在大型项目里很容易导致throws满天飞方法签名被异常声明污染可读性变差。所以现代框架和大型项目反而越来越倾向用非受检异常配合全局处理器统一兜底这也是后面自定义业务异常要继承RuntimeException的重要原因。2. 异常处理三条路try-catch-finally、throws、try-with-resources2.1 try-catch-finally 的真正执行顺序try-catch-finally是 Java 异常处理的骨架但很多写了两三年代码的人对它的执行顺序也未必有准确认知。先看一个最简单的结构try { // 可能出问题的代码 } catch (IOException e) { // 处理 IOException } catch (Exception e) { // 兜底处理其他异常 } finally { // 无论是否发生异常都会执行的代码 }try块里的代码一旦抛出异常catch会按顺序匹配异常类型匹配到第一个能接住它的块就停下。所以写catch的顺序要从具体到宽泛先catch子类再catch父类。反过来的话子类的catch永远不会被执行编译器也会直接报错。finally块则负责“善后”通常用来释放资源、关闭连接、恢复状态。关于finally有几个细节值得单独说。第一finally不一定会执行如果 JVM 在 try 之前或者 try 过程中直接退出了比如调用System.exit(0)、发生OOM导致进程崩溃那finally就指望不上了。第二finally里如果又抛出了新异常这个新异常会覆盖掉原本的异常导致原始问题丢失。第三千万不要在finally里写return。我举个例子下面这段代码返回什么public static int test() { int i 1; try { return i; } finally { i; } }答案是 1。因为当try里执行return i时会先把i的当前值 1 保存到返回值的位置然后才去执行finally此时finally里i改的是局部变量已经影响不到返回值了。但如果finally里写了return i这个返回值就会覆盖try里的返回值编译器会让你知道这不是一个好主意。这个知识点在面试里出现频率极高后文专门再说。2.2 throw 与 throws抛出异常的两副面孔throw和throws看起来只差一个字母意思完全不同。throw是在代码里主动抛出一个异常对象后面跟的是一个具体的异常实例比如throw new IllegalArgumentException(id 不能为空);throws是写在方法签名上的声明告诉调用方“我这个方法可能会抛出下列异常你调用的时候要处理”。比如public void readFile(String path) throws IOException { // ... }对于受检异常如果方法内没有用try-catch处理就必须用throws声明由调用方去处理。这是编译器的硬性要求。但对于非受检异常你既可以不加声明直接让它往上抛也可以在签名上写上throws作为一种显式提示。我建议在容易被误用的方法上即使是非受检异常也可以用throws写明相当于给调用方的文档。还有一个点是throw之后方法不会继续执行。如果你写了throw后面的代码属于不可达代码编译器会提示。我见过不少新手在同一个方法里先抛了异常后面又写一堆逻辑以为异常不会中断流程这其实说明对异常传播还没有概念。异常从throw抛出后会沿着方法调用栈一层层往上抛直到被某个catch接住或者一直抛到 JVM 手里导致线程终止。2.3 try-with-resources把资源关闭交给编译器Java 7 引入了try-with-resources处理必须关闭的资源时非常省心。比如读文件用传统方式你得写一个finally去关流还得处理关闭时可能抛出的 IOException。用try-with-resources之后长这样try (BufferedReader reader new BufferedReader(new FileReader(data.txt))) { String line reader.readLine(); // 处理业务 } catch (IOException e) { log.error(读取文件失败, e); }try后面的括号里可以放多个资源对象用分号分隔。代码块结束时编译器会自动调用每个资源的close()方法顺序和声明顺序相反。前提是这个资源类实现了AutoCloseable接口这个接口只有一个close()方法。像InputStream、OutputStream、Connection、Statement、ResultSet这些都实现了可以直接用。try-with-resources还有个细节如果try块里抛了一个异常close()也抛了一个异常那么try块里的原始异常会保留close()抛出的异常会被标记为“被抑制的异常”suppressed可以通过Throwable.getSuppressed()查出来。这个机制和finally里异常覆盖原始异常完全不同设计上合理得多。所以能用try-with-resources的地方就不要再用传统finally手动关闭了。3. 从常见异常到自定义业务异常3.1 高频异常类速查看到名字就知道发生了什么Java 里异常类非常多但日常开发和面试翻来覆去就那几个。我把它们整理成一个速查表遇到异常能快速定位排查方向异常类典型触发场景排查方向NullPointerException对 null 对象调用方法、访问属性先打日志确认哪个对象是 null补判空或用Objects.requireNonNullArrayIndexOutOfBoundsException访问了数组中不存在的下标检查索引是否在 0 到 length-1 之间注意循环边界IllegalArgumentException方法参数不满足前置约束检查调用方传入的值是否为 null、空串、负数IllegalStateException对象当前状态不允许执行操作检查操作顺序和状态流转比如未初始化就调用NumberFormatException字符串转数值失败如Integer.parseInt(abc)确认字符串格式必要时先做正则校验ClassCastException强制类型转换失败转换前用instanceof判断类型UnsupportedOperationException调用了未实现的方法常见于Arrays.asList返回的列表调用 add 操作检查是否对不可变集合做了修改操作这些异常绝大多数都是非受检异常编译器不强制处理。但它们背后往往对应着程序的 Bug。NullPointerException是最常见的很多团队在新代码里已经强制使用Optional或提前参数校验来规避。IllegalArgumentException则代表“方法对你的输入不满意”这类异常通常说明调用方违反了约定。工程上有一个原则公共方法入口处要把好参数校验这道关用Objects.requireNonNull、范围判断等手段尽早失败比让异常在内部绕一大圈再冒出来清晰得多。3.2 为什么项目里需要自定义业务异常很多新手会有疑问Java 自带的异常类已经够多了为什么还要自己定义直接throw new RuntimeException(库存不足)不行吗行但用起来很难受。假设你的系统有几十个业务报错点库存不足、订单状态不对、用户不存在、余额不够这些如果全部抛一个RuntimeException前端和调用方拿到异常后只能看 message 字符串没办法用代码准确判断到底发生了什么。自定义业务异常的核心价值有两个。第一个是“语义化”StockNotEnoughException比RuntimeException一眼就能看出来是库存问题。第二个是“结构化”业务异常可以携带错误码、错误描述、上下文参数等结构化信息这样在接口层可以统一转成错误响应日志系统可以根据错误码归类监控系统可以按错误码统计告警。打个比方你去银行柜台办事工作人员如果说一句“不行”你根本不知道是哪里不行是你资料没带齐还是账户余额不够还是业务已停办。但如果对方告诉你“错误码 1001当前余额不足请先充值”你就能按这个信息去行动。自定义业务异常就是为了避免“一句不行”式的模糊错误。3.3 自定义业务异常的四个设计要点结合项目经验设计自定义业务异常我只留四个核心要点。第一继承RuntimeException。原因前面提过受检异常会强制在每一层方法签名上声明throws侵入性太强。继承RuntimeException后业务代码里可以直接抛由最外层的全局异常处理器统一接收不用每个 Service 方法都声明异常。另外Spring 框架的事务默认只对RuntimeException回滚继承它可以在异常发生时自动触发事务回滚省掉手动rollback的麻烦。第二提供多种有实用价值的构造方法。只提供一个(String message)构造器是不够的。项目里通常需要支持“根据枚举构造”“自定义 message 覆盖枚举默认值”“携带原始 cause”这几种场景。比如系统里业务异常如果从底层IOException包装上来的一定要把原始异常传进去不然堆栈就断了。第三带上错误码字段。错误码建议用一个独立的枚举管理不要用魔法数字散落在代码里。错误码的分段要有规则比如 1xxx 是订单域2xxx 是用户域3xxx 是支付域这样一看到错误码就知道出问题的模块。第四加上serialVersionUID。异常类要实现SerializableThrowable已经实现了这个序列化 ID 是为了保证版本兼容。开发工具经常会提示你补上它别忽略否则类结构一变动同一个异常在新旧版本之间反序列化可能失败。下面是一个比较标准的自定义异常基类写法public class BizException extends RuntimeException { private static final long serialVersionUID 1L; private final int code; public BizException(ErrorCode errorCode) { super(errorCode.getMessage()); this.code errorCode.getCode(); } public BizException(ErrorCode errorCode, String message) { super(message); this.code errorCode.getCode(); } public BizException(ErrorCode errorCode, Throwable cause) { super(errorCode.getMessage(), cause); this.code errorCode.getCode(); } public BizException(int code, String message) { super(message); this.code code; } public int getCode() { return code; } }4. 落地一套业务异常体系从错误码到全局兜底4.1 错误码枚举 BizException 基类在真正的项目里我不建议每个业务点都去自定义一个新异常类而是定义一个BizException基类配合一个错误码枚举使用。这样既保证了灵活性又不会让异常类爆炸式增长。错误码枚举可以这样写public enum ErrorCode { PARAM_ERROR(400, 参数错误), NOT_FOUND(404, 资源不存在), STOCK_NOT_ENOUGH(1001, 库存不足), ORDER_STATUS_ERROR(1002, 订单状态异常), USER_NOT_EXIST(2001, 用户不存在), SYSTEM_ERROR(500, 系统繁忙); private final int code; private final String message; ErrorCode(int code, String message) { this.code code; this.message message; } public int getCode() { return code; } public String getMessage() { return message; } }使用的时候只需要一行throw new BizException(ErrorCode.STOCK_NOT_ENOUGH);如果你需要在错误信息里带具体的上下文比如当前库存还剩多少可以用带 message 的构造方法throw new BizException(ErrorCode.STOCK_NOT_ENOUGH, 库存不足当前剩余: stock);这样做的好处是调用方和服务端都只需要认codemessage 只是给人看的。前端拿到 code 之后就可以做对应的交互提示甚至直接映射成多语言文案。错误码分段是我后来才体会到多重要的事。刚开始图省事直接用 1、2、3 这样流水编号结果系统一大根本分不清是哪个模块报的错。后来全面改成按域分段比如 1 开头订单域、2 开头用户域、3 开头支付域再后面两位是具体错误。这个改动虽然是一次性成本但从此以后告警和排查的效率明显高了一截。4.2 统一捕获与统一响应别让异常裸奔自定义异常设计得再好如果每个 Controller 里都自己try-catch再写响应代码会非常难维护。正确做法是让异常一路抛到最外层由唯一的全局异常处理器来兜底。以 Spring 项目为例通常会写一个RestControllerAdvice类里面配上几个ExceptionHandler方法。整体的处理逻辑是业务异常BizException转成对应的错误码和消息返回给调用方参数校验异常转成参数错误其他未预期异常全部记日志并且对外只返回“系统繁忙”避免把堆栈细节暴露给外部。这里的日志要记完整堆栈对外响应要只留安全信息这两者并不冲突。我在实际项目里还会额外留一个坑位逻辑全局处理器里判断当前请求是否来自内部调用如果是内部系统可以把更详细的错误信息返回方便联调如果是对外接口则一律只返回到错误码级别。用一个开关控制上线前关掉详细模式联调时打开。这个经验让我少加了很多夜班。4.3 日志记录把异常现场留下异常处理的另一个关键问题是日志。很多人习惯写log.error(出错了: e.getMessage())只在日志里记一句话这样排查时经常发现信息不够。最朴素也最正确的做法是log.error(查询订单失败, orderId{}, orderId, e);第三参数传异常对象日志框架会把完整的堆栈打印出来。堆栈里有异常发生的位置、调用链、根本原因这些信息比 message 珍贵得多。千万不要用e.printStackTrace()它会把堆栈打到标准错误流在生产环境里基本捞不到而且是非线程安全的。日志里带上上下文参数也很重要。比如查订单失败至少要记订单号入库失败至少要记主键和唯一键值。这样出问题的时候你可以按订单号直接搜日志而不是从海量日志里翻上下文。我踩过最重的一次坑是某次线上批量任务报错日志里只有一行“定时任务执行失败”没有批次号、没有具体任务 ID结果从几十万行日志里搜了半天最后加上上下文参数之后这种问题再没出现过。5. 面试高频题与实战排查实录5.1 finally、return、异常吞噬经典三连问Java 异常相关的面试题里最经典的就是finally和return的执行顺序。面试官经常给出代码让你判断输出或者是问“try 里有 returnfinally 会执行吗”“finally 里 return 会怎样”。我来把这几层全部说清楚。第一层try里有returnfinally会执行。finally一定会执行在return表达式求值之后、方法真正返回之前。第二层finally里如果没有return它不影响try里已经确定的返回值。前面已经演示过这个例子。第三层如果finally里有return它会直接覆盖try或catch里的return这是最危险的写法。第四层如果try和finally都抛异常finally里的异常会覆盖原始异常原始异常直接丢失。还有一个衍生考法异常被“吞噬”的场景。例如try { throw new RuntimeException(try异常); } catch (RuntimeException e) { throw new RuntimeException(catch异常); } finally { throw new RuntimeException(finally异常); }最终抛出的只有finally异常前面两个异常都不会出现在堆栈里。这种代码放在生产环境里就是灾难现场原始信息完全没了。凡是出现这种情况先怀疑是不是有人在finally或者catch里又抛了新异常把根因盖住了。排查的时候可以顺着堆栈往上追如果发现异常链被覆盖就去看最后有没有initCause或者构造时有没有传cause。5.2 别用异常控制流程性能和语义都扛不住我见过一段写得很“潇洒”的代码用一个循环去遍历数组里面用try-catch当 if 用。甚至有人写“先 int i 0; try { while (true) { arr[i]; i; } } catch (ArrayIndexOutOfBoundsException e) { 结束 }”这种逻辑把数组越界当成循环出口。这样虽然能跑但问题是巨大的。首先异常对象的创建要填充堆栈这是一个非常昂贵的操作。异常本来应该出现在异常路径上结果你把它放在正常流程里等于每次循环结束都要经历一次昂贵的堆栈填充。数据量一大性能直接崩。其次语义上完全错误。数组越界是程序 Bug不应该被当作正常的结束信号。正确写法就是for (int i 0; i arr.length; i)用条件判断控制边界。这个原则延伸到业务代码里也一样。比如判断一个字符串能不能转成数字不要用try-catch包住Integer.parseInt来当校验逻辑而是先做格式校验或者用更安全的解析方式。异常处理的正确姿势是可预期的业务分支用条件判断真正的未知异常才交给try-catch。5.3 数组越界与非法参数两个现场复盘这里分享两个我实际排查过的案例都很典型。第一个是数组越界。同事反馈某个接口偶发报ArrayIndexOutOfBoundsException我转到对应代码发现是取列表最后一个元素时用了list.get(list.size())而不是list.get(list.size() - 1)。这个 Bug 平时数据量少的时候不触发只有某些条件下集合大小变化才会踩到。排查时我是先看异常堆栈定位到具体行号再看集合初始化逻辑。这类问题的通用排查套路就是先拿堆栈找到行号再回头看数组中下标计算是否有“差一”错误确认边界条件是 length还是 length。第二个是非法参数。一个上传接口偶尔报IllegalArgumentException: No enum constant原因是调用方传了一个枚举里不存在的字符串我用的又是Enum.valueOf一旦找不到就直接抛这个异常。后来改成先用循环遍历匹配一次匹配不到就给默认值同时在入口处做参数校验异常一下子就消失了。这类问题告诉我们的道理是底层抛出的异常信息虽然准确但最好在入口处就用 if 拦下来不要让用户感受到一个花哨的内部异常。排查异常时还有一个习惯很重要先确认异常发生的线程。如果日志里看到Exception in thread http-nio-8080-exec-3说明是请求线程如果是kafka-consumer-thread说明是消息消费线程。不同线程的处理策略完全不同请求线程的异常可以从网关层换掉消费线程的异常处理不好就可能导致消息不落库或者一直重试。5.4 我的异常处理习惯最后聊一点我在项目里沉淀下来的习惯也是我觉得玩懂 Java 异常后真正值钱的部分。我会把项目里的异常处理分成三层思考。最外层是接口和外部依赖统一走全局异常处理器对外只暴露错误码和必要信息所有细节进日志中间层是业务服务主动抛出带错误码的BizException业务代码里不写大段try-catch让异常自然向上一层传播最底层是基础设施比如数据库、文件、网络这些地方出现的受检异常统一在最外层转换成系统异常并保留完整堆栈。这样每一层各司其职异常不会在中途被静默吞掉排查时又能靠错误码快速缩小范围。还有一个容易忽略的点是异常信息的可读性。我在写throw new BizException时message 一定会写清楚“发生了什么 相关关键值”而不仅仅是“失败”。比如“订单 20240913001 重复提交原状态 PAID”这种 message 让日志系统变得非常好用。如果只是写“订单状态异常”运维和开发看到都要再去查一次数据才能定位沟通成本高得吓人。异常处理没有银弹把基础体系捋清楚之后剩下的就是在真实项目里不断打磨自己的分层、命名、日志习惯。这篇文章能帮你把 Java 异常从 Throwable 到自定义业务异常这条主线走通后面再踩坑的时候至少知道往哪个方向找了。
返回列表