【Java基础 | 11】异常处理进阶:throw、throws、自定义异常与异常链讲清楚
【Java基础】异常处理进阶throw、throws、自定义异常与异常链讲清楚概念入口一、先看进阶异常处理要解决什么问题1.1 上篇和下篇的分工1.2 核心地图二、throw主动抛出异常2.1 throw 是什么2.2 用参数校验理解 throw2.3 throw 适合用在哪些场景2.4 throw 和 return 的区别三、throws把异常风险写在方法声明上3.1 throws 是什么3.2 throw 和 throws 的区别3.3 受检异常为什么经常配合 throws四、自定义异常让错误语义更准确4.1 为什么需要自定义异常4.2 自定义运行时异常4.3 自定义受检异常4.4 到底继承 Exception 还是 RuntimeException五、异常链与异常包装不要弄丢真正原因5.1 什么是异常链5.2 自定义异常要支持 cause5.3 异常包装的正确姿势六、遇到异常时到底捕获、上抛还是包装6.1 三种选择6.2 当前层能处理就处理6.3 当前层不能处理就继续抛6.4 当前层需要换语义就包装后抛七、进阶规则重写、多异常捕获和资源边界7.1 方法重写时 throws 不能随便扩大7.2 多异常捕获7.3 try-with-resources 放到 IO 章节讲八、异常处理最佳实践8.1 不要吞异常8.2 不要捕获过宽8.3 不要滥用异常控制正常流程8.4 异常消息要有上下文8.5 异常分支也要测试九、常见误区速查表总结知识点总表 博主名称超级苦力怕 个人专栏《Java 后端修炼手册》《Java 基础语言》 每一次思考都是突破的前奏每一次复盘都是精进的开始文章元信息适合读者已经理解 Throwable、try-catch-finally、受检异常和非受检异常想继续学会主动抛异常、设计异常类型的 Java 初学者前置知识建议先读《异常处理上——基础》理解异常体系、异常传播路径和方法调用栈概念入口上一篇整理了异常处理最基础的问题异常是什么try-catch-finally怎么执行异常为什么会沿着方法调用栈向上传播。学到这里已经能看懂大多数报错信息也能写出基本的捕获逻辑。但真正写代码时还会遇到更进一步的问题什么时候应该主动throw方法声明上的throws到底有什么用Java 内置异常不够表达业务错误时能不能自己定义异常包装异常时为什么一定要保留原始原因本文就围绕这些问题把异常处理从“会接住”推进到“会设计”。一、先看进阶异常处理要解决什么问题1.1 上篇和下篇的分工异常处理可以分成两层层次重点问题代表知识点基础篇异常怎么被捕获、怎么传播、怎么阅读异常栈Throwable、try-catch-finally、调用栈进阶篇异常什么时候主动抛、怎么声明、怎么设计、怎么包装throw、throws、自定义异常、异常链也就是说基础篇更像是在回答程序出错以后Java 怎么处理进阶篇更像是在回答我们写代码时应该怎样主动表达失败1.2 核心地图可以先用一张表建立整体直觉知识点一句话理解throw在方法内部主动抛出一个异常对象throws在方法声明上告诉调用者这个方法可能抛异常自定义异常用自己的异常类型表达更准确的错误语义异常链包装异常时保留底层原始原因异常包装把底层异常转换成当前层更合适的异常最佳实践不吞异常、不乱捕获、不丢 cause、不用异常控制正常流程 核心结论异常处理不是只会写catch更重要的是知道“当前方法发现失败时应该自己处理、继续抛出还是转换成更合适的异常”。二、throw主动抛出异常2.1 throw 是什么throw用在方法体内部用于主动抛出一个异常对象。基本格式thrownew异常类名(异常信息);例如thrownewIllegalArgumentException(age 不能为负数);这行代码的意思是当前方法发现参数不合法已经无法按照正常流程继续执行于是主动创建一个异常对象并抛出去。2.2 用参数校验理解 throw✅ 参数校验失败时主动抛出异常publicstaticvoidsetAge(intage){if(age0){thrownewIllegalArgumentException(age 不能为负数ageage);}System.out.println(年龄设置成功age);}这里的逻辑很清楚age 0继续正常执行。age 0参数不合法方法无法完成“设置年龄”这个职责直接抛出异常。throw执行后当前正常流程会被打断。也就是说异常抛出之后同一个分支后面的普通代码不会继续执行。2.3 throw 适合用在哪些场景常见场景包括场景常见异常参数不合法IllegalArgumentException对象状态不允许当前操作IllegalStateException数组下标越界ArrayIndexOutOfBoundsException业务规则失败自定义业务异常当前操作不支持UnsupportedOperationException例如除数不能为 0publicstaticintdivide(inta,intb){if(b0){thrownewIllegalArgumentException(除数不能为 0);}returna/b;}这里不建议随便返回0或-1因为“除数为 0”不是一个正常计算结果而是方法无法完成职责。2.4 throw 和 return 的区别throw和return都会让当前方法停止继续向下执行但语义完全不同。对比项returnthrow表达含义方法正常完成方法无法正常完成返回内容正常结果异常对象传播方向只返回给直接调用者沿调用栈向上传播直到被捕获常见场景计算完成、查询成功、条件满足参数错误、状态错误、外部失败、业务规则失败 核心结论能给出正常结果时用return方法已经无法正常完成时用throw表达失败。三、throws把异常风险写在方法声明上3.1 throws 是什么throws写在方法声明上用于说明这个方法可能向外抛出哪些异常。基本格式修饰符 返回值类型 方法名(参数列表)throws异常类型1,异常类型2{// 方法体}示例publicstaticvoidparseExpression(Stringexpression)throwsExpressionParseException{if(expressionnull||expression.isBlank()){thrownewExpressionParseException(表达式不能为空);}}这里有两个动作方法声明上的throws ExpressionParseException告诉调用者这个方法可能抛出ExpressionParseException。方法体里的throw new ExpressionParseException(...)真正创建并抛出异常对象。3.2 throw 和 throws 的区别这两个关键字非常容易混但它们不是一回事。对比项throwthrows位置方法体内部方法声明上后面跟什么一个异常对象一个或多个异常类型是否真的抛异常是否只是声明作用主动抛出异常提醒调用者方法可能失败✅ throw 与 throws 同时出现的示例publicstaticvoidcheckAge(intage)throwsIllegalArgumentException{if(age0){thrownewIllegalArgumentException(age 不能为负数ageage);}}严格来说IllegalArgumentException是运行时异常不强制写throws。这里保留它只是为了帮助观察两个关键字的位置差异。3.3 受检异常为什么经常配合 throws如果一个方法内部可能抛出受检异常并且当前方法不打算捕获处理就必须在方法声明上写throws。先定义一个受检异常publicclassExpressionParseExceptionextendsException{publicExpressionParseException(Stringmessage){super(message);}}再让解析方法声明它publicstaticvoidparse(Stringexpression)throwsExpressionParseException{if(expressionnull||expression.isBlank()){thrownewExpressionParseException(表达式不能为空);}}调用者有两种选择。方式一自己捕获处理。try{parse();}catch(ExpressionParseExceptione){System.out.println(解析失败e.getMessage());}方式二继续声明抛出。publicstaticvoidcompile(Stringexpression)throwsExpressionParseException{parse(expression);}这就是受检异常的核心规则要么当前层处理要么继续告诉上一层。⚠️误区throws 表示这个方法一定会抛异常正确理解throws只是声明“可能抛出”。至于运行时到底抛不抛要看方法体中的实际执行路径。四、自定义异常让错误语义更准确4.1 为什么需要自定义异常Java 内置异常已经很多例如NullPointerException IllegalArgumentException IllegalStateException IOException SQLException但实际开发中经常会遇到更具体的业务错误用户名已存在。余额不足。订单状态不允许取消。配置格式不合法。表达式解析失败。如果这些问题全部用RuntimeException表示代码确实能跑但语义很模糊。调用者只知道“出错了”却不知道“到底是哪类错误”。自定义异常的价值就是让异常类型本身带有语义。4.2 自定义运行时异常自定义运行时异常通常继承RuntimeException。✅ 自定义业务运行时异常publicclassBusinessExceptionextendsRuntimeException{publicBusinessException(Stringmessage){super(message);}publicBusinessException(Stringmessage,Throwablecause){super(message,cause);}}使用publicstaticvoidpay(intbalance,intamount){if(amountbalance){thrownewBusinessException(余额不足balancebalance, amountamount);}System.out.println(支付成功);}运行时异常适合表达参数错误。状态错误。业务规则失败。调用者通常无法在当前代码附近恢复的问题。4.3 自定义受检异常自定义受检异常通常继承Exception。✅ 自定义解析异常publicclassExpressionParseExceptionextendsException{publicExpressionParseException(Stringmessage){super(message);}publicExpressionParseException(Stringmessage,Throwablecause){super(message,cause);}}使用publicstaticvoidparseExpression(Stringexpression)throwsExpressionParseException{if(expressionnull||expression.isBlank()){thrownewExpressionParseException(表达式不能为空);}if(!expression.endsWith(;)){thrownewExpressionParseException(表达式缺少结束符 ;);}}受检异常适合表达调用者确实有机会恢复的问题。API 希望强制调用者面对的问题。外部输入、外部资源或可预期失败。4.4 到底继承 Exception 还是 RuntimeException可以先用这张表判断选择编译器是否强制处理适合场景典型例子继承Exception强制调用者有明确恢复方式解析失败、配置错误继承RuntimeException不强制参数错误、状态错误、业务规则失败余额不足、非法参数入门阶段可以先记住一个朴素判断如果你希望调用者必须处理这个失败考虑受检异常。如果这个失败更多表示参数、状态或业务规则不满足通常使用运行时异常更自然。 核心结论自定义异常不是为了“显得高级”而是为了让错误类型更准确让调用者更容易判断该怎么处理。五、异常链与异常包装不要弄丢真正原因5.1 什么是异常链异常链用于把“当前层抛出的异常”和“底层原始异常”连接起来。例如try{loadConfig();}catch(ConfigSourceExceptione){thrownewConfigException(加载系统配置失败,e);}这里的e就是原始异常也叫cause。如果把它传给新异常thrownewConfigException(加载系统配置失败,e);那么后续排查问题时既能看到“加载系统配置失败”这个当前层语义也能继续往下看到底层到底为什么失败。5.2 自定义异常要支持 cause为了保留异常链自定义异常建议提供带Throwable cause的构造方法。publicclassConfigExceptionextendsRuntimeException{publicConfigException(Stringmessage){super(message);}publicConfigException(Stringmessage,Throwablecause){super(message,cause);}}两个构造方法分别解决构造方法适用场景ConfigException(String message)只有当前层错误信息ConfigException(String message, Throwable cause)包装底层异常同时保留原始原因5.3 异常包装的正确姿势异常包装不是把异常藏起来而是把底层异常转换成当前层更合适的语义。错误示例try{queryUser(username);}catch(DataSourceExceptione){thrownewRuntimeException(查询用户失败);}问题是原始异常e被丢掉了。以后看到异常栈只知道“查询用户失败”却看不到底层到底发生了什么。更好的写法try{queryUser(username);}catch(DataSourceExceptione){thrownewRuntimeException(查询用户失败usernameusername,e);}这样做有两个好处username这样的当前上下文被补充进异常消息。底层的DataSourceException作为 cause 被保留下来。⚠️误区包装异常时只写 message 就够了正确理解包装异常时应该尽量保留cause。只保留 message 会让原始异常链断掉排查问题会困难很多。六、遇到异常时到底捕获、上抛还是包装6.1 三种选择一个方法遇到异常时通常有三种选择选择适用情况例子捕获并处理当前层知道怎么恢复提示用户重新输入继续抛出当前层没有足够上下文工具方法把失败交给调用方包装后抛出当前层需要转换错误语义底层数据异常包装成业务异常判断标准很简单谁有足够上下文谁处理。6.2 当前层能处理就处理例如用户输入年龄try{intageInteger.parseInt(input);setAge(age);}catch(NumberFormatExceptione){System.out.println(请输入合法数字);}这里当前层知道可以提示用户重新输入所以捕获处理是合理的。6.3 当前层不能处理就继续抛如果当前方法只是一个底层工具方法不知道调用者想怎么处理失败就不要擅自决定。publicstaticvoidparse(Stringexpression)throwsExpressionParseException{if(expressionnull||expression.isBlank()){thrownewExpressionParseException(表达式不能为空);}}这个方法只负责解析。解析失败后提示用户、记录日志、终止流程还是换一个表达式重试应该由更上层决定。6.4 当前层需要换语义就包装后抛例如底层异常叫DataSourceException但当前业务动作是“查询用户”try{queryUser(username);}catch(DataSourceExceptione){thrownewUserQueryException(查询用户失败usernameusername,e);}这样上层看到的是更清晰的业务语义用户查询失败。 核心结论异常不是“越早 catch 越好”而是谁真正知道怎么处理谁再处理。七、进阶规则重写、多异常捕获和资源边界7.1 方法重写时 throws 不能随便扩大继承里有一个容易忽略的规则子类重写父类方法时不能随便扩大受检异常范围。可以这样理解父类方法已经向调用者承诺了“我可能抛哪些受检异常”。子类重写后如果突然抛出更宽、更陌生的受检异常调用者按父类类型使用时就会被坑。合法示例classSaveExceptionextendsException{}classDetailSaveExceptionextendsSaveException{}classParent{publicvoidsave()throwsSaveException{}}classChildextendsParent{Overridepublicvoidsave()throwsDetailSaveException{}}如果DetailSaveException是SaveException的子类这样是可以的。不合理示例classParent{publicvoidsave(){}}classChildextendsParent{Overridepublicvoidsave()throwsSaveException{}}如果SaveException是受检异常这样不可以。因为父类方法没有声明这个受检异常子类不能突然新增。7.2 多异常捕获如果多种异常的处理逻辑完全一致可以使用多异常捕获。try{intnumberInteger.parseInt(input);intresult100/number;System.out.println(result);}catch(NumberFormatException|ArithmeticExceptione){System.out.println(输入不合法e.getMessage());}适合多个异常处理方式相同。只是统一提示、统一记录或统一转换。不适合不同异常需要不同恢复方式。不同异常需要不同提示。为了省事把完全不同的问题混在一起处理。注意multi-catch 里的异常类型不能有父子类关系。比如不能同时写NumberFormatException | IllegalArgumentException因为NumberFormatException本身就是IllegalArgumentException的子类。7.3 try-with-resources 放到 IO 章节讲try-with-resources是非常重要的异常处理写法它常用于自动关闭资源。不过它和文件读写、字节流、字符流、资源关闭关系很紧密。为了避免异常篇提前展开 IO本文只先留一个边界结论需要关闭资源时不要依赖“记得手写 close”后续 IO 流章节会系统讲 try-with-resources。这里先知道它存在即可不急着展开。八、异常处理最佳实践8.1 不要吞异常最危险的写法之一try{doSomething();}catch(Exceptione){}这段代码的问题是异常消失了。后果包括真实问题没有任何线索。后续代码可能在错误状态下继续运行。排查问题时只能靠猜。至少应该做其中一件事记录异常信息。给调用者返回明确失败结果。重新抛出异常。包装成更合适的异常再抛出。更合理的示例try{doSomething();}catch(BusinessExceptione){System.out.println(业务处理失败e.getMessage());throwe;}8.2 不要捕获过宽不推荐在底层代码里随手写try{doSomething();}catch(Exceptione){System.out.println(出错了);}问题在于真实异常类型被掩盖。不同错误被迫走同一套处理逻辑。原本应该暴露的编程错误也可能被吃掉。更好的思路是能捕获具体异常就捕获具体异常。try{register(username);}catch(DuplicateUsernameExceptione){System.out.println(用户名已存在username);}当然在程序最外层做兜底捕获是另一回事。顶层兜底可以防止程序直接崩掉但也应该记录完整异常信息而不是把异常静默忽略。8.3 不要滥用异常控制正常流程异常适合表达非正常情况不适合替代普通条件判断。不推荐try{intnumberInteger.parseInt(input);System.out.println(number);}catch(Exceptione){System.out.println(输入不是数字);}如果“输入是不是数字”是一个经常发生的普通分支可以先做条件校验。if(input!nullinput.matches(\\d)){intnumberInteger.parseInt(input);System.out.println(number);}else{System.out.println(请输入数字);}注意这里重点不是推荐所有场景都用正则而是强调普通分支优先用条件判断异常用于非正常失败。8.4 异常消息要有上下文异常消息不要只写thrownewIllegalArgumentException(error);更好的写法thrownewIllegalArgumentException(score 必须在 0 到 100 之间scorescore);好的异常消息通常包含哪个参数或对象出了问题。当前值是什么。为什么不合法。当前正在执行什么操作。8.5 异常分支也要测试异常分支也是代码逻辑的一部分也需要测试。例如用 JUnit 风格写assertThrows(IllegalArgumentException.class,()-{setScore(-1);});如果要检查异常消息IllegalArgumentExceptioneassertThrows(IllegalArgumentException.class,()-{setScore(-1);});assertTrue(e.getMessage().contains(score));测试异常的意义是防止以后改代码时删掉参数校验。防止异常类型变得过宽。防止异常消息丢失关键上下文。防止包装异常时忘记保留 cause。⚠️误区运行时异常不用管正确理解运行时异常只是编译器不强制捕获不代表设计上可以忽略。参数、状态、业务规则这些问题仍然需要清晰表达和测试。九、常见误区速查表常见误区更准确的理解throw和throws差不多throw是真正抛异常throws是声明可能抛异常throws表示方法一定会抛异常throws只表示可能抛出自定义异常只是换个名字自定义异常的核心价值是表达更准确的错误语义捕获异常就算处理了捕获后必须有明确动作恢复、记录、提示、重新抛出或包装包装异常时只保留 message 就够了应该保留 cause否则原始异常链会断catch (Exception e)最省事低层代码捕获过宽容易掩盖真正问题运行时异常不用管编译器不强制处理不代表设计上可以忽略异常可以代替所有if判断普通业务分支优先用条件判断异常用于非正常情况总结知识点总表知识点关键结论throw方法内部主动抛出异常对象表示当前方法无法正常完成throws方法声明上说明可能抛出的异常类型尤其常见于受检异常自定义异常用更具体的异常类型表达更准确的业务或领域错误运行时异常编译器不强制处理常用于参数、状态、业务规则失败受检异常编译器强制处理或继续声明适合调用者可恢复的问题异常链用cause保留底层原始异常异常包装把底层异常转换成当前层更合适的语义多异常捕获适合同一处理逻辑但异常类型之间不能有父子关系最佳实践不吞异常、不捕获过宽、不丢 cause、不滥用异常控制流程这个知识点的最终记忆可以压缩成四句话能处理就捕获并给出明确处理动作。不能处理就继续向上抛。需要转换语义就包装后抛并保留原始 cause。异常不是为了掩盖错误而是为了让失败更清楚、更可控地传播。