Lesson 42 · Java 高级特性

异常处理最佳实践:自定义异常与全局处理

中级·#Java·#异常·#实战

第 1 站

Java 异常体系:先搞清楚家族谱

"面试官问:受检异常和非受检异常的区别?什么时候用哪种?"

Java 异常体系的根是 Throwable,它有两个直接子类:ErrorException。面试中关注的是 Exception 分支。

Java 异常体系 Throwable Error OutOfMemoryError StackOverflowError 不应该被捕获 Exception 受检异常 (Checked) IOException SQLException 编译时必须处理 try-catch 或 throws RuntimeException NullPointerException IllegalArgumentException 编译时不强制处理 程序逻辑错误 现代实践趋势:减少受检异常,优先用 RuntimeException + 全局异常处理 Spring 框架几乎全部使用 RuntimeException,配合 @ExceptionHandler 统一处理
图 1 Java 异常体系——受检异常 vs 非受检异常 vs Error
面试答题要点

受检异常(Checked Exception)在编译时强制要求处理,适用于可恢复的外部错误(如文件不存在、网络超时)。非受检异常(RuntimeException)编译时不检查,适用于程序逻辑错误(如参数非法、空指针)。现代实践倾向于减少受检异常,用 RuntimeException + 全局处理器统一处理。

第 2 站

自定义异常:什么时候该造、怎么造

"面试官问:你项目中有哪些自定义异常?为什么要自定义?"

自定义异常的判断标准:当标准异常无法准确表达业务含义时,就应该创建自定义异常。好处是让调用方能精确捕获不同类型的业务错误。

BusinessException.java · 自定义业务异常基类
public class BusinessException extends RuntimeException {

    private final String errorCode;
    private final Object[] args;  // 国际化参数

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

    public BusinessException(String errorCode, String message,
                           Throwable cause) {
        super(message, cause);  // 保留异常链!
        this.errorCode = errorCode;
        this.args = null;
    }

    public String getErrorCode() { return errorCode; }
}

// 更具体的子异常
public class UserNotFoundException extends BusinessException {
    public UserNotFoundException(Long userId) {
        super("USER_NOT_FOUND", "用户不存在: " + userId);
    }
}

自定义异常设计原则:

  • 继承 RuntimeException(现代实践,配合全局处理器)
  • Exception 结尾命名,清晰表达异常场景
  • 携带业务上下文(errorCode、userId 等),便于排查
  • 提供 (message, cause) 构造函数,支持异常链
  • 不要为每个业务场景都建一个异常类——按错误类别分组
分层异常类型示例
DAO 层DataAccessException(受检)SQL 执行失败、连接超时
Service 层BusinessException(非受检)业务规则违反、数据不一致
Controller 层全局异常处理器统一转换为 HTTP 响应
第 3 站

异常链:从底层到上层的因果传递

异常链(Exception Chaining)是指在抛出新异常时,把原始异常作为 cause 传入,这样堆栈跟踪能完整还原错误发生的因果链。

ExceptionChaining.java · 正确 vs 错误
// ❌ 错误:丢失了原始异常信息
try {
    userDao.save(user);
} catch (SQLException e) {
    throw new BusinessException("SAVE_FAILED", "保存用户失败");
    // 堆栈里只有 BusinessException,看不到原始 SQLException!
}

// ✅ 正确:保留异常链
try {
    userDao.save(user);
} catch (SQLException e) {
    throw new BusinessException("SAVE_FAILED",
        "保存用户失败", e);  // 传入 cause!
}
// 堆栈中可以看到:BusinessException caused by: SQLException
异常链传递过程 DAO 层 SQLException "Duplicate key" 包装为 cause Service 层 BusinessException "保存用户失败" 全局处理 ExceptionHandler HTTP 400 "保存用户失败" com.app.BusinessException: 保存用户失败 at com.app.service.UserService.save(UserService.java:42) Caused by: java.sql.SQLException: Duplicate key at com.app.dao.UserDao.save(UserDao.java:18)
图 2 异常链传递——从 DAO 到 Controller 的因果还原

异常链规则:

throw new XxxException("message", originalException)

永远把原始异常传入 cause 参数,这样堆栈跟踪才能完整还原错误链路。

铁律

每次 catch 后重新 throw 时,必须传入原始异常作为 cause。丢失 cause 等于丢失证据——排查线上问题时你根本找不到根因。

第 4 站

Spring 全局异常处理:@ExceptionHandler 设计

"面试官问:Spring 怎么做统一异常处理?@ControllerAdvice 和 @ExceptionHandler 的关系?"

Spring 提供了 @RestControllerAdvice + @ExceptionHandler 的全局异常处理机制,将异常统一转换为 HTTP 响应,避免在每个 Controller 中写 try-catch。

GlobalExceptionHandler.java · 标准实现
@RestControllerAdvice
public class GlobalExceptionHandler {

    private static final Logger log =
        LoggerFactory.getLogger(GlobalExceptionHandler.class);

    // 业务异常 → 400
    @ExceptionHandler(BusinessException.class)
    public ResponseEntity<ApiResponse<?>> handleBiz(
            BusinessException e) {
        log.warn("业务异常: {}", e.getMessage());
        return ResponseEntity.badRequest()
            .body(ApiResponse.error(e.getErrorCode(), e.getMessage()));
    }

    // 参数校验异常 → 400
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ApiResponse<?>> handleValidation(
            MethodArgumentNotValidException e) {
        String msg = e.getBindingResult().getFieldErrors().stream()
            .map(f -> f.getField() + ": " + f.getDefaultMessage())
            .collect(Collectors.joining("; "));
        return ResponseEntity.badRequest()
            .body(ApiResponse.error("VALIDATION_ERROR", msg));
    }

    // 兜底:未知异常 → 500
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ApiResponse<?>> handleUnknown(
            Exception e) {
        log.error("未预期异常", e);  // 完整堆栈
        return ResponseEntity.status(500)
            .body(ApiResponse.error("INTERNAL_ERROR",
                "服务器内部错误"));
    }
}

关键设计原则:

  • 具体异常放前面:Spring 按类型匹配,越具体的 Handler 优先级越高
  • 兜底异常放最后:用 Exception.class 捕获所有未处理的异常
  • 不暴露内部信息:对客户端只返回 errorCode + 友好消息,不返回堆栈
  • 日志分级:业务异常用 warn,未知异常用 error(含完整堆栈)
面试加分项

"全局异常处理的好处是 Controller 不用写 try-catch,业务代码只关心正常逻辑。@ExceptionHandler 方法按类型匹配,具体类型优先。注意 Handler 方法不能是 private,否则不生效。日志中业务异常用 warn 级别,未知异常用 error 级别并打印完整堆栈。"

第 5 站

异常日志:怎么记才不丢信息

LoggingPatterns.java · 正确 vs 错误
// ❌ 错误 1:吞掉异常(最严重的反模式)
catch (IOException e) {
    // 什么都不做
}

// ❌ 错误 2:只记消息,不记堆栈
catch (IOException e) {
    log.error("读取失败: {}", e.getMessage());
    // 丢失了完整的堆栈和 cause 链
}

// ❌ 错误 3:e.printStackTrace()(生产环境禁用)
catch (Exception e) {
    e.printStackTrace();  // 输出到 System.err,不进日志系统
}

// ✅ 正确:异常对象作为最后一个参数
catch (IOException e) {
    log.error("读取文件失败: path={}", filePath, e);
    // SLF4J 会自动打印完整堆栈
    throw new BusinessException("READ_FAILED", "读取失败", e);
}

异常日志规则:

  • 异常对象必须作为 log 方法的最后一个参数(SLF4J 自动识别并打印堆栈)
  • 日志消息中包含上下文信息(文件名、用户 ID、请求参数),方便排查
  • 业务异常用 log.warn(可预期的业务错误),未知异常用 log.error(含完整堆栈)
  • 生产环境禁止 e.printStackTrace()——它输出到 System.err,不进日志系统,无法被日志采集工具收集
  • 不要记录后又吞掉——要么处理,要么抛出,不要两样都不做
日志黄金法则

log.error("操作失败: context={}", context, exception)——消息提供人可读的上下文,异常对象提供机器可读的堆栈。两者缺一不可。

第 6 站

六大反模式:这些坑你踩过几个?

反模式问题正确做法
空 catch 块异常被吞掉,线上出问题无日志可查至少 log.error 或重新抛出
catch(Exception e)捕获范围太大,隐藏了真正的 bug捕获最具体的异常类型
异常控制流程性能差、语义不清用 if 判断或 Optional
丢失 cause堆栈断裂,无法定位根因new XxxException(msg, cause)
finally 里 return覆盖 try/catch 的返回值和异常finally 只放清理代码
e.printStackTrace()输出到 System.err,不进日志系统log.error("msg", e)
AntiPatterns.java · 典型错误示范
// ❌ 反模式:用异常控制流程
try {
    User user = userService.findById(id);
    process(user);
} catch (UserNotFoundException e) {
    // 用异常来处理"用户不存在"这种正常分支
}

// ✅ 正确:用 Optional 或 if 判断
Optional<User> user = userService.findById(id);
user.ifPresent(this::process);

// ❌ 反模式:finally 中 return
try {
    return doSomething();  // 这个返回值会被 finally 覆盖
} finally {
    return defaultValue;   // 覆盖了 try 的 return!
}

// ❌ 反模式:catch 范围太大
catch (Exception e) {  // 连 NullPointerException 也吞了
    log.error("出错了", e);
}

// ✅ 正确:精确捕获
catch (FileNotFoundException e) {
    log.error("文件不存在: {}", path, e);
    throw new BusinessException("FILE_NOT_FOUND", "文件不存在", e);
}
面试怎么答?

"异常处理的核心原则是:不吞掉、不扩大、不丢链。具体说:空 catch 是最严重的反模式;catch(Exception) 范围太大会隐藏 bug;重新抛出时必须传入 cause 保留异常链;不要用异常控制正常流程;finally 中不要 return。"

第 7 站

总结:异常处理全景回顾

全文核心要点回顾

  • 异常体系:Error 不捕获,受检异常编译时强制处理,RuntimeException 不强制
  • 自定义异常:继承 RuntimeException,携带 errorCode 和上下文,提供 cause 构造函数
  • 异常链:每次 re-throw 必须传入原始异常作为 cause,保留完整因果链
  • Spring 全局处理:@RestControllerAdvice + @ExceptionHandler,具体类型优先
  • 日志规范:异常对象作为 log 最后一个参数,业务异常 warn,未知异常 error
  • 反模式:空 catch、catch(Exception)、异常控制流程、丢失 cause、e.printStackTrace()
面试 30 秒总结

"Java 异常分为 Error、受检异常和 RuntimeException。自定义异常继承 RuntimeException,携带 errorCode 和业务上下文。异常链的核心是每次 re-throw 传入 cause,否则堆栈断裂无法排查。Spring 用 @RestControllerAdvice + @ExceptionHandler 做全局异常处理,按类型匹配,具体优先。日志中异常对象必须作为最后一个参数传入,业务异常 warn 级别,未知异常 error 级别。六大反模式:空 catch、范围太大、异常控制流程、丢失 cause、finally 里 return、e.printStackTrace()。"