首页 / Java 入门教程 / 自定义异常

Java 入门教程

自定义异常

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

JavaJava 入门教程自定义异常RuntimeException异常体系设计

69. 自定义异常

本节目标:知道什么时候该自己造异常类、该继承哪个父类,学完能写出构造方法完整、能携带业务信息的自定义异常。

先问一句:真的需要吗

JDK 自带的异常类型已经覆盖了大部分场景。造新类之前,先在这张表里找找有没有现成的。

场景用这个
参数不合法IllegalArgumentException
对象状态不对,不能执行该操作IllegalStateException
字符串转数字失败NumberFormatException
传了不该为 null 的 nullNullPointerException
索引越界IndexOutOfBoundsException
方法不支持这个操作UnsupportedOperationException
IO 出错IOException

能用现成的就用现成的。每多一个自定义异常类,团队就多一份认知成本。

那什么时候该自定义?两个信号。

第一,需要区分处理。 调用方要根据错误类型走不同分支。用户不存在跳注册页,密码错误提示重试——这两种情况用同一个 IllegalArgumentException 就分不开了。

第二,需要携带业务数据。 除了错误消息,还得带上订单号、错误码、剩余重试次数这类字段。

继承 Exception 还是 RuntimeException

这是自定义异常的第一个决策点,直接决定了调用方是否被编译器强制处理。

继承 Exception → 受检异常 → 调用方必须 try...catchthrows

继承 RuntimeException → 非受检异常 → 调用方爱管不管。

// 受检异常:调用方被编译器逼着处理
public class InsufficientBalanceException extends Exception {
    public InsufficientBalanceException(String message) {
        super(message);
    }
}

// 非受检异常:调用方自由决定
public class UserNotFoundException extends RuntimeException {
    public UserNotFoundException(String message) {
        super(message);
    }
}
```bash

怎么选?看这个错误**调用方有没有能力恢复**

余额不足这种,调用方可以提示用户充值、可以走透支流程,有恢复余地,适合受检异常。

用户 ID 在数据库里查不到,多半是上游传错了或者数据有问题,调用方当场也修不好,适合非受检异常。

> [!NOTE]
> 实际项目里,**继承 `RuntimeException` 是主流做法**。Spring、MyBatis 这些框架的异常几乎全是非受检的。原因是受检异常会污染整条调用链——底层加一个 `throws`,上面十几层方法全得跟着改签名,或者被迫写一堆无意义的 `try...catch`。

## 四个标准构造方法

自定义异常至少要提供构造方法,否则用起来处处别扭。JDK 的异常类都提供四个,照抄就行。

```java
public class BusinessException extends RuntimeException {

    // 1. 无参:什么信息都不带
    public BusinessException() {
        super();
    }

    // 2. 只带消息:最常用
    public BusinessException(String message) {
        super(message);
    }

    // 3. 消息 + 原因:包装底层异常时用
    public BusinessException(String message, Throwable cause) {
        super(message, cause);
    }

    // 4. 只带原因:消息自动取 cause 的字符串
    public BusinessException(Throwable cause) {
        super(cause);
    }
}

这四个方法体全是 super(...),一行代码都不用自己写。IDE 里在类名上按快捷键就能一键生成。

第三个构造方法最重要。它保存了原始异常,让调用栈能一路追溯到问题源头。第 70 章会专门讲这条链。

完整用例

把异常类和使用它的代码放在一起看:

// 文件 1:InsufficientBalanceException.java
public class InsufficientBalanceException extends RuntimeException {
    public InsufficientBalanceException(String message) {
        super(message);
    }

    public InsufficientBalanceException(String message, Throwable cause) {
        super(message, cause);
    }
}
```bash

```java
// 文件 2:Account.java
public class Account {
    private final String id;
    private double balance;

    public Account(String id, double balance) {
        this.id = id;
        this.balance = balance;
    }

    public void withdraw(double amount) {
        if (amount <= 0) {
            throw new IllegalArgumentException("取款金额必须为正数:" + amount);
        }
        if (amount > balance) {
            throw new InsufficientBalanceException(
                    "账户 " + id + " 余额不足,当前 " + balance + ",需要 " + amount);
        }
        balance -= amount;
        System.out.println("取款成功,余额剩余 " + balance);
    }

    public static void main(String[] args) {
        Account acc = new Account("A001", 100.0);
        try {
            acc.withdraw(500.0);
        } catch (InsufficientBalanceException e) {
            System.out.println("业务失败:" + e.getMessage());
        }
    }
}

输出:

业务失败:账户 A001 余额不足,当前 100.0,需要 500.0
```bash

编译运行:

```bash
javac InsufficientBalanceException.java Account.java
java Account

给异常加业务字段

只有一句错误消息往往不够。调用方想拿到结构化的数据,比如错误码,好据此决定返回什么 HTTP 状态。

public class ApiException extends RuntimeException {
    private final int code;      // 业务错误码
    private final String detail; // 附加说明

    public ApiException(int code, String message) {
        this(code, message, null);
    }

    public ApiException(int code, String message, String detail) {
        super(message);
        this.code = code;
        this.detail = detail;
    }

    public int getCode() {
        return code;
    }

    public String getDetail() {
        return detail;
    }
}
```java

用起来就能精准分流:

```java
public class ApiDemo {
    public static void main(String[] args) {
        try {
            callApi(404);
        } catch (ApiException e) {
            System.out.println("错误码 " + e.getCode() + ":" + e.getMessage());
            if (e.getCode() == 404) {
                System.out.println("走资源不存在的处理分支");
            }
        }
    }

    static void callApi(int status) {
        if (status != 200) {
            throw new ApiException(status, "接口调用失败", "请检查请求路径");
        }
    }
}
Tip

字段建议加 final,异常对象创建后就不该被改。异常在多线程环境下可能被多处读取,不可变最安全。

搭建项目异常体系

项目一大,异常类会越来越多。散着放,调用方就得写一长串 catch

常见做法是定义一个根异常,所有业务异常都从它派生

// 根异常
public class BaseException extends RuntimeException {
    public BaseException() {
        super();
    }

    public BaseException(String message) {
        super(message);
    }

    public BaseException(String message, Throwable cause) {
        super(message, cause);
    }

    public BaseException(Throwable cause) {
        super(cause);
    }
}
```java

往下按业务模块分层:

```java
public class UserNotFoundException extends BaseException {
    public UserNotFoundException(String userId) {
        super("用户不存在:" + userId);
    }
}

public class LoginFailedException extends BaseException {
    public LoginFailedException(String message) {
        super(message);
    }
}

public class OrderExpiredException extends BaseException {
    public OrderExpiredException(String orderId) {
        super("订单已过期:" + orderId);
    }
}

体系长这样:

RuntimeException
└─ BaseException
   ├─ UserNotFoundException
   ├─ LoginFailedException
   └─ OrderExpiredException
```java

好处立刻显现:**上层可以一把兜住所有业务异常**。

```java
try {
    processOrder();
} catch (BaseException e) {
    // 所有业务异常统一处理,转成友好提示返回前端
    System.out.println("业务错误:" + e.getMessage());
}

也可以在需要时精确捕获某一种:

try {
    login(user, pwd);
} catch (UserNotFoundException e) {
    System.out.println("跳转注册页");
} catch (LoginFailedException e) {
    System.out.println("提示重新输入密码");
}
```bash

## 几个设计要点

**命名以 Exception 结尾。** `UserNotFoundException` 而不是 `UserNotFoundError` 或 `UserNotFound`。这是全 Java 生态的共识。

**层级别超过三层。** `RuntimeException` → `BaseException` → 具体异常,够用了。再深就成了给自己找麻烦。

**别为每个错误消息造一个类。** 「用户名太短」「用户名太长」「用户名含非法字符」不需要三个异常类,一个 `InvalidUsernameException` 带上不同消息就行。

**类的数量控制在个位数到十几个。** 超过这个量级,说明粒度切得太细了。

> [!WARNING]
> 不要继承 `Error` 来自定义异常。`Error` 是 JVM 级严重故障的专属分支,业务代码占用它会让排查问题的人产生严重误判。

## 一个可以直接跑的完整例子

把前面的知识串起来,写一个能编译运行的最小示例:

```java
public class CustomExceptionDemo {

    // 根异常
    static class BaseException extends RuntimeException {
        BaseException(String message) {
            super(message);
        }

        BaseException(String message, Throwable cause) {
            super(message, cause);
        }
    }

    // 业务异常
    static class ConfigParseException extends BaseException {
        private final String key;

        ConfigParseException(String key, String message, Throwable cause) {
            super(message, cause);
            this.key = key;
        }

        String getKey() {
            return key;
        }
    }

    static int parsePort(String value) {
        try {
            return Integer.parseInt(value);
        } catch (NumberFormatException e) {
            // 包装成业务异常,同时保留原始异常
            throw new ConfigParseException("server.port",
                    "端口配置格式错误:" + value, e);
        }
    }

    public static void main(String[] args) {
        try {
            parsePort("八千零八十");
        } catch (ConfigParseException e) {
            System.out.println("出错的配置项:" + e.getKey());
            System.out.println("错误信息:" + e.getMessage());
            System.out.println("根本原因:" + e.getCause());
        }
    }
}

输出:

出错的配置项:server.port
错误信息:端口配置格式错误:八千零八十
根本原因:java.lang.NumberFormatException: For input string: "八千零八十"
```bash

原始异常没丢,业务信息也带上了。

## 用工厂方法收敛创建逻辑

异常类多了以后,构造参数容易写得五花八门。给异常类加静态工厂方法,能让调用点整齐很多。

```java
public class ResourceException extends RuntimeException {
    private final String resourceType;
    private final String resourceId;

    private ResourceException(String resourceType, String resourceId, String message) {
        super(message);
        this.resourceType = resourceType;
        this.resourceId = resourceId;
    }

    // 工厂方法:语义清晰,消息格式统一
    public static ResourceException notFound(String type, String id) {
        return new ResourceException(type, id, type + " 不存在,id=" + id);
    }

    public static ResourceException duplicated(String type, String id) {
        return new ResourceException(type, id, type + " 已存在,id=" + id);
    }

    public String getResourceType() {
        return resourceType;
    }

    public String getResourceId() {
        return resourceId;
    }

    public static void main(String[] args) {
        try {
            throw ResourceException.notFound("用户", "u001");
        } catch (ResourceException e) {
            System.out.println(e.getMessage());
            System.out.println("资源类型:" + e.getResourceType());
        }
    }
}

输出:

用户 不存在,id=u001
资源类型:用户
```bash

好处是**消息格式集中在一处**。哪天要改文案,改工厂方法就行,不用满项目搜索。

## 关于序列化警告

自定义异常时,IDE 可能会提示「未定义 serialVersionUID」。

原因是 `Throwable` 实现了 `Serializable` 接口,所有异常类都是可序列化的。编译器建议显式声明一个版本号。

```java
public class MyException extends RuntimeException {
    private static final long serialVersionUID = 1L;

    public MyException(String message) {
        super(message);
    }
}

加上这一行警告就消失了。

不加会怎样?只在异常对象需要跨 JVM 传输(比如 RMI 远程调用)的场景才有影响。绝大多数项目里异常不出进程,加不加都行。团队有统一规范就跟着走。

Note

如果异常类里加了自定义字段,注意这些字段的类型也应该是可序列化的。带上一个不可序列化的对象(比如数据库连接),真要序列化时会抛 NotSerializableException

小结

JDK 有现成的就别自己造,只在需要区分处理或携带业务数据时才自定义。

主流选择是继承 RuntimeException,避免受检异常污染整条调用链。

四个构造方法照抄 JDK 的模板,其中 (String message, Throwable cause) 最关键,用于保留原始异常。

项目里建一个 BaseException 当根,业务异常从它派生,上层就能一把兜住所有业务错误。