Java基础知识梳理(四):异常体系、finally与统一异常处理

异常处理是Java项目中非常重要的一部分。

初学者通常只关注try-catch语法,但在真实项目中,异常处理还关系到错误分类、日志记录、接口返回格式、事务回滚、敏感信息保护以及系统可维护性。

要掌握Java异常机制,首先需要理解异常体系中不同类型的职责。

一、Java异常体系的基本结构

Java中的异常和错误都继承自Throwable

基本结构可以表示为:

复制代码
Throwable
├── Error
└── Exception
    ├── RuntimeException
    └── 其他Exception

1. Error

Error通常表示JVM或运行环境中的严重问题,例如:

  • OutOfMemoryError:内存不足;

  • StackOverflowError:栈溢出;

  • NoClassDefFoundError:运行时找不到需要的类。

这类问题通常不是普通业务代码能够合理处理的,因此一般不建议随意捕获后继续运行。

2. Exception

Exception表示程序运行过程中可以被发现和处理的异常情况。

根据编译器是否强制处理,可以进一步分为:

  • Checked Exception;

  • Unchecked Exception。

二、Checked Exception是什么

Checked Exception通常翻译为受检异常或检查型异常。

它是指编译器要求程序必须显式处理的异常。

处理方式通常有两种:

  1. 使用try-catch捕获;

  2. 使用throws声明向上抛出。

例如:

复制代码
public void readFile(String path) throws IOException {
    Files.readString(Path.of(path));
}

IOException属于Checked Exception。

如果调用的方法可能抛出IOException,调用者就必须捕获它,或者继续在方法签名中声明抛出。

复制代码
try {
    readFile("data.txt");
} catch (IOException e) {
    System.out.println("文件读取失败");
}

常见的Checked Exception包括:

  • IOException

  • SQLException

  • ClassNotFoundException

  • ParseException

这类异常通常表示外部环境或资源操作可能失败,例如文件不存在、数据库访问失败或网络中断。

三、Unchecked Exception是什么

Unchecked Exception通常指RuntimeException及其子类,也称运行时异常。

编译器不会强制要求程序捕获或声明它们。

常见运行时异常包括:

  • NullPointerException

  • IllegalArgumentException

  • IndexOutOfBoundsException

  • ArithmeticException

  • ClassCastException

例如:

复制代码
String name = null;
System.out.println(name.length());

这段代码可以正常通过编译,但运行时会抛出NullPointerException

Unchecked Exception通常与以下问题有关:

  • 参数不合法;

  • 对象状态不正确;

  • 编程逻辑错误;

  • 前置条件未满足;

  • 业务规则不允许当前操作。

虽然编译器不强制处理,但这不代表可以忽略运行时异常。开发者仍然需要通过参数校验、合理设计和统一异常处理控制它们。

四、Checked和Unchecked应该如何选择

二者最主要的区别如下:

对比项 Checked Exception Unchecked Exception
是否必须显式处理
主要继承关系 Exception的非RuntimeException子类 RuntimeException及其子类
常见原因 外部资源或可恢复问题 参数、状态、逻辑或业务问题
编译器是否检查 检查 不检查
常见示例 IOException、SQLException NullPointerException、IllegalArgumentException

在项目设计中,并不是所有可以预见的问题都应该定义为Checked Exception。

如果一个异常在每一层都只能被捕获后继续向上抛出,就会产生大量重复的try-catchthrows代码。

因此,现代Web项目中的业务异常通常会继承RuntimeException,再通过统一异常处理器集中处理。

例如:

复制代码
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("STOCK_NOT_ENOUGH", "商品库存不足");

业务方法不需要在每一层重复捕获。

五、捕获异常时需要注意什么

1. 不要捕获后完全忽略

下面的写法非常危险:

复制代码
try {
    process();
} catch (Exception e) {
}

异常被完全吞掉后,调用者会误以为方法执行成功,也无法通过日志定位问题。

至少应当记录异常,或者转换为更合适的异常继续抛出。

2. 不要只记录异常信息

下面的写法会丢失异常堆栈:

复制代码
log.error(e.getMessage());

更合理的写法是:

复制代码
log.error("订单处理失败,orderId={}", orderId, e);

最后一个参数传入异常对象,日志框架才能记录完整堆栈信息。

3. 优先捕获具体异常

不建议所有地方都直接捕获Exception

复制代码
try {
    readFile();
} catch (Exception e) {
}

如果能够明确异常类型,应优先捕获具体异常:

复制代码
try {
    readFile();
} catch (IOException e) {
    log.error("文件读取失败", e);
}

这样可以针对不同问题采取不同处理策略。

4. 不要用异常代替正常流程控制

例如,不应依赖数组越界异常结束循环:

复制代码
try {
    while (true) {
        System.out.println(array[index++]);
    }
} catch (ArrayIndexOutOfBoundsException e) {
}

异常用于处理非正常情况,而不是代替普通的条件判断。

六、finally一定会执行吗

finally中的代码通常会执行,无论try中是否发生异常,也无论异常是否被catch捕获。

例如:

复制代码
try {
    System.out.println("执行try");
    int value = 10 / 0;
} catch (ArithmeticException e) {
    System.out.println("捕获异常");
} finally {
    System.out.println("执行finally");
}

输出结果为:

复制代码
执行try
捕获异常
执行finally

但是,不能绝对地说finally在任何情况下都会执行。

以下情况下,finally可能无法正常执行:

  • trycatch中调用System.exit()终止JVM;

  • 调用Runtime.halt()强制停止JVM;

  • JVM崩溃;

  • 操作系统强制结束进程;

  • 机器断电;

  • 当前线程永久阻塞或进入死循环,始终无法离开try代码块。

因此,更准确的回答是:

在JVM正常运行、线程能够正常离开try或catch代码块的情况下,finally通常会执行,但并不是绝对执行。

七、try和finally都有return时返回什么

观察下面的代码:

复制代码
public static int getNumber() {
    try {
        return 1;
    } finally {
        return 2;
    }
}

最终返回结果为:

复制代码
2

原因是方法执行try中的return 1时,会先保存准备返回的结果,然后在真正返回前执行finally

finally中又出现了return 2,因此覆盖了原来准备返回的结果。

更危险的是,finally中的return还可能吞掉异常:

复制代码
public static int test() {
    try {
        int value = 10 / 0;
        return value;
    } finally {
        return 2;
    }
}

虽然try中发生了ArithmeticException,但finally直接返回了2,异常不会继续向外传播。

这会使问题难以发现和排查。

因此,实际开发中应避免在finally中编写return语句,也应避免在finally中抛出新的异常覆盖原异常。

八、return变量时finally修改变量会怎样

下面的代码经常出现在面试题中:

复制代码
public static int test() {
    int number = 1;

    try {
        return number;
    } finally {
        number = 2;
    }
}

最终返回值仍然是:

复制代码
1

执行return number时,当前数值1会被保存为待返回结果。随后执行finally,虽然局部变量number被修改为2,但之前保存的返回值不会发生变化。

如果返回的是引用对象,情况有所不同:

复制代码
public static User test() {
    User user = new User();
    user.setName("张三");

    try {
        return user;
    } finally {
        user.setName("李四");
    }
}

最终得到的对象名称通常是"李四"。

因为待返回结果中保存的是对象引用,finally修改的是该引用指向对象的内部状态。

九、finally通常用于什么

finally传统上常用于释放资源,例如:

  • 关闭文件流;

  • 关闭数据库连接;

  • 释放锁;

  • 清理临时资源。

例如:

复制代码
InputStream input = null;

try {
    input = new FileInputStream("data.txt");
} catch (IOException e) {
    log.error("读取文件失败", e);
} finally {
    if (input != null) {
        try {
            input.close();
        } catch (IOException e) {
            log.error("关闭文件失败", e);
        }
    }
}

这种写法比较冗长。

Java 7以后,更推荐使用try-with-resources

复制代码
try (InputStream input = new FileInputStream("data.txt")) {
    // 使用输入流
} catch (IOException e) {
    log.error("文件处理失败", e);
}

只要资源实现了AutoCloseable接口,离开try代码块时就会自动关闭。

这种方式代码更简洁,也可以减少资源泄漏。

十、项目中如何设计业务异常和系统异常

在项目中,可以把异常大致分为三类:

1. 参数校验异常

表示用户提交的数据不符合接口要求,例如:

  • 手机号格式错误;

  • 必填字段为空;

  • 数量小于零;

  • 日期格式不正确。

这类异常通常返回明确的字段错误信息,提示调用者修改请求参数。

2. 业务异常

表示请求格式正确,但违反了业务规则,例如:

  • 用户余额不足;

  • 商品库存不足;

  • 订单已经取消;

  • 当前用户无权修改该数据;

  • 优惠券已经过期。

业务异常通常是可以预期的,不一定需要记录完整错误堆栈。

可以定义统一业务异常:

复制代码
public class BusinessException extends RuntimeException {

    private final String errorCode;

    public BusinessException(String errorCode, String message) {
        super(message);
        this.errorCode = errorCode;
    }

    public String getErrorCode() {
        return errorCode;
    }
}

再为错误码定义枚举:

复制代码
public enum ErrorCode {

    USER_NOT_FOUND("USER_NOT_FOUND", "用户不存在"),
    STOCK_NOT_ENOUGH("STOCK_NOT_ENOUGH", "商品库存不足"),
    ORDER_STATUS_ERROR("ORDER_STATUS_ERROR", "订单状态不允许当前操作");

    private final String code;
    private final String message;

    ErrorCode(String code, String message) {
        this.code = code;
        this.message = message;
    }
}

3. 系统异常

系统异常表示非预期的技术问题,例如:

  • 数据库连接失败;

  • Redis不可用;

  • 第三方接口超时;

  • 文件系统异常;

  • 代码空指针;

  • 消息队列发送失败。

这类异常通常需要记录完整日志和堆栈,并向客户端返回统一的模糊提示,例如:

复制代码
系统繁忙,请稍后重试

不应把数据库地址、SQL语句、服务器路径或完整堆栈直接返回给前端,否则可能泄露系统内部信息。

十一、如何实现统一异常处理

以Spring Boot项目为例,可以通过@RestControllerAdvice集中处理控制器层抛出的异常:

复制代码
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(BusinessException.class)
    public ApiResponse<Void> handleBusinessException(
            BusinessException exception) {

        return ApiResponse.fail(
                exception.getErrorCode(),
                exception.getMessage()
        );
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ApiResponse<Void> handleValidationException(
            MethodArgumentNotValidException exception) {

        String message = exception.getBindingResult()
                .getFieldErrors()
                .stream()
                .findFirst()
                .map(FieldError::getDefaultMessage)
                .orElse("请求参数不合法");

        return ApiResponse.fail("PARAM_ERROR", message);
    }

    @ExceptionHandler(Exception.class)
    public ApiResponse<Void> handleUnknownException(
            Exception exception) {

        log.error("系统发生未知异常", exception);

        return ApiResponse.fail(
                "SYSTEM_ERROR",
                "系统繁忙,请稍后重试"
        );
    }
}

统一返回对象可以设计为:

复制代码
public class ApiResponse<T> {

    private boolean success;
    private String code;
    private String message;
    private T data;
}

这样所有接口都可以返回相对统一的结构:

复制代码
{
  "success": false,
  "code": "STOCK_NOT_ENOUGH",
  "message": "商品库存不足",
  "data": null
}

十二、统一异常处理需要遵循哪些原则

第一,异常信息应当便于调用者理解,但不能泄露系统内部细节。

第二,业务异常和系统异常应分开处理。业务异常向用户返回明确提示,系统异常记录完整日志并返回统一信息。

第三,错误码应当稳定,不能只依赖中文提示。前端或其他系统可以根据错误码执行对应逻辑。

第四,日志应包含必要的业务上下文,例如用户编号、订单编号、请求地址和链路追踪编号。

第五,不要重复记录同一个异常。如果业务层记录一次,统一异常处理器又记录一次,会产生大量重复日志。

第六,异常处理不能代替事务设计。发生异常时是否回滚事务,还要结合异常类型和事务配置判断。

总体来说,良好的异常设计不是简单地给代码加上try-catch,而是让错误能够被正确分类、准确记录、稳定返回并方便排查。业务异常要让用户知道哪里不符合规则,系统异常则要让开发人员能够快速定位问题。

相关推荐
长谷深风1115 小时前
Java基础知识梳理(三):String、字符串常量池与包装类缓存机制
java基础·integer·stringbuilder·stringbuffer·字符串常量池·string不可变·自动装箱拆箱
troyzhxu2 天前
列表查询的 GraphQL —— 一行代码终结你的 if-else 地狱!
java·springboot·graphql
深念Y2 天前
CC Switch 显示错误模型名的排查与解决
网关·开源·agent·项目·代理
极光代码工作室3 天前
基于SpringBoot的在线博客系统
java·springboot·web开发·后端开发
微露清风3 天前
高并发内存池 - page cache 回收内存
项目·内存池
Java爱好狂.4 天前
Java就业需要学习哪些内容?
spring·程序员·springboot·架构师·java面试·java面试题·java八股文
码上有光4 天前
异常和智能指针
java·大数据·c++·servlet·异常·智能指针
华科大胡子6 天前
条款14:如果函数不抛出异常,请使用 noexcept
异常·noexcept·modern c++
极光代码工作室6 天前
基于SpringBoot的内容管理系统(CMS)
java·springboot·web开发·后端开发