1. 为什么说异常处理是 Java 入门绕不过的坎
我做过不少 Java 基础的辅导,见过最典型的画面是这样的:一段看起来没什么问题的代码,运行起来突然抛个NullPointerException,初学者盯着控制台看半天,第一反应是把整个方法体都塞进try-catch,接着在 catch 里不知道写什么,最后干脆空着。结果程序不崩了,业务结果却莫名其妙不对。异常处理这门课,难的不是记住几个关键字,而是把“程序出错时应该怎么走”这件事想清楚。
JAVA 异常处理基础练习题这套内容,就是把异常处理拆成一个个小场景:有判断题、有读代码题、有让你自己动手写异常类的编码题。它不覆盖什么高深框架,只围绕 Java 语言本身的异常体系展开,重点训练三个能力:第一,看见堆栈信息能不像看天书一样慌乱;第二,知道什么时候用 try-catch、什么时候用 throws 把问题抛给上层;第三,写完代码之后能保证资源正确关闭、异常不被悄悄吞掉。
这套题适合正在学 Java 基础语法、准备考证,或者刚接触真实项目但被编译器报错吓住的同学。JDK 8 以上任意版本都行,用 IDEA 或者直接命令行编译都没问题。我强烈建议不要把题目当阅读理解来做,而是老老实实粘到环境里运行一遍,再故意改几个地方看结果。很多异常问题,只有亲手踩过坑、见过那种诡异现象,后面写代码才会本能地避开。
2. 练习题设计的总体思路:从“看见报错不慌”到“会主动抛错”
2.1 知识点地图:到底要练哪些东西
设计这套练习题的时候,我把 Java 异常处理的知识点画成了一张地图,所有题目围绕这张地图展开。第一层是异常体系本身,也就是Throwable下面哪部分是Error、哪部分是Exception,在Exception里哪些是非受检异常、哪些是受检异常,这是后面所有判断的基础。第二层是处理语法,包括 try-catch-finally 的执行顺序、catch 块的排序规则、多异常捕获写法。第三层是主动抛出,也就是 throw 和 throws 的区别、自定义异常类怎么写、异常从底层方法往上传播的链路。第四层是现代写法,比如 try-with-resources 自动关闭资源。
这几层不是平铺的,而是递进的。如果一上来就让学生自定义异常,他连受检异常和非受检异常都分不清,写出来的异常类也没法用。但如果只讲语法不做题,学生照样会在真实场景里乱用。所以这套题的顺序是先确认认知,再训练处理,最后训练设计。
2.2 难度递进逻辑:判断、理解、编码、综合
练习题的难度我分成了四档。第一档是判断题和选择题,题目里给一段代码,问你会抛出什么异常、编译能不能通过。第二档是读代码题,让你解释 catch 块顺序、finally 执行结果这类问题。第三档是编码题,比如自己定义一个业务异常类,然后在调用处做捕获。第四档是综合场景题,把用户输入、资源关闭、业务校验、异常提示串在同一个程序里。
每一档之间我特意留了“认知台阶”。比如第三档编码题里,我会先给一个自定义异常继承Exception的示例,因为受检异常会被编译器强制要求处理,学习者在写调用代码时能立刻感受到“必须捕获”的约束。等到第四档综合题,他就要自己判断哪些异常该捕获、哪些异常该继续往外抛。这个设计逻辑和我平时写真实系统的思路是一致的:异常处理不是为了消灭异常,而是为了让程序在最外层能给出一个清晰、安全的反馈。
2.3 为什么刻意保留“编译错误”类题目
这套题里有一个容易被忽视的设计:不少题目故意让代码无法编译。比如 catch 顺序写反、受检异常没有处理,这些在我带新人的时候是最高发的问题。初学者往往认为编译不通过就是自己“记错语法”,但实际上很多编译错误的根源是对异常概念理解偏了。把这类题目放进题库,就是要让大家明白,编译器报错不是洪水猛兽,它是最耐心的老师,会明确告诉你哪个片段需要处理异常。
我印象很深的是,有同学问我说:既然ArithmeticException是非受检异常,不处理也能编译,那为什么练习里还要专门去 catch 它?这个问题问得特别好。能编译不代表能正确运行,除以零虽然不会强制你处理,但一个记账程序如果因为用户输入了 0 就直接崩溃,那显然是不可接受的。受检异常是编译器逼你处理,非受检异常是责任在你的判断力,两道门槛都不能省。
3. 核心语法速览:把概念边界先理清楚
3.1 异常体系与受检异常、非受检异常的分辨
Java 的异常类根是Throwable,下面分了两大支。一支是Error,比如OutOfMemoryError、StackOverflowError,这类属于 JVM 层面的严重问题,普通程序不应该去捕获,捕获了也基本没法恢复。另一支是Exception,里面又分两类:一类是RuntimeException及其子类,包括NullPointerException、ArrayIndexOutOfBoundsException、ArithmeticException、NumberFormatException,这些叫非受检异常,编译时不强制处理;另一类是RuntimeException之外的受检异常,比如IOException、FileNotFoundException、InterruptedException,编译器会在编译阶段强制要求处理,不处理直接报错。
一个特别容易混淆的地方是,很多人觉得“运行时异常”就是指“运行期才会发生的异常”,于是想当然地认为其他异常就是编译期发生的。其实不是这样。受检异常也可以是运行期像 IOException 那样在读取文件时产生;区别在于是不是强制要求处理。非受检异常在处理上没有硬性检查,但它依然会在运行期打断程序,所以设计时同样要认真对待。
我整理了一个简单的对比表,做题之前建议先扫一眼:
| 类别 | 典型例子 | 编译期强制处理 | 合适处理方式 |
|---|---|---|---|
| Error | OutOfMemoryError | 否 | 不捕获,从程序结构上规避 |
| RuntimeException | NullPointerException | 否 | 主动判空、参数校验 |
| 其他 Exception | IOException、FileNotFoundException | 是 | try-catch 或 throws 声明 |
| 自定义受检异常 | 账户余额不足异常 | 是 | 调用处捕获并做业务提示 |
| 自定义运行异常 | 参数非法异常 | 否 | 调用方按约定处理或不处理 |
3.2 try-catch-finally 的执行顺序不能靠背
很多初学者背了一段顺口溜,说“异常来了找 catch,找完就去 finally,finally 一定会执行”。这个顺口溜说出了大概,但没有覆盖真正细节。我见过太多在这种题上翻车的情况,所以这里把几条硬规则先放出来。
第一条,try 块里没抛异常,catch 块会被跳过,finally 块在 try 块之后执行。第二条,try 块里抛了异常,会立刻跳转到第一个匹配的 catch 块,后面的 try 块代码不会再执行,然后继续执行 finally。第三条,catch 声明顺序很重要,子类必须写在父类前面。第四条,如果 catch 没有捕获住异常,异常会继续向上传播,但 finally 依然会执行。第五条,finally 里出现了 return,会把 try 或 catch 块里的 return 值覆盖掉。
实际代码中,我最强调第五条。因为很多人写资源关闭时习惯在 finally 里做清理,如果顺手 return 一个状态码,很可能把真正的异常结果覆盖。等一下我给的练习题里就有这个坑,现在先记住最后执行的 finally 拥有“一票否决权”就够了,细节后面细看。
3.3 throw 与 throws:一个负责“抛出”,一个负责“声明”
这两个关键字只差一个字母,但职责完全不同。throw 出现在方法体内部,后面跟一个异常对象,表示“这个地方我主动制造一个异常,让程序在这里停下”。throws 出现在方法声明处,后面跟异常类型,表示“我这个方法自己不处理,调用我的人要负责处理,否则编译期就会报错”。
举个例子,一个取款方法检查到余额不足,想提醒调用方,就可以在方法内部写throw new InsufficientBalanceException("余额不足"),同时方法签名写上throws InsufficientBalanceException。throw 负责制造问题,throws 负责说明问题归属。这时候如果调用的地方不处理,编译器是过不去的,这就是受检异常带来的约束感。选择受检还是非受检,其实是设计判断,后面综合题会专门讨论。
还要注意一点:如果一个方法声明抛出了多个异常类型,throws 后面用逗号分隔;如果多个异常之间是父子关系,只声明父类型即可。catch 块则可以用多异常捕获语法catch (IOException | SQLException e),变量 e 默认是 final 的,不需要也不能给它重新赋值。
3.4 try-with-resources:资源关闭的正确姿势
在 Java 7 之前,手动关闭资源是一件痛苦的事。文件流、数据库连接、网络连接,都要在 finally 里判断非空再关,而且 close 本身还会再抛一个 IOException,于是还得再 try-catch 一层。很多代码就这样被三层嵌套搞得没法看。
try-with-resources 就是用来解决这个问题的。语法上把资源对象放进 try 后面的括号里,比如try (BufferedReader reader = new BufferedReader(new FileReader("config.txt"))),只要是实现了AutoCloseable接口的类型,都能这么写。退出 try 块时,虚拟机自动调用 close,而且关闭时机是在 catch 或 finally 逻辑之前,不会污染业务代码。
Java 9 以后还允许使用 already-final 或 effectively final 的外部资源变量,不一定要在 try 括号里 new。这一点可靠友好,但很多人也确实不熟悉。练习里我安排了一道原版手工关闭和 try-with-resources 重写的对照题,就是为了帮大家把这条路径彻底走顺。
4. 基础练习题与参考答案(含讲解)
4.1 第一题:识别异常类型
题目给出下面几段独立代码,请你判断每一段分别会抛出什么类型的异常,并写出它的父类。
// 片段 A int[] nums = new int[3]; nums[3] = 10; // 片段 B String s = null; s.length(); // 片段 C int result = 10 / 0;参考答案:片段 A 是ArrayIndexOutOfBoundsException,片段 B 是NullPointerException,片段 C 是ArithmeticException。这三个异常都是RuntimeException的子类,属于非受检异常,编译阶段不会被强制处理。
这道题的目的不是让大家背异常名字,而是训练第一反应。数组长度为 3,下标范围是 0 到 2,访问 3 就是越界。对 null 调用方法,必然空指针。整数除以 0,得到算数异常。初学者最容易把 B 段和 C 段记混,看到s.length()就怀疑是数组问题,其实这里根本没数组,s 本身就是 null,所以是空指针。做题的时候不要只看症状,要看异常的触发点。
4.2 第二题:多异常捕获顺序的判断题
题目给出下面的代码片段,问是否能正常编译,如果不能请说明原因并修正:
try { int result = Integer.parseInt("abc"); } catch (Exception e) { System.out.println("捕获到 Exception"); } catch (NumberFormatException e) { System.out.println("捕获到 NumberFormatException"); }参考答案:不能编译。因为NumberFormatException是IllegalArgumentException的子类,而IllegalArgumentException是RuntimeException的子类,最终它也是Exception的子类。当第一个 catch 写了Exception时,编译器判定后面的NumberFormatException永远不会到达,于是给出“已经被捕获”的编译错误。
修正做法是把范围小的异常写在前面,范围大的写在后面,也就是先catch (NumberFormatException e),再catch (Exception e)。这是一个高频考点,不只是笔试里爱考,实际编码中如果你写了一个总异常在最前面,后面所有细化异常分支都相当于死的,会让排查问题变得非常困难。有人问过:为什么不干脆只写一个catch (Exception e),反正都能接住?这样可以,但你会丢掉异常类型本身的区分度,日志里只知道有异常,不知道是哪一类出了错,这会让你在线上排查时多花好几倍的时间。
4.3 第三题:finally 里的 return 覆盖问题
题目:写出下面方法的返回值,并解释为什么。
public static int test() { try { return 1; } finally { return 2; } }参考答案:返回值是 2。这段代码里 try 块准备返回 1,但在真正 return 之前,finally 块抢先执行了,而且 finally 里也有 return,于是这个 return 的结果把 1 覆盖掉了。
这道题属于“看着简单、做起来容易错”的典型。很多人记住了 finally 一定会执行,却没有记住 finally 里的 return 有更高的优先级。更深一层是,finally 不仅能覆盖正常 return,还能覆盖异常。假如 try 块里抛了一个异常,catch 块里已经有了处理结果,结果 finally 又 return 了一个值,那这个返回值会把整个异常处理链路打断,上层调用方拿到的状态是完全错误的。这种代码一旦进到真实项目,排错成本极高。所以我在实际开发时有一个铁律:不要在 finally 里写 return,也不要在 try 或 catch 里用 return 包装复杂逻辑,清晰的流程比少写几行代码重要得多。
4.4 第四题:受检异常必须处理
题目:下面代码能否编译?如果不处理会发生什么?
public class FileReadDemo { public static void main(String[] args) { java.io.FileReader reader = new java.io.FileReader("data.txt"); System.out.println(reader.read()); } }参考答案:不能编译。new FileReader("data.txt")这个构造方法声明了throws FileNotFoundException,而FileNotFoundException是受检异常。reader.read()又声明了throws IOException,同样是受检异常。编译器会直接要求处理,否则报错。
修正方案有两种。第一种是把异常交给 main 方法继续往外抛:
public static void main(String[] args) throws IOException { java.io.FileReader reader = new java.io.FileReader("data.txt"); System.out.println(reader.read()); }不过这种方式在 main 方法里不太现实,因为 main 是程序入口,继续往外抛意味着 JVM 直接终止。更合理的是在当前方法内捕获并处理:
try { java.io.FileReader reader = new java.io.FileReader("data.txt"); System.out.println(reader.read()); } catch (IOException e) { System.out.println("文件读取失败:" + e.getMessage()); }这里可以顺带区分一下:受检异常不是编译期一定发生,而是编译期强制要求你“为它留下处理路径”。哪怕你只是声明了 throws,也算一种处理方式。我见过不少人为了编译通过,在方法上无脑加throws Exception,这确实让编译通过了,但把所有异常责任都推给了调用方,很多层方法串起来之后,异常会一路甩到最外层,最后你能拿到的堆栈又长又难读。正确做法是先想清楚:这个异常谁更适合处理?如果当前方法有足够的上下文给出业务提示,就自己 catch;如果当前方法根本没有能力处理,再设计 throws 让上层统一收口。
4.5 第五题:自定义业务异常
题目:银行卡取款时,如果余额不足,要求程序给出明确的业务提示而不是一个冷冰冰的运行时崩溃,请定义一个自定义异常类,并在取款方法中主动抛出。
参考答案:
class InsufficientBalanceException extends Exception { public InsufficientBalanceException(String message) { super(message); } } class Account { private double balance; public void deposit(double amount) { balance += amount; } public void withdraw(double amount) throws InsufficientBalanceException { if (amount < 0) { throw new IllegalArgumentException("取款金额不能为负数"); } if (amount > balance) { throw new InsufficientBalanceException("余额不足,当前余额:" + balance); } balance -= amount; } public double getBalance() { return balance; } }这段代码里的关键点有两个。第一个,自定义异常继承了Exception,所以它是受检异常,编译器要求所有调用withdraw的地方都必须处理这个异常。这一点对业务场景很有价值,因为余额不足是一个业务上必须让用户感知的条件,不应该被静默吞掉。第二个,我特意在取款方法里还加了一层参数校验,金额为负数时抛出IllegalArgumentException,这是个非受检异常。为什么会这样混着用?因为“金额为负数”属于调用方传参错误,属于程序契约被破坏,不能假装正常的业务失败;而“余额不足”属于预期内的业务分支,是用户可以理解、可以主动通过换卡或充值来补救的情况。两类问题的性质不同,在异常类型上分开,调用方才能正确区分处理级别。
很多同学会问:自定义异常到底继承Exception还是继承RuntimeException?我的经验是,如果这个异常希望强迫调用者意识到“可能失败”并采取措施,比如余额不足、库存不够、重复提交订单,就用受检异常;如果这个异常是因为代码本身写错了、参数校验没做好,比如空指针、格式错误,就用非受检异常。真实项目里团队往往会约定规范,但在练习阶段,把这两种感受都体验一遍是最有价值的。
4.6 第六题:异常传播与堆栈轨迹
题目:看下面的代码,描述异常从发生到被捕获经过了哪些层,并说明堆栈轨迹是什么。
public class ExceptionPropagation { public static void main(String[] args) { try { level1(); } catch (IOException e) { System.out.println("在最外层捕获:" + e.getMessage()); e.printStackTrace(); } } public static void level1() throws IOException { level2(); } public static void level2() throws IOException { throw new IOException("磁盘访问失败"); } }参考答案:异常在level2()方法中通过throw new IOException("磁盘访问失败")被制造出来。level2()的方法签名声明了throws IOException,所以它不处理,把异常交给调用它的level1()。level1()也声明了throws IOException,同样不处理,继续往上传。最终异常传到main方法的 try 块里,被 catch 捕获。
输出内容中最重要的是e.printStackTrace()打印的堆栈轨迹。堆栈轨迹的第一行会指明异常类型和信息,下面每一行都是“方法名、所在类、源码行号”,并列出一条完整的调用链。比如at ExceptionPropagation.level2、at ExceptionPropagation.level1、at ExceptionPropagation.main,这就是异常从实际抛出点一级一级往上传播的证据。
我看到过很多初学者踩的坑是:只调用e.getMessage(),不打印堆栈。这样确实能得到“磁盘访问失败”这样的信息,但完全不知道这个异常是哪个文件、哪一行产生的。真实项目里,异常堆栈就是程序员的第一现场,宁可日志多打几行,也不要为了日志美观把它截断。练习这一题时,建议大家故意把某个方法改成不声明 throws,观察编译器怎么提示;或者把 catch 放到 main 外面的不同层,观察堆栈变化。这种变形练习比单做一道题更有收获。
4.7 第七题:用 try-with-resources 重构资源关闭
题目:下面的代码使用 BufferedReader 读取文件,你能找出资源关闭方面的问题吗?用 try-with-resources 改写。
BufferedReader reader = null; try { reader = new BufferedReader(new FileReader("config.txt")); System.out.println(reader.readLine()); } catch (IOException e) { e.printStackTrace(); } finally { if (reader != null) { try { reader.close(); } catch (IOException e) { e.printStackTrace(); } } }原代码的问题在于:读取文件时如果new FileReader("config.txt")就抛出了异常,那么 reader 还是 null,finally 里的非空判断可以规避;但如果reader.readLine()抛了 IOException,try 块立即退出进入 catch,此时 reader 已经创建成功,finally 里能正常关闭,逻辑上是对的。不过这种写法有太多嵌套,可读性差,而且稍有不慎容易漏掉关闭。如果是多个资源,比如还要同时操作输入流和输出流,这种写法会变得极其恶劣。
用 try-with-resources 改写后,逻辑会简化很多:
try (BufferedReader reader = new BufferedReader(new FileReader("config.txt"))) { System.out.println(reader.readLine()); } catch (IOException e) { e.printStackTrace(); }改写后最明显的差别是:不需要自己写 finally,不需要非空判断,也不需要再处理 close 时抛出的 IOException,资源关闭由 JVM 负责。这里有一个细节要记住:资源关闭的顺序与 try 括号里声明顺序相反,也就是后声明的先关闭。这在依赖关系里很关键,比如你有一个 HTTP 响应流依赖请求流,必须先关闭响应流再关闭请求流,那么声明顺序就要反过来,请求流先声明,响应流后声明。
这道题还有一段进阶内容:如果在 try 块体里抛出业务异常,同时资源 close 时也抛异常,那么 close 的异常会被抑制,但主异常对象会把被抑制的异常记在suppressedExceptions里面。从日志看,它是一层特殊的堆栈后处理。平时了解这个现象就够了,不必纠结,重点是别自己写 finally 手忙脚乱把主异常覆盖掉。
4.8 第八题:综合场景题——账号取款流程
题目:编写一个控制台程序,模拟账号取款流程。用户输入取款金额,程序判断金额格式、判断账户余额是否充足,最后输出结果,并保证输入资源被正确关闭。
public class WithdrawDemo { public static void main(String[] args) { Account account = new Account(); account.deposit(1000); try (Scanner scanner = new Scanner(System.in)) { System.out.print("请输入取款金额:"); double amount = Double.parseDouble(scanner.nextLine()); account.withdraw(amount); System.out.println("取款成功,剩余余额:" + account.getBalance()); } catch (NumberFormatException e) { System.out.println("金额格式不正确,请输入数字。"); } catch (InsufficientBalanceException e) { System.out.println("业务校验失败:" + e.getMessage()); } } }这段代码覆盖了三种异常的处理。Double.parseDouble在用户输入非数字文本时会抛NumberFormatException,这是非受检异常,但在这里必须被 catch,因为用户体验上要给出“格式不对”的提示。account.withdraw可能抛InsufficientBalanceException,这是之前自定义的受检异常,在业务上要明确提示余额不足。Scanner 实现了AutoCloseable,用 try-with-resources 包裹后,哪怕取款过程中抛了异常,资源也能安全关闭。
这道题需要重点反思的是异常处理的“分工”。格式错误和余额不足虽然都会导致取款失败,但它们产生的位置不同、性质也不同。格式错误是入口校验问题,理论上可以在更早的输入阶段用正则或类型判断拦截,连解析都不走到;余额不足是账户业务规则问题,必须由 Account 类的取款方法来定义。把它们放在同一个 catch 链里,是因为在控制台程序层面,最终都要给用户一个友好的文字反馈,但再往上层走,这两种异常可能一个是系统日志的输入告警,一个是写入审计记录的业务事件,处理位置可能会完全不同。
5. 高频报错与排查技巧实录
5.1 异常被空 catch 吞掉,程序安静地错下去
我在练习批改中最痛心的代码就是空 catch。有的初学者为了避免控制台输出红色错误,直接写出这样的代码:
try { int result = Integer.parseInt(input); } catch (Exception e) { // 什么都不做 }程序确实不会崩,但没有提示、没有日志、没有重试,用户会觉得点了个按钮然后毫无反应。这种问题在练习阶段看起来只是“不够细心”,到了真实项目就是线上事故。我在实际开发中给自己定了一条规则:catch 块里要么给出用户可见的提示,要么写日志记录完整堆栈,要么重新抛出并说明原因,绝不允许空块。
如果是在学习阶段,最简单的做法是至少保留e.printStackTrace()。虽然正式项目里应该用日志框架把堆栈输出到日志文件,但这个填空功能能让你立刻看到异常来自哪里。不要用System.out.println(e.getMessage())取代它,因为 getMessage 很可能为 null,而且没有堆栈轨迹。
5.2 finally 里的 return 把异常结果盖掉
有一次我给一个模拟项目排查问题,方法里明明 catch 到了某个异常并返回了业务错误码,可调用方拿到的永远是 0,也就是“成功”。追了半天发现在 finally 里有一行return 0;,它把 catch 块里的return -1完全覆盖了。这个坑在练习题第三题里已经出现过,但真实场景里它往往隐藏得更深,因为它不会让程序崩溃,只会让状态静默错乱。
排查这类问题的思路是:先看方法里有没有 finally,再看 finally 里有没有 return,最后看返回值有没有被 finally 逻辑改写。如果代码里有很多个 return,建议先重构掉,避免多条分支交叉。正常写法只让 try 和 catch 承载业务流程,finally 只负责清理资源,不承载任何“最后结果”。
5.3 catch 顺序写反,编译器报错还是逻辑失效
catch 顺序问题在编译期有两种表现。如果先写了父类再写子类,编译器通常会直接报错,提示子类异常已经被捕获,因为这是不可达的代码块。但如果你用的是同级别异常,比如先catch (IOException e)再catch (SQLException e),它们没有继承关系,顺序无所谓;如果是RuntimeException和NumberFormatException,编译器同样会报错。
然而真实代码里的一个隐蔽情况是:catch 顺序本身没有编译问题,但写在前面的 catch 类型范围太宽,导致后面的窄范围 catch 永远没机会执行。排查时可以先看 catch 列表里有没有宽泛类型在窄类型之前,再去业务日志里比对异常实际类型。顺序问题最好的预防手段就是遵守一条经验法则:子类在前,父类在后;具体异常在前,通用异常在后。
5.4 空指针定位三板斧
NullPointerException是 Java 里出现频率最高的非受检异常。它的问题在于本身信息很少,老版本 JDK 的报错往往只告诉你空指针发生在哪一行,但没说哪个对象是 null。我排查时一般按三个步骤走。
第一,看堆栈第一行,确定行号。打开对应源码行,看这一行里调用方法或访问属性的对象是哪个。第二,去这个对象创建的地方打断点,运行时看变量面板里哪个对象实际是 null。如果是方法参数,检查调用方传给它的值;如果是纠结的链式调用,比如order.getUser().getName(),就需要拆开写降级。第三,在代码结构上加防线,比如对可能为 null 的对象先判空,或者用Optional包装返回值,让调用方明确知道这一层可能为空。
这里还要提醒一点:NullPointerException是非受检异常,编译器不会强制你处理。所以别依赖编译器,靠的是自己写代码时保持判断。练习时最常见的错误是盲目把整个方法塞进 try-catch 去接住空指针,结果掩埋了真正的问题。空指针应该从源头解决,而不是统一捕获后当什么都没发生。
5.5 重新抛出异常时把原始堆栈弄丢
在项目里经常能看到这样的封装写法:
catch (IOException e) { throw new ServiceException("文件读取失败"); }这样写有一个很大的问题:原始的IOException堆栈完全丢了,等到上层排查时,只能看到ServiceException,根本不知道底层是哪一行、哪个文件路径出的错。正确的做法是把原始异常作为新异常的 cause 传进去:
catch (IOException e) { throw new ServiceException("文件读取失败", e); }这样有一个好处:日志里既能保留业务语义“文件读取失败”,又能通过 cause 一路追溯到最底层的 IOException 堆栈。我带的很多新人在做项目时第一次看到因果链和主异常的关系,都会恍然大悟。练习题阶段虽然不需要写很复杂的异常包装,但建议你们养成这个习惯,别在 throw 新异常时把老异常扔掉。
6. 从基础练习到真实代码的几点体会
处理异常的思路,其实在练习题阶段就能建立起来。比如判断题让你区分受检异常和非受检异常,是在培养分类思维;自定义异常题目是在培养设计意识;综合题里把输入、业务、资源关闭放在一起,是在锻炼全局把控。真正进入真实项目后,我的体会是多了一层“契约感”。
一个方法在对外暴露时,异常就是它契约的一部分。声明了throws的方法,调用方必须知道它可能抛什么;不声明但实际抛RuntimeException的方法,调用方就要从参数和文档里推断风险。好的代码在异常设计上是清晰的,不会让上层拿到一个不知道该怎么处理的Exception,也不会写一长串让人看了头晕的嵌套 try-catch。
我对“什么时候捕获、什么时候抛出去”的经验是:如果你在这个方法里能给出具体的、面向用户的反馈,就实现 catch;如果这个方法只是一个中间层,并没有能力决定错误给谁看,就继续抛出。捕获不是越早越好,抛出也不是越多越好。异常处理更像责任分配,每个层级只处理自己该处理的那一段,剩下的传给上家。
最后再分享一个小细节:练习时不要把报错当成失败。每一道异常题的答案区域,都应该先自己故意写错几次,再回来对照解析。我之前整理这些题的时候,自己每道题都跑过“错误版本”,发现印象最深的就是那些报错信息本身。等你真正养成了看见堆栈就精神起来的习惯,异常处理这一关才算稳稳过了。