首页 / Java 入门教程 / 异常链与常见异常

Java 入门教程

异常链与常见异常

本教程共 100 篇 · 第 70 篇 · 更新于 2026-08-05 · 约 17 分钟阅读

JavaJava 入门教程异常链getCauseNullPointerException

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() 这种链式调用,以前根本分不清是 aa.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 看起,那才是根本原因。

常见异常里,NullPointerExceptionClassCastException 属于代码 bug,该修代码;NumberFormatException 处理用户输入时值得捕获。

ConcurrentModificationException 跟并发无关,单线程遍历时改集合就会触发。