首页 / Java 入门教程 / 异常处理最佳实践

Java 入门教程

异常处理最佳实践

本教程共 100 篇 · 第 71 篇 · 更新于 2026-08-05 · 约 17 分钟阅读

JavaJava 入门教程异常最佳实践早抛晚捕日志记录

71. 异常处理最佳实践

本节目标:把前面六章的知识串成一套可执行的准则,学完知道在什么位置抛、在什么位置捕、怎么记日志才不添乱。

早抛:错误发现得越早越好

「早抛」(Fail Fast)的意思是:一旦发现输入或状态不对,立刻抛异常,别往下走

对比两种写法。

// 不好:错误潜伏,到很深的地方才爆
static void register(String name, int age) {
    User user = new User();
    user.setName(name);
    user.setAge(age);
    saveToDatabase(user); // 到这一层才发现 age 是 -5
}
```bash

```java
// 好:方法入口就拦住
static void register(String name, int age) {
    if (name == null || name.isBlank()) {
        throw new IllegalArgumentException("用户名不能为空");
    }
    if (age < 0 || age > 150) {
        throw new IllegalArgumentException("年龄超出范围:" + age);
    }
    User user = new User();
    user.setName(name);
    user.setAge(age);
    saveToDatabase(user);
}

第二种写法的好处:异常栈直接指向调用方那一行,一眼看出谁传了脏数据。第一种得从数据库层往回追好几层。

参数校验写在方法最开头,这是一条几乎无脑正确的规则。

JDK 提供了现成的工具方法,能少写几行:

import java.util.Objects;

static void process(String data) {
    // null 就抛 NPE,消息是第二个参数
    Objects.requireNonNull(data, "data 不能为 null");
    System.out.println(data.trim());
}
```bash

## 晚捕:在能真正处理的地方才捕获

「晚捕」的意思是:**别在半路上乱捕,让异常传到那个真正知道该怎么办的层**

什么叫「知道该怎么办」?举例说明。

数据访问层查数据库失败,它能做什么?重试?决定不了。返回默认值?不知道业务需要什么默认值。**它什么都做不了,就该让异常往上传**

到了 Web 控制器层,就能做事了:把异常转成 HTTP 500,返回一个友好提示给前端。这里才是捕获的地方。

```java
// 数据层:不捕获,让异常传上去
class UserRepository {
    User findById(String id) throws SQLException {
        // 查库,出错直接抛
        return doQuery(id);
    }
}

// 服务层:包装成业务异常,加上业务上下文
class UserService {
    private final UserRepository repo = new UserRepository();

    User getUser(String id) {
        try {
            return repo.findById(id);
        } catch (SQLException e) {
            throw new UserQueryException("查询用户失败,id=" + id, e);
        }
    }
}

// 控制器层:真正处理,转成给用户看的响应
class UserController {
    private final UserService service = new UserService();

    String handle(String id) {
        try {
            User u = service.getUser(id);
            return "成功:" + u;
        } catch (UserQueryException e) {
            logger.error("用户查询失败", e); // 记完整日志
            return "系统繁忙,请稍后重试";     // 给用户看的话
        }
    }
}

三层各司其职:底层抛,中层包装加上下文,顶层处理并记录。

Tip

判断该不该在这里捕获,问自己一句:捕获之后我能做什么有意义的事吗? 答不上来就别捕,让它继续往上走。

记日志:只记一次,记全一点

最常见的日志反模式是同一个异常记了好几遍

// 反模式:一路记,日志爆炸
class Layer1 {
    void doWork() {
        try {
            layer2.doWork();
        } catch (Exception e) {
            logger.error("layer1 出错", e); // 第一遍
            throw e;
        }
    }
}

class Layer2 {
    void doWork() {
        try {
            layer3.doWork();
        } catch (Exception e) {
            logger.error("layer2 出错", e); // 第二遍
            throw e;
        }
    }
}
```bash

一个错误在日志里出现三次,每次都带着几十行栈。日志文件迅速膨胀,排查时还得费劲判断这三条到底是一个错还是三个错。

**规则:谁最终处理,谁记录日志。中间层只包装、只往上抛,不记日志。**

```java
// 中间层:只包装,不记日志
try {
    repo.findById(id);
} catch (SQLException e) {
    throw new UserQueryException("查询用户失败,id=" + id, e);
}

// 最上层:记一次完整的
try {
    service.getUser(id);
} catch (UserQueryException e) {
    logger.error("处理用户请求失败, id={}", id, e); // 就这一次
    return errorResponse();
}

别用 printStackTrace

练习代码里用 e.printStackTrace() 没问题,但正式项目里必须换成日志框架

三个理由。

printStackTrace() 输出到标准错误流,不进日志文件。线上服务器上这些输出很可能直接丢失。

它没有时间戳、没有日志级别、没有线程名,跟其他日志混在一起完全对不上号。

它不受日志级别控制,想在生产环境关掉都关不掉。

// 练习可以
catch (IOException e) {
    e.printStackTrace();
}

// 正式代码用这个(以 SLF4J 为例)
catch (IOException e) {
    logger.error("读取配置文件失败: {}", path, e);
}
```bash

注意 `logger.error` 的写法:**异常对象放最后一个参数,不要用字符串拼接**

```java
// 错:只有消息,栈信息丢了
logger.error("出错了:" + e.getMessage());

// 对:完整栈都进日志
logger.error("出错了", e);

异常消息要能定位问题

异常消息的读者是几个月后半夜被叫起来排查故障的人,可能就是你自己。

// 无效消息
throw new IllegalArgumentException("参数错误");
throw new RuntimeException("失败");
throw new IllegalStateException("状态不对");

// 有效消息:说清哪个东西、什么值、期望什么
throw new IllegalArgumentException("折扣率必须在 0.0-1.0 之间,实际收到:" + rate);
throw new IllegalStateException("连接已关闭,无法发送消息,connectionId=" + id);
throw new IllegalArgumentException("orderId 不能为空");
```bash

标准是:**看到这条消息,不翻代码也能猜到问题在哪**

> [!WARNING]
> 消息里不要放密码、令牌、身份证号这类敏感信息。异常日志经常被广泛采集和传阅,泄露风险很高。

## 抛具体的,别抛笼统的

抛异常时优先选最贴切的类型,不要图省事抛父类。

```java
// 不好:调用方分不清是什么问题
throw new RuntimeException("转换失败");
throw new Exception("出错");

// 好:类型本身就是信息
throw new NumberFormatException("无法解析为数字:" + s);
throw new IllegalStateException("订单已支付,不能重复支付");

捕获时反过来,也要捕具体的:

// 不好:把 NPE 这种 bug 也一起吞了
try {
    parseConfig();
} catch (Exception e) {
    logger.error("配置解析失败", e);
}

// 好:只接自己预期的
try {
    parseConfig();
} catch (IOException | NumberFormatException e) {
    logger.error("配置解析失败", e);
}
```bash

**只有在系统最外层做兜底时,才适合 `catch (Exception e)`**,比如 Web 框架的全局异常处理器。

## 不用异常做流程控制

异常是给「意外情况」准备的,不是给正常分支用的。

```java
// 反模式:拿异常当 if 用
static boolean isNumber(String s) {
    try {
        Integer.parseInt(s);
        return true;
    } catch (NumberFormatException e) {
        return false;
    }
}

功能上没错,但如果这个方法在循环里被调用几万次,而且大部分输入都不是数字,性能会很难看。

// 改进:先用低成本判断挡掉大部分情况
static boolean isNumber(String s) {
    if (s == null || s.isEmpty()) {
        return false;
    }
    for (int i = 0; i < s.length(); i++) {
        if (!Character.isDigit(s.charAt(i))) {
            return false;
        }
    }
    return true;
}
```bash

判断标准:**这件事在正常业务流程里会频繁发生吗?** 会,就用条件判断;只在极少数意外时发生,才用异常。

## 异常到底有多慢

坊间常说「异常性能差」,但要说清慢在哪。

慢的是**填充调用栈**(`fillInStackTrace`),这一步要遍历整个方法调用链。异常对象越深、栈越长,越慢。

不慢的是 `try...catch` 结构本身。**没有异常抛出时,`try` 块的开销接近于零**。JVM 用的是异常表机制,正常路径上不做任何额外检查。

结论有两条。

第一,**放心用 `try...catch` 包代码,不抛异常就不花钱**。

第二,**别在高频循环里疯狂抛异常**。如果确实需要,可以重写 `fillInStackTrace()` 返回 `this` 来跳过栈填充,不过这属于极端优化,一般项目用不上。

## 不要把异常存成静态变量

有人为了省对象创建开销,把异常做成常量复用:

```java
// 反模式
private static final RuntimeException TIMEOUT =
        new RuntimeException("超时");

void doWork() {
    if (isTimeout()) {
        throw TIMEOUT; // 栈信息永远是类初始化时的那一份
    }
}

问题在于异常对象的调用栈是创建时记录的,不是抛出时。这个常量的栈永远指向类加载那一刻,跟实际出错位置完全无关,比没有栈还误导人。

每次都 new 一个新的异常对象,这点开销完全值得。

try 块要小

try 包住的代码越少越好。

// 不好:try 包了一大坨,出了异常不知道是哪一句
try {
    Config c = loadConfig();
    Connection conn = connect(c);
    List<Data> data = query(conn);
    process(data);
    save(data);
} catch (Exception e) {
    logger.error("处理失败", e);
}
```bash

包这么大,`catch` 里根本没法针对性处理,只能笼统地记一句「处理失败」。

```java
// 好:只包真正需要处理的那一段
Config c;
try {
    c = loadConfig();
} catch (IOException e) {
    logger.warn("配置读取失败,使用默认配置", e);
    c = Config.defaults(); // 有针对性的补救
}

// 其他部分让异常自然往上传
Connection conn = connect(c);
List<Data> data = query(conn);
process(data);
save(data);

清单速查

把上面的准则压缩成一份检查表:

场景做法
方法入口校验参数,不合法立刻抛
底层技术异常不捕获,让它往上传
中间业务层包装成业务异常,带上 cause 和上下文,不记日志
最外层捕获、记一次完整日志、返回友好提示
日志写法logger.error("消息", e),异常放最后一个参数
异常消息带上出错的实际值,不含敏感信息
catch 类型捕具体的;只有全局兜底才用 Exception
finally只做清理,不写 return、不 throw
资源关闭一律用 try-with-resources
高频判断if,不用异常

小结

早抛:参数一进方法就校验,错了立刻抛,让栈信息直指调用方。

晚捕:捕获点放在真正能处理的那一层,中间层只包装不吞。

日志只记一次,记在最终处理的地方,用日志框架而不是 printStackTrace()

try 块尽量小,catch 类型尽量具体,异常消息尽量具体。异常留给意外情况,正常分支用 if