本节摘要:throw 在方法体内当场抛出异常对象,throws 在签名上声明上报可能抛出的异常类型——一字之差,前者是动作、后者是声明。自定义异常继承 Exception(受检)或 RuntimeException(非受检),工程上常建"根异常加具体子类"的两层结构,携带错误码与用户可读信息。本节先厘清两个关键字的分工,再用取款业务完整实现一套自定义异常与捕获侧处理。
throw 与 throws 长得像,干的完全是两件事。throw 是语句:后面跟一个异常对象,执行到这行就当场抛出、方法中断;throws 是签名的一部分:写在方法参数列表后,声明"本方法可能抛这些类型的异常,调用方你看着办"。一个发生在方法体内、一个标在方法头上;一个真的抛、一个只是报信:
import java.io.IOException; public class KeywordDemo { // throws:签名上声明 可能抛受检异常 上报给调用方 static void check(int age) throws IOException { if (age < 0) { // throw:体内动作 当场造一个异常对象抛出去 throw new IOException("年龄不能为负 收到的是 " + age); } System.out.println("年龄校验通过:" + age); } public static void main(String[] args) { try { check(28); // 输出:年龄校验通过:28 check(-1); // 抛出 下一行不执行 } catch (IOException e) { System.out.println("捕获:" + e.getMessage()); // 输出:捕获:年龄不能为负 收到的是 -1 } } }
两者通常配合出现:体内 throw 了受检异常,签名上就得 throws(或者就地 try 接住,二选一,7.1 节的边界规矩)。运行时异常不需要这套配合——throw 完可以不声明,它属于"代码缺陷"类,不强制进契约。
throw 抛的不一定是新造的对象,也可以是接住的异常——再抛常见于两层处理:底层捕获后记日志,再包一层往上抛,让上层决定对用户的展示。捕获的异常对象上有几个方法要熟:getMessage 拿描述、printStackTrace 打印完整堆栈到控制台(排错用,别留在生产代码里)、getCause 拿被包装的原因异常。
JDK 的异常条目描述的都是通用状况(参数非法、状态不对),业务系统的差错需要自己的名字。"余额不足""库存不够""授权过期"如果都抛通用异常,调用方只能靠解析消息字符串来分支——脆弱且难看。自定义异常就是给业务差错一个类型名,让调用方用 catch 分支精确处理。做法是继承 Exception(受检,调用方必须处理)或 RuntimeException(非受检,不强制处理),选哪个是设计决定:
| 选择 | 特征 | 适用 |
|---|---|---|
| 继承 Exception | 受检 调用方被迫 try 或 throws | 调用方有能力也必须处理的业务失败 |
| 继承 RuntimeException | 非受检 不给调用方语法压力 | 编程错误 系统性故障 处理不了就让它冒出来 |
完整案例:背景:账户取款业务,差错有三种——余额不足、账户冻结、金额非法。要求上层能精确分支处理、用户看到可读提示、日志里有错误码。操作:两层结构——一个业务根异常(带错误码),三个具体子类(带业务细节):
// 第一层:业务根异常 携带错误码 所有业务差错的共同祖先 class BizException extends Exception { private final int code; // 错误码:给日志与监控用 BizException(int code, String message) { super(message); // 消息走父类 给用户看 this.code = code; } int getCode() { return code; } } // 第二层:三个具体条目 各自携带业务语义 class InsufficientFundsException extends BizException { final double shortage; // 缺口金额 独有信息 InsufficientFundsException(double shortage) { super(4001, "余额不足 还差 " + shortage + " 元"); this.shortage = shortage; } } class AccountFrozenException extends BizException { AccountFrozenException(String until) { super(4002, "账户已冻结 解冻日期 " + until); } } class IllegalAmountException extends BizException { IllegalAmountException(double amount) { super(4003, "取款金额非法 " + amount); } }
业务方法在条件不满足时 throw 对应条目,签名 throws 根异常:
public class WithdrawService { private double balance = 500; private boolean frozen = false; // throws 根异常 具体是哪个子类 由运行时的 throw 决定 void withdraw(double amount) throws BizException { if (amount <= 0 || amount > 100000) { throw new IllegalAmountException(amount); // 立条目一 } if (frozen) { throw new AccountFrozenException("2027-01-01"); // 立条目二 } if (amount > balance) { throw new InsufficientFundsException(amount - balance); // 立条目三 } balance -= amount; System.out.println("取款成功 余额 " + balance); } public static void main(String[] args) { WithdrawService svc = new WithdrawService(); // 调用方:按具体子类分支处理 兼顾用户提示与日志 double[] requests = {200, -50, 800}; for (double req : requests) { try { svc.withdraw(req); } catch (InsufficientFundsException e) { System.out.println("提示用户:" + e.getMessage()); System.out.println("日志归档:错误码 " + e.getCode() + " 缺口 " + e.shortage); } catch (AccountFrozenException e) { System.out.println("提示用户:" + e.getMessage()); } catch (BizException e) { // 根类兜底 捕获未来的新子类 System.out.println("其他业务差错:错误码 " + e.getCode()); } } } }
结果:三次请求分别走成功、非法金额(根类兜底分支)、余额不足(具体分支)——输出依次为取款成功余额三百、其他业务差错错误码四千零三、余额不足的提示与含缺口的日志。解读:两层结构的收益全在调用方——catch 具体子类做精细处理(余额不足还能拿到缺口金额推荐"最高可取"),根类兜底保证未来新增子类不会漏接;错误码与消息分工,一个进日志监控、一个面向用户。变式:根异常若改继承 RuntimeException,调用方就不被迫处理——适合"处理不了就统一冒到最外层"的快速失败风格,代价是调用方可能忘记处理;错误码集中成常量类或枚举,配合监控按码聚合;跨层系统里,服务层异常常在网关层统一翻译成对外的错误响应,那是对这套结构的延伸。
立条目也要克制,两条边界。别为每个小差错建一个类:只在"调用方需要精确分支处理"或"需要携带业务数据(如缺口金额)"时立类;否则用根异常加不同消息即可——异常类泛滥与工具类泛滥同样是设计噪音。别把异常当返回值(7.2 节反模式四):常规的"查询无结果"用返回空值或Optional表达,异常留给"坏了"的状况——判断标准是"这个状况下继续执行还有意义吗"。
至此差错登记科办结。回看第 6 章:每个流操作的 throws 都有了章法,自动关闭的 try 也不再是黑盒。最后一章开特殊窗口——多个线程同时办公的并发世界,以及翻档案与贴标签的反射注解机制。