Java异常机制详解

O泡李华 8

Java 异常机制详解

本章位置:Java 核心基础
学习目标:理解 Java 异常体系,掌握 try-catch-finally、throw、throws、自定义异常、异常链等核心内容,并知道在实际项目中如何正确处理异常。


一、什么是异常

程序运行时,不可能永远按照正常情况执行。

例如:

用户输入了错误的数据

数组访问越界

字符串转数字失败

除数为 0

文件不存在

数据库连接失败

网络请求失败

这些运行过程中出现的问题,可以统一理解为:

异常

英文:

Exception

二、为什么需要异常机制

如果没有异常机制,程序遇到问题时很容易直接崩溃。

例如:

public class Demo {

    public static void main(String[] args) {

        int a = 10;

        int b = 0;

        int result = a / b;

        System.out.println(result);

        System.out.println("程序结束");
    }
}

运行时:

10 / 0

会出现异常。

后面的:

System.out.println("程序结束");

不会继续执行。


三、异常机制的作用

Java 异常机制可以帮助程序:

发现问题
描述问题
传递问题
处理问题

例如:

try {

    int result = 10 / 0;

} catch (ArithmeticException e) {

    System.out.println("除数不能为 0");
}

程序不会直接因为这个问题结束。


四、异常不是错误提示那么简单

很多初学者会把异常理解成:

控制台上的红色报错

实际上异常是一个:

对象

例如:

ArithmeticException
NullPointerException
NumberFormatException

这些本质上都是类。

程序发生异常时:

JVM 会创建异常对象
并把异常对象抛出来

五、Java 异常体系

Java 中异常体系最顶层:

Throwable

结构可以简单理解:

Throwable
│
├─ Error
│
└─ Exception
    │
    ├─ RuntimeException
    │   ├─ NullPointerException
    │   ├─ ArithmeticException
    │   ├─ ArrayIndexOutOfBoundsException
    │   ├─ NumberFormatException
    │   └─ ClassCastException
    │
    └─ 其他 Exception
        ├─ IOException
        ├─ SQLException
        └─ ...

六、Throwable

Throwable 是 Java 异常体系的顶层父类。

它主要有两个重要子类:

Error
Exception

七、Error

Error 表示:

比较严重的问题

例如:

JVM 内存不足
栈溢出
虚拟机内部错误

常见:

OutOfMemoryError
StackOverflowError

八、Error 一般不由普通业务代码处理

例如:

服务器内存已经完全耗尽

这种问题不是简单:

try-catch

就能真正解决的。

通常需要:

修改程序
调整 JVM 参数
优化内存使用
检查递归
调整服务器资源

所以初学阶段重点学习:

Exception

而不是 Error。


九、Exception

Exception 表示程序运行过程中:

可以预料
可以捕获
可以处理

的问题。

例如:

用户输入错误
文件读取失败
数字格式错误
数据库访问异常

十、异常的两大分类

Exception 可以重点分成:

运行时异常

编译时异常

也经常叫:

非受检异常

受检异常

英文:

Unchecked Exception
Checked Exception

十一、运行时异常

继承:

RuntimeException

的异常通常称为:

运行时异常

例如:

NullPointerException
ArithmeticException
ArrayIndexOutOfBoundsException
NumberFormatException
ClassCastException

十二、运行时异常特点

运行时异常:

编译器不强制要求处理

例如:

int result = 10 / 0;

编译时可能没有问题。

真正运行到这里时才出现:

ArithmeticException

十三、编译时异常

不是 RuntimeException 子类的某些 Exception:

编译器要求必须处理

例如:

IOException
SQLException

如果不处理:

代码无法通过编译

十四、编译时异常不是说编译时一定出错

“编译时异常”这个名字容易误解。

它不是说:

异常发生在编译阶段

而是:

编译器会检查这种异常是否被处理。

异常真正发生依然通常是在:

程序运行阶段

十五、常见运行时异常

1. ArithmeticException

例如:

int result = 10 / 0;

2. NullPointerException

例如:

String name = null;

System.out.println(
        name.length()
);

3. ArrayIndexOutOfBoundsException

int[] array = {
        10,
        20,
        30
};

System.out.println(
        array[3]
);

4. NumberFormatException

int number =
        Integer.parseInt(
                "abc"
        );

5. ClassCastException

例如错误向下转型:

Animal animal =
        new Cat();

Dog dog =
        (Dog) animal;

十六、异常默认处理机制

如果程序没有主动处理异常:

JVM 会处理

大致过程:

1. 创建异常对象

2. 打印异常信息

3. 打印异常位置

4. 当前线程结束

例如:

Exception in thread "main"
java.lang.ArithmeticException: / by zero

十七、异常信息怎么看

例如:

java.lang.NumberFormatException:
For input string: "abc"

重点看:

异常类型
异常原因
代码位置

十八、异常堆栈

异常后通常还会看到:

at com.example.Demo.test(Demo.java:20)
at com.example.Demo.main(Demo.java:10)

这叫:

异常堆栈

英文:

Stack Trace

排查问题时非常重要。


十九、try-catch

最基本的异常处理方式:

try {

    可能发生异常的代码;

} catch (异常类型 变量) {

    异常处理代码;
}

二十、try-catch 基础案例

public class Demo {

    public static void main(String[] args) {

        try {

            int result =
                    10 / 0;

            System.out.println(
                    result
            );

        } catch (
                ArithmeticException e
        ) {

            System.out.println(
                    "除数不能为 0"
            );
        }

        System.out.println(
                "程序继续执行"
        );
    }
}

输出:

除数不能为 0
程序继续执行

二十一、try 中的执行规律

例如:

try {

    System.out.println("A");

    int result = 10 / 0;

    System.out.println("B");

} catch (ArithmeticException e) {

    System.out.println("C");
}

输出:

A
C

为什么没有:

B

因为:

try 中一旦发生异常
当前 try 后续代码不会继续执行

程序会直接去寻找匹配的:

catch

二十二、catch 参数

例如:

catch (ArithmeticException e) {

}

其中:

ArithmeticException

表示捕获的异常类型。

e

表示接收到的异常对象。


二十三、getMessage()

获取异常的简单描述:

System.out.println(
        e.getMessage()
);

例如:

/ by zero

二十四、printStackTrace()

打印完整异常堆栈:

e.printStackTrace();

开发排错时非常常见。

但正式项目中通常会配合:

日志框架

例如:

SLF4J
Logback

而不是所有地方直接 printStackTrace()。


二十五、不要把异常直接吃掉

不推荐:

try {

    // 业务代码

} catch (Exception e) {

}

什么都不做。

这种做法叫:

吞异常

问题是:

程序出错了

但日志中完全没有信息

后续排查非常困难

二十六、多个 catch

一个 try 可以对应多个 catch。

例如:

try {

    String text = "abc";

    int number =
            Integer.parseInt(
                    text
            );

    int result =
            10 / number;

} catch (
        NumberFormatException e
) {

    System.out.println(
            "数字格式错误"
    );

} catch (
        ArithmeticException e
) {

    System.out.println(
            "算术异常"
    );
}

二十七、多个 catch 的匹配规律

Java 会根据实际异常类型:

从上往下寻找第一个匹配的 catch

找到后:

执行对应 catch

其他 catch:

不会执行

二十八、父类异常要放后面

例如:

try {

} catch (Exception e) {

} catch (RuntimeException e) {

}

会有问题。

因为:

Exception

已经可以捕获:

RuntimeException

后面的 catch 永远没有机会执行。

正确:

try {

} catch (
        NumberFormatException e
) {

} catch (
        RuntimeException e
) {

} catch (
        Exception e
) {

}

原则:

子类异常放前面
父类异常放后面

二十九、catch Exception 可以吗

可以:

catch (Exception e) {

}

它可以捕获很多异常。

但不建议:

任何地方都只写 Exception

如果已经知道具体异常:

NumberFormatException
IOException
SQLException

更推荐捕获:

具体异常

这样代码表达更清晰。


三十、Java 7 多异常捕获

如果多个异常处理方式完全一样:

catch (
        NumberFormatException
        | ArithmeticException e
) {

    System.out.println(
            "输入或计算错误"
    );
}

使用:

|

连接多个异常类型。


三十一、finally

finally 表示:

无论是否发生异常,通常都会执行的代码。

语法:

try {

} catch (Exception e) {

} finally {

}

三十二、finally 基础案例

try {

    System.out.println(
            "执行 try"
    );

} catch (Exception e) {

    System.out.println(
            "执行 catch"
    );

} finally {

    System.out.println(
            "执行 finally"
    );
}

三十三、finally 常见用途

以前经常用于:

关闭文件流
关闭数据库连接
释放资源

例如:

finally {

    // 关闭资源
}

现代 Java 对很多资源推荐使用:

try-with-resources

后面会讲。


三十四、finally 真的百分百一定执行吗

一般业务流程中可以理解为:

通常都会执行

但也有特殊情况。

例如:

System.exit(0);

直接终止 JVM。

这种情况下:

finally

可能无法正常执行。

所以不要死记:

finally 绝对 100% 执行

三十五、try-finally

try 也可以只配:

finally

例如:

try {

    // 代码

} finally {

    // 清理资源
}

但是:

异常并没有被真正处理

它仍然可能继续向上抛。


三十六、return 和 finally

例如:

public static int test() {

    try {

        return 10;

    } finally {

        System.out.println(
                "finally"
        );
    }
}

调用:

System.out.println(
        test()
);

通常先执行:

finally

再完成返回。


三十七、不要在 finally 中 return

例如:

try {

    return 10;

} finally {

    return 20;
}

这种代码非常不推荐。

因为:

finally 中的 return
可能覆盖 try 或 catch 中的 return
甚至影响异常传播

实际开发中:

不要在 finally 里写 return。


三十八、throw

throw 用于:

主动抛出一个异常对象

例如:

throw new RuntimeException(
        "发生异常"
);

三十九、主动抛异常的意义

有些业务问题并不是 JVM 自动发现的。

例如:

年龄不能小于 0

余额不足

订单不存在

用户没有权限

库存不足

这些都需要程序主动判断。

例如:

if (age < 0) {

    throw new IllegalArgumentException(
            "年龄不能小于 0"
    );
}

四十、IllegalArgumentException

表示:

参数不合法

例如:

public void setAge(
        int age
) {

    if (age < 0
            || age > 150) {

        throw new IllegalArgumentException(
                "年龄必须在 0~150 之间"
        );
    }

    this.age = age;
}

四十一、throw 后面的代码

例如:

if (age < 0) {

    throw new IllegalArgumentException(
            "年龄错误"
    );

    // System.out.println("后续代码");
}

throw 执行后:

当前正常流程被中断

异常开始向上寻找处理位置。


四十二、throws

throws 写在:

方法声明

后面。

作用:

声明这个方法可能抛出某些异常

例如:

public void readFile()
        throws IOException {

}

四十三、throw 和 throws 区别

throw

写在:

方法体内部

作用:

真正抛出一个异常对象

例如:

throw new IOException();

throws

写在:

方法声明位置

作用:

告诉调用者
这个方法可能抛出异常

例如:

public void test()
        throws IOException {

}

四十四、throw 和 throws 对比

对比 throw throws
位置 方法体内部 方法声明后
作用 真正抛异常 声明可能异常
后面跟什么 异常对象 异常类型
数量 一次一个对象 可声明多个类型

四十五、throws 多个异常

例如:

public void test()
        throws IOException,
               SQLException {

}

调用者需要:

继续抛
或者处理

四十六、异常传播

例如:

public static void methodC() {

    int result = 10 / 0;
}

A 调 B:

public static void methodB() {

    methodC();
}

main 调 B:

public static void main(
        String[] args
) {

    methodB();
}

如果中间都不处理:

异常会沿着方法调用栈向上传播

四十七、异常传播过程

可以理解:

main()
  ↓
methodB()
  ↓
methodC()
  ↓
发生异常
  ↑
methodB 没处理
  ↑
main 没处理
  ↑
JVM 处理

四十八、在哪里处理异常

一个很重要的问题:

异常应该在哪里 catch?

不是:

哪里发生就一定在哪里 catch

而应该看:

哪一层真正有能力决定怎么处理。

例如底层 DAO:

数据库连接失败

底层可能只知道:

数据库访问失败

真正返回什么提示给用户:

Service / Controller

可能更清楚。


四十九、不要为了“消除红线”乱 catch

例如:

try {

    service.pay();

} catch (Exception e) {

    System.out.println(
            "出错了"
    );
}

如果只是为了让代码“不报错”,没有真正处理:

记录
转换
补偿
重试
返回错误

这不算好的异常处理。


五十、受检异常的处理方式

编译器要求处理的异常一般有两种方式:

1. try-catch

2. throws

五十一、方式一:try-catch

例如:

try {

    // 可能发生 IOException 的代码

} catch (IOException e) {

    // 处理
}

五十二、方式二:继续 throws

例如:

public void test()
        throws IOException {

    // 可能抛 IOException
}

把处理责任交给:

调用者

五十三、File 读取中的异常概念

例如:

Files.readString(
        Path.of(
                "data.txt"
        )
);

读取文件时可能出现:

IOException

因此需要:

try-catch

或者:

throws IOException

五十四、try-with-resources

Java 7 提供:

try-with-resources

用于自动关闭资源。

语法:

try (
        资源对象
) {

    使用资源;
}

离开 try 后:

资源自动关闭

五十五、为什么 try-with-resources 更好

以前:

FileInputStream input = null;

try {

    input =
            new FileInputStream(
                    "a.txt"
            );

} finally {

    if (input != null) {

        input.close();
    }
}

比较繁琐。

现在:

try (
        FileInputStream input =
                new FileInputStream(
                        "a.txt"
                )
) {

    // 使用 input
}

更简洁。


五十六、哪些资源能自动关闭

实现:

AutoCloseable

接口的资源,可以使用:

try-with-resources

例如:

InputStream
OutputStream
Reader
Writer
数据库 Connection
Statement
ResultSet

很多资源都支持。


五十七、自定义异常

实际业务中,Java 自带异常不一定能准确描述业务问题。

例如:

余额不足
库存不足
用户不存在
订单已取消
登录失败

可以定义:

自定义异常

五十八、自定义运行时异常

例如:

public class BalanceNotEnoughException
        extends RuntimeException {

    public BalanceNotEnoughException(
            String message
    ) {

        super(message);
    }
}

五十九、使用自定义异常

public void pay(
        BigDecimal balance,
        BigDecimal amount
) {

    if (
        balance.compareTo(amount)
                < 0
    ) {

        throw new BalanceNotEnoughException(
                "余额不足"
        );
    }

    System.out.println(
            "支付成功"
    );
}

六十、自定义异常为什么常继承 RuntimeException

现代业务开发中很多:

业务异常

会选择继承:

RuntimeException

原因之一:

调用链更干净
不用每一层都强制 throws

例如 Spring 项目中经常看到:

BusinessException
        extends RuntimeException

六十一、自定义异常完整写法

public class BusinessException
        extends RuntimeException {

    private final int code;

    public BusinessException(
            int code,
            String message
    ) {

        super(message);

        this.code = code;
    }

    public int getCode() {

        return code;
    }
}

使用:

throw new BusinessException(
        40001,
        "库存不足"
);

六十二、异常类可以携带业务信息

例如:

错误码
错误消息
业务模块
附加数据

例如:

private int code;

private String message;

后面 SpringBoot 全局异常处理中会很常见。


六十三、异常链

有时底层异常需要包装成更适合业务的异常。

例如:

try {

    // 数据库访问

} catch (SQLException e) {

    throw new RuntimeException(
            "查询用户失败",
            e
    );
}

这里把原始异常:

e

继续作为:

cause

保留下来。


六十四、为什么要保留 cause

错误写法:

catch (SQLException e) {

    throw new RuntimeException(
            "查询失败"
    );
}

原始 SQLException 信息丢失很多。

更好:

throw new RuntimeException(
        "查询失败",
        e
);

这样排查时还能看到:

真正底层原因

六十五、getCause()

可以获取:

导致当前异常的原始异常

例如:

Throwable cause =
        e.getCause();

六十六、异常包装

异常包装常用于:

底层技术异常
↓
转换成业务层异常

例如:

SQLException
↓
UserQueryException

或者:

IOException
↓
FileStorageException

这样上层代码不用了解太多底层实现细节。


六十七、断言 assert 简单了解

Java 提供:

assert

例如:

assert age >= 0;

但是:

默认 JVM 不一定启用断言

所以不要把它用作:

正式业务参数校验

业务校验更适合:

if + throw

六十八、异常和 if 判断的区别

并不是所有错误都应该:

throw Exception

例如:

年龄是否成年
订单是否已支付
库存是否为 0

有些只是:

正常业务分支

应该使用:

if

六十九、什么时候更适合异常

异常更适合:

程序无法按当前正常流程继续

例如:

用户不存在
数据非法
权限不足
数据库失败
文件读取失败

而不是把异常当成普通流程控制工具。


七十、不要使用异常代替普通判断

不推荐:

try {

    int number =
            array[index];

} catch (
        ArrayIndexOutOfBoundsException e
) {

    // 用异常判断索引是否合法
}

更合理:

if (
    index >= 0
    && index < array.length
) {

    int number =
            array[index];
}

七十一、业务异常和系统异常

项目中经常可以粗略分成:

业务异常

系统异常

七十二、业务异常

例如:

余额不足
库存不足
用户不存在
订单状态错误
验证码失效
权限不足

这些通常是:

业务规则导致的问题

七十三、系统异常

例如:

数据库连接失败
Redis 访问失败
第三方接口超时
磁盘异常
网络异常

这类问题通常:

不是用户正常操作导致

七十四、异常日志

实际项目中异常通常需要:

记录日志

例如:

log.error(
        "查询用户失败",
        e
);

而不是:

System.out.println(
        e.getMessage()
);

七十五、为什么不能只打印 e.getMessage()

例如:

System.out.println(
        e.getMessage()
);

可能只有:

Connection refused

缺少:

异常类型
调用链
代码位置
原始 cause

所以排查严重问题时通常需要:

完整异常堆栈

七十六、日志级别简单了解

常见:

TRACE
DEBUG
INFO
WARN
ERROR

异常通常会根据严重程度使用:

WARN
ERROR

后面学习 SpringBoot 日志时再深入。


七十七、不要重复记录同一个异常

例如:

DAO 记录一遍
Service 再记录一遍
Controller 再记录一遍
全局异常处理再记录一遍

同一个异常可能在日志中出现四遍。

实际项目中需要统一约定:

在哪一层记录
记录哪些上下文
是否继续抛

避免日志污染。


七十八、异常信息不要泄露敏感数据

例如:

密码
身份证号
银行卡
Token
数据库密码
完整 SQL 参数中的敏感字段

不要直接拼进异常信息或日志。

例如不推荐:

throw new RuntimeException(
        "登录失败,密码="
                + password
);

七十九、异常消息应该有上下文

不推荐:

throw new RuntimeException(
        "失败"
);

更好:

throw new RuntimeException(
        "读取用户配置文件失败"
);

再结合原始 cause:

throw new RuntimeException(
        "读取用户配置文件失败",
        e
);

八十、异常处理的基本原则

可以先记住:

能处理就处理

不能处理就继续抛

不要无意义吞掉

保留原始原因

不要滥用 Exception

不要滥用 try-catch

八十一、参数校验案例

public static void register(
        String username,
        int age
) {

    if (
        username == null
        || username.isBlank()
    ) {

        throw new IllegalArgumentException(
                "用户名不能为空"
        );
    }

    if (
        age < 0
        || age > 150
    ) {

        throw new IllegalArgumentException(
                "年龄不合法"
        );
    }

    System.out.println(
            "注册成功"
    );
}

八十二、数字输入异常案例

import java.util.Scanner;

public class InputDemo {

    public static void main(
            String[] args
    ) {

        Scanner scanner =
                new Scanner(
                        System.in
                );

        System.out.print(
                "请输入年龄:"
        );

        String text =
                scanner.nextLine();

        try {

            int age =
                    Integer.parseInt(
                            text
                    );

            System.out.println(
                    "年龄:"
                            + age
            );

        } catch (
                NumberFormatException e
        ) {

            System.out.println(
                    "请输入正确的整数"
            );
        }

        scanner.close();
    }
}

八十三、循环输入直到正确

import java.util.Scanner;

public class InputLoopDemo {

    public static void main(
            String[] args
    ) {

        Scanner scanner =
                new Scanner(
                        System.in
                );

        while (true) {

            System.out.print(
                    "请输入整数:"
            );

            String text =
                    scanner.nextLine();

            try {

                int number =
                        Integer.parseInt(
                                text
                        );

                System.out.println(
                        "输入成功:"
                                + number
                );

                break;

            } catch (
                    NumberFormatException e
            ) {

                System.out.println(
                        "格式错误,请重新输入"
                );
            }
        }

        scanner.close();
    }
}

八十四、ATM 业务异常案例

自定义异常:

public class BalanceNotEnoughException
        extends RuntimeException {

    public BalanceNotEnoughException(
            String message
    ) {

        super(message);
    }
}

账户类:

import java.math.BigDecimal;

public class Account {

    private BigDecimal balance;

    public Account(
            BigDecimal balance
    ) {

        this.balance =
                balance;
    }

    public void withdraw(
            BigDecimal amount
    ) {

        if (
            amount == null
            || amount.compareTo(
                    BigDecimal.ZERO
            ) <= 0
        ) {

            throw new IllegalArgumentException(
                    "取款金额必须大于 0"
            );
        }

        if (
            balance.compareTo(
                    amount
            ) < 0
        ) {

            throw new BalanceNotEnoughException(
                    "账户余额不足"
            );
        }

        balance =
                balance.subtract(
                        amount
                );
    }

    public BigDecimal getBalance() {

        return balance;
    }
}

八十五、调用 ATM 业务

Account account =
        new Account(
                new BigDecimal(
                        "1000.00"
                )
        );

try {

    account.withdraw(
            new BigDecimal(
                    "1200.00"
            )
    );

} catch (
        BalanceNotEnoughException e
) {

    System.out.println(
            e.getMessage()
    );
}

八十六、用户登录异常案例

定义:

public class LoginException
        extends RuntimeException {

    public LoginException(
            String message
    ) {

        super(message);
    }
}

业务:

public static void login(
        String username,
        String password
) {

    if (
        !"admin".equals(
                username
        )
    ) {

        throw new LoginException(
                "用户不存在"
        );
    }

    if (
        !"123456".equals(
                password
        )
    ) {

        throw new LoginException(
                "密码错误"
        );
    }

    System.out.println(
            "登录成功"
    );
}

八十七、异常分层思想

真实项目经常类似:

Controller
   ↓
Service
   ↓
Mapper / DAO
   ↓
Database

底层可能发生:

SQLException

Service 可能转换为:

BusinessException

Controller 或全局异常处理器再统一:

返回前端错误信息

八十八、SpringBoot 中异常机制会怎么用

后面学习 SpringBoot 时,经常会看到:

throw new BusinessException(
        "用户不存在"
);

然后使用:

全局异常处理器

统一捕获。

例如概念上:

@RestControllerAdvice

配合:

@ExceptionHandler

统一返回:

错误码
错误消息

当前阶段只需要理解:

Java 异常机制
是 SpringBoot 全局异常处理的基础

八十九、方法重写中的 throws 规则

子类重写父类方法时:

不能抛出比父类声明更宽泛的受检异常

例如父类:

public void test()
        throws IOException {

}

子类可以:

@Override
public void test()
        throws FileNotFoundException {

}

但不能随便扩大成:

throws Exception

九十、RuntimeException 在重写中的特点

运行时异常通常不受上述受检异常限制那么严格。

例如:

throw new IllegalArgumentException();

很多地方可以直接抛。


九十一、finally 和资源释放

传统资源:

InputStream input = null;

如果不用 try-with-resources:

finally

中关闭时也可能:

再次发生异常

这也是为什么现代 Java 更推荐:

try-with-resources

九十二、try-with-resources 多资源

例如:

try (
        InputStream input =
                ...;
        OutputStream output =
                ...
) {

    // 使用资源
}

多个资源用:

;

分隔。

关闭顺序通常与创建顺序相反。


九十三、异常抑制简单了解

try-with-resources 中:

业务代码异常
+
close() 也异常

Java 会保留主要异常,并记录被抑制异常。

可通过:

getSuppressed()

获取。

初学阶段知道即可。


九十四、异常设计不要太细碎

例如:

UsernameEmptyException
UsernameTooLongException
UsernameContainsIllegalCharException
UsernameAlreadyExistsException

如果每个小问题都建一个异常类:

异常类数量可能爆炸

实际项目常通过:

BusinessException
+
错误码
+
错误消息

统一描述业务异常。


九十五、统一业务异常示例

public class BusinessException
        extends RuntimeException {

    private final String code;

    public BusinessException(
            String code,
            String message
    ) {

        super(message);

        this.code = code;
    }

    public String getCode() {

        return code;
    }
}

使用:

throw new BusinessException(
        "USER_NOT_FOUND",
        "用户不存在"
);

九十六、异常错误码

实际项目中常见:

40001 参数错误
40002 用户不存在
40003 权限不足
50001 系统异常

相比只返回:

"出错了"

更方便前后端协作。


九十七、不要把技术异常直接返回用户

例如数据库报错:

Communications link failure

不应该直接返回前端用户。

用户应该看到:

系统繁忙,请稍后重试

详细技术信息:

写入服务器日志

九十八、异常处理中的安全问题

错误信息不要暴露:

数据库表名
服务器路径
SQL 语句
内部 IP
Token
密码
堆栈详情

给普通用户。

这些信息可以进入:

受控日志

但不应该直接展示在页面。


九十九、异常和事务

后面学习 Spring 事务时:

异常类型

会直接影响:

事务是否回滚

Spring 默认情况下:

RuntimeException

通常会触发事务回滚。

这也是很多业务异常继承:

RuntimeException

的原因之一。

详细规则后面事务章节再学。


一百、常见异常快速表

异常 常见原因
NullPointerException 对 null 调用方法/访问成员
ArithmeticException 整数除 0
ArrayIndexOutOfBoundsException 数组索引越界
NumberFormatException 字符串转数字失败
ClassCastException 类型强转错误
IllegalArgumentException 参数不合法
IOException IO 操作失败
FileNotFoundException 文件不存在
SQLException 数据库访问异常

一百零一、NullPointerException 排查思路

看到:

NullPointerException

不要只想着:

加 try-catch

更重要的是找:

谁是 null
为什么是 null
为什么本不应该为 null

例如:

user.getName();

可能是:

user == null

应该检查:

数据是否查询到
参数是否为空
对象是否初始化

一百零二、数组越界排查

看到:

ArrayIndexOutOfBoundsException

检查:

数组长度
访问索引
循环条件

通常错误:

i <= array.length

正确:

i < array.length

一百零三、NumberFormatException 排查

例如:

Integer.parseInt(
        text
);

检查:

text 是否为空
是否包含空格
是否真的是数字
是否超出 int 范围

例如:

text = text.trim();

再根据业务判断是否转换。


一百零四、ClassCastException 排查

例如:

Dog dog =
        (Dog) animal;

检查实际对象类型:

if (
    animal instanceof Dog dog
) {

}

不要盲目强转。


一百零五、异常调试顺序

看到异常时,建议:

1. 看异常类型

2. 看异常消息

3. 看第一条属于自己代码的堆栈

4. 找到对应源码行

5. 查看当时变量值

6. 分析为什么进入异常状态

7. 修复根因

不要只:

复制整段报错到搜索框

而完全不看堆栈。


一百零六、IDEA 中查看异常

IDEA 控制台中通常:

文件名.java:行号

可以点击。

例如:

Demo.java:25

直接跳到:

发生异常的源码位置

排错非常方便。


一百零七、IDEA Debug 配合异常排查

可以在异常前设置:

断点

然后 Debug。

重点观察:

变量值
调用栈
条件结果
对象状态

有时候比大量打印:

System.out.println()

更高效。


一百零八、IDEA Exception Breakpoint 简单了解

IDEA 支持:

异常断点

例如针对:

NullPointerException

设置断点。

异常一发生:

调试器立即停住

在复杂项目中很实用。


一百零九、不要 catch Throwable

一般不要:

catch (Throwable e) {

}

因为这连:

Error

都可能捕获。

普通业务代码一般处理:

Exception

或更具体的异常即可。


一百一十、不要 catch Error

例如:

catch (OutOfMemoryError e) {

}

普通业务层通常没有真正恢复能力。

严重 JVM 问题应该:

定位根因
监控报警
合理恢复/重启

而不是简单吞掉。


一百一十一、异常不要过度捕获

例如:

try {

    // 500 行业务代码

} catch (Exception e) {

}

范围太大时:

很难知道究竟哪里失败

更好的原则:

try 包住真正可能发生异常
且你准备处理的代码

一百一十二、异常不要过度细分 try

另一种极端:

try {
    a();
} catch (...) {}

try {
    b();
} catch (...) {}

try {
    c();
} catch (...) {}

如果这些属于一个完整事务流程:

过度拆分也会破坏逻辑

异常处理需要结合:

业务边界
事务边界
恢复能力

设计。


一百一十三、检查型异常和非检查型异常总结

Checked Exception

特点:

编译器强制处理

常见:

IOException
SQLException

处理:

try-catch
或者 throws

Unchecked Exception

主要:

RuntimeException

编译器:

不强制处理

常见:

NullPointerException
IllegalArgumentException
NumberFormatException

一百一十四、什么时候自定义 Checked Exception

理论上可以:

class MyException
        extends Exception

这种异常会要求调用者:

强制处理

适合:

你真的希望调用者明确处理
且有恢复能力

的场景。

普通 Web 业务开发中:

自定义 RuntimeException

通常更常见。


一百一十五、自定义 Checked Exception

public class MyCheckedException
        extends Exception {

    public MyCheckedException(
            String message
    ) {

        super(message);
    }
}

方法:

public void test()
        throws MyCheckedException {

    throw new MyCheckedException(
            "测试异常"
    );
}

调用者必须处理或继续抛。


一百一十六、异常构造方法常见写法

自定义异常建议至少提供:

public BusinessException(
        String message
) {

    super(message);
}

以及:

public BusinessException(
        String message,
        Throwable cause
) {

    super(
            message,
            cause
    );
}

这样方便保留异常链。


一百一十七、完整 BusinessException 示例

public class BusinessException
        extends RuntimeException {

    private final String code;

    public BusinessException(
            String code,
            String message
    ) {

        super(message);

        this.code = code;
    }

    public BusinessException(
            String code,
            String message,
            Throwable cause
    ) {

        super(
                message,
                cause
        );

        this.code = code;
    }

    public String getCode() {

        return code;
    }
}

一百一十八、完整业务案例:转账

需求:

转账金额必须大于 0

余额必须足够

账户不能为空

账户:

import java.math.BigDecimal;

public class Account {

    private final String name;

    private BigDecimal balance;

    public Account(
            String name,
            BigDecimal balance
    ) {

        this.name = name;

        this.balance =
                balance;
    }

    public void subtract(
            BigDecimal amount
    ) {

        balance =
                balance.subtract(
                        amount
                );
    }

    public void add(
            BigDecimal amount
    ) {

        balance =
                balance.add(
                        amount
                );
    }

    public BigDecimal getBalance() {

        return balance;
    }

    public String getName() {

        return name;
    }
}

一百一十九、转账业务方法

public static void transfer(
        Account from,
        Account to,
        BigDecimal amount
) {

    if (
        from == null
        || to == null
    ) {

        throw new BusinessException(
                "ACCOUNT_NULL",
                "转账账户不能为空"
        );
    }

    if (
        amount == null
        || amount.compareTo(
                BigDecimal.ZERO
        ) <= 0
    ) {

        throw new BusinessException(
                "AMOUNT_ERROR",
                "转账金额必须大于 0"
        );
    }

    if (
        from.getBalance()
                .compareTo(
                        amount
                ) < 0
    ) {

        throw new BusinessException(
                "BALANCE_NOT_ENOUGH",
                "账户余额不足"
        );
    }

    from.subtract(amount);

    to.add(amount);
}

一百二十、调用转账业务

try {

    transfer(
            accountA,
            accountB,
            new BigDecimal(
                    "500.00"
            )
    );

    System.out.println(
            "转账成功"
    );

} catch (
        BusinessException e
) {

    System.out.println(
            "转账失败:"
                    + e.getMessage()
    );
}

一百二十一、try-catch 模板

try {

    // 可能发生异常的代码

} catch (
        SomeException e
) {

    // 处理异常
}

一百二十二、try-catch-finally 模板

try {

    // 业务代码

} catch (
        SomeException e
) {

    // 处理异常

} finally {

    // 清理操作
}

一百二十三、throw 模板

if (条件不合法) {

    throw new IllegalArgumentException(
            "参数不合法"
    );
}

一百二十四、throws 模板

public void read()
        throws IOException {

}

一百二十五、自定义异常模板

public class BusinessException
        extends RuntimeException {

    public BusinessException(
            String message
    ) {

        super(message);
    }
}

一百二十六、异常链模板

try {

    // 底层调用

} catch (
        SomeException e
) {

    throw new BusinessException(
            "业务处理失败",
            e
    );
}