Skip to content

异常处理最佳实践

概述

在掌握 异常体系异常处理语法自定义异常 之后,还需要在工程实践中遵循一些最佳实践,才能让异常机制真正提升程序的健壮性和可维护性。本文从「何时处理」「如何记录」「资源与异常链」「受检与非受检的选择」等角度总结常见原则,并指出需要避免的反模式。


核心原则概览

原则说明
明确责任边界在「能处理且有价值」的层级处理异常;否则声明抛出或包装后抛出,由上层或统一入口处理。
不要吞掉异常捕获后至少要记录日志或重新抛出,避免空的 catch 导致问题被隐藏。
资源用 try-with-resources对实现了 AutoCloseable 的流、连接等,用 try-with-resources 自动关闭,避免泄漏。
保留异常链包装异常时使用带 cause 的构造方法,便于排查根因。
用日志而非仅 printStackTrace生产环境应使用日志框架记录异常信息与堆栈,便于分级与检索。

下面按「该做的」和「不该做的」分别说明。


该做的:推荐实践

在能处理的层级处理异常

只有在当前层级能做出有意义处理(如重试、降级、转换错误码、记录并返回友好提示)时才 catch;否则应 throws 交给调用方,或包装成业务异常后抛出,由上层或全局异常处理器统一处理。

java
// 推荐:当前方法无法恢复,声明抛出,由调用方或统一异常处理负责
public String loadConfig(String path) throws IOException {
    return Files.readString(Path.of(path));
}

// 推荐:当前层能处理(如返回默认值),在此处 catch
public int parsePort(String value) {
    try {
        return Integer.parseInt(value);
    } catch (NumberFormatException e) {
        return 8080;  // 有意义的降级
    }
}

捕获后至少要记录或重新抛出

不要吞掉异常:catch 块里若什么都不做,问题会被隐藏,排查困难。

java
// 不推荐:吞掉异常,无法发现错误
try {
    doSomething();
} catch (IOException e) {
    // 空 catch,危险
}

// 推荐方式 1:记录日志后根据业务决定是否再抛出
try {
    doSomething();
} catch (IOException e) {
    log.warn("操作失败,使用默认配置", e);
    applyDefaultConfig();
}

// 推荐方式 2:无法处理时包装后重新抛出,保留原因链
try {
    doSomething();
} catch (IOException e) {
    throw new BusinessException("加载配置失败", e);  // cause 保留 e
}

提示

生产代码中应使用 SLF4J + Logback 等日志框架的 log.error("描述", e) 记录异常,避免仅用 e.printStackTrace() 输出到标准错误。

使用 try-with-resources 管理资源

InputStreamOutputStreamReaderWriterConnection 等实现了 AutoCloseable 的资源,优先使用 try-with-resources,确保发生异常时也会自动关闭,避免资源泄漏。

java
// 推荐:自动关闭,异常时也会正确释放
try (BufferedReader br = new BufferedReader(new FileReader("data.txt"))) {
    String line;
    while ((line = br.readLine()) != null) {
        process(line);
    }
}

// 不推荐:手动 close,若中间抛异常可能未执行 close(即使写 finally 也容易漏、代码冗长)
BufferedReader br = new BufferedReader(new FileReader("data.txt"));
try {
    // ...
} finally {
    br.close();  // 不如 try-with-resources 简洁可靠
}

详见 异常处理 - try-with-resources

包装异常时保留原因链(cause)

将底层异常(如 IOExceptionSQLException)包装成业务异常时,应使用 带 cause 的构造方法,这样堆栈中会保留完整原因链,便于定位根因。

java
// 推荐:保留 cause,堆栈中能看到原始 IOException
try {
    return Files.readString(Path.of(path));
} catch (IOException e) {
    throw new ConfigLoadException("加载配置失败: " + path, e);
}

// 不推荐:仅传 message,丢失了底层异常信息
} catch (IOException e) {
    throw new ConfigLoadException("加载配置失败");  // e 被丢弃
}

自定义异常时应提供 (String message, Throwable cause) 构造方法,并在 自定义异常 中调用 super(message, cause)

选择受检异常还是非受检异常

  • 受检异常:调用方必须处理(try-catch 或 throws),适合「可恢复的、预期可能发生的」情况(如文件不存在、网络超时)。
  • 非受检异常:不强制处理,适合「编程错误、前置条件不满足、非法状态」等(如空指针、非法参数)。

自定义业务异常时:若希望强制调用方处理(如「格式不支持」),用受检异常;若由上层或全局统一处理(如「用户不存在」),用非受检异常(继承 RuntimeException)更常见,可减少无意义的 throws 和 catch。


不该做的:常见反模式

避免空的 catch 或仅 printStackTrace

java
// 反模式:空 catch
try {
    risky();
} catch (Exception e) {}

// 反模式:仅打印堆栈,生产环境难以集中收集与检索
} catch (Exception e) {
    e.printStackTrace();
}

应改为:记录日志(含堆栈),或包装后重新抛出。

避免过度使用 catch (Exception e)

在业务代码里笼统地 catch (Exception e) 会吞掉所有异常(包括本应暴露的编程错误),并难以针对不同异常做不同处理。应尽量捕获具体类型,必要时再补一层 catch (Exception e) 做兜底并记录日志后重新抛出。

java
// 不推荐:一把抓,无法区分 IO 错误与编程错误
try {
    readFile();
    parseData();
} catch (Exception e) {
    log.error("出错", e);
    return null;
}

// 推荐:区分具体异常类型,无法处理的再抛出
try {
    readFile();
    parseData();
} catch (IOException e) {
    log.warn("文件读取失败", e);
    return loadDefault();
} catch (NumberFormatException e) {
    throw new IllegalStateException("数据格式错误", e);  // 编程/数据错误,应暴露
}

注意

在框架层或边界层(如全局异常处理器)中,用 catch (Exception e) 做统一兜底是可以的,但应记录完整日志并酌情转换为统一错误响应,而不是静默吞掉。

不要用异常做正常流程控制

异常机制是有成本的(构造堆栈、查找 catch)。正常分支应用 if/返回值/Optional 等表达,不要用「抛异常再 catch」来代替。

java
// 反模式:用异常表示「未找到」,正常流程走 catch
try {
    User u = findUser(id);
    return u.getName();
} catch (UserNotFoundException e) {
    return "未知用户";
}

// 推荐:用返回值或 Optional 表达「可能不存在」
User u = findUser(id);  // 返回 Optional<User> 或 null(并约定语义)
return u != null ? u.getName() : "未知用户";

若「未找到」在业务上确实是异常情况,用非受检异常由上层统一处理也可接受,但不要用异常去驱动正常分支逻辑。

不要捕获 Error

Error 及其子类(如 OutOfMemoryErrorStackOverflowError)表示 JVM 或系统级严重错误,业务代码不应捕获它们,捕获后也难以做出可靠恢复。参见 异常体系 - Error


综合示例:分层中的异常处理

下面用「配置加载 + 解析」串联:资源用 try-with-resources、包装时保留 cause、能处理的层处理、不能处理的声明抛出。

java
// 底层:只负责 IO,无法处理时声明抛出
public static String readFileContent(String path) throws IOException {
    try (BufferedReader br = Files.newBufferedReader(Path.of(path))) {
        StringBuilder sb = new StringBuilder();
        String line;
        while ((line = br.readLine()) != null) {
            sb.append(line).append("\n");
        }
        return sb.toString();
    }
}

// 中层:将 IOException 包装为业务异常并保留 cause,便于上层统一处理
public static AppConfig loadConfig(String path) {
    try {
        String content = readFileContent(path);
        return parseConfig(content);
    } catch (IOException e) {
        throw new ConfigLoadException("加载配置失败: " + path, e);
    }
}

// 上层或全局:捕获业务异常,记录日志并返回友好错误(如 HTTP 500 与统一错误码)
try {
    AppConfig config = loadConfig("app.conf");
    run(config);
} catch (ConfigLoadException e) {
    log.error("配置加载失败", e);  // 含 cause 链
    return errorResponse("CONFIG_ERROR", "配置不可用");
}

小结

场景建议
能处理且有价值在当前层 catch 并处理(重试/降级/记录后返回)
不能处理throws 或包装后抛出,保留 cause,由上层或全局处理
资源(流、连接)使用 try-with-resources
记录异常用日志框架记录 message + 堆栈,避免空 catch 或仅 printStackTrace
自定义异常受检/非受检按「是否强制调用方处理」选择;包装时传 cause
避免空 catch、过度 catch(Exception)、用异常做正常流程控制、捕获 Error

相关链接

基于 VitePress 构建