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

文章详情

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

Java异常处理从入门到实战:受检异常、自定义异常与资源关闭核心解析

Java异常处理从入门到实战:受检异常、自定义异常与资源关闭核心解析 1. 异常处理练习题的设计思路为什么初学者总在这里栽跟头我带过的初学者里十个有九个在学到异常处理这一章的时候开始怀疑人生。前面的语法、循环、数组都还好好的一碰到try-catch-finally、受检异常和非受检异常这些概念整个人就懵了。这不怪大家异常处理本身牵扯到Java语言比较底层的机制——调用栈、异常对象传播、资源释放再加上编译器强制要求的受检异常如果练习题的编排不合理很容易变成死记硬背。所以我设计这套练习题的时候刻意避开了“给一段代码问输出是什么”的单维度考法。这类题目只能测出你有没有背过结论测不出你真正理解了多少。我的思路是分层递进先从选择题建立概念框架再通过改错题锻炼代码敏感度然后用代码阅读题训练调用栈视角最后用一道综合实战题把受检异常、自定义异常、资源关闭这些知识点全部串起来。每一层的题目之间是有逻辑关联的不是随便拼凑的题库。这套题适合什么人用正在学Java基础的学生、准备求职笔试的应届生、以及带新人的技术导师。你不需要额外准备别的资料把示例代码一行行敲进去运行、故意制造异常看结果比自己空想要有效得多。我还会在每个题目后面补充“为什么这样设计”的说明这部分往往是书上不会写、但面试官最爱问的。1.1 练习题规划的四个层次我见过很多练习册上来就扔一堆综合题读者连单点概念都没建立就被砸晕了。这套题按认知难度分成四个层次每一层解决一类核心问题。第一层是概念辨析题解决“异常到底是什么”的问题。重点考察受检异常和非受检异常的分类逻辑、异常和错误的区别、异常处理的关键字各自的作用。这一层不需要写代码但是必须能把概念说清楚能用生活化的例子讲明白。第二层是代码改错题解决“能不能看懂别人的异常代码”的问题。给出几段有问题的代码让学习者找出编译错误和运行时错误。这一层考察的是对异常处理语法的敏感度比如catch块的顺序、finally块有没有return、受检异常有没有被声明或捕获等。第三层是代码阅读题解决“能不能推断异常传播路径”的问题。给出一段多方法嵌套调用的代码要求分析异常是在哪一层被抛出、被哪一层捕获、调用栈打印出来是什么顺序。这一层是最能训练实战能力的因为日常开发中你经常要根据堆栈信息去定位问题。第四层是综合实战题解决“能不能自己设计异常体系”的问题。要求自定义一个业务异常类、在合适的时机抛出、在调用方捕获并做相应处理同时保证资源能正确释放。这一层完全是模拟真实业务的流程。2. 核心细节解析这些异常基础概念最容易混淆2.1 受检异常和非受检异常到底差在哪里很多初学者搞不懂为什么有些异常编译器强制你处理有些不用。这个门道其实源于Java设计的理念编译器在编译阶段帮你做了一次“风险预判”。受检异常Checked Exception是编译器认为可以预见、应该提前处理的异常。比如你要读取一个文件这个文件可能不存在你读取的路径可能没有权限这些都是编写代码时就能预见到的风险。所以编译器强制你在编译期就给出处理方案——要么用try-catch包住要么在方法签名上加throws声明交给上层处理。我给学生打过一个比方受检异常像是坐飞机前要过安检机场不会因为你没带违禁品就不设安检规则是固定的你必须走这个流程。非受检异常Unchecked Exception是指编译器认为无法在编译期预见的异常包括运行时异常RuntimeException和错误Error。比如你的代码里访问了一个null对象这只有在程序运行的某个特定时刻才会触发数组越界、类型转换错误同理。这类异常编译器不强制你处理因为它们太依赖于运行时的具体数据。坐飞机过安检的比喻里非受检异常更像是路上突发的交通事故你出发前没办法预知哪条路会出事。这里有一个很重要的实践认知Java的这套受检异常机制确实能避免很多低级错误但在实际项目中过度的受检异常会严重恶化代码的可读性。所以很多现代框架倾向于使用非受检异常。比如Spring框架的数据访问异常全部是运行时异常目的就是不让每一层代码都被try-catch污染。练习题的第一个选择题就是围绕这个点展开的因为理解了这个设计哲学你才能判断什么时候该抛出受检异常、什么时候该抛出非受检异常而不是无脑按照编译器的提示走。2.2 try、catch、finally三兄弟的执行顺序和return之谜先抛出一个常见的陷阱题try块里有return语句finally块里也有return语句最后返回的是什么答案很多初学者记不住但只要你理解了finally的设计意图就永远忘不掉。finally块的设计意图是“无论什么情况都要执行的收尾工作”。它的执行时机在return表达式的值被计算出来之后、真正把值返回给调用方之前。如果finally块里出现了return它会直接覆盖掉try或者catch块中已经计算好的返回值。这就像你在银行柜台办完取款业务钱都已经拿到手了柜台工作人员非要再递给你一张优惠券你手里最终拿的是钱加优惠券的组合——最终返回值被重写成了一个复合结果。所以在实际开发中我给自己立了一条规矩绝不建议在finally块中写return。这不仅是风格问题更是正确性问题。如果你的代码在finally里写了return恰好try块中捕获了一个异常这个异常会被静默吞掉调用方根本感知不到。这种bug极其隐蔽排查起来简直灾难。下面这道改错题就是专门针对这个陷阱设计的让你亲眼看到吞掉异常的严重后果。2.3 多个catch块的匹配顺序为什么顺序错了代码编译不过很多人把多个catch块理解成“只要有一个匹配就行”忽略了它们是从上到下顺序匹配的。而且这里有个硬性规则异常类型存在继承关系时子类异常的catch必须写在父类异常的catch之前。比如IOException是Exception的子类你必须先写catch(IOException)再写catch(Exception)。如果你把catch(Exception)写在前面编译器直接报错因为Exception的catch会拦截所有其子类异常后面的catch(IOException)就变得不可达了。编译器的错误提示是exception IOException has already been caught说的就是不可达代码问题。我给学生讲这块的时候惯用的解释是多个catch块就像一个流水线上的分拣员异常对象从第一个catch块开始逐个尝试匹配看这个异常对象是不是当前catch参数类型的实例。一旦匹配成功就进入对应的处理代码后面的catch块全部跳过。这隐含了一个重要结论异常处理是互斥的一次异常只会进入一个catch块不可能同时进入多个。3. 实操过程与核心环节实现一套可以直接练的异常处理习题下面这些题目建议你按顺序来。先不要看答案自己用IDE跑一遍把代码手动敲进去会比复制粘贴的效果好很多。我会在每题后面给出参考代码和设计意图解析。3.1 概念辨析题异常分类与关键字的作用题目1下面哪种说法是正确的A. 所有异常都是RuntimeException的子类B. Error类表示程序可以恢复的严重问题C. 受检异常可以不在编译期处理只要运行时存在即可D. 非受检异常包括RuntimeException及其子类正确答案是D。Error类代表程序无法恢复的严重问题比如内存溢出、栈溢出这类问题不应该被捕获和恢复。勉强去捕获Error不仅没有意义还可能掩盖系统级的故障。这道题表面考分类深层考的是Java对异常的分层设计思想Throwable作为基类下面分Error和Exception两条支线Exception下面是受检异常的领域和RuntimeException的领域。你只有从这一个宏观视角去看才不会在具体题目上纠结。题目2关于throws关键字的使用场景下列说法正确的是A. throws只能用在try块中B. throws用在方法声明处表示这个方法可能抛出某种异常由调用方处理C. throws用在catch块中用于定义捕获的异常类型D. throws可以用在类声明处正确答案是B。throws和throw是两个非常容易混淆的关键字。throws声明“可能抛出的异常类型”throw是真正地创建一个异常对象并抛出。一个国际惯例记忆法throws带s是声明用的statementthrow不带s是执行用的action。3.2 代码改错题找出异常处理代码中的致命问题题目3阅读下面的代码找出至少三处错误。public void readFile(String filePath) { try { FileInputStream fis new FileInputStream(filePath); BufferedReader reader new BufferedReader(new InputStreamReader(fis)); String line reader.readLine(); System.out.println(line); reader.close(); } catch (Exception e) { System.out.println(出错了); } catch (FileNotFoundException e) { System.out.println(文件不存在); } }这段代码的问题如下第一处FileNotFoundException是IOException的子类而IOException又是Exception的子类所以catch(Exception)在前面的写法会导致catch(FileNotFoundException)永远无法执行。编译器会直接报错提示FileNotFoundException的捕获已经被前面的catch处理了。第二处FileInputStream构造方法和BufferedReader的readLine方法都会抛出IOException这是受检异常代码中确实用try-catch包住了所以这一处不算错但要注意如果方法签名中加上throws IOException代码会更灵活。我漏掉这点说一下吧初学者容易把所有受检异常都吞掉在catch块里只打印一句话就完了这在真实业务中是非常危险的做法等于把错误信息瞒报了。第三处reader.close()放在try块的末尾当readLine抛出异常时close根本不会执行。这涉及到资源泄漏问题。正确做法是把close放到finally块中或者更现代的做法是使用try-with-resources语法这个后面实战题会细讲。3.3 代码阅读题异常在调用链中是如何传播的题目4下面这段代码最终输出的内容是public class ExceptionFlowTest { public static void main(String[] args) { try { System.out.println(main start); methodA(); System.out.println(main end); } catch (ArithmeticException e) { System.out.println(caught in main: e.getMessage()); } } public static void methodA() { System.out.println(methodA start); methodB(); System.out.println(methodA end); } public static void methodB() { System.out.println(methodB start); int result 10 / 0; System.out.println(methodB end); } }输出结果是main start methodA start methodB start caught in main: / by zero我解释一下这个传播过程这是异常处理里最核心的机制之一。程序从main方法的try块开始执行打印main start然后调用methodA。methodA打印自己进入的消息后调用methodB。methodB打印进入消息后执行10/0触发了ArithmeticException。异常对象在methodB中被创建后先看methodB里有没有try-catch可以处理。没有于是异常沿着调用栈回到methodA。methodA也没有try-catch于是继续向上回到main方法的try-catch结构。main中catch的参数类型是ArithmeticException正好匹配于是进入catch块打印消息。注意输出结果中没有methodB end和methodA end。原因很简单异常在methodB的第3条语句抛出后面的语句不再执行methodB的调用点methodA内部的后续语句也不会再执行。初学者经常在这一步绕晕其实记住一条规律就行异常一旦抛出当前方法中抛出点之后的代码全都不执行方法调用方中调用点之后的代码同样不执行直到遇到匹配的catch块为止。这道题还有一个变体在methodB内部用try-catch包裹除法运算那么异常在methodB内部就被消化了main里的catch永远不会触发输出结果会多出methodA end和main end。建议你手动跑一下这个变体对比两种结果的差异比看十遍概念都管用。3.4 综合实战题设计一个带自定义异常的账户余额查询系统现在我们把前面所有概念熔到一道实战题里。假设你要实现一个简单的银行账户余额查询系统要求余额不足时抛出一个自定义异常InsufficientBalanceException查询余额的方法声明中抛出上述异常调用方捕获异常并给出友好提示查询过程中使用到的资源要保证正确关闭先定义自定义异常类。注意自定义异常应根据自身语义选择继承哪个父类。我觉得在这个场景下余额不足属于业务规则问题不是编译期的可预见风险更适合继承RuntimeException。这样的好处是service层的方法不需要在签名里强制声明它代码会更干净。但如果你的项目规范要求所有的业务异常都是受检异常继承Exception也没有问题。下面是继承Exception的版本用于练习受检异常的完整流程。class InsufficientBalanceException extends Exception { public InsufficientBalanceException(String message) { super(message); } }然后是账户类和查询逻辑。这里我故意不用try-with-resources而是用最传统的finally方式目的是让你看到资源关闭的完整模板。在实际项目中我也倾向于直接用try-with-resources后面会给对比。import java.io.BufferedReader; import java.io.FileReader; import java.io.IOException; public class BankAccount { private String accountId; private double balance; public BankAccount(String accountId, double balance) { this.accountId accountId; this.balance balance; } public double getBalance(String operatorId) throws InsufficientBalanceException, IOException { FileReader fileReader null; BufferedReader bufferedReader null; try { fileReader new FileReader(operator_log.txt); bufferedReader new BufferedReader(fileReader); String logLine bufferedReader.readLine(); if (blocked-operator.equals(logLine)) { throw new InsufficientBalanceException(操作员 operatorId 已被限制访问); } return this.balance; } finally { if (bufferedReader ! null) { bufferedReader.close(); } if (fileReader ! null) { fileReader.close(); } } } }这个实现有几个非常关键的设计细节初学者务必逐条看懂。第一getBalance方法同时抛出了两个受检异常所以方法声明处必须写throws InsufficientBalanceException, IOException。漏掉任何一个编译器都会报错。如果你觉得两个异常签名太长可以合并为throws Exception但这是极度不推荐的做法等于把异常处理的职责完全丢给了调用方调用方根本不知道具体是什么异常出了问题。第二finally块的close操作是在方法返回之前执行的。如果finally的close过程中抛出了IOException它会覆盖掉try块中的return值或者覆盖掉try块中抛出的InsufficientBalanceException。极端情况下如果try中抛出了业务异常finally中的close又抛出了IOException调用方最终收到的是IOException。这个异常覆盖机制非常隐蔽是线上事故常见的凶手之一。第三如果使用try-with-resources写法代码会简洁得多try (FileReader fileReader new FileReader(operator_log.txt); BufferedReader bufferedReader new BufferedReader(fileReader)) { String logLine bufferedReader.readLine(); if (blocked-operator.equals(logLine)) { throw new InsufficientBalanceException(操作员 operatorId 已被限制访问); } return this.balance; }try-with-resources会在try块结束时自动按逆序关闭声明的资源同时还会把close方法抛出的异常“附加”到try块中原始异常的堆栈上而不是覆盖掉原来的异常。这一点对排查问题太重要了你用传统finally写法遇到异常覆盖问题真正的原因可能完全丢失用try-with-resources写法则可以同时看到两个异常的完整堆栈信息。调用方代码也很关键public class BankService { public void queryAndDisplay(String accountId, String operatorId) { BankAccount account new BankAccount(accountId, 1000.0); try { double balance account.getBalance(operatorId); System.out.println(账户余额 balance); } catch (InsufficientBalanceException e) { System.out.println(查询被拒绝 e.getMessage()); // 这里可以做业务补偿操作比如发送通知、记录审计日志 } catch (IOException e) { System.out.println(系统日志读取失败请联系管理员); // 这里应该配合日志框架记一条error级别日志 } } }注意catch块的次序符合前面说的匹配规则InsufficientBalanceException和IOException没有继承关系所以先后无所谓。如果你再写一个catch(Exception e)放在最后那就成了兜底方案。这个兜底捕获在真实项目里很常见但千万别把它当成常态它只能用来捕获意料之外的异常。4. 常见问题与排查技巧实录手把手带你避坑4.1 为什么try块中的资源关闭代码没执行很多初学者认为只要把close()写在try块末尾资源就一定能够被关闭。实际上这只在一条路径上成立当try块中的所有语句都正常执行完之后close才会被调用。如果try块中间任意一条语句抛出异常后面的所有语句直接被跳过close连执行的机会都没有。这个问题在真实项目中出现过很多次表现特征就是连接数持续增长最终把数据库连接池打满服务不可用。排查的手段有几种一是看系统的连接监控曲线二是分析堆栈看看连接是被哪个方法打开的三是检查代码里有没有上述的“提前异常导致close被跳过”的场景。修复方案非常简单把close放进finally块或者改造为try-with-resources。我个人的习惯是涉及InputStream、OutputStream、Connection、Statement、ResultSet这些资源一律使用try-with-resources。JDK 7以后这个语法完全稳定成熟还能自动处理多资源的逆序关闭。除非你要兼容极老的项目否则没有理由再手写finally去关资源。4.2 finally块中的return吞掉的异常怎么排查如果一个方法在try中抛出了异常而finally块中写了return语句异常会被完全吞掉。调用方看到的是“这个方法正常返回了某个值”完全没有异常的迹象。这类问题的隐蔽性在于它不是每次都出错可能偶尔出现一个奇怪的值你完全摸不着头脑。我提供一个排查思路先在IDE中对finally块中的return行设置断点再在try块中故意抛出一个异常单步执行看异常对象的去向。你会发现异常对象确实被创建但最终极致的return把这个异常对象丢弃了。一旦确认是这个问题修复方式也很简单记住一条铁律finally块中只做资源释放和状态清理永远不要写return也不要用return来控制方法的返回值。4.3 为什么catch到异常后日志里看不到完整堆栈这个问题很常见不少人排查线上问题的时候发现日志只有一行消息没有堆栈信息定位不到具体是哪个类、哪个方法、哪一行出了错。原因大多是打印日志时只调用了e.getMessage()或e.toString()没有记录完整堆栈。正确的写法是把异常对象本身作为最后一个参数传进去不同日志框架的API略有差异核心都是把异常堆栈完整记录下来。比如log.error(查询用户失败, userId{}, userId, e)。这样输出的日志会包含完整的堆栈信息排错效率能提升不少。这道练习题我要单独拿出来强调一下异常处理绝不是catch住了就完事记录日志时怎么记录、记录哪些信息才是决定你能否快速定位问题的关键。很多公司生产环境日志质量差一大半原因就是代码里到处是logger.error(xxx失败: e.getMessage())这种写法。4.4 自定义异常到底是继承Exception还是RuntimeException这是我被问得最多的问题之一。我的判断标准是如果异常代表的是一种可以被调用方预判并合理应对的业务状态通常使用受检异常比如余额不足、订单已关闭、用户不存在。如果异常代表的是程序内部错误、系统状态异常或者防御性编程时主动抛出的问题通常使用非受检异常比如参数校验失败、配置缺失、依赖服务不可用。Spring框架给出了一个很好的示范它的数据访问异常体系全部是运行时异常。原因是如果做成受检异常那么所有使用数据库访问的Service层方法都必须声明throws并且逐层捕获代码会被异常签名污染得惨不忍睹。这个问题您在实际项目里体会会更深受检异常不但没法提升健壮性反而逼着程序员到处写空catch块或者直接把异常包一层抛出去最终完全失去意义。4.5 多个异常类型需要不同处理时怎么组织catch块更合理真实业务中经常需要针对不同类型的异常做不同的补偿策略。比如调用远程接口时可能会抛出连接超时、业务失败、数据格式错误等多种异常。处理方式的组织原则是先把继承层级最具体的异常放在最前面然后逐步放宽最后再放一个兜底的Exception。层次结构上最合理的顺序是子类在前、父类在后、Exception垫底。我见过一个随手写的代码catch顺序完全乱来结果程序永远只进最上面的catch下面的catch全成了僵尸代码。这种问题编译器完全不会报警只会在测试阶段暴露出来你发现某个异常明明被捕获了但走的却是错误的处理分支。为了避免这种隐性问题写完catch块之后建议把每一个catch的参数类型用instanceof关系梳理一遍确认顺序符合从小到大、从具体到宽泛的原则。5. 基于这套练习题的扩展学习建议把上面这些题做完并且理解了异常处理的基础算是稳了。但只做题目还不够我建议做两个方向的扩展训练让知识真正落地。第一个方向是重构成代码实战。拿一个你之前写的小项目比如一个简单的文件读写工具或者用户注册模块把里面所有的异常处理代码翻出来重新审查。重点检查三件事受检异常有没有被错误地包成RuntimeException抛出去、资源有没有通过try-with-resources正确关闭、日志里有没有记录完整堆栈。每一处问题都改一遍这个过程比做十道题都管用。第二个方向是阅读优秀开源框架的异常设计。找一个成熟项目观察它内部如何定义异常体系、如何区分业务异常和系统异常、如何通过一个全局处理器统一处理异常。我看过一些优秀的源码之后才真正理解异常处理不是简单地try-catch而是一套贯穿代码架构的设计思维。练习的目的不是记住几个套路而是形成一种习惯写代码的时候下意识地判断哪些位置可能抛出受检异常需要声明或捕获哪些资源需要在极端情况下保证关闭哪些异常信息值得记录完整堆栈。这种习惯靠听讲建立不起来必须靠一道一道题、一行一行代码去磨。我在实际练习中发现把一套异常处理练习题认真做完再配合真实项目复盘对代码质量的提升效果是很显著的。很多之前习以为常的“糟糕写法”比如把所有的异常都catch后吃干抹净、finally里写return、日志只打印消息不打印堆栈在脑子里会形成条件反射式的警觉。建议你把这套题保存起来每隔一段时间重做一遍尤其是动手运行每一段代码、故意制造异常去观察运行结果这种“亲手触发异常”的体验远比刷完十套试卷更有帮助。
返回列表