程序运行有时不是一帆风顺的,如果出现了错误,程序直接退出,或带着错误继续运行直到我们根本不知道错误出现在什么地方,都是十分可怕的。
为了解决这个问题,人们引入了 Exception,它可以隔离错误,优雅退出程序,让系统变得更加健壮。
所以什么是 Exception?
Exception 是对非预期状态的对象化。
执行程序,本质上是对 stack push 和 pop 的过程:
A 调用了 B,B 调用了 C,C 运行完了返回给 B,B 再给 A。
非预期状态,说的是如果 C 的执行发生了异常,不能遵循正常的返回路径。
异常发生后,JVM 会寻找「异常处理器」,即 catch 模块,如果此时 B 没有,那么直接会 pop 它,再去找 A,最后返回。
对象化,即 Java 会把错误包装成一个 Object,其中包含了有用的信息:
- 类型:发生了什么
e.getClass().getName() - 状态:错误的详细描述
e.getMessage() - 上下文:Stack Trace
e.printStackTrace()
举个例子
public class ExceptionDemo {
public static void main(String[] args) {
try {
calculate(10, 0);
} catch (ArithmeticException e) {
// e 就是那个被包装出来的 Object
System.out.println("1. 类型 (Class): " + e.getClass().getName());
System.out.println("2. 状态 (Message): " + e.getMessage());
System.out.println("3. 上下文 (Stack Trace):");
e.printStackTrace();
}
}
public static void calculate(int a, int b) {
int res = a / b; // 这里会产生异常对象
}
}
try 中发生的异常都继承自 Throwable,主要分为三大类:
- Checked Exception(受检异常): 必须在编译期处理(try-catch 或 throws),否则无法编译。如
IOException。 - Runtime Exception(运行时异常): 也叫非受检异常(Unchecked Exception),通常是代码逻辑错误,编译器不强制要求捕获。如
NullPointerException或ArithmeticException。 - Error:通常是 JVM 级别的严重错误,如
OutOfMemoryError,程序一般无法恢复,不建议捕获。
例中的 ArithmeticException 就属于 Runtime Exception。
它在运行时会跳出。
Throw 和 Throws
我们也可以手动抛出一个 exception,即使用 throw,比如:
public void setAge(int age) {
if (age < 0 || age > 150) {
throw new IllegalArgumentException("年龄非法:" + age + ",必须在0-150之间");
}
this.age = age;
}
还有一个类似的代码 throws,在方法签名中使用。
它意味着,这个方法明确不会处理 exception,需要由调用者处理。
举个例子:
public void loadConfig() throws FileNotFoundException {
FileReader fr = new FileReader("config.txt");
}
这里必须声明 FileNotFoundException,因为编译器知道读取文件可能会失效。
自定义 Exception
Java 内置的 Exception 通常是一些技术错误,比如 空指针,网络断开等等,但是无法描述业务错误。
大部分情况下,我们应该继承 RuntimeException。
比如:
public class InsufficientBalanceException extends RuntimeException {
// 1. 除了消息,还可以携带具体的业务数据(这是自制的核心优势)
private final double balance;
private final double amountRequested;
// 2. 提供构造函数,把信息传递给父类
public InsufficientBalanceException(double balance, double amountRequested) {
// 调用父类构造器,生成标准的错误描述
super("转账失败:当前余额 " + balance + " 元,尝试转账 " + amountRequested + " 元");
this.balance = balance;
this.amountRequested = amountRequested;
}
// 3. 提供 Getter,方便在 catch 块里提取这些数据做补偿逻辑
public double getBalance() { return balance; }
}
我们可以精准捕获这个特殊异常:
try {
// 这里会发生 InsufficientBalanceException 异常
bankService.withdraw(100);
} catch (InsufficientBalanceException e) {
// 只处理钱不够的情况
showDepositDialog();
}
在例子的 super 中,我们将拼接好的字段传给了父类。当后续调用 e.getMessage() 的时候,就会打印该信息。
另外,我们还可以传入 Throwable:
public InsufficientBalanceException(double balance, double amountRequested, Throwable cause) {
super("转账失败:当前余额 " + balance + " 元,尝试转账 " + amountRequested + " 元", cause);
this.balance = balance;
this.amountRequested = amountRequested;
}
cause 代表了罪魁祸首,它可以接受其它异常信息,比如:
try {
// 1. 模拟从数据库获取余额,可能会抛出 SQLException
currentBalance = database.getBalance(userId);
if (currentBalance < amount) {
// 2. 情况 A:逻辑错误,主动抛出业务异常(此时没有上级异常,cause 传 null)
throw new InsufficientBalanceException(currentBalance, amount, null);
}
// do something
} catch (SQLException sqlEx) {
// 3. 情况 B:技术错误,数据库挂了
// 包装成业务异常,并把“元凶” sqlEx 传给 cause 记录在案
throw new InsufficientBalanceException(currentBalance, amount, sqlEx);
}
JVM 处理 Exception
针对这个例子。
try {
a / 0;
} catch (ArithmeticException e) {
// Do something
}
当 JVM 运行到 a/0 的时候,错误发生,应该立刻跳转到 catch。而它并不是通过逐行扫描代码来寻找 catch 块的,那样效率太低了。
实际上,为了跳过从错误发生处到 catch 块之间不需要运行的代码,Java 编译器(javac)在编译时,会在 .class 文件中生成了一张异常表(Exception Table)。
它包含了如下信息:
- From:try 块开始的字节码指令行。
- To:try 块结束的字节码指令行。
- Target:对于 catch 开始的字节码指令行号。
- Type:可以捕获的类型。
当 a/0 触发了错误,JVM 会立刻拿着当前的行号查表。
如果在 From 和 To 之间,且类型批匹配,那么直接跳转到 Target 处执行,非常快。
如果没有 try-catch 呢?
如果错误发生了,但此时没有 try-catch,JVM 也没有办法在异常表中找到匹配的项,那该怎么办?
它会做一个很重的操作:栈帧回溯
比如 执行 C 发生了异常:
- 强行 pop:JVM 不会管 C 中的局部变量,而是直接把 Stack Frame 从虚拟机栈中弹出。
- 恢复现场:恢复上一层方法 B 的执行环境。
- 继续查表:拿着刚才 C 的异常对象,查 B 的表。
- 循环直至死亡:循环刚才的过程,直到退到
main退无可退为止,最后导致当前线程意外终止,甚至整个程序崩溃退出。
所以说,不要忘记写 try-catch,不然它会掉入深渊。
关于 Exception 的性能问题
在高并发的场景下,大量抛出异常会导致服务器的 cpu 飙升。
原因在于我们之前提到的 e.printStackTrace(),它记录了 Stack Trace。
对底层而言,当 new InsufficientBalanceException() 时,祖先类 Throwable 的构造函数中的本地方法(Native Method)fillInStackTrace() 会被调用。
重点是,调用 fillInStackTrace() 会暂停 Java 执行流或当前线程,抓取从栈顶到 main 方法的每一个 Stack Frame(类名,方法名…),最后记录下来。
它消耗巨大。
所以,对于像 InsufficientBalanceException 类似的,自己定义的业务异常,我们只关心状态码和具体的业务消息,不太在乎堆栈信息,因此可以直接返回 this:
// 在 InsufficientBalanceException 里加入
@Override
public synchronized Throwable fillInStackTrace() {
// 阻断 JVM 抓取堆栈的操作,从而提升性能
return this;
}
总结
Java Exception 是将被打断的「非预期执行路径」封装成一个携带现场数据和堆栈信息的 Object。
在实践中,我们可以通过 throw 触发异常、throws 声明风险,并利用「异常链」在保证底层溯源的同时实现业务逻辑的解耦。