被一个 catch 吃掉的异常:Java 异常处理里那些查到崩溃的坑

这个 bug 我查了整整三天,最折磨人的地方在于:日志里干干净净,什么都没有。线上偶尔有几笔订单金额对不上,概率很低、毫无规律,可一旦出现就是真金白银的差错。把整条链路的日志翻了个底朝天,期待看到一行报错、一段堆栈,结果一片祥和。直到逐个方法去读源码,才在一个不起眼的工具类里看到那行让我血压飙升的代码——一个空的 catch 块,把异常静悄悄咽了下去,程序若无其事地把错误金额写进了库。没有报错、没有堆栈、没有任何痕迹。这篇就从这个被吃掉的异常讲起,把 Java 异常处理讲透:受检异常与运行时异常怎么分、为什么吞异常是头号大罪、finally 与 try-with-resources 的坑、异常链 cause 为什么不能丢、什么时候该 throw 什么时候该 catch、自定义业务异常怎么设计、如何在系统边界用全局处理器统一落地,以及用 Optional 把 NPE 前移到编译期。

这个 bug 我查了整整三天,而它最折磨人的地方在于:日志里干干净净,什么都没有。线上偶尔会有几笔订单的金额对不上,概率很低、毫无规律,可一旦出现就是真金白银的差错。我把相关链路的日志翻了个底朝天,期待能看到一行报错、一段堆栈——结果一片祥和,仿佛什么都没发生过。直到我逐个方法去读源码,才在一个不起眼的工具类里看到了那行让我血压飙升的代码:

try {
    amount = parseAndConvert(raw);   // 这里偶尔会抛异常
} catch (Exception e) {
    // 什么都没做
}

一个空的 catch 块。当 parseAndConvert 因为某些边界数据抛出异常时,这个 catch 静悄悄地把它咽了下去,amount 保持着上一次的旧值,程序若无其事地继续往下走,把一个错误的金额写进了库。没有报错、没有堆栈、没有任何痕迹——异常被吃掉的那一刻,这个 bug 就注定要查三天。那一行空 catch,本质上是把一个本该"当场喊停"的错误,变成了一颗延迟引爆、且不留指纹的炸弹。

这篇就从这个被吃掉的异常讲起,把 Java 的异常处理讲透:受检异常和运行时异常到底该怎么分、为什么"吞异常"是头号大罪、finally 和 try-with-resources 里藏着哪些坑、异常链 cause 为什么不能丢、什么时候该 throw 什么时候该 catch——以及一套能让你的异常"会说话"、而不是默默把 bug 埋进土里的处理原则。

先认清:Java 异常处理这几种常见的"埋雷"姿势

在拆解之前,先把那些反复制造"查三天" bug 的写法摆出来。它们的共同点是:当时写着省事,出事时要命。

埋雷写法 真相 后果
空 catch 块(吞掉异常) 错误被彻底掩盖,程序带病继续跑 脏数据、无堆栈,查到崩溃
catch 只打 e.getMessage() 丢掉了最关键的堆栈和 cause 知道出错,却不知道在哪出错
finally 里 return / 抛异常 会覆盖掉 try 中的返回值和异常 原始异常凭空消失
包装异常时不带 cause 原始异常链断裂 只看到表层错误,根因丢失
用异常做正常流程控制 异常构造要抓堆栈,代价不小 性能差、逻辑绕、可读性烂
catch (Exception) 一网打尽 把不该处理的也吃了(如中断) 掩盖严重错误、吞掉中断信号

这张表的共同主题是:异常机制的本意,是让错误"响亮地暴露出来",好让你尽早发现、尽早定位;而上面每一种写法,都是在反其道而行——把响亮的错误,捂成无声的隐患。要躲开这些坑,得先搞清楚 Java 的异常到底分几类、各自意味着什么。

第一件事:受检、运行时、Error,先把这三类分清

Java 的异常不是铁板一块,它有一套继承体系,顶层是 Throwable,往下分成两大支,理解这套分类是正确处理的前提:

这三类的处理哲学完全不同:Error 是 JVM 级的灾难(内存耗尽、栈溢出),你通常无力回天,别去 catch 它假装没事;RuntimeException(运行时异常)绝大多数是代码本身的 bug——空指针、数组越界、参数非法,正确的做法不是到处 try-catch 把它兜住,而是修好导致它的代码;受检异常(checked exception)则代表"可以预期、但不由你控制"的外部故障——文件读不到、网络断了、SQL 执行失败,编译器强制你必须显式处理或声明抛出。搞混这三类,就会写出"该修 bug 时却在 catch、该处理故障时却在裸奔"的拧巴代码。下一节,直面那个头号大罪:吞异常。

第二件事:头号大罪——别把异常吞了

开篇那个空 catch,是吞异常最赤裸的形态。但吞异常还有个更隐蔽、更常见的变种,很多人甚至以为自己已经"处理"了——catch 之后只打印 e.getMessage():

// 反例一:彻底吞掉,毫无痕迹
try {
    amount = parseAndConvert(raw);
} catch (Exception e) {
    // 空的,最致命
}

// 反例二:看似处理了,其实丢掉了最关键的信息
try {
    amount = parseAndConvert(raw);
} catch (Exception e) {
    log.error("转换失败:" + e.getMessage());   // ← 只有一句话,没有堆栈!
}

第二种为什么也算吞?因为 e.getMessage() 往往只是一句干巴巴的描述(有时甚至是 null),它丢掉了异常最宝贵的两样东西:完整的调用堆栈(到底是哪一行、经过哪些方法抛出来的)和 cause(根本原因)。你看到日志里写着"转换失败",却依然不知道它失败在哪、为什么失败,等于只比空 catch 多了一句安慰。正确的写法,是把整个异常对象交给日志框架,让它打出完整堆栈:

try {
    amount = parseAndConvert(raw);
} catch (NumberFormatException e) {
    // ① 把 e 整个传给 logger(注意:e 作为最后一个参数,不要拼进字符串)
    log.error("金额转换失败,raw={}", raw, e);
    // ② 关键:决定接下来怎么办,而不是假装没事继续跑
    throw new BillingException("无法解析订单金额: " + raw, e);
}

这里的两个动作缺一不可:一是记录时把异常对象 e 作为最后一个参数传给 logger(SLF4J 等框架会自动识别并打印完整堆栈),而不是 e.getMessage() 拼字符串;二是想清楚 catch 之后该干什么——能恢复就恢复,不能恢复就重新抛出(带上原始 cause),让上层知道出事了。catch 块里唯一不能做的,就是"什么都不做"或"只记一句话然后继续往下跑"。异常被你捕获,意味着你接管了"如何应对这个错误"的责任,而不是获得了"假装它没发生"的许可。

第三件事:finally 里那两个会"吃掉异常"的陷阱

finally 块保证"无论如何都会执行",常用来释放资源。但它有两个反直觉的坑,会让 try 里抛出的异常凭空消失。第一个是在 finally 里 return:

// 反例:finally 里的 return 会吞掉 try 中抛出的异常
int badMethod() {
    try {
        throw new RuntimeException("真正的错误");   // 这个异常……
    } finally {
        return -1;   // ← 被这个 return 彻底覆盖,调用方只拿到 -1,异常没了!
    }
}

调用方拿到的是 -1,那个 RuntimeException 像没发生过一样消失了。同理,finally 里如果自己抛出异常,也会覆盖掉 try 中原本的异常(比如 try 抛了业务异常,finally 里 close() 又抛了 IO 异常,最后传出去的是 IO 异常,真正的业务错误被掩盖)。规避方法很明确:

// 正解:finally 只做清理,不 return、不抛出会覆盖原异常的东西
int goodMethod() {
    try {
        return doWork();
    } finally {
        cleanup();   // 只清理,不 return;若 cleanup 可能抛异常,在其内部消化
    }
}

一句话铁律:finally 里只做清理动作,绝不 return,也绝不让它抛出会盖掉原始异常的新异常。它是用来"善后"的,不是用来"改写结局"的。

第四件事:关资源,交给 try-with-resources

说到 finally 关资源,其实 Java 7 之后早就有了更好的写法,不该再手写 finally { close() } 了。看看老式写法有多容易出错:

// 老式写法:啰嗦,且 close 本身可能抛异常、还可能盖掉主异常
InputStream in = null;
try {
    in = new FileInputStream(path);
    process(in);
} catch (IOException e) {
    log.error("读取失败", e);
} finally {
    if (in != null) {
        try { in.close(); } catch (IOException e) { /* 又得套一层 */ }
    }
}

try-with-resources 重写,只要资源实现了 AutoCloseable,它就会在退出时自动、按逆序关闭,而且妥善处理 close 时的异常(用"抑制异常 suppressed"机制保留主异常,不会盖掉):

// 现代写法:声明在括号里,自动关闭,主异常不会被 close 异常掩盖
try (InputStream in = new FileInputStream(path)) {
    process(in);
} catch (IOException e) {
    log.error("读取失败", e);
}
// 多个资源也行,会按声明的逆序自动关闭:
// try (var a = open(); var b = open()) { ... }

这一改,代码短了一半,还顺手堵上了"忘了关""close 异常盖掉主异常"两个坑。原则很简单:凡是实现了 AutoCloseable 的东西(流、连接、锁的封装……),一律用 try-with-resources,别再手写 finally close。

第五件事:包装异常时,千万别把 cause 弄丢

实战里我们经常要把底层异常"翻译"成业务异常往上抛——比如把 SQLException 包成 UserNotFoundException,让上层不必关心存储细节。这本身是好习惯,但有个极易犯的错:包装的时候只 new 了一个新异常,却没把原始异常作为 cause 传进去:

// 反例:原始异常被丢弃,根因彻底断链
try {
    return userDao.findById(id);
} catch (SQLException e) {
    throw new ServiceException("查询用户失败");   // ← e 没了!到底是连接超时?语法错?天知道
}

// 正解:把原始异常作为 cause 传入,链条不断
try {
    return userDao.findById(id);
} catch (SQLException e) {
    throw new ServiceException("查询用户失败, id=" + id, e);   // ← 第二个参数就是 cause
}

带上 cause 之后,日志里打出的堆栈会是一条完整的链:ServiceException ... Caused by: SQLException ...,你能一眼从业务层的"查询用户失败"顺藤摸瓜到最底层的"连接池耗尽"。这就是"异常链(exception chaining)"的价值:它让每一层都能加上自己的上下文,又不丢失最初的真相。记住一条:几乎所有标准异常类都有 (String message, Throwable cause) 这个构造器——包装异常时,把原始的那个传进去,这是举手之劳,却能在出事时帮你省下几个小时。

第六件事:别拿异常当 if-else 用

最后一个常见误用,是把异常当成正常的流程控制工具。比如"用抛异常来表示循环结束""用 catch 来判断一个字符串是不是数字":

// 反例:用异常判断"是不是数字",每次失败都付出昂贵代价
boolean isNumber(String s) {
    try {
        Integer.parseInt(s);
        return true;
    } catch (NumberFormatException e) {
        return false;   // ← 非数字时,白白构造了一个带完整堆栈的异常
    }
}

问题出在:异常的构造是有成本的——它默认要抓取并填充当前线程的完整调用栈(fillInStackTrace),这在热点路径上反复发生时,开销相当可观。更别说用异常表达的逻辑往往绕、难读。能用普通判断解决的,就别动用异常:

// 正解:用常规手段判断,异常留给"真正异常的情况"
boolean isNumber(String s) {
    if (s == null || s.isEmpty()) return false;
    return s.chars().allMatch(Character::isDigit);   // 没有异常开销,意图也更清晰
}

判断标准:异常是给"预期之外、无法在当前正常流程里处理"的情况用的,不是给"可以预料、属于正常分支"的情况用的。"这个字符串可能不是数字"是完全可预料的正常分支,用 if 判断;"磁盘突然读不出来了"才是异常。下面这张决策图,帮你在"抓到一个异常后到底该怎么办"时快速定位:

几条可以直接抄走的铁律

  1. 永远不要吞异常。空 catch、只打 e.getMessage()、catch 后继续若无其事地跑——这三种都是在制造"查三天"的 bug。
  2. 记录异常时,把异常对象整个传给 logger。log.error("xxx", e),而不是把 e.getMessage() 拼进字符串,否则丢掉堆栈和 cause。
  3. 包装异常一定带上 cause。new XxxException(msg, e),保住完整的异常链,别让根因断在半路。
  4. finally 只做清理,绝不 return、绝不抛出会盖掉原异常的新异常。
  5. 能用 try-with-resources 就别手写 finally close。自动关闭、逆序关闭、还不会用 close 异常盖掉主异常。
  6. 分清三类异常:Error 别 catch,RuntimeException 多半去修代码,受检异常才是要认真处理的外部故障。
  7. 异常不是流程控制工具。可预料的正常分支用 if 判断,别用 try-catch,省下构造堆栈的开销,也让代码更直白。

那些关于异常的常见误解

误解一:"catch (Exception e) 一把抓最省事。"——它会把你根本没想处理的异常也一并吞了,包括一些代表严重问题的运行时异常,甚至(如果写成 catch Throwable)连 InterruptedException 这种中断信号都给吃掉,让线程无法正常响应取消。catch 应该尽量精确,抓你确实知道该如何应对的那几类,而不是一网打尽。

误解二:"受检异常太烦,全包成 RuntimeException 抛出去就清净了。"——这是个有争议的话题。无脑全转 RuntimeException 确实让签名清爽,但也意味着调用方完全失去了"编译器提醒你这里可能出错"的保护。比较稳妥的折中是:对于调用方真的能够并且应该处理的、可恢复的故障,保留受检异常的语义;只把那些"调用方无能为力、只能上抛"的,转成运行时异常。

误解三:"异常处理就是多写几个 try-catch。"——恰恰相反,好的异常处理往往是"写 try-catch":让异常自然地向上冒泡,在真正有能力处理它的那一层(通常是较高层,比如统一的全局异常处理器)集中应对,而不是在每一个方法里都套一层 catch 然后又不知道该干嘛。在错误的层级 catch,比不 catch 还糟。

更进一步:让异常在"系统边界"统一落地

前面那条"在真正有能力处理的那一层 catch"落到实处,通常意味着:业务代码里尽量别零散地 try-catch,而是把异常放出来,在系统的边界统一接住。对一个 Web 服务而言,这个边界就是控制器之外的全局异常处理器。以 Spring 为例:

@RestControllerAdvice
public class GlobalExceptionHandler {

    // 业务异常:转成对用户友好的、带错误码的响应
    @ExceptionHandler(BusinessException.class)
    public ResponseEntity<ApiError> handleBusiness(BusinessException e) {
        log.warn("业务异常: {}", e.getMessage());     // 业务异常通常 warn 即可
        return ResponseEntity.status(400)
                .body(new ApiError(e.getCode(), e.getMessage()));
    }

    // 兜底:任何没被预料到的异常,记完整堆栈,对外只给一个安全的提示
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ApiError> handleUnexpected(Exception e) {
        log.error("未预期异常", e);                    // ← 这里才是 error,带完整堆栈
        return ResponseEntity.status(500)
                .body(new ApiError("INTERNAL_ERROR", "服务器开小差了,请稍后再试"));
    }
}

这套做法有几个好处:业务方法只管把异常抛出来、专注表达"出了什么错",而"怎么记日志、怎么转成 HTTP 响应、给用户看什么"这些横切关注点,全部收敛到一个地方统一处理。这样既不会满代码都是重复的 try-catch,也保证了"对内记录完整堆栈、对外只暴露安全提示"——绝不把数据库错误、堆栈细节直接甩到用户脸上(那既不友好,也是信息泄露)。

顺带说一句日志级别的分寸:可预期的业务异常(参数不对、余额不足)用 warn 就够了,真正"不该发生"的未预期异常才用 error 并打完整堆栈。如果什么异常都 error + 全堆栈,日志很快就会被业务噪音淹没,真正的告警反而被埋掉——这又是另一种形式的"信息丢失"。

一个延伸:与其满地 catch NPE,不如用 Optional 表达"可能没有"

运行时异常里最臭名昭著的就是 NullPointerException。很多人对付它的方式是层层 if (x != null) 或者干脆 try-catch 兜住——但这都是治标。Java 8 的 Optional 提供了一种更诚实的表达:让"这个值可能不存在"这件事,直接写进类型签名里,逼调用方正视它:

// 返回 Optional,等于在签名上明说"可能查不到"
public Optional<User> findByEmail(String email) {
    return Optional.ofNullable(userMap.get(email));
}

// 调用方被迫处理"没有"的情况,而不是裸取一把然后 NPE
String name = findByEmail(email)
        .map(User::getName)
        .orElse("匿名用户");      // 优雅地给出默认值,而不是空指针

当然 Optional 也不是万金油:它主要适合作为方法返回值来表达"可能为空",不建议拿它做字段、做方法参数(那只会让代码更啰嗦)。但用在该用的地方,它能把一大批"忘了判空"导致的 NPE,从运行时崩溃前移成编译期就必须面对的选择。这和异常处理的精神是一脉相承的:把错误和"缺失"显式化、早暴露,而不是用空值和空 catch 把它们藏起来。

顺手聊聊:自定义业务异常该怎么设计

前面反复提到"包装成业务异常",那一个好的业务异常本身该长什么样?见过太多项目要么从头到尾只用一个万能的 RuntimeException,要么走另一个极端,为每种错误都造一个类,几百个异常类把代码淹没。比较实用的折中,是设计一个带错误码的基类,让"错误的类型"靠 code 区分,而不是靠类的数量:

public class BusinessException extends RuntimeException {
    private final String code;     // 机器可读的错误码,如 "ORDER_NOT_FOUND"

    public BusinessException(String code, String message) {
        super(message);
        this.code = code;
    }
    // 关键:保留带 cause 的构造器,包装底层异常时不丢链
    public BusinessException(String code, String message, Throwable cause) {
        super(message, cause);
        this.code = code;
    }
    public String getCode() { return code; }
}

这样设计有三个考量值得说道:第一,继承 RuntimeException 而非受检异常——业务异常通常是"调用方无法在本地恢复、只能上抛到边界统一处理"的,做成非受检的,能省去中间每一层都要 throws 声明的噪音。第二,必带错误码 code——给前端和上下游一个稳定的、机器可判断的标识,而错误信息(message)则可以随时优化措辞、做国际化,两者职责分开。第三,务必提供带 cause 的构造器,否则前面讲的"异常链"在你自己的异常类上就先断了。

至于要不要为每种业务错误派生子类(OrderNotFoundException extends BusinessException),取决于上层是否需要用 catch 区别对待它们:需要区分处理的,值得拆子类;只是想给个不同提示的,用同一个基类加不同 code 就够了。判断标准始终是"调用方会不会因为类型不同而走不同的处理分支"——会,才拆;不会,就别为了拆而拆。

写在最后

那个查了三天的金额 bug,最终的修复只是把那个空 catch 改成了"记录完整堆栈 + 抛出业务异常让上层中止处理"——代码没多几行,但效果是立竿见影的:类似的问题再发生,第一时间就会在日志里炸出完整的堆栈,从"查三天"变成"看一眼"。这件事让我彻底改了对异常的态度:异常不是要被"消灭"的麻烦,而是系统在向你求救的声音;你要做的不是捂住它的嘴,而是听清它在说什么。

回头看,异常处理的所有原则,其实都收敛到同一个核心:让错误尽可能响亮、尽可能完整、尽可能在正确的地方被听见。吞异常是捂嘴,丢 cause 是断章取义,在错误的层级乱 catch 是答非所问,用异常做流程控制是小题大做。把这几样反着做——异常照原样向上冒、每层加上下文且保住 cause、在系统边界统一落地、可预料的情况用普通判断——你的系统就从"出了事悄无声息"变成了"出了事第一时间喊得清清楚楚"。而这一字之差,往往就是一次"看一眼就修好"和一次"查三天还心惊肉跳"的区别。

把这些原则连起来看,你会发现它们指向的其实是同一种工程素养:对"错误"这件事保持诚实。诚实地记录它发生在哪、诚实地把根因一路带上来、诚实地在能处理的地方处理、诚实地告诉调用方"这里可能会出事"。代码里每一处对错误的遮掩——一个空 catch、一个被丢掉的 cause、一个被 finally 吃掉的异常——都是在透支未来某个深夜的自己。把今天的省事还回去,换的是出事那天的从容。这笔账,任何写过线上服务的人,迟早都会算明白。

所以下次当你的手指即将敲下那个空 catch 时,不妨停半秒,问自己一句:如果这里真的出错了,三个月后的我,能从日志里看懂发生了什么吗?能,就放心往下写;不能,就老老实实把异常记全、抛对地方。就这半秒的犹豫,可能就帮未来的你省下了一个查三天的周末。

毕竟,代码会骗人,日志不会——前提是,你没有亲手把它捂住。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

Go 并发踩坑实录:一个共享 map 如何让整个服务瞬间崩溃

2026-5-29 22:00:20

技术教程

一条慢 SQL 打满数据库:那些让你索引白建的失效陷阱

2026-5-29 22:13:16

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索