异常链与常见异常
本教程共 100 篇 · 第 70 篇 · 更新于 2026-08-05 · 约 17 分钟阅读
70. 异常链与常见异常
本节目标:学会用异常链保住原始错误信息,并认识八个最常见的运行时异常,看到栈信息就知道去哪找 bug。
包装异常时最容易犯的错
底层抛了个技术异常,上层想把它转成业务异常。很多人的第一反应是这么写:
// 错误示范:原始信息全丢了
public class LostCause {
public static void main(String[] args) {
try {
loadConfig();
} catch (RuntimeException e) {
e.printStackTrace();
}
}
static void loadConfig() {
try {
Integer.parseInt("not-a-number");
} catch (NumberFormatException e) {
throw new IllegalStateException("配置加载失败"); // 把 e 弄丢了
}
}
}
```text
打印出来的栈信息:
```text
java.lang.IllegalStateException: 配置加载失败
at LostCause.loadConfig(LostCause.java:15)
at LostCause.main(LostCause.java:5)
看得出「配置加载失败」,但为什么失败?完全不知道。真正的元凶 NumberFormatException 连同它所在的那一行代码,一起蒸发了。
线上出这种日志,排查基本靠猜。
正确写法:把原因传进去
Throwable 及其子类都提供了带 cause 参数的构造方法。把捕获到的异常传进去,链条就接上了。
public class WithCause {
public static void main(String[] args) {
try {
loadConfig();
} catch (RuntimeException e) {
e.printStackTrace();
}
}
static void loadConfig() {
try {
Integer.parseInt("not-a-number");
} catch (NumberFormatException e) {
// 第二个参数就是「原因」
throw new IllegalStateException("配置加载失败", e);
}
}
}
```java
这次的栈信息:
```text
java.lang.IllegalStateException: 配置加载失败
at WithCause.loadConfig(WithCause.java:15)
at WithCause.main(WithCause.java:5)
Caused by: java.lang.NumberFormatException: For input string: "not-a-number"
at java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:67)
at java.base/java.lang.Integer.parseInt(Integer.java:665)
at WithCause.loadConfig(WithCause.java:13)
... 1 more
多出来的 Caused by: 就是异常链。它告诉你:表面错误是配置加载失败,根本原因是 13 行那个字符串转数字失败了。
Tip看异常栈的正确姿势:先看最后一个
Caused by。那才是第一案发现场,上面那些都是层层包装的结果。
异常链的四个 API
Throwable 提供了这几个跟 cause 相关的成员:
Throwable getCause() // 取出直接原因,没有返回 null
Throwable initCause(Throwable cause) // 事后设置原因,只能设一次
Throwable(String message, Throwable cause) // 构造方法:消息 + 原因
Throwable(Throwable cause) // 构造方法:只带原因
```java
`getCause()` 用来在代码里读取原因:
```java
public class GetCauseDemo {
public static void main(String[] args) {
try {
doWork();
} catch (IllegalStateException e) {
System.out.println("表面异常:" + e.getMessage());
Throwable cause = e.getCause();
while (cause != null) {
System.out.println(" 原因:" + cause);
cause = cause.getCause(); // 继续往下挖
}
}
}
static void doWork() {
try {
Integer.parseInt("bad");
} catch (NumberFormatException e) {
throw new IllegalStateException("处理失败", e);
}
}
}
输出:
表面异常:处理失败
原因:java.lang.NumberFormatException: For input string: "bad"
```bash
链条可以有很多层,`while` 循环一直挖到 `getCause()` 返回 `null` 为止,那就是根异常。
`initCause()` 用于那些没提供 cause 构造方法的异常类:
```java
// 某些老异常类只有 (String) 构造方法
Exception e = new Exception("外层错误");
e.initCause(originalException); // 事后补上
throw e;
Warning
initCause()一个异常对象上只能调用一次,重复调用会抛IllegalStateException。能用带 cause 的构造方法就别用它。
什么时候该包装,什么时候该直接抛
不是所有异常都需要包装。判断标准是:包装之后信息量有没有增加。
该包装: 底层是技术异常,上层需要转成业务语义。
// SQLException 对上层调用者没有意义,转成业务异常
try {
userDao.findById(id);
} catch (SQLException e) {
throw new UserQueryException("查询用户失败,id=" + id, e);
}
```bash
**不该包装:** 只是原样再抛一遍,什么都没加。
```java
// 多此一举
try {
doWork();
} catch (IOException e) {
throw new RuntimeException(e); // 没加任何信息,纯粹添乱
}
这种情况直接让异常往上传就好,不用 try...catch。
NullPointerException
排名第一的常见异常,简称 NPE。对一个 null 引用调用方法或访问字段就会触发。
public class NpeDemo {
public static void main(String[] args) {
String s = null;
System.out.println(s.length()); // NullPointerException
}
}
```java
**Java 14 起,NPE 的错误信息大幅增强**,会直接告诉你哪个变量是 null:
```text
Exception in thread "main" java.lang.NullPointerException: Cannot invoke
"String.length()" because "<local1>" is null
对于 a.b.c.method() 这种链式调用,以前根本分不清是 a、a.b 还是 a.b.c 为 null,现在一目了然。
怎么防?三个习惯。
成员变量声明时就初始化,别让它裸着为 null:
public class Person {
private String name = ""; // 空字符串而不是 null
private List<String> tags = new ArrayList<>(); // 空集合而不是 null
}
```bash
**返回空集合而不是 null**:
```java
// 推荐
static List<String> findNames() {
if (noData()) {
return List.of(); // 空列表,调用方直接遍历不会炸
}
return actualNames();
}
确实可能「没有值」时,用 Optional:
import java.util.Optional;
public class OptionalDemo {
static Optional<String> findUser(String id) {
if ("u001".equals(id)) {
return Optional.of("张三");
}
return Optional.empty();
}
public static void main(String[] args) {
String name = findUser("u999").orElse("未知用户");
System.out.println(name); // 未知用户
}
}
```bash
`Optional` 逼着调用方处理「没有值」的情况,比返回 `null` 安全得多。
> [!WARNING]
> NPE 是**代码 bug**,不是业务异常。用 `try...catch` 把它捂住是最糟糕的做法——问题还在,只是你看不见了。正确做法是让它暴露,然后去改代码。
## ArrayIndexOutOfBoundsException
数组下标越界。合法范围是 `0` 到 `length - 1`。
```java
int[] arr = new int[3]; // 合法下标:0、1、2
System.out.println(arr[3]); // 越界
错误信息很直白:Index 3 out of bounds for length 3。
最常见的原因是循环条件写成了 i <= arr.length。记住循环边界用 < 不用 <=:
for (int i = 0; i < arr.length; i++) { // 正确
System.out.println(arr[i]);
}
```bash
集合的越界异常是 `IndexOutOfBoundsException`,字符串的是 `StringIndexOutOfBoundsException`,道理完全一样。
## ClassCastException
强制类型转换时,对象的实际类型和目标类型不兼容。
```java
Object obj = "我是字符串";
Integer num = (Integer) obj; // ClassCastException
错误信息:class java.lang.String cannot be cast to class java.lang.Integer。
防范手段有两个。优先用泛型,让编译器在编译期就拦住类型错误。确实需要向下转型时,先用 instanceof 检查:
Object obj = getSomething();
// Java 16 起的模式匹配写法,检查和转换一步到位
if (obj instanceof Integer num) {
System.out.println(num + 1);
} else {
System.out.println("不是整数");
}
```bash
## NumberFormatException
字符串转数字失败,是 `IllegalArgumentException` 的子类。
```java
Integer.parseInt("abc"); // 非数字
Integer.parseInt(""); // 空串
Integer.parseInt(null); // null
Integer.parseInt("12.5"); // 小数点,parseInt 不认
Integer.parseInt(" 12 "); // 有空格
处理用户输入时高频出现。这是少数值得捕获的运行时异常——用户输错字符不是你的 bug:
public class ParseSafely {
static int parseOrDefault(String s, int defaultValue) {
if (s == null || s.isBlank()) {
return defaultValue;
}
try {
return Integer.parseInt(s.trim());
} catch (NumberFormatException e) {
return defaultValue;
}
}
public static void main(String[] args) {
System.out.println(parseOrDefault(" 42 ", 0)); // 42
System.out.println(parseOrDefault("abc", 0)); // 0
}
}
```bash
## ArithmeticException
整数除以零。
```java
int a = 10 / 0; // ArithmeticException: / by zero
int b = 10 % 0; // 取模也一样
只有整数运算会抛异常。浮点数除零不抛,而是得到特殊值:
System.out.println(1.0 / 0); // Infinity
System.out.println(-1.0 / 0); // -Infinity
System.out.println(0.0 / 0); // NaN
```bash
这个差异经常让人措手不及。做除法前判断除数是否为零,是最简单的防护。
## ConcurrentModificationException
遍历集合的过程中修改了集合结构,就会触发它。
```java
import java.util.ArrayList;
import java.util.List;
public class CmeDemo {
public static void main(String[] args) {
List<String> list = new ArrayList<>(List.of("a", "b", "c"));
for (String s : list) {
if ("b".equals(s)) {
list.remove(s); // ConcurrentModificationException
}
}
}
}
名字里有 Concurrent,但单线程照样会触发,跟并发没必然关系。原理是集合内部有个修改计数器,迭代器发现计数对不上就报错。
正确的删除姿势有两种:
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
public class SafeRemove {
public static void main(String[] args) {
List<String> list1 = new ArrayList<>(List.of("a", "b", "c"));
// 方式一:removeIf,最简洁
list1.removeIf(s -> s.equals("b"));
System.out.println(list1); // [a, c]
List<String> list2 = new ArrayList<>(List.of("a", "b", "c"));
// 方式二:显式迭代器的 remove
Iterator<String> it = list2.iterator();
while (it.hasNext()) {
if ("b".equals(it.next())) {
it.remove();
}
}
System.out.println(list2); // [a, c]
}
}
```bash
这个机制叫 fail-fast,第 81 章会讲它的实现细节。
## UnsupportedOperationException
对不可变集合做修改操作。
```java
List<String> list = List.of("a", "b");
list.add("c"); // UnsupportedOperationException
List.of()、Map.of()、Arrays.asList()、Collections.unmodifiableList() 返回的都是不可变或固定长度的集合。
需要能改的集合,用它包一层:
List<String> mutable = new ArrayList<>(List.of("a", "b"));
mutable.add("c"); // 正常
```bash
## StackOverflowError
递归没有终止条件,把调用栈撑爆了。
```java
public class StackOverflow {
static void recurse() {
recurse(); // 没有出口
}
public static void main(String[] args) {
recurse(); // StackOverflowError
}
}
注意它是 Error 不是 Exception。写递归时第一件事就是写终止条件,写完再写递归调用。
快速对照表
| 异常 | 一句话原因 | 优先防范手段 |
|---|---|---|
NullPointerException | 对 null 调方法 | 初始化默认值、返回空集合、用 Optional |
ArrayIndexOutOfBoundsException | 下标越界 | 循环用 < length |
ClassCastException | 强转类型不符 | 用泛型、instanceof 模式匹配 |
NumberFormatException | 字符串转数字失败 | 校验输入 + 捕获给默认值 |
ArithmeticException | 整数除零 | 除前判断除数 |
ConcurrentModificationException | 遍历时改集合 | removeIf 或迭代器 remove |
UnsupportedOperationException | 改不可变集合 | 用 new ArrayList<>(...) 包一层 |
StackOverflowError | 递归无出口 | 先写终止条件 |
小结
包装异常时一定要把原始异常作为 cause 传进去,否则栈信息里的 Caused by 就没了,排查全靠猜。
读异常栈从最后一个 Caused by 看起,那才是根本原因。
常见异常里,NullPointerException 和 ClassCastException 属于代码 bug,该修代码;NumberFormatException 处理用户输入时值得捕获。
ConcurrentModificationException 跟并发无关,单线程遍历时改集合就会触发。