抛出异常
本教程共 100 篇 · 第 68 篇 · 更新于 2026-08-05 · 约 18 分钟阅读
68. 抛出异常
本节目标:分清 throw 和 throws 的区别,学会主动抛出异常和在方法签名上声明异常,理解异常沿调用栈向上传播的完整过程。
前面只学了「接」,现在学「发」
到目前为止我们都在接异常——try...catch 把别人抛来的异常接住。
但异常总得有人抛。Integer.parseInt("abc") 抛的 NumberFormatException,就是 JDK 源码里主动抛出来的。
轮到你写方法时,发现参数不对、状态不合法,也该主动抛异常告诉调用方「这样不行」。
throw:现在就抛
throw 是一条语句,作用是立刻抛出一个异常对象。
分两步走:先创建异常实例,再用 throw 扔出去。
public class ThrowDemo {
public static void main(String[] args) {
try {
setAge(-5);
} catch (IllegalArgumentException e) {
System.out.println("捕获:" + e.getMessage());
}
}
static void setAge(int age) {
if (age < 0 || age > 150) {
// 创建实例并抛出,通常合并成一行
throw new IllegalArgumentException("年龄必须在 0-150 之间,收到:" + age);
}
System.out.println("年龄设置为 " + age);
}
}
```java
输出:`捕获:年龄必须在 0-150 之间,收到:-5`
`throw` 后面必须跟一个 `Throwable` 类型的对象。抛 `String` 或者数字都不行,编译不过。
**`throw` 执行后,当前方法立即终止**,后面的代码一行都不会跑。这一点和 `return` 很像。
```java
static void demo() {
System.out.println("A");
throw new RuntimeException("停");
// System.out.println("B"); // 编译报错:unreachable statement
}
编译器能识别出 throw 之后的代码永远执行不到,直接报「无法访问的语句」。
异常消息要写得有用
创建异常时传的那个字符串,最终会出现在日志和调用栈里。写得好不好,直接影响排查效率。
// 没用的消息
throw new IllegalArgumentException("参数错误");
// 有用的消息:说清哪个参数、什么值、期望什么
throw new IllegalArgumentException("orderId 不能为空");
throw new IllegalArgumentException("折扣率超出范围:" + rate + ",应在 0.0-1.0 之间");
```bash
> [!TIP]
> 一个好的异常消息应该让人**不看代码就知道哪里错了**。把出错的实际值带上,比任何形容词都管用。
## throws:声明我可能会抛
`throws` 是**方法签名的一部分**,写在参数列表后面,用来告诉调用方「调我可能会出这些异常」。
```java
import java.io.IOException;
public class ThrowsDemo {
// 声明本方法可能抛出 IOException
static void readConfig(String path) throws IOException {
if (path == null) {
throw new IOException("配置路径为空");
}
System.out.println("读取 " + path);
}
public static void main(String[] args) {
try {
readConfig(null);
} catch (IOException e) {
System.out.println("处理:" + e.getMessage());
}
}
}
一次可以声明多个,逗号隔开:
static void process() throws IOException, InterruptedException {
// ...
}
```bash
## throw 和 throws 对比
两个词只差一个字母,作用完全不同。这是初学者最容易混的点之一。
| | `throw` | `throws` |
| --- | --- | --- |
| 是什么 | 语句 | 方法签名的修饰部分 |
| 写在哪 | 方法体内部 | 方法名和参数表之后 |
| 后面跟 | 一个异常**对象** | 一个或多个异常**类名** |
| 什么时候起作用 | 运行时,立刻抛出 | 编译期,约束调用方 |
| 数量 | 一次抛一个 | 可声明多个 |
一句话记:**`throw` 是动作,`throws` 是声明**。
```java
// throws 是声明:我可能会抛
static void demo(int n) throws IOException {
if (n < 0) {
throw new IOException("负数不行"); // throw 是动作:现在真抛了
}
}
什么时候必须写 throws
规则跟第 65 章讲的受检异常一脉相承。
方法内部抛出受检异常(非 RuntimeException 分支)且不自己捕获,就必须在签名上用 throws 声明。否则编译不通过。
// 编译报错:unreported exception IOException
static void bad() {
throw new IOException("出错");
}
// 正确:声明出去
static void good() throws IOException {
throw new IOException("出错");
}
// 也正确:自己捕获掉
static void alsoGood() {
try {
throw new IOException("出错");
} catch (IOException e) {
System.out.println("自己处理了");
}
}
```bash
抛出**非受检异常**(`RuntimeException` 分支)则完全不需要声明:
```java
// 不用写 throws,编译照过
static void checkAge(int age) {
if (age < 0) {
throw new IllegalArgumentException("年龄不能为负");
}
}
Note语法上也允许给
RuntimeException写throws,比如throws IllegalArgumentException。这不影响编译,但能起到文档作用,提醒调用方注意。实际项目里更常用 Javadoc 的@throws标签来做这件事。
异常沿调用栈向上传播
一个方法抛出异常后,如果自己没接住,异常会逐层往调用它的方法传,直到遇到某个 catch。
public class PropagationDemo {
public static void main(String[] args) {
try {
level1();
} catch (RuntimeException e) {
System.out.println("在 main 接住了:" + e.getMessage());
e.printStackTrace();
}
}
static void level1() {
level2(); // 不捕获,异常穿过去
}
static void level2() {
level3(); // 不捕获,异常穿过去
}
static void level3() {
throw new RuntimeException("最底层出错");
}
}
```text
输出:
```text
在 main 接住了:最底层出错
java.lang.RuntimeException: 最底层出错
at PropagationDemo.level3(PropagationDemo.java:19)
at PropagationDemo.level2(PropagationDemo.java:15)
at PropagationDemo.level1(PropagationDemo.java:11)
at PropagationDemo.main(PropagationDemo.java:4)
调用栈完整记录了传播路径。从下往上读:main 调 level1,level1 调 level2,level2 调 level3,异常在 level3 第 19 行诞生。
排查线上问题时,这份栈信息就是全部线索来源。
没人接会怎样
一路传到 main 还是没人接,JVM 会接管:打印异常栈到标准错误流,然后终止当前线程。
public class UncaughtDemo {
public static void main(String[] args) {
System.out.println("开始");
throw new IllegalStateException("没人管我");
// 后面的代码不会执行
}
}
```text
控制台输出:
```text
开始
Exception in thread "main" java.lang.IllegalStateException: 没人管我
at UncaughtDemo.main(UncaughtDemo.java:4)
main 方法也可以声明 throws Exception,把所有异常交给 JVM:
public class MainThrows {
// 写测试代码时的偷懒写法
public static void main(String[] args) throws Exception {
String content = java.nio.file.Files.readString(
java.nio.file.Path.of("demo.txt"));
System.out.println(content);
}
}
```bash
省掉了 `try...catch`,代价是一出错程序直接退出。**写临时脚本或练习代码时可以,正式代码不要这么干**。
## 重写方法时的异常约束
子类重写父类方法时,`throws` 声明不能随便改。规则是:**子类不能抛出比父类更宽泛的受检异常**。
```java
import java.io.FileNotFoundException;
import java.io.IOException;
class Parent {
void doWork() throws IOException {
System.out.println("父类实现");
}
}
class Child1 extends Parent {
@Override
void doWork() throws FileNotFoundException { // 子类,范围更小,允许
System.out.println("子类实现");
}
}
class Child2 extends Parent {
@Override
void doWork() { // 一个都不抛,允许
System.out.println("子类实现");
}
}
反过来就不行:
class BadChild extends Parent {
@Override
void doWork() throws Exception { // 编译报错:范围变大了
}
}
```bash
道理很实在。多态调用时,调用方拿的可能是 `Parent` 类型的引用,它只准备了处理 `IOException` 的代码。子类要是偷偷抛出更宽泛的异常,调用方就接不住了。
**这条约束只管受检异常。非受检异常想抛多少抛多少,不受限制。**
## 什么时候该主动抛异常
三种典型场景。
**参数校验不通过。** 方法开头检查入参,不合法就抛 `IllegalArgumentException`。
```java
static double divide(int a, int b) {
if (b == 0) {
throw new IllegalArgumentException("除数不能为 0");
}
return (double) a / b;
}
对象状态不允许当前操作。 抛 IllegalStateException。
class Connection {
private boolean open = false;
void send(String msg) {
if (!open) {
throw new IllegalStateException("连接未打开,不能发送数据");
}
System.out.println("发送:" + msg);
}
}
```java
**遇到不该出现的情况。** 比如 `switch` 走到了理论上不可能的分支。
```java
static String describe(int level) {
return switch (level) {
case 1 -> "低";
case 2 -> "中";
case 3 -> "高";
default -> throw new IllegalArgumentException("未知级别:" + level);
};
}
Tip优先复用 JDK 已有的异常类型。
IllegalArgumentException、IllegalStateException、UnsupportedOperationException这几个能覆盖绝大多数场景,别一上来就自己造。
用 Objects 简化参数校验
java.util.Objects 里有几个现成的校验方法,比手写 if 更简洁。
import java.util.Objects;
public class ObjectsCheck {
static void save(String name, int index, int size) {
// null 检查:为 null 就抛 NPE,消息用第二个参数
Objects.requireNonNull(name, "name 不能为 null");
// 索引检查:越界抛 IndexOutOfBoundsException
Objects.checkIndex(index, size);
System.out.println("保存 " + name);
}
public static void main(String[] args) {
try {
save(null, 0, 5);
} catch (NullPointerException e) {
System.out.println("捕获:" + e.getMessage());
}
}
}
```java
输出:`捕获:name 不能为 null`
`requireNonNull` 还有一个返回值版本,可以直接用在赋值语句里:
```java
public class Order {
private final String id;
public Order(String id) {
// 校验 + 赋值一步到位
this.id = Objects.requireNonNull(id, "订单号不能为空");
}
}
别在 finally 里抛异常
第 67 章讲过 finally 会覆盖返回值,抛异常同理,而且后果更严重。
// 反面教材
static void bad() {
try {
throw new IllegalArgumentException("真正的错误");
} finally {
throw new IllegalStateException("finally 的错误"); // 把上面那个吞了
}
}
```bash
调用方只能看到 `IllegalStateException`,真正的问题被彻底掩盖。
`finally` 里如果调用了可能抛异常的方法,要么自己捕获掉,要么改用 `try-with-resources`。
## throw null 会发生什么
一个容易被忽略的边界情况:
```java
public class ThrowNull {
public static void main(String[] args) {
Exception e = null;
try {
throw e; // 编译能过
} catch (Exception ex) {
System.out.println(ex.getClass().getSimpleName());
}
}
}
输出是 NullPointerException。
JVM 发现要抛的对象是 null,就自动抛出一个 NullPointerException 顶替。这个行为不算错,但会让人一头雾水——日志里的 NPE 跟你写的 throw 语句对不上号。
抛异常前确保对象不是 null,尤其是从方法返回值里拿异常对象的时候。
小结
throw 是语句,写在方法体里,抛出一个具体的异常对象,执行后方法立即终止。
throws 是签名的一部分,写在参数表后面,声明方法可能抛出哪些异常,主要约束受检异常。
异常没被捕获就沿调用栈逐层向上传,一直传到 main 还没人接,JVM 打印栈信息并终止线程。
重写方法时,子类的受检异常范围不能超过父类;非受检异常不受此限制。