Java异常处理
异常用于表达程序运行过程中的非正常情况。好的异常处理能让问题被准确发现、定位和恢复;差的异常处理会吞掉错误,让系统进入不可预期状态。
为什么需要异常机制
如果所有错误都靠返回值表达,调用方必须每一步都判断错误码,很容易漏掉。异常机制把正常逻辑和错误逻辑分开:正常路径继续执行,异常路径沿调用栈向上传递,直到被合适的位置处理。
flowchart TD
A["底层方法发现错误"] --> B["抛出异常"]
B --> C["调用栈向上传递"]
C --> D{"是否有匹配 catch"}
D -- "有" --> E["处理或转换异常"]
D -- "没有" --> F["继续向上抛出"]异常不是为了“让程序不报错”,而是为了让错误在正确的位置被发现、记录、恢复或中断。
异常体系
flowchart TD
A["Throwable"] --> B["Error"]
A --> C["Exception"]
C --> D["Checked Exception"]
C --> E["RuntimeException"]Error和Exception
| 类型 | 说明 |
|---|---|
| Error | JVM 或系统级严重错误,通常不由业务代码处理 |
| Exception | 程序可处理异常 |
| RuntimeException | 运行时异常,例如空指针、参数非法 |
| Checked Exception | 编译期要求处理,例如 IOException |
不要把异常体系理解成“Error 不重要,Exception 才重要”。在生产排查里,OutOfMemoryError、StackOverflowError 这类 Error 很关键,只是业务代码通常不应该在局部 catch 住它然后假装能恢复。
异常对象里有什么
异常不是一段字符串,而是一个对象。它通常包含:
| 内容 | 作用 |
|---|---|
| 异常类型 | 判断问题类别,例如参数错误、空指针、IO 失败 |
| message | 给人看的错误说明 |
| stack trace | 记录异常从哪里抛出、经过哪些调用层 |
| cause | 记录底层原始异常,便于异常转换后仍能定位根因 |
| suppressed | try-with-resources 中被压制的关闭异常 |
Demo:
public class ExceptionObjectDemo {
public static void main(String[] args) {
try {
parseAmount("abc");
} catch (IllegalArgumentException e) {
System.out.println(e.getClass().getName());
System.out.println(e.getMessage());
e.printStackTrace();
}
}
private static int parseAmount(String value) {
if (value == null) {
throw new IllegalArgumentException("金额不能为空");
}
return Integer.parseInt(value);
}
}printStackTrace 会打印调用路径,但生产项目不要直接用它,而要交给日志框架。
异常沿调用栈传播
异常抛出后,会从当前方法退出,沿调用栈一层层向上找匹配的 catch。
public class ExceptionStackDemo {
public static void main(String[] args) {
try {
controller();
} catch (RuntimeException e) {
System.out.println("main 捕获:" + e.getMessage());
}
}
static void controller() {
service();
}
static void service() {
repository();
}
static void repository() {
throw new RuntimeException("数据库连接失败");
}
}传播流程:
flowchart TD
A["repository 抛异常"] --> B["repository 方法结束"]
B --> C["回到 service 查找 catch"]
C --> D{"service 是否捕获"}
D -- "否" --> E["继续回到 controller"]
E --> F{"controller 是否捕获"}
F -- "否" --> G["继续回到 main"]
G --> H["main catch 处理异常"]这就是为什么日志堆栈要从上到下看:最上面通常是异常类型和直接抛出点,下面是它经过的业务调用链。
throw 和 throws
throw 是真正抛出一个异常对象,写在方法体里。
if (amount <= 0) {
throw new IllegalArgumentException("金额必须大于 0");
}throws 是方法声明,告诉调用方这个方法可能向外抛出某些异常。
public void readFile(String path) throws IOException {
Files.readAllBytes(Paths.get(path));
}区别:
| 关键字 | 位置 | 含义 |
|---|---|---|
throw | 方法体内 | 抛出一个具体异常对象 |
throws | 方法签名上 | 声明方法可能抛出异常 |
受检异常必须被捕获或继续 throws,运行时异常不强制声明。
try-catch-finally
try {
// 可能异常的代码
} catch (IOException e) {
// 异常处理
} finally {
// 释放资源
}执行流程:
flowchart TD
A["执行 try"] --> B{"是否异常"}
B -- "否" --> C["正常执行"]
B -- "是" --> D["匹配 catch"]
C --> E["执行 finally"]
D --> E
E --> F["继续后续逻辑或继续抛出"]catch 匹配顺序
多个 catch 时,要先捕获子类,再捕获父类。
try {
Integer.parseInt("abc");
} catch (NumberFormatException e) {
System.out.println("数字格式错误");
} catch (RuntimeException e) {
System.out.println("运行时异常");
}如果先写 RuntimeException,后面的 NumberFormatException 永远到不了,编译器会报错。因为 NumberFormatException 是 RuntimeException 的子类。
catch 后应该做什么
捕获异常后通常有四种选择:
| 处理方式 | 适用场景 |
|---|---|
| 记录日志并返回错误 | Web 接口、任务入口、消息消费入口 |
| 转换异常再抛出 | DAO 层转业务异常、第三方异常转统一异常 |
| 重试或降级 | 远程调用、临时网络抖动、限流失败 |
| 补偿或人工处理 | 支付、库存、消息通知、数据同步 |
不能处理的地方不要硬 catch。例如 DAO 层不知道应该给用户返回什么错误码,就应该带着上下文继续向上抛,让 Service 或全局异常处理器统一处理。
finally 的执行细节和常见坑
finally 的定位是“收尾”。它通常会在 try 或 catch 执行结束后运行,即使中间发生异常也会尽量执行。
public class FinallyDemo {
public static void main(String[] args) {
try {
throw new RuntimeException("业务失败");
} finally {
System.out.println("释放资源、记录收尾日志");
}
}
}但有两个点必须讲清楚。
finally 不等于一定执行
极端情况下,finally 也可能没有机会执行,比如:
- JVM 进程被强制杀掉。
- 机器断电。
Runtime.getRuntime().halt()。- 代码在
try里进入无限循环且不退出。
所以关键业务可靠性不能只依赖 finally。比如“支付成功后一定通知下游”不能靠 finally,而要靠本地消息表、事务消息、补偿任务和对账。
不要在 finally 里 return
finally 里的 return 会覆盖 try/catch 的返回值,甚至吞掉异常。
public class FinallyReturnDemo {
public static int getStatus() {
try {
throw new RuntimeException("真实异常");
} finally {
return 200; // 会吞掉上面的异常,非常危险
}
}
public static void main(String[] args) {
System.out.println(getStatus()); // 200
}
}这类代码在线上特别可怕:日志里看不到真实异常,调用方还以为成功了。实际开发中,finally 里只做释放资源、清理上下文,不做业务返回。
finally 修改返回对象的坑
如果 try 已经准备返回一个可变对象,finally 仍然能修改这个对象内部状态。
import java.util.ArrayList;
import java.util.List;
public class FinallyModifyDemo {
public static List<String> names() {
List<String> result = new ArrayList<>();
try {
result.add("try");
return result;
} finally {
result.add("finally");
}
}
public static void main(String[] args) {
System.out.println(names()); // [try, finally]
}
}原因是返回变量保存的是引用。finally 没有换掉引用,但修改了引用指向的对象内容。
try-with-resources
资源对象实现 AutoCloseable 后,可以使用 try-with-resources 自动关闭。
try (InputStream input = new FileInputStream("demo.txt")) {
// 读取文件
}这比在 finally 中手动 close 更简洁,也更安全。
try-with-resources 为什么更安全
手写 finally close() 容易遇到两个问题:
- 业务异常和关闭异常同时发生时,关闭异常可能覆盖业务异常。
- 多个资源关闭顺序容易写错。
try-with-resources 会按资源声明的反向顺序关闭,并把关闭时发生的异常作为 suppressed exception 挂到主异常上。
try (FileInputStream in = new FileInputStream("input.txt");
FileOutputStream out = new FileOutputStream("output.txt")) {
out.write(in.readAllBytes());
}执行顺序可以理解为:
flowchart TD
A["打开 FileInputStream"] --> B["打开 FileOutputStream"]
B --> C["执行业务代码"]
C --> D["先关闭 FileOutputStream"]
D --> E["再关闭 FileInputStream"]
E --> F["异常统一向外抛出"]所以文件、网络流、数据库连接、游标、压缩流这些资源,能用 try-with-resources 就不要手写复杂 finally。
suppressed exception 怎么看
当业务代码抛异常,关闭资源也抛异常时,try-with-resources 会保留主异常,把关闭异常放进 suppressed 列表。
class BadResource implements AutoCloseable {
public void work() {
throw new RuntimeException("业务异常");
}
@Override
public void close() {
throw new RuntimeException("关闭异常");
}
}
public class SuppressedDemo {
public static void main(String[] args) {
try (BadResource resource = new BadResource()) {
resource.work();
} catch (RuntimeException e) {
System.out.println("主异常:" + e.getMessage());
for (Throwable suppressed : e.getSuppressed()) {
System.out.println("被压制异常:" + suppressed.getMessage());
}
}
}
}这比手写 finally 安全,因为不会让关闭异常覆盖真正的业务异常。
自定义异常
业务系统通常会定义业务异常。
public class BizException extends RuntimeException {
public BizException(String message) {
super(message);
}
}业务异常适合表达:
- 参数不合法。
- 状态不允许。
- 权限不足。
- 资源不存在。
更完整的业务异常通常包含错误码:
public class BizException extends RuntimeException {
private final String code;
public BizException(String code, String message) {
super(message);
this.code = code;
}
public BizException(String code, String message, Throwable cause) {
super(message, cause);
this.code = code;
}
public String getCode() {
return code;
}
}带 cause 的构造方法很重要。它能在异常转换时保留底层异常:
try {
callHospitalApi();
} catch (IOException e) {
throw new BizException("HOSPITAL_API_ERROR", "医院接口调用失败", e);
}如果不传 cause,堆栈里可能只剩“医院接口调用失败”,真实的连接超时、DNS 失败、连接被拒绝就丢了。
Checked Exception 和 RuntimeException 怎么选
Java 异常可以分为受检异常和运行时异常。
| 类型 | 编译器是否强制处理 | 常见例子 | 适合表达 |
|---|---|---|---|
| Checked Exception | 强制 try/catch 或 throws | IOException、SQLException | 调用方有机会恢复或必须感知的外部失败 |
| RuntimeException | 不强制处理 | NullPointerException、IllegalArgumentException | 参数错误、状态错误、业务规则失败 |
业务系统里常见做法是:定义统一的 BizException extends RuntimeException,再由全局异常处理转换成统一响应。
public class BizException extends RuntimeException {
private final String code;
public BizException(String code, String message) {
super(message);
this.code = code;
}
public String getCode() {
return code;
}
}为什么很多业务异常用运行时异常?因为业务规则失败通常不是调用方本地能“修好”的,比如状态不允许、权限不足、数据不存在。它应该被统一拦截、记录并返回错误码,而不是要求每一层都写重复的 try/catch。
注意 Spring 声明式事务默认只对 RuntimeException 和 Error 回滚。受检异常如果也要回滚,需要显式配置:
@Transactional(rollbackFor = Exception.class)
public void importMedicalRecords() throws IOException {
// 读取文件并落库
}如果不了解这个规则,可能出现“方法抛异常了,但数据库却提交了”的问题。
异常和事务回滚
异常与事务关系非常密切,尤其是 Spring 项目。
flowchart TD
A["进入 @Transactional 方法"] --> B["开启事务"]
B --> C["执行业务代码"]
C --> D{"是否抛出异常"}
D -- "无异常" --> E["提交事务"]
D -- "RuntimeException 或 Error" --> F["默认回滚"]
D -- "Checked Exception" --> G{"是否配置 rollbackFor"}
G -- "是" --> F
G -- "否" --> E几个高频坑:
| 场景 | 后果 | 正确做法 |
|---|---|---|
方法内部 catch 后不再抛出 | Spring 认为方法正常结束,事务提交 | 捕获后继续抛出,或手动标记回滚 |
抛出受检异常但没配置 rollbackFor | 默认可能不回滚 | @Transactional(rollbackFor = Exception.class) |
| 异步线程里抛异常 | 外层事务感知不到 | 异步任务单独事务、状态表、补偿机制 |
| finally 里吞异常 | 真实失败被隐藏 | finally 只清理资源,不 return、不吞错 |
错误示例:
@Transactional
public void pay(Long orderId) {
try {
updateOrderPaid(orderId);
notifyWarehouse(orderId);
} catch (Exception e) {
log.error("支付处理失败, orderId={}", orderId, e);
// 没有继续抛出,事务可能提交
}
}更合理:
@Transactional(rollbackFor = Exception.class)
public void pay(Long orderId) {
try {
updateOrderPaid(orderId);
notifyWarehouse(orderId);
} catch (Exception e) {
log.error("支付处理失败, orderId={}", orderId, e);
throw e;
}
}如果通知仓库是外部系统调用,实际商业项目更推荐“订单状态变更 + 本地消息表”在同一个事务内提交,通知失败由补偿任务重试,而不是把远程调用硬塞在数据库事务里。
异常、重试和补偿
不是所有异常都应该重试。
| 异常类型 | 是否适合重试 | 原因 |
|---|---|---|
| 参数错误 | 不适合 | 重试多少次参数还是错 |
| 权限不足 | 不适合 | 需要授权或配置,不是临时失败 |
| 网络超时 | 可以有限重试 | 可能是临时抖动 |
| 下游限流 | 谨慎重试 | 立刻重试可能加剧雪崩,应退避 |
| 数据库死锁 | 可以重试 | 短事务死锁重试可能成功 |
| 支付扣款不明确 | 不能盲目重试 | 必须先查单确认状态,避免重复扣款 |
生产处理流程:
flowchart TD
A["捕获异常"] --> B{"是否参数或权限错误"}
B -- "是" --> C["直接失败,不重试"]
B -- "否" --> D{"是否临时性故障"}
D -- "是" --> E["有限次数退避重试"]
D -- "否" --> F{"是否状态不明确"}
F -- "是" --> G["查单、对账或进入补偿"]
F -- "否" --> H["记录失败并告警"]重试必须配合幂等。消息消费、支付通知、库存扣减、接口回调都要有业务幂等键,否则“异常后重试”可能把一次业务执行成多次。
商业 Demo:文件导入异常分层处理
下面的 Demo 展示 Controller、Service、解析层如何处理异常。
class ImportException extends RuntimeException {
ImportException(String message, Throwable cause) {
super(message, cause);
}
}
class ImportService {
public void importFile(String filePath) {
if (filePath == null || filePath.trim().length() == 0) {
throw new IllegalArgumentException("文件路径不能为空");
}
try {
parseFile(filePath);
saveBatch();
} catch (java.io.IOException e) {
throw new ImportException("文件读取失败:" + filePath, e);
} catch (RuntimeException e) {
throw new ImportException("文件导入失败:" + filePath, e);
}
}
private void parseFile(String filePath) throws java.io.IOException {
if (!filePath.endsWith(".csv")) {
throw new java.io.IOException("只支持 CSV 文件");
}
}
private void saveBatch() {
// 批量入库
}
}这个 Demo 的重点:
- 参数错误直接抛
IllegalArgumentException。 - IO 失败转换成业务能理解的
ImportException。 - 转换异常时保留
cause。 - Service 不直接返回乱七八糟的错误码,由上层统一转响应。
- 日志应在入口层或全局处理层统一记录,避免每层重复打印。
异常处理原则
不要吞异常
不要这样写:
try {
doSomething();
} catch (Exception e) {
}这会让问题消失在日志里,排查非常困难。
不要只打印不处理
catch (Exception e) {
e.printStackTrace();
}生产环境应使用日志框架,并根据业务决定返回错误、重试、补偿或继续抛出。
异常要带上下文
日志中应包含业务主键,例如订单号、用户 ID、文件 ID。
医疗采集平台里尤其要带这些上下文:
| 上下文 | 为什么重要 |
|---|---|
batchNo | 定位哪个采集批次失败 |
hospitalCode | 判断是否某个医院接口异常 |
sourceRecordId | 定位原始数据 |
traceId | 串起网关、服务、数据库和 MQ 日志 |
retryCount | 判断是否进入补偿或人工处理 |
推荐写法:
log.error("采集记录解析失败, batchNo={}, hospitalCode={}, sourceRecordId={}",
batchNo, hospitalCode, sourceRecordId, ex);最后一个参数传异常对象,日志框架才会打印完整堆栈。
Spring中的异常
在 Web 项目中,常用全局异常处理统一返回错误。
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BizException.class)
public Result<Void> handleBiz(BizException e) {
return Result.fail(e.getMessage());
}
}更接近生产的写法会区分业务异常、参数异常和系统异常:
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BizException.class)
public Result<Void> handleBiz(BizException e) {
return Result.fail(e.getCode(), e.getMessage());
}
@ExceptionHandler(IllegalArgumentException.class)
public Result<Void> handleIllegalArgument(IllegalArgumentException e) {
return Result.fail("PARAM_ERROR", e.getMessage());
}
@ExceptionHandler(Exception.class)
public Result<Void> handleSystem(Exception e) {
log.error("系统异常", e);
return Result.fail("SYSTEM_ERROR", "系统繁忙,请稍后重试");
}
}为什么系统异常不要把 e.getMessage() 直接返回给前端?因为里面可能包含 SQL、服务器路径、第三方地址、内部字段名等敏感信息。前端拿到统一错误提示,日志里保留完整堆栈。
线上排查流程
flowchart TD
A["线上出现异常"] --> B["先看异常类型和第一行 message"]
B --> C["看最上方业务栈帧定位抛出点"]
C --> D["结合 traceId 查完整调用链"]
D --> E{"异常是否被转换"}
E -- "是" --> F["沿 cause 找原始异常"]
E -- "否" --> G["直接定位代码和入参"]
F --> H["判断参数、状态、下游、资源或并发问题"]
G --> H
H --> I["修复、补偿、告警或加校验"]排查时不要只看最后一行日志。很多框架会包装异常,真正根因可能在 Caused by 后面:
ImportException: 文件导入失败
Caused by: java.net.SocketTimeoutException: Read timed out这说明表面是导入失败,根因是下游读取超时。处理方向不是改导入状态判断,而是查下游接口、超时配置、重试和补偿。
开发建议
- 能处理就处理,不能处理就继续抛出。
- 不要捕获顶层 Exception 后静默忽略。
- 业务异常和系统异常要区分。
- 日志要包含异常堆栈和业务上下文。
- 资源释放优先使用 try-with-resources。
面试常问
Error 和 Exception 区别?Error 表示 JVM 或系统级严重问题,例如 OOM、StackOverflow,业务代码通常不应该局部捕获后继续执行;Exception 表示程序可处理的问题,分为受检异常和运行时异常。业务开发重点是正确处理 Exception,同时线上排查不能忽略 Error。
Checked Exception 和 RuntimeException 区别?
Checked Exception 编译器强制处理,适合调用方可能恢复或必须感知的外部失败,比如 IO;RuntimeException 编译器不强制处理,适合参数错误、状态错误、业务规则失败。Spring 默认事务回滚也和这个区别有关。
throw 和 throws 区别?throw 在方法体里抛出一个具体异常对象;throws 在方法签名上声明这个方法可能抛出异常。前者是真正动作,后者是对调用方的契约说明。
finally 一定执行吗?
通常会执行,但不是绝对。进程被 kill、机器断电、Runtime.halt()、无限循环等情况下可能没有机会执行。生产可靠性不能只靠 finally,关键业务要靠事务、状态表、消息表、补偿和对账。
为什么不要在 finally 里 return?
因为 finally 的 return 会覆盖 try/catch 的返回值,甚至吞掉真实异常,导致线上明明失败却表现为成功。finally 只适合释放资源、清理上下文,不应该做业务返回。
异常转换为什么要保留 cause?
因为上层需要统一业务语义,但排查时仍要看到底层根因。转换异常时传入 cause,可以同时得到“业务上是什么失败”和“技术上为什么失败”。不保留 cause 会丢失连接超时、SQL 错误、解析失败等关键线索。
Spring 事务遇到异常一定回滚吗?
不一定。默认主要对 RuntimeException 和 Error 回滚;受检异常需要配置 rollbackFor。如果方法内部 catch 后吞掉异常,Spring 认为方法正常结束,也可能提交事务。
