Lesson 42 · Java 高级特性
异常处理最佳实践:自定义异常与全局处理
Java 异常体系:先搞清楚家族谱
"面试官问:受检异常和非受检异常的区别?什么时候用哪种?"
Java 异常体系的根是 Throwable,它有两个直接子类:Error 和 Exception。面试中关注的是 Exception 分支。
受检异常(Checked Exception)在编译时强制要求处理,适用于可恢复的外部错误(如文件不存在、网络超时)。非受检异常(RuntimeException)编译时不检查,适用于程序逻辑错误(如参数非法、空指针)。现代实践倾向于减少受检异常,用 RuntimeException + 全局处理器统一处理。
自定义异常:什么时候该造、怎么造
"面试官问:你项目中有哪些自定义异常?为什么要自定义?"
自定义异常的判断标准:当标准异常无法准确表达业务含义时,就应该创建自定义异常。好处是让调用方能精确捕获不同类型的业务错误。
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 响应 |
异常链:从底层到上层的因果传递
异常链(Exception Chaining)是指在抛出新异常时,把原始异常作为 cause 传入,这样堆栈跟踪能完整还原错误发生的因果链。
// ❌ 错误:丢失了原始异常信息
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
异常链规则:
throw new XxxException("message", originalException)
永远把原始异常传入 cause 参数,这样堆栈跟踪才能完整还原错误链路。
每次 catch 后重新 throw 时,必须传入原始异常作为 cause。丢失 cause 等于丢失证据——排查线上问题时你根本找不到根因。
Spring 全局异常处理:@ExceptionHandler 设计
"面试官问:Spring 怎么做统一异常处理?@ControllerAdvice 和 @ExceptionHandler 的关系?"
Spring 提供了 @RestControllerAdvice + @ExceptionHandler 的全局异常处理机制,将异常统一转换为 HTTP 响应,避免在每个 Controller 中写 try-catch。
@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 级别并打印完整堆栈。"
异常日志:怎么记才不丢信息
// ❌ 错误 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)——消息提供人可读的上下文,异常对象提供机器可读的堆栈。两者缺一不可。
六大反模式:这些坑你踩过几个?
| 反模式 | 问题 | 正确做法 |
|---|---|---|
| 空 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) |
// ❌ 反模式:用异常控制流程
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。"
总结:异常处理全景回顾
全文核心要点回顾
- 异常体系:Error 不捕获,受检异常编译时强制处理,RuntimeException 不强制
- 自定义异常:继承 RuntimeException,携带 errorCode 和上下文,提供 cause 构造函数
- 异常链:每次 re-throw 必须传入原始异常作为 cause,保留完整因果链
- Spring 全局处理:@RestControllerAdvice + @ExceptionHandler,具体类型优先
- 日志规范:异常对象作为 log 最后一个参数,业务异常 warn,未知异常 error
- 反模式:空 catch、catch(Exception)、异常控制流程、丢失 cause、e.printStackTrace()
"Java 异常分为 Error、受检异常和 RuntimeException。自定义异常继承 RuntimeException,携带 errorCode 和业务上下文。异常链的核心是每次 re-throw 传入 cause,否则堆栈断裂无法排查。Spring 用 @RestControllerAdvice + @ExceptionHandler 做全局异常处理,按类型匹配,具体优先。日志中异常对象必须作为最后一个参数传入,业务异常 warn 级别,未知异常 error 级别。六大反模式:空 catch、范围太大、异常控制流程、丢失 cause、finally 里 return、e.printStackTrace()。"