首页 / Java 入门教程 / 异常体系

Java 入门教程

异常体系

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

JavaJava 入门教程异常体系Throwable受检异常

本节目标:搞懂 Java 为什么用异常表示错误,认清 Throwable 家族的继承树,学完能一眼判断某个异常是必须捕获还是可以放过。

程序总会出错

写程序时你会发现,代码本身没问题,运行起来照样能崩。

用户输入年龄,你期待一个数字,他敲了 abc。程序要读某个文件,文件被人删了。网络突然断开,磁盘突然写满。

这些情况不是「写错代码」,而是运行环境不配合。健壮的程序必须能应对它们,而不是一崩了之。

public class ErrorDemo {
    public static void main(String[] args) {
        String s = "abc";
        int n = Integer.parseInt(s); // 这里会炸:NumberFormatException
        System.out.println(n);
    }
}

编译能过,运行直接终止。控制台会打印一串红字,程序后面的代码一行都执行不到。

错误码方案为什么被淘汰

早期 C 语言的做法是返回错误码:函数返回 0 表示成功,返回其他数字代表不同的失败原因。

// 这是「反面教材」,演示错误码风格的写法
int code = processFile("test.txt");
if (code == 0) {
    // 成功
} else if (code == 1) {
    // 文件不存在
} else if (code == 2) {
    // 没有读权限
} else {
    // 未知错误
}

这套方案有三个硬伤。

第一,错误码是光秃秃的数字,2 到底代表什么,全靠文档和记忆。

第二,正常返回值和错误码挤在一个返回位置上。如果方法本来就要返回 int,那 -1 到底是计算结果还是错误标记?说不清。

第三,也是最要命的——调用方可以直接忽略。你不检查返回值,编译器一句话都不会说,错误就这么被吞掉了。

Java 的答案:把错误做成对象

Java 换了个思路:错误也是对象,出错时不返回数字,而是「抛出」一个异常对象。

异常对象是一个类的实例,类名本身就说明了错误类型,对象里还能装错误消息和调用栈。

抛出的异常会沿着方法调用链往上传,直到被某一层 try...catch 接住。这样一来,出错的地方和处理错误的地方就解耦了

try {
    String s = processFile("test.txt");
    System.out.println(s);
} catch (FileNotFoundException e) {
    System.out.println("文件不存在");
} catch (SecurityException e) {
    System.out.println("没有权限");
} catch (IOException e) {
    System.out.println("其他 IO 错误");
}

对比错误码写法,这段代码的意图一目了然:哪种错走哪个分支,全靠类型说话。

Throwable 继承树

Java 的异常全部是类,它们有一棵共同的继承树,根是 Throwable

                     ┌───────────┐
                     │  Object   │
                     └───────────┘

                     ┌───────────┐
                     │ Throwable │
                     └───────────┘

                 ┌─────────┴─────────┐
           ┌───────────┐       ┌───────────┐
           │   Error   │       │ Exception │
           └───────────┘       └───────────┘
                 ▲                   ▲
         ┌───────┘              ┌────┴──────────┐
┌─────────────────┐   ┌──────────────────┐ ┌───────────┐
│OutOfMemoryError │…  │ RuntimeException │ │IOException│…
└─────────────────┘   └──────────────────┘ └───────────┘

                   ┌───────────┴─────────────┐
        ┌─────────────────────┐ ┌─────────────────────────┐
        │NullPointerException │ │IllegalArgumentException │…
        └─────────────────────┘ └─────────────────────────┘

记住三个层次就够用了。

Throwable 是所有可抛出对象的祖先,它直接继承 Object。只有 Throwable 及其子类的对象才能被 throw 抛出、被 catch 捕获。

Throwable 底下分成两大阵营:ErrorException。这两个分支的性质完全不同。

Error:别管,管不了

Error 代表 JVM 层面的严重故障,属于「程序自己修不好」的那一类。

  • OutOfMemoryError:内存耗尽
  • StackOverflowError:栈溢出,通常是无限递归导致的
  • NoClassDefFoundError:运行时找不到某个类文件

碰上 Error,正确姿势是让程序挂掉,然后去查是内存配置不够,还是代码写出了死循环。在业务代码里捕获 Error 几乎没有意义,捕获了也无力回天。

Warning

别写 catch (Throwable t) 这种「一网打尽」的代码。它会连 OutOfMemoryError 一起吞掉,让真正致命的问题被掩盖在日志里。

Exception:这才是你要处理的

Exception 代表程序运行中可预期、可处理的错误,这是我们平时打交道最多的分支。

Exception 内部又分两类,这个划分直接决定了编译器怎么对待你的代码

  1. RuntimeException 及其子类
  2. 其余所有 Exception 子类,比如 IOExceptionSQLException

受检异常:编译器盯着你

第二类(非 RuntimeException)叫受检异常(Checked Exception)。

「受检」的意思是——编译器会检查。只要一个方法可能抛出受检异常,调用它的代码就必须做出选择:要么当场 try...catch 捕获,要么在自己的方法签名上写 throws 继续往外扔。

两样都不做?编译不通过。

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;

public class CheckedDemo {
    public static void main(String[] args) {
        // Files.readString 声明了 throws IOException,必须处理
        try {
            String content = Files.readString(Path.of("demo.txt"));
            System.out.println(content);
        } catch (IOException e) {
            System.out.println("读文件失败:" + e.getMessage());
        }
    }
}

如果把 try...catch 去掉,编译器会直接报错:unreported exception IOException; must be caught or declared to be thrown

这套强制机制的用意很实在:文件可能不存在、网络可能断,这些是客观事实,你不能假装看不见

常见的受检异常有 IOExceptionFileNotFoundExceptionSQLExceptionClassNotFoundExceptionInterruptedException

非受检异常:编译器不管

RuntimeException 及其子类,还有整个 Error 分支,都属于非受检异常(Unchecked Exception)。

编译器完全不管它们。你既不用捕获,也不用声明,代码照样能编译通过。

public class UncheckedDemo {
    public static void main(String[] args) {
        int[] arr = new int[3];
        // 编译没有任何提示,运行时才炸
        System.out.println(arr[5]); // ArrayIndexOutOfBoundsException
    }
}

为什么放它们一马?因为这类异常基本都是代码逻辑写错了

数组越界,说明你的下标计算有 bug。空指针,说明你没判断对象是否为 null。参数非法,说明调用方传错了值。

这些问题的正解是改代码,不是加 try...catch 遮丑。编译器要是逼你在每次数组访问、每次方法调用外面都套一层 try,代码就没法看了。

常见的非受检异常:

异常类触发场景
NullPointerExceptionnull 调用方法或访问字段
ArrayIndexOutOfBoundsException数组下标越界
IndexOutOfBoundsException集合/字符串索引越界
ClassCastException强制类型转换失败
IllegalArgumentException方法收到不合法的参数
NumberFormatException字符串转数字失败(IllegalArgumentException 的子类)
ArithmeticException整数除以零
Note

「编译器不强制捕获」不等于「不该捕获」。比如解析用户输入时,NumberFormatException 就该捕获并给出友好提示——用户输错字符不是你的 bug。是否捕获,看具体场景。

一张判定表

新手最容易混的就是这几个概念的关系。用一张表理清楚:

类型编译器强制处理典型代表该怎么办
ErrorOutOfMemoryError让它崩,去查环境或配置
RuntimeExceptionNullPointerException改代码修 bug
其他 ExceptionIOException捕获处理或声明抛出

判定口诀:看是不是 RuntimeException 的后代。是,编译器放行;不是(且属于 Exception 分支),编译器强制你表态。

Throwable 的常用方法

不管哪种异常,都继承了 Throwable 的这几个方法,用得非常频繁:

public class ThrowableMethodDemo {
    public static void main(String[] args) {
        try {
            Integer.parseInt("hello");
        } catch (NumberFormatException e) {
            // 错误描述文本
            System.out.println(e.getMessage());
            // 类名 + 消息
            System.out.println(e.toString());
            // 打印完整调用栈到标准错误流
            e.printStackTrace();
        }
    }
}

运行输出大致是这样:

For input string: "hello"
java.lang.NumberFormatException: For input string: "hello"
java.lang.NumberFormatException: For input string: "hello"
    at java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:67)
    at java.base/java.lang.Integer.parseInt(Integer.java:665)
    at java.base/java.lang.Integer.parseInt(Integer.java:781)
    at ThrowableMethodDemo.main(ThrowableMethodDemo.java:4)

printStackTrace() 打印的调用栈从下往上读,最下面是 main 方法,最上面是真正抛异常的那一行。定位 bug 时,先看最上面属于你自己代码的那一行。

Tip

调用栈里 java.base/ 开头的都是 JDK 内部的类。跳过它们,直接找你自己的类名,那才是问题源头。

小结

Java 用异常对象代替错误码,让错误自带类型信息,还能跨越多层调用向上传播。

Throwable 是根,往下分 Error(严重故障,别管)和 Exception(可处理错误,重点关注)。

Exception 里,RuntimeException 分支属于非受检异常,编译器不管,出现了就去改代码;其余分支属于受检异常,编译器强制你捕获或声明。

判断一个异常归哪类,只需要问一句:它是 RuntimeException 的子类吗。