异常处理是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通常翻译为受检异常或检查型异常。
它是指编译器要求程序必须显式处理的异常。
处理方式通常有两种:
-
使用
try-catch捕获; -
使用
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-catch和throws代码。
因此,现代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可能无法正常执行:
-
在
try或catch中调用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,而是让错误能够被正确分类、准确记录、稳定返回并方便排查。业务异常要让用户知道哪里不符合规则,系统异常则要让开发人员能够快速定位问题。
