Java 异常处理:从 try-catch-finally 到项目最佳实践

文章目录

    • 前言
    • 一、异常是什么
      • [1.1 异常的核心定义](#1.1 异常的核心定义)
      • [1.2 异常不是错误流程的替代品](#1.2 异常不是错误流程的替代品)
    • [二、Java 异常体系](#二、Java 异常体系)
      • [2.1 `Throwable`、Error、Exception](#2.1 Throwable、Error、Exception)
      • [2.2 checked 异常和 unchecked 异常](#2.2 checked 异常和 unchecked 异常)
        • [2.2.1 checked异常](#2.2.1 checked异常)
        • [2.2.2 unchecked异常](#2.2.2 unchecked异常)
        • [2.2.3 常见 unchecked 异常逐个理解](#2.2.3 常见 unchecked 异常逐个理解)
          • [1. `NullPointerException`](#1. NullPointerException)
          • [2. `ArithmeticException`](#2. ArithmeticException)
          • [3. `NumberFormatException`](#3. NumberFormatException)
          • [4. `IndexOutOfBoundsException`](#4. IndexOutOfBoundsException)
          • [5. `IllegalArgumentException`](#5. IllegalArgumentException)
    • 三、try-catch-finally
      • [3.1 try:放可能出问题的代码](#3.1 try:放可能出问题的代码)
      • [3.2 catch:捕获并处理异常](#3.2 catch:捕获并处理异常)
      • [3.3 finally:无论成功失败都执行](#3.3 finally:无论成功失败都执行)
      • [3.4 不要在 finally 中 return](#3.4 不要在 finally 中 return)
    • [四、throw 和 throws](#四、throw 和 throws)
      • [4.1 throw:方法体里主动抛异常](#4.1 throw:方法体里主动抛异常)
      • [4.2 throws:方法签名上声明异常](#4.2 throws:方法签名上声明异常)
    • 五、自定义异常和异常链
      • [5.1 为什么需要自定义异常](#5.1 为什么需要自定义异常)
        • [5.1.1 直接抛出 RuntimeException 的问题](#5.1.1 直接抛出 RuntimeException 的问题)
        • [5.1.2 自定义异常让错误具有业务名字](#5.1.2 自定义异常让错误具有业务名字)
        • [5.1.3 自定义异常不是为了"多写一个类"](#5.1.3 自定义异常不是为了“多写一个类”)
      • [5.2 自定义异常的基本写法](#5.2 自定义异常的基本写法)
        • [5.2.1 继承 RuntimeException:定义 unchecked 业务异常](#5.2.1 继承 RuntimeException:定义 unchecked 业务异常)
        • [5.2.2 继承 Exception:定义 checked 业务异常](#5.2.2 继承 Exception:定义 checked 业务异常)
      • [5.3 什么是异常包装](#5.3 什么是异常包装)
      • [5.4 异常链到底长什么样](#5.4 异常链到底长什么样)
    • 六、try-with-resources
      • [6.1 为什么需要 try-with-resources](#6.1 为什么需要 try-with-resources)
      • [6.2 try-with-resources 的写法](#6.2 try-with-resources 的写法)
      • [6.3 suppressed exception](#6.3 suppressed exception)
    • 七、项目和面试如何考察
      • [7.1 项目中常见用法](#7.1 项目中常见用法)
      • [7.2 面试高频问题](#7.2 面试高频问题)
      • [7.3 面试回答思路](#7.3 面试回答思路)
    • 总结

前言

程序运行时并不总是一帆风顺。文件可能不存在,网络可能超时,用户可能输入非法数据,对象可能是 null。这些"计划之外的情况",就是异常处理要面对的问题。

异常就像程序道路上的警示牌:它告诉我们"这里出问题了",同时也给我们一个机会,决定是修复、提示、重试、回滚,还是把问题继续向上抛。

Java 的异常机制,就像给程序建立了一套"故障处理通道":正常情况下,代码沿着主流程向前执行;一旦出现异常,就可以通过 throw 发出问题,通过 catch 接住并处理,通过 throws 声明风险,还可以利用异常链保留最初的故障原因。

本文将从异常的基本概念开始,逐步讲解 Java 异常体系、try-catch-finallythrowthrows、自定义异常、异常链以及 try-with-resources


一、异常是什么

1.1 异常的核心定义

异常是程序执行过程中出现的非正常情况。它会打断当前正常流程,把问题交给异常处理机制。

比如:

java 复制代码
int result = 10 / 0;

这行代码会抛出:

text 复制代码
java.lang.ArithmeticException: / by zero

如果没有异常处理,程序会中断。加上 try-catch 后,我们可以给用户一个更友好的提示。

java 复制代码
public static String divideSafely(int left, int right) {
    try {
        return "result:" + (left / right);
    } catch (ArithmeticException e) {
        return "error:" + e.getMessage();
    }
}

1.2 异常不是错误流程的替代品

异常应该处理"不正常情况",不应该替代普通业务判断。

推荐:

java 复制代码
if (right == 0) {
    return "除数不能为 0";
}

不推荐:

java 复制代码
try {
    return left / right;
} catch (ArithmeticException e) {
    return 0;
}

能用正常判断解决的,就不要靠异常绕路。异常像消防通道,不是日常上下班电梯。


二、Java 异常体系

2.1 Throwable、Error、Exception

Java 中能被抛出的对象,必须是 Throwable 或它的子类。

整体结构可以简化为:

text 复制代码
Throwable
├── Error					// unchecked 异常
│   ├── OutOfMemoryError
│   └── StackOverflowError
│
└── Exception
    ├── IOException			// checked 异常
    ├── SQLException		// checked 异常
    ├── ClassNotFoundException		// checked 异常
    │
    └── RuntimeException		// unchecked 异常
        ├── NullPointerException
        ├── ArithmeticException
        ├── NumberFormatException
        ├── IndexOutOfBoundsException
        └── IllegalArgumentException

Error 表示严重错误,比如 OutOfMemoryErrorStackOverflowError。这类问题通常不是业务代码可以优雅恢复的。

Exception 表示程序中可以被处理的问题,比如文件不存在、参数不合法、网络读取失败等。

2.2 checked 异常和 unchecked 异常

Exception 又可以分成两类:

  • checked 异常:编译器要求必须处理,要么 try-catch,要么 throws
  • unchecked 异常:运行时异常,编译器不强制处理。
2.2.1 checked异常

所谓 checked,检查的是:

代码中某个操作可能抛出这种异常时,你有没有明确处理它。

例如,读取文件时,文件可能不存在、没有权限或读取失败。这些问题不是代码写错了,而是程序运行环境中本来就可能发生的情况。

常见 checked 异常:

  • IOException
  • ClassNotFoundException
  • SQLException

例如代码:

java 复制代码
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;

public class FileDemo {
    public static void main(String[] args) {
        String content = Files.readString(Path.of("user.txt"));
        System.out.println(content);
    }
}

这段代码无法通过编译,因为 Files.readString(...) 可能抛出 IOException,而代码没有说明如何处理它。

此时编译器会要求你二选一:

  1. 当前方法自己处理:使用try-catch
  2. 声明交给调用者处理:throws。

这就是 Java 中的 Catch or Specify Requirement :可能抛出 checked 异常的代码,必须捕获它,或者在方法声明中使用 throws 明确声明。否则代码不能通过编译。

2.2.2 unchecked异常

unchecked 异常主要指 RuntimeException 及其子类。

它们的特点是:

  • 编译器不要求必须使用 try-catch
  • 编译器不要求方法必须声明 throws
  • 程序仍然可能在运行时抛出这些异常;
  • 很多情况下表示代码逻辑、参数校验或 API 使用方式存在问题。

常见 unchecked 异常:

  • NullPointerException
  • ArithmeticException
  • NumberFormatException
  • IndexOutOfBoundsException
  • IllegalArgumentException

例如:

java 复制代码
public class RuntimeDemo {
    public static void main(String[] args) {
        int a = 10;
        int b = 0;

        int result = a / b;

        System.out.println(result);
    }
}

这段代码可以正常编译。

但是运行时会抛出:

bash 复制代码
java.lang.ArithmeticException: / by zero

因为整数不能除以 0

这里编译器没有强制你写:

java 复制代码
try {
    int result = a / b;
} catch (ArithmeticException e) {
    // 处理异常
}

原因是:在大量代码中,许多运行时问题理论上都可能发生。如果编译器要求每一次数组访问、方法调用、类型转换、整数运算都必须捕获异常,代码会变得非常臃肿。

2.2.3 常见 unchecked 异常逐个理解
1. NullPointerException

当变量为 null,却继续调用它的方法或访问它的属性时,会抛出该异常。

java 复制代码
public class NullDemo {
    public static void main(String[] args) {
        String username = null;

        System.out.println(username.length());
    }
}

执行过程:

java 复制代码
username = null
        ↓
调用 username.length()
        ↓
null 并不是一个真实对象
        ↓
抛出 NullPointerException

更好的处理方式通常不是到处捕获异常:

java 复制代码
try {
    System.out.println(username.length());
} catch (NullPointerException e) {
    System.out.println("用户名不存在");
}

而是提前保证数据合法:

java 复制代码
if (username != null) {
    System.out.println(username.length());
}

或者在业务入口直接拒绝不合法参数:

java 复制代码
import java.util.Objects;

public class UserService {

    public void register(String username) {
        Objects.requireNonNull(username, "用户名不能为空");

        System.out.println("注册用户:" + username);
    }
}

2. ArithmeticException

常见于整数除以 0

java 复制代码
public class AverageCalculator {

    public static int average(int totalScore, int studentCount) {
        if (studentCount <= 0) {
            throw new IllegalArgumentException("学生人数必须大于 0");
        }

        return totalScore / studentCount;
    }
}

这里没有等到程序真正发生 / by zero,而是提前校验参数。


3. NumberFormatException

当字符串无法转换成数字时,会抛出该异常。

java 复制代码
public class AgeParser {
    public static void main(String[] args) {
        String input = "十八";

        int age = Integer.parseInt(input);

        System.out.println(age);
    }
}

运行时会抛出 NumberFormatException,因为 "十八" 不是 Java 可以直接解析的整数格式。

但是,这里有一个重要细节:

unchecked 异常不一定永远代表程序员写错了代码。

如果字符串来自用户输入,那么用户输入错误本身就是正常业务情况,此时捕获 NumberFormatException 是合理的。

java 复制代码
public class AgeParser {

    public static void main(String[] args) {
        String input = "十八";

        try {
            int age = Integer.parseInt(input);
            System.out.println("年龄:" + age);
        } catch (NumberFormatException e) {
            System.out.println("请输入正确的数字年龄");
        }
    }
}

因此,不能机械地认为:

复制代码
unchecked 异常 = 永远不能 catch

更准确的理解是:

复制代码
unchecked 异常 = 编译器不强制处理
是否处理,取决于业务场景

4. IndexOutOfBoundsException

访问集合或数组中不存在的位置时,会抛出该异常。

java 复制代码
import java.util.List;

public class ListDemo {
    public static void main(String[] args) {
        List<String> names = List.of("小明", "小红");

        System.out.println(names.get(2));
    }
}

集合中只有:

复制代码
下标 0 -> 小明
下标 1 -> 小红

但是代码访问了下标 2,因此程序运行时抛出异常。

正确做法通常是保证下标合法:

java 复制代码
if (index >= 0 && index < names.size()) {
    System.out.println(names.get(index));
}

5. IllegalArgumentException

当调用者传入不符合方法规则的参数时,通常可以主动抛出该异常。

例如,银行转账金额不能小于等于 0

java 复制代码
public class BankService {

    public void transfer(double amount) {
        if (amount <= 0) {
            throw new IllegalArgumentException("转账金额必须大于 0");
        }

        System.out.println("成功转账:" + amount + " 元");
    }

    public static void main(String[] args) {
        BankService service = new BankService();
        service.transfer(-100);
    }
}

这里的错误不是网络中断,也不是数据库临时不可用,而是调用者传入了明显不合法的参数。

所以使用 unchecked 异常比较合适。


三、try-catch-finally

3.1 try:放可能出问题的代码

try 块里放可能抛出异常的代码。

java 复制代码
try {
    int result = 10 / 0;
}

不要把大段无关代码都塞进 try。范围越大,越难判断真正出错的位置。

3.2 catch:捕获并处理异常

catch 用来处理异常。

java 复制代码
try {
    int result = 10 / 0;
} catch (ArithmeticException e) {
    System.out.println("除数不能为 0");
}

多个 catch 时,子类异常要写在前面,父类异常写在后面。

java 复制代码
try {
    // code
} catch (NumberFormatException e) {
    // 更具体
} catch (RuntimeException e) {
    // 更宽泛
}

如果把 RuntimeException 放前面,后面的 NumberFormatException 就永远匹配不到。

3.3 finally:无论成功失败都执行

finally 通常用于释放资源、清理现场。

java 复制代码
public static List<String> tryCatchFinallyTrace(boolean throwException) {
    List<String> trace = new ArrayList<>();
    try {
        trace.add("try");
        if (throwException) {
            throw new IllegalStateException("business failed");
        }
        trace.add("success");
    } catch (IllegalStateException e) {
        trace.add("catch");
    } finally {
        trace.add("finally");
    }
    return trace;
}

测试结果:

java 复制代码
assertEquals(Arrays.asList("try", "catch", "finally"),
        ExceptionKeywordDemo.tryCatchFinallyTrace(true));
assertEquals(Arrays.asList("try", "success", "finally"),
        ExceptionKeywordDemo.tryCatchFinallyTrace(false));

不管是否发生异常,finally 都会执行。

3.4 不要在 finally 中 return

finally 里写 return 是非常危险的。

java 复制代码
public static String badReturnInFinally() {
    try {
        return "try-return";
    } finally {
        return "finally-return";
    }
}

最终返回的是:

text 复制代码
finally-return

finallyreturn 会覆盖 try 里的 return,也可能吞掉原本的异常。项目中应当避免这种写法。


四、throw 和 throws

4.1 throw:方法体里主动抛异常

throw 用于主动抛出一个异常对象。

throw 后面必须跟一个可以被抛出的对象。Java 中只有 Throwable 或其子类对象能够被 throw 抛出,例如 ExceptionRuntimeExceptionIOException 以及自定义异常对象。

假设系统要求用户年龄必须大于等于 18 岁。

java 复制代码
public class UserService {

    public static void validateAge(int age) {
        if (age < 18) {
            throw new InvalidAgeException("年龄必须大于等于 18 岁,当前年龄:" + age);
        }

        System.out.println("年龄校验通过");
    }
}

自定义异常如下:

java 复制代码
public class InvalidAgeException extends RuntimeException {

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

调用代码:

java 复制代码
public class Application {

    public static void main(String[] args) {
        UserService.validateAge(16);

        System.out.println("准备创建用户账号");
    }
}

执行过程如下:

复制代码
调用 validateAge(16)
        ↓
判断 age < 18,条件成立
        ↓
执行 throw new InvalidAgeException(...)
        ↓
当前方法立即终止
        ↓
异常向调用者传播
        ↓
后面的"准备创建用户账号"不会执行

运行结果类似:

复制代码
Exception in thread "main" InvalidAgeException: 年龄必须大于等于 18 岁,当前年龄:16

这里最重要的是:

java 复制代码
throw new InvalidAgeException(...);

并不是打印一个错误提示,也不是返回一个失败结果,而是:

立即中断当前正常执行流程,并抛出一个异常对象。

Java 语言规范将这种情况称为语句的"突然完成":throw 执行后,当前语句不会正常完成,而是立即发生控制流转移,直到找到能够捕获该异常的 catch;如果始终没有找到,当前线程会因未捕获异常而终止。

4.2 throws:方法签名上声明异常

throws 用于告诉调用者:这个方法可能抛出某些异常。

有些方法只负责完成底层任务,并不知道失败后应该如何提示用户。

例如,一个工具方法只负责读取文件:

java 复制代码
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;

public class FileService {

    public static String readUserFile(String fileName) throws IOException {
        return Files.readString(Path.of(fileName));
    }
}

这里的 throws IOException 表示:

我这个方法可能读取失败,但我不知道失败后应该弹窗、重试、退出程序还是记录日志,所以交给调用我的代码决定。

调用者可以在更合适的位置处理:

java 复制代码
import java.io.IOException;

public class Application {

    public static void main(String[] args) {
        try {
            String content = FileService.readUserFile("user.txt");
            System.out.println(content);
        } catch (IOException e) {
            System.out.println("文件读取失败,请检查文件是否存在");
        }
    }
}

在 Java 中,checked 异常会成为方法契约的一部分:调用者看到方法声明中的 throws IOException,就知道调用这个方法时必须考虑读取失败的情况。

所以throw它不是处理异常,而是把异常处理责任交给调用方。

throwthrows 的区别:

  • throw 在方法体内,后面跟异常对象。
  • throws 在方法声明上,后面跟异常类型。
  • throw 是真的把异常抛出去。
  • throws 是声明可能会抛。

throw:抛出异常,使正常流程中断。

catch:捕获并处理异常。

throws:声明该方法可能把异常继续交给调用者。


五、自定义异常和异常链

异常不仅仅是"程序出错了"的提示,它还承担着两个重要任务:

  1. 告诉调用者:发生了什么业务问题。
  2. 告诉开发者:这个问题最初是由什么底层原因引起的。

如果项目中所有异常都直接写成:

java 复制代码
throw new RuntimeException("出错了");

那么程序虽然能够中断执行,但异常表达的信息非常模糊。

例如:注册失败了

到底是:

  • 年龄不合法?
  • 用户名重复?
  • 数据库连接失败?
  • 文件写入失败?
  • 网络请求超时?

调用者无法根据异常类型做出准确处理,开发者排查问题时也很困难。

因此,实际项目中通常需要使用:

  1. 自定义异常:表达业务语义
  2. 异常链:保留底层原因

5.1 为什么需要自定义异常

5.1.1 直接抛出 RuntimeException 的问题

假设现在有一个用户注册功能,要求年龄必须在 0 ~ 150 之间。

最简单的写法如下:

java 复制代码
public class UserService {

    public void register(String username, int age) {
        if (age < 0 || age > 150) {
            throw new RuntimeException("年龄不合法");
        }

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

这段代码虽然可以工作,但存在一个问题:

java 复制代码
throw new RuntimeException("年龄不合法");

RuntimeException 只能说明程序运行时出现了问题,却无法从异常类型上直接看出这是一个"年龄校验失败"的业务问题。

调用方看到的只是:

java 复制代码
try {
    userService.register("小明", -10);
} catch (RuntimeException e) {
    System.out.println("注册失败");
}

这里的 catch (RuntimeException e) 范围过大。

因为它可能捕获到:

年龄不合法

用户名为空

数据库保存失败

空指针异常

数组越界异常

程序状态错误

也就是说,所有错误都被装进了同一个模糊的袋子里。

可以把它理解为:

医院只给所有病人开一张诊断单,上面统一写着"身体不舒服"。虽然没有说错,但完全无法指导下一步治疗。


5.1.2 自定义异常让错误具有业务名字

更好的方式是定义一个专门表示年龄不合法的异常:

java 复制代码
public class InvalidAgeException extends RuntimeException {

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

然后在业务代码中使用它:

java 复制代码
public class UserService {

    public void register(String username, int age) {
        if (age < 0 || age > 150) {
            throw new InvalidAgeException("年龄必须在 0 到 150 之间,当前值:" + age);
        }

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

调用代码如下:

java 复制代码
public class Application {

    public static void main(String[] args) {
        UserService userService = new UserService();

        try {
            userService.register("小明", -10);
        } catch (InvalidAgeException e) {
            System.out.println("注册失败:" + e.getMessage());
        }
    }
}

运行结果:

注册失败:年龄必须在 0 到 150 之间,当前值:-10

现在异常类型本身就具有明确语义:调用者立刻知道问题发生在年龄校验阶段

这就是自定义异常最重要的价值:

把模糊的技术错误,转换成清晰的业务语言。


5.1.3 自定义异常不是为了"多写一个类"

初学者可能会觉得:

使用 RuntimeException 已经可以抛异常了,为什么还要额外写一个异常类?

因为在真实项目中,异常不仅给开发者看,也会影响程序后续处理逻辑。

例如,用户注册时可能发生三种不同问题:

java 复制代码
public class InvalidAgeException extends RuntimeException {

    public InvalidAgeException(String message) {
        super(message);
    }
}
public class UsernameAlreadyExistsException extends RuntimeException {

    public UsernameAlreadyExistsException(String message) {
        super(message);
    }
}
public class UserSaveException extends RuntimeException {

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

业务代码:

java 复制代码
public class UserService {

    public void register(String username, int age) {
        if (age < 0 || age > 150) {
            throw new InvalidAgeException("年龄不合法:" + age);
        }

        if ("admin".equals(username)) {
            throw new UsernameAlreadyExistsException("用户名已存在:" + username);
        }

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

调用方就可以针对不同错误给出不同提示:

java 复制代码
public class Application {

    public static void main(String[] args) {
        UserService userService = new UserService();

        try {
            userService.register("admin", 18);
        } catch (InvalidAgeException e) {
            System.out.println("请输入正确年龄");
        } catch (UsernameAlreadyExistsException e) {
            System.out.println("该用户名已被占用,请更换用户名");
        } catch (UserSaveException e) {
            System.out.println("系统繁忙,请稍后重试");
        }
    }
}

执行逻辑如下:

复制代码
用户注册
   ↓
年龄错误 ─────────→ 提示重新填写年龄
   ↓
用户名重复 ───────→ 提示更换用户名
   ↓
保存失败 ─────────→ 提示稍后重试

如果所有异常都写成 RuntimeException,调用方就很难准确区分这些情况。


5.2 自定义异常的基本写法

5.2.1 继承 RuntimeException:定义 unchecked 业务异常

例如,年龄非法通常属于参数或业务规则不合法,可以定义为 unchecked 异常:

java 复制代码
public class InvalidAgeException extends RuntimeException {

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

因为它继承自 RuntimeException,所以调用方不需要被编译器强制捕获。RuntimeException 本身继承自 Exception,它及其子类属于 unchecked 异常。

使用方式:

java 复制代码
public class UserService {

    public void updateAge(int age) {
        if (age < 0 || age > 150) {
            throw new InvalidAgeException("年龄必须在 0 到 150 之间");
        }

        System.out.println("年龄修改成功:" + age);
    }
}

调用者可以选择捕获:

java 复制代码
try {
    userService.updateAge(-1);
} catch (InvalidAgeException e) {
    System.out.println(e.getMessage());
}

也可以不捕获,让它继续向上传播,交给项目中的统一异常处理机制处理。


5.2.2 继承 Exception:定义 checked 业务异常

如果某个异常表示调用者必须明确处理的失败场景,也可以继承 Exception

例如,系统导入用户数据文件失败后,调用者可能需要提示用户重新上传文件:

java 复制代码
public class UserImportException extends Exception {

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

业务代码:

java 复制代码
public class UserImportService {

    public void importUserFile(String filePath) throws UserImportException {
        if (filePath == null || filePath.isBlank()) {
            throw new UserImportException("导入文件路径不能为空");
        }

        System.out.println("开始导入文件:" + filePath);
    }
}

调用方必须处理:

java 复制代码
public class Application {

    public static void main(String[] args) {
        UserImportService service = new UserImportService();

        try {
            service.importUserFile("");
        } catch (UserImportException e) {
            System.out.println("导入失败:" + e.getMessage());
        }
    }
}

5.3 什么是异常包装

在真实项目中,代码通常会分层。

例如,一个配置读取功能可能分为:

复制代码
Application 层:负责启动程序、展示最终提示
        ↓
ConfigService 层:负责配置业务逻辑
        ↓
File 操作层:负责真正读取文件

底层读取文件时,可能抛出:IOException

但是对于上层业务来说,它真正关心的不是:某个字节流读取失败

而是:系统配置加载失败

因此,业务层通常会捕获底层异常,再抛出一个更符合当前业务含义的新异常:

java 复制代码
try {
    Files.readString(Path.of("application.properties"));
} catch (IOException e) {
    throw new ConfigLoadException("配置文件加载失败", e);
}

这个过程称为:异常包装

也可以理解为:将底层技术异常转换成上层能够理解的业务异常

例如:

复制代码
IOException
        ↓ 包装
ConfigLoadException
        ↓ 上层看到
系统配置加载失败

异常包装的目的不是隐藏错误,而是让不同层看到适合自己的异常语义。


5.4 异常链到底长什么样

假设现在有如下异常链:

复制代码
ConfigLoadException
        ↓ caused by
IOException
        ↓ caused by
NoSuchFileException

它表达的含义是:

复制代码
程序配置加载失败,
是因为文件读取失败,
而文件读取失败的根本原因是文件不存在。

程序打印堆栈信息时,通常会看到类似内容:

复制代码
Exception in thread "main" ConfigLoadException: 配置加载失败
    at ConfigService.loadConfig(ConfigService.java:20)
    at Application.main(Application.java:8)
Caused by: java.nio.file.NoSuchFileException: application.properties
    at java.base/sun.nio.fs.WindowsException.translateToIOException(...)
    at java.base/java.nio.file.Files.readString(...)
    at ConfigService.loadConfig(ConfigService.java:18)

这里最关键的部分是:

复制代码
Caused by: java.nio.file.NoSuchFileException: application.properties

它告诉开发者:

复制代码
业务层看到的是"配置加载失败",
真正需要修复的是"配置文件不存在"。

因此,阅读异常堆栈时,不应该只看最上面的异常名称,还要继续寻找:Caused by:

直到找到最底层的根本原因。


六、try-with-resources

6.1 为什么需要 try-with-resources

文件流、网络连接、数据库连接这类资源,用完必须关闭。

传统写法通常是:

java 复制代码
TestResource resource = new TestResource(false, false);
try {
    return resource.read();
} finally {
    resource.close();
}

这种写法能关闭资源,但当 read()close() 都抛异常时,原始异常可能被 close() 的异常覆盖。

Java 语言规范规定:普通 try-finally 中,如果 try 因异常而突然结束,随后 finally 又因新的异常而突然结束,最终以 finally 的异常为准,原先 try 中的异常原因会被丢弃。

6.2 try-with-resources 的写法

Java 7 引入了 try-with-resources

java 复制代码
public static String useTryWithResources(boolean readFailed, boolean closeFailed) throws IOException {
    try (TestResource resource = new TestResource(readFailed, closeFailed)) {
        return resource.read();
    }
}

只要资源实现了 AutoCloseableCloseable,就可以放进 try (...) 里。代码块结束后,资源会自动关闭。

6.3 suppressed exception

如果 read() 抛出异常,close() 也抛出异常,try-with-resources 会保留主异常,并把关闭资源时的异常放进 suppressed 列表。

测试代码:

java 复制代码
try {
    ExceptionKeywordDemo.useTryWithResources(true, true);
    fail("Expected IOException");
} catch (IOException e) {
    assertEquals("read failed", e.getMessage());
    assertEquals(1, e.getSuppressed().length);
    assertEquals("close failed", e.getSuppressed()[0].getMessage());
}

这比手写 finally 更可靠,因为它不会把真正的业务异常弄丢。


七、项目和面试如何考察

7.1 项目中常见用法

项目中异常处理通常出现在这些位置:

  • Controller:统一把异常转换成 HTTP 响应。
  • Service:抛出业务异常,如库存不足、订单状态非法。
  • DAO:处理数据库异常,必要时转换为数据访问异常。
  • Job:捕获任务异常,记录日志,避免调度线程直接中断。
  • 消息消费:单条消息失败时决定重试、丢弃还是进死信队列。

一个成熟项目通常不会到处散落 try-catch,而是有统一异常处理机制。

7.2 面试高频问题

常见问题:

  1. ExceptionError 的区别?
  2. checked 异常和 unchecked 异常的区别?
  3. throwthrows 的区别?
  4. finally 一定会执行吗?
  5. finally 中 return 会发生什么?
  6. try-with-resources 的原理是什么?
  7. 什么是 suppressed exception?
  8. 为什么不建议捕获 Throwable
  9. 如何避免 NullPointerException
  10. try-catch 会影响性能吗?

7.3 面试回答思路

回答异常问题时,不要只背概念。可以按这条线说:

text 复制代码
异常体系 -> 编译期约束 -> 运行时处理 -> 项目实践 -> 底层原理

比如回答 try-with-resources

  • 它用于自动关闭资源。
  • 资源必须实现 AutoCloseable
  • 编译器会把它转换成类似 try-finally 的结构。
  • 如果业务异常和关闭异常同时发生,关闭异常会进入 suppressed 列表。
  • 项目中处理 IOJDBC、流对象时优先使用它。

这样回答比只说"它能自动关闭资源"更有深度。


总结

Java 异常处理的核心,是在程序出现问题时及时中断错误流程,并保留清晰的排查线索。

本文主要介绍了以下内容:

  • checked 异常要求显式处理,常用于文件、数据库等外部风险。
  • unchecked 异常通常表示参数非法或代码逻辑问题。
  • try-catch-finally 用于捕获异常和清理资源。
  • throw 表示真正抛出异常,throws 表示声明异常风险。
  • 自定义异常能够让业务含义更加清晰,异常链能够保留原始原因。
  • try-with-resources 可以自动关闭资源,并避免关闭异常覆盖主异常。
相关推荐
咕噜咕噜啦啦12 小时前
从spring到spring boot——JAVA项目开发
java·前端·spring boot·后端·spring
松☆12 小时前
10分钟上手pypto:用Python直接调PTO虚拟指令集
开发语言·python
并不喜欢吃鱼12 小时前
从零开始 C++----十【C++ 数据结构】AVL 树详解:从原理到实现
开发语言·数据结构·c++
晚烛12 小时前
CANN 大模型推理优化实战:FlashAttention、推测解码与连续批处理的工程实现
开发语言·人工智能·python·深度学习·数据挖掘
sycmancia12 小时前
Qt——发送自定义事件(下)
开发语言·qt
*愿风载尘*12 小时前
Python多重继承MRO报错问题处理
开发语言·python
asdfg125896312 小时前
使用正则表达式str.split(“\\W+“)拆分句子
java·正则表达式
yqcoder12 小时前
数据的“洁癖”管家:深入解析 JavaScript Set
开发语言·javascript·ecmascript
今天背单词了吗98012 小时前
MySQL InnoDB引擎八大核心特性详解(高频面试题)
java·数据库·mysql