Skip to content

Java异常处理

异常用于表达程序运行过程中的非正常情况。好的异常处理能让问题被准确发现、定位和恢复;差的异常处理会吞掉错误,让系统进入不可预期状态。

为什么需要异常机制

如果所有错误都靠返回值表达,调用方必须每一步都判断错误码,很容易漏掉。异常机制把正常逻辑和错误逻辑分开:正常路径继续执行,异常路径沿调用栈向上传递,直到被合适的位置处理。

mermaid
flowchart TD
    A["底层方法发现错误"] --> B["抛出异常"]
    B --> C["调用栈向上传递"]
    C --> D{"是否有匹配 catch"}
    D -- "有" --> E["处理或转换异常"]
    D -- "没有" --> F["继续向上抛出"]

异常不是为了“让程序不报错”,而是为了让错误在正确的位置被发现、记录、恢复或中断。

异常体系

mermaid
flowchart TD
    A["Throwable"] --> B["Error"]
    A --> C["Exception"]
    C --> D["Checked Exception"]
    C --> E["RuntimeException"]

Error和Exception

类型说明
ErrorJVM 或系统级严重错误,通常不由业务代码处理
Exception程序可处理异常
RuntimeException运行时异常,例如空指针、参数非法
Checked Exception编译期要求处理,例如 IOException

不要把异常体系理解成“Error 不重要,Exception 才重要”。在生产排查里,OutOfMemoryErrorStackOverflowError 这类 Error 很关键,只是业务代码通常不应该在局部 catch 住它然后假装能恢复。

异常对象里有什么

异常不是一段字符串,而是一个对象。它通常包含:

内容作用
异常类型判断问题类别,例如参数错误、空指针、IO 失败
message给人看的错误说明
stack trace记录异常从哪里抛出、经过哪些调用层
cause记录底层原始异常,便于异常转换后仍能定位根因
suppressedtry-with-resources 中被压制的关闭异常

Demo:

java
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

java
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("数据库连接失败");
    }
}

传播流程:

mermaid
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 是真正抛出一个异常对象,写在方法体里。

java
if (amount <= 0) {
    throw new IllegalArgumentException("金额必须大于 0");
}

throws 是方法声明,告诉调用方这个方法可能向外抛出某些异常。

java
public void readFile(String path) throws IOException {
    Files.readAllBytes(Paths.get(path));
}

区别:

关键字位置含义
throw方法体内抛出一个具体异常对象
throws方法签名上声明方法可能抛出异常

受检异常必须被捕获或继续 throws,运行时异常不强制声明。

try-catch-finally

java
try {
    // 可能异常的代码
} catch (IOException e) {
    // 异常处理
} finally {
    // 释放资源
}

执行流程:

mermaid
flowchart TD
    A["执行 try"] --> B{"是否异常"}
    B -- "否" --> C["正常执行"]
    B -- "是" --> D["匹配 catch"]
    C --> E["执行 finally"]
    D --> E
    E --> F["继续后续逻辑或继续抛出"]

catch 匹配顺序

多个 catch 时,要先捕获子类,再捕获父类。

java
try {
    Integer.parseInt("abc");
} catch (NumberFormatException e) {
    System.out.println("数字格式错误");
} catch (RuntimeException e) {
    System.out.println("运行时异常");
}

如果先写 RuntimeException,后面的 NumberFormatException 永远到不了,编译器会报错。因为 NumberFormatExceptionRuntimeException 的子类。

catch 后应该做什么

捕获异常后通常有四种选择:

处理方式适用场景
记录日志并返回错误Web 接口、任务入口、消息消费入口
转换异常再抛出DAO 层转业务异常、第三方异常转统一异常
重试或降级远程调用、临时网络抖动、限流失败
补偿或人工处理支付、库存、消息通知、数据同步

不能处理的地方不要硬 catch。例如 DAO 层不知道应该给用户返回什么错误码,就应该带着上下文继续向上抛,让 Service 或全局异常处理器统一处理。

finally 的执行细节和常见坑

finally 的定位是“收尾”。它通常会在 trycatch 执行结束后运行,即使中间发生异常也会尽量执行。

java
public class FinallyDemo {
    public static void main(String[] args) {
        try {
            throw new RuntimeException("业务失败");
        } finally {
            System.out.println("释放资源、记录收尾日志");
        }
    }
}

但有两个点必须讲清楚。

finally 不等于一定执行

极端情况下,finally 也可能没有机会执行,比如:

  1. JVM 进程被强制杀掉。
  2. 机器断电。
  3. Runtime.getRuntime().halt()
  4. 代码在 try 里进入无限循环且不退出。

所以关键业务可靠性不能只依赖 finally。比如“支付成功后一定通知下游”不能靠 finally,而要靠本地消息表、事务消息、补偿任务和对账。

不要在 finallyreturn

finally 里的 return 会覆盖 try/catch 的返回值,甚至吞掉异常。

java
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 仍然能修改这个对象内部状态。

java
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 自动关闭。

java
try (InputStream input = new FileInputStream("demo.txt")) {
    // 读取文件
}

这比在 finally 中手动 close 更简洁,也更安全。

try-with-resources 为什么更安全

手写 finally close() 容易遇到两个问题:

  1. 业务异常和关闭异常同时发生时,关闭异常可能覆盖业务异常。
  2. 多个资源关闭顺序容易写错。

try-with-resources 会按资源声明的反向顺序关闭,并把关闭时发生的异常作为 suppressed exception 挂到主异常上。

java
try (FileInputStream in = new FileInputStream("input.txt");
     FileOutputStream out = new FileOutputStream("output.txt")) {
    out.write(in.readAllBytes());
}

执行顺序可以理解为:

mermaid
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 列表。

java
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 安全,因为不会让关闭异常覆盖真正的业务异常。

自定义异常

业务系统通常会定义业务异常。

java
public class BizException extends RuntimeException {
    public BizException(String message) {
        super(message);
    }
}

业务异常适合表达:

  • 参数不合法。
  • 状态不允许。
  • 权限不足。
  • 资源不存在。

更完整的业务异常通常包含错误码:

java
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 的构造方法很重要。它能在异常转换时保留底层异常:

java
try {
    callHospitalApi();
} catch (IOException e) {
    throw new BizException("HOSPITAL_API_ERROR", "医院接口调用失败", e);
}

如果不传 cause,堆栈里可能只剩“医院接口调用失败”,真实的连接超时、DNS 失败、连接被拒绝就丢了。

Checked Exception 和 RuntimeException 怎么选

Java 异常可以分为受检异常和运行时异常。

类型编译器是否强制处理常见例子适合表达
Checked Exception强制 try/catchthrowsIOExceptionSQLException调用方有机会恢复或必须感知的外部失败
RuntimeException不强制处理NullPointerExceptionIllegalArgumentException参数错误、状态错误、业务规则失败

业务系统里常见做法是:定义统一的 BizException extends RuntimeException,再由全局异常处理转换成统一响应。

java
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 声明式事务默认只对 RuntimeExceptionError 回滚。受检异常如果也要回滚,需要显式配置:

java
@Transactional(rollbackFor = Exception.class)
public void importMedicalRecords() throws IOException {
    // 读取文件并落库
}

如果不了解这个规则,可能出现“方法抛异常了,但数据库却提交了”的问题。

异常和事务回滚

异常与事务关系非常密切,尤其是 Spring 项目。

mermaid
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、不吞错

错误示例:

java
@Transactional
public void pay(Long orderId) {
    try {
        updateOrderPaid(orderId);
        notifyWarehouse(orderId);
    } catch (Exception e) {
        log.error("支付处理失败, orderId={}", orderId, e);
        // 没有继续抛出,事务可能提交
    }
}

更合理:

java
@Transactional(rollbackFor = Exception.class)
public void pay(Long orderId) {
    try {
        updateOrderPaid(orderId);
        notifyWarehouse(orderId);
    } catch (Exception e) {
        log.error("支付处理失败, orderId={}", orderId, e);
        throw e;
    }
}

如果通知仓库是外部系统调用,实际商业项目更推荐“订单状态变更 + 本地消息表”在同一个事务内提交,通知失败由补偿任务重试,而不是把远程调用硬塞在数据库事务里。

异常、重试和补偿

不是所有异常都应该重试。

异常类型是否适合重试原因
参数错误不适合重试多少次参数还是错
权限不足不适合需要授权或配置,不是临时失败
网络超时可以有限重试可能是临时抖动
下游限流谨慎重试立刻重试可能加剧雪崩,应退避
数据库死锁可以重试短事务死锁重试可能成功
支付扣款不明确不能盲目重试必须先查单确认状态,避免重复扣款

生产处理流程:

mermaid
flowchart TD
    A["捕获异常"] --> B{"是否参数或权限错误"}
    B -- "是" --> C["直接失败,不重试"]
    B -- "否" --> D{"是否临时性故障"}
    D -- "是" --> E["有限次数退避重试"]
    D -- "否" --> F{"是否状态不明确"}
    F -- "是" --> G["查单、对账或进入补偿"]
    F -- "否" --> H["记录失败并告警"]

重试必须配合幂等。消息消费、支付通知、库存扣减、接口回调都要有业务幂等键,否则“异常后重试”可能把一次业务执行成多次。

商业 Demo:文件导入异常分层处理

下面的 Demo 展示 Controller、Service、解析层如何处理异常。

java
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 的重点:

  1. 参数错误直接抛 IllegalArgumentException
  2. IO 失败转换成业务能理解的 ImportException
  3. 转换异常时保留 cause
  4. Service 不直接返回乱七八糟的错误码,由上层统一转响应。
  5. 日志应在入口层或全局处理层统一记录,避免每层重复打印。

异常处理原则

不要吞异常

不要这样写:

java
try {
    doSomething();
} catch (Exception e) {
}

这会让问题消失在日志里,排查非常困难。

不要只打印不处理

java
catch (Exception e) {
    e.printStackTrace();
}

生产环境应使用日志框架,并根据业务决定返回错误、重试、补偿或继续抛出。

异常要带上下文

日志中应包含业务主键,例如订单号、用户 ID、文件 ID。

医疗采集平台里尤其要带这些上下文:

上下文为什么重要
batchNo定位哪个采集批次失败
hospitalCode判断是否某个医院接口异常
sourceRecordId定位原始数据
traceId串起网关、服务、数据库和 MQ 日志
retryCount判断是否进入补偿或人工处理

推荐写法:

java
log.error("采集记录解析失败, batchNo={}, hospitalCode={}, sourceRecordId={}",
        batchNo, hospitalCode, sourceRecordId, ex);

最后一个参数传异常对象,日志框架才会打印完整堆栈。

Spring中的异常

在 Web 项目中,常用全局异常处理统一返回错误。

java
@RestControllerAdvice
public class GlobalExceptionHandler {
    @ExceptionHandler(BizException.class)
    public Result<Void> handleBiz(BizException e) {
        return Result.fail(e.getMessage());
    }
}

更接近生产的写法会区分业务异常、参数异常和系统异常:

java
@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、服务器路径、第三方地址、内部字段名等敏感信息。前端拿到统一错误提示,日志里保留完整堆栈。

线上排查流程

mermaid
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 后面:

text
ImportException: 文件导入失败
Caused by: java.net.SocketTimeoutException: Read timed out

这说明表面是导入失败,根因是下游读取超时。处理方向不是改导入状态判断,而是查下游接口、超时配置、重试和补偿。

开发建议

  1. 能处理就处理,不能处理就继续抛出。
  2. 不要捕获顶层 Exception 后静默忽略。
  3. 业务异常和系统异常要区分。
  4. 日志要包含异常堆栈和业务上下文。
  5. 资源释放优先使用 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 认为方法正常结束,也可能提交事务。