Java异常处理:catch里写Throwable和Exception有什么区别?
2026/9/13 21:51:28 网站建设 项目流程

写了好几年Java,try catch 这玩意儿几乎天天见,但真要问一句“catch 里写 Throwable 和写 Exception 到底有什么区别”,很多人会愣一下。毕竟两种写法都能通过编译,业务代码跑起来好像也没什么不同。不过要是把这个坑留到线上,代价通常不小——轻则异常被莫名吞掉,重则 JVM 已经内存溢出了,程序还在那“带病运行”,日志里却什么都查不到。

这篇文章就把这件事彻底讲透。我会从 Java 异常体系的继承结构说起,结合 JVM 字节码层面的匹配机制,再落到生产环境里到底该怎么选捕获粒度,最后整理一批面试和实际开发里高频遇到的相关问题。无论你是刚学 Java 基础的新手,还是准备 java 面试八股文的老兵,都能从这里拿到可以直接用的结论和写法。

1. 先把Java异常体系这张地图画清楚

1.1 Throwable、Exception、Error到底谁是谁

Java 里所有能被抛出、能被捕获的“东西”,共同的祖宗只有一个,就是java.lang.Throwable。它下面直接分了两大阵营:ExceptionError。这个结构很多教程里都画过,但真正理解它的人不多,我先把这张图用文本方式放出来:

Throwable (java.lang) ├── Error │ ├── OutOfMemoryError │ ├── StackOverflowError │ ├── NoClassDefFoundError │ ├── LinkageError │ └── ... └── Exception ├── RuntimeException │ ├── NullPointerException │ ├── IllegalArgumentException │ ├── IndexOutOfBoundsException │ ├── ClassCastException │ └── ... └── 受检异常 (Checked Exception) ├── IOException ├── SQLException ├── FileNotFoundException ├── ClassNotFoundException └── ...

Error这一支专门用来表示 JVM 层面的严重问题。OutOfMemoryErrorStackOverflowError都属于这一类,它们的特点是:发生了之后,JVM 本身可能已经处于不稳定的状态,你再怎么 catch 也救不回来,强行继续执行反而会让系统更糟糕。Exception才是我们日常打交道最多的类型,它再往下分两类,这个分法直接决定了代码能不能编译通过:

  • 受检异常(Checked Exception):编译器强制要求你处理,要么用 try catch 包住,要么在方法签名上写 throws。IOExceptionSQLException就是典型。
  • 非受检异常(Unchecked Exception):也就是RuntimeException及其子类,编译器不强制处理,NullPointerExceptionArrayIndexOutOfBoundsException每天都在制造麻烦。

这里有个容易混淆的点:RuntimeException本身也是Exception的子类,所以从继承关系上看,catch (Exception e)是能把RuntimeException底下的所有子孙全接住的。而ThrowableExceptionError共同的父类,所以catch (Throwable t)的范围要比catch (Exception e)大得多。

1.2 受检异常与非受检异常,编译器盯上的差别

受检异常的设计初衷是“强制程序员面对现实”。比如你写一句new FileInputStream("a.txt"),编译器知道这个操作可能文件不存在,于是强制你用 try catch 或者 throws 处理,否则直接编译失败。来看这段代码:

public void readFile() { // 编译报错:Unhandled exception type FileNotFoundException FileInputStream in = new FileInputStream("a.txt"); }

如果把方法签名改掉,加一个 throws,编译就过了:

public void readFile() throws FileNotFoundException { FileInputStream in = new FileInputStream("a.txt"); }

这种“强制约束”在当年被视为 Java 的优点,但实际用久了你就会发现,受检异常在大型项目里经常造成接口签名污染——一个底层方法抛出 IOException,上层每个方法都得跟着声明或者捕获,代码就会变得很啰嗦。所以后来很多框架(比如 Spring)干脆把底层异常都包装成RuntimeException往上抛,让开发者自己决定在哪一层处理。

理解这一点后,你再看 try catch 里的选择就会更清楚:catch (Exception)其实已经涵盖了业务代码里绝大多数会出现的异常,包括受检和不受检的;而你如果直接用catch (Throwable),等于把手伸到了 JVM 的急救室门口,连Error都要一并接管。

1.3 为什么说“继承关系”决定了catch的范围

在 Java 中,catch子句匹配异常时遵循一个简单的规则:只要抛出的异常对象是 catch 参数类型的本身,或者是它的子类实例,就匹配成功。所以:

  • catch (Exception e)能捕获IOExceptionNullPointerExceptionNumberFormatException等一切 Exception 子类;
  • catch (Throwable t)能捕获上面所有的,再加上OutOfMemoryErrorStackOverflowErrorNoClassDefFoundError等 Error 子类。

这个概念如果只停留在“继承”层面就太浅了。后面你会发现,JVM 的真实匹配流程还涉及字节码里的异常表,而且异常表的顺序很关键——如果把父类写在前面,子类的 catch 块可能永远等不到执行。这部分的细节我放到第3章讲,现在先把结论记住:catch 的参数写什么,决定了你“允许哪些问题从这里经过”;写的越宽,兜住的东西越多,但真正有用的信息也可能越稀薄。

2. try catch里写Exception和Throwable,代码上的实际差别

2.1 语法层面:编译器会不会放行

从编译器角度来说,catch (Throwable t)catch (Exception e)都是合法的,不会报错。二者的差异不会在语法检查阶段暴露出来,而是体现在捕获范围和运行行为上。有些同学为了省事,直接在项目里搜替换成catch (Throwable),代码确实能跑,但隐患非常大。

举一个最典型的例子:

public void doSomething() { try { // 业务逻辑 allocateMemory(); } catch (Throwable t) { // 这里连 OutOfMemoryError 都能接住 log.error("捕获到异常", t); } }

上方代码里,如果allocateMemory()真的抛出了OutOfMemoryError,这段 catch 会“成功”拦截住它。表面看起来系统很健壮,但这个线程的 JVM 可能已经内存告急,后续对象的创建大概率还会继续失败,更可怕的是程序不会立刻停止,而是一直处于一种“半死不活”的状态,运维监控一时半会儿还发现不了。

2.2 捕获范围:一棵树和一支树杈的区别

为了直观,我用一个表格把两个 catch 的差异固定下来,后面排查问题的时候可以直接对照:

对比项catch (Exception e)catch (Throwable t)
捕获 IOException、SQLException 等受检异常
捕获 NullPointerException 等运行时异常
捕获 OutOfMemoryError、StackOverflowError不能
捕获 NoClassDefFoundError不能
捕获 ThreadDeath不能
是否可能掩盖严重 JVM 级故障一般不会非常容易
是否符合阿里 Java 开发手册规范符合(建议再细化)违反“不要捕获 Throwable”

我们看到,catch (Throwable)catch (Exception)多出来的那部分,几乎全是 Error 家族。而 Error 的设计意图就是“让 JVM 处理,程序员不要管”。很多人没意识到,ThreadDeath也是 Error 的一种,它是Thread.stop()机制里用到的东西,捕获它可能导致线程终止逻辑被破坏,这在旧代码里甚至会成为很难排查的偶发 Bug。

2.3 直接catch Throwable会带来什么后果

后果可以总结成三类。

第一类是掩盖致命错误。内存溢出、栈溢出发生时,程序的状态已经不可信,你很难保证 catch 块里写日志的操作不会再次触发异常。就算日志写成功了,你看到的也只是“某行抛了 OOM”,但真正的根因——比如堆外内存泄漏、无限递归——已经被中断了,现场信息丢失。

第二类是违反团队规范,埋下维护地雷。当你写了一个catch (Throwable t)后,后面接手的同事往往不敢随便动这块代码,因为不知道当初为什么要兜这么大的范围。时间一长,这种代码就成了“为什么存活至今”的都市传说,谁都不敢改,谁也不敢删。

第三类是吞掉了本该由 JVM 抛出的异常信号。比如OutOfMemoryError抛出的时机通常意味着内存已经耗尽,此时最好的策略是快速失败(Fail-Fast)、保留现场、重启节点,而不是在 catch 块里做一些看似“自救”实则加剧问题的清理动作。

说到这里,插一句题外话:网上搜 Java 异常处理相关的问题时,经常能看到类似“unhandled exception: exception_access_violation reading address 0x0000000000”或者“I/O exception (java.net.SocketException) caught when processing request”这样的报错。这些东西之所以难排查,多半不是因为你 catch 写错了,而是因为捕获级别、日志记录和异常链路没有配合好。后面第4章我会专门讲怎么从日志里定位这类问题。

3. JVM层面异常匹配机制是如何运作的

3.1 字节码层面的异常表是怎么回事

很多 Java 程序员写了好几年 try catch,却不知道这段代码再 JVM 里到底怎么被执行的。其实,Java 源码被编译成 class 文件后,每个方法里会有一个“异常表”(Exception table),它决定了 try 块覆盖的字节码范围、catch 块入口,以及捕获的异常类型。

用一段简单的 Java 代码来做说明:

public void test() { try { risky(); } catch (Exception e) { handle(e); } }

对应到异常表的概念就是:

Exception table: from to target type 0 10 13 java/lang/Exception

这里的含义是:字节码偏移量 0 到 10 是 try 块的监控范围,如果这段范围内抛出的异常能匹配java/lang/Exception(包括它的子类),就跳到偏移量 13 的处理器继续执行。你写的catch (Throwable t)在异常表里对应的 type 就是java/lang/Throwable,匹配范围自然更大。

3.2 异常抛出后的匹配链:先子类后父类

了解了异常表,就能解释一个很重要的实践规则:多个 catch 块的顺序不能把父类放在子类前面。比如下面这段代码直接编译失败:

try { // ... } catch (Exception e) { // 父类 catch // 编译报错:已捕获的异常 Exception 不会抛出 NullPointerException } catch (NullPointerException e) { // 子类 catch 永远到不了 // ... }

原因很简单:一旦第一个 catch 的匹配范围完全覆盖了第二个 catch 的类型,第二个 catch 就成了不可达代码,编译器认为这是一个低级错误。正确写法是把子类放前面、父类放后面:

try { // ... } catch (NullPointerException e) { // 更具体的处理 } catch (RuntimeException e) { // 兜底运行时异常 } catch (Exception e) { // 兜底所有异常 }

匹配顺序实际上就是检查异常表里每个 handler 的顺序,第一个类型匹配成功的 handler 会被执行。这也是 JVM 层面的硬逻辑,不是谁想出来的风格建议。

3.3 finally在机制里的位置

finally 块在字节码层面也是通过异常表来实现的。JVM 会把 finally 块的逻辑复制到正常路径和异常路径的多个分支中,目的是确保无论是正常 return 还是抛出异常,finally 都会执行。这里有一个容易翻车的地方:不要在 finally 块里写 return

public int getValue() { try { return 1; } finally { return 2; } }

上方代码返回的结果永远是 2。因为 finally 在方法返回前会覆盖掉 try 里 return 的返回值,而且这种覆盖还会吞掉 try 或 catch 中抛出的异常,非常隐蔽。遇到这种代码我一般直接大改,因为它的行为完全不符合正常直觉。

从机制上说,finally 里如果抛异常,也会把原始异常顶掉。Java 1.7 之后引入了try-with-resources和“被抑制的异常”(SuppressedException)来解决资源关闭场景的异常丢失问题:

try (InputStream in = new FileInputStream("a.txt")) { // 使用流 } catch (IOException e) { log.error("读取文件失败", e); }

这种写法中,如果 try 块里抛异常,同时close()也抛异常,后者会被追加到e.getSuppressed()里,而不是把原始异常覆盖掉。这是日常开发里非常实用的一个特性,建议优先使用。

4. 生产环境实战:catch粒度怎么选,异常日志怎么记

4.1 常规业务的异常兜底策略

我自己的实践经验是:业务代码里优先捕获具体异常,一个 catch 块只处理一种类型的异常;在最外层入口(比如 Controller、MQ 消费者、定时任务入口)才做 Exception 级别的兜底。来看一个标准的写法:

public void processOrder(Order order) { try { orderService.validate(order); orderMapper.insert(order); messageSender.send(order.getId()); } catch (IllegalArgumentException e) { // 参数问题,返回给调用方提示信息 log.warn("订单参数不合法: orderId={}, msg={}", order.getId(), e.getMessage()); } catch (Exception e) { // 未知异常,必须留全堆栈 log.error("处理订单失败: orderId={}", order.getId(), e); } }

注意第二处 catch 用的是Exception而不是Throwable。这是底线。OutOfMemoryError这类问题应该交给 JVM 本身和监控系统去处理,而不是放在业务代码里“兜住”。

如果你用的是 Java 7 以上版本,多个不相干的异常可以写在一个 catch 里:

} catch (IOException | SQLException e) { log.error("数据访问失败", e); }

这样写的好处是避免重复代码,坏处是两个异常如果还需要不同的处理逻辑,混在一起反而别扭。我的习惯是:逻辑相同才合并,否则分开写。

4.2 什么场景需要处理Error级别的东西

说了半天别碰 Error,但 Error 也并非完全见不得人。少数场景下,你需要“观察”它,而不是“捕获”它。比如更后台线程里,你想记录一下 OOM 出现的现场:

try { backgroundWorker.run(); } catch (Throwable t) { // 唯一目的:记录日志后退出,绝不尝试恢复 log.error("后台任务发生严重错误,线程退出", t); throw t; }

这里有一个关键动作:记录后把异常重新抛出,让 JVM 的默认处理机制继续接管。如果你只记录不抛出,线程会安静地死去,监控很难及时感知。所以如果你真的因为某些原因暂时漏掉了一个 Throwable catch,请务必至少做到“日志完整 + 原样重抛”,而不是默默吞掉。

还有一个常见的认知误区:NoClassDefFoundErrorClassNotFoundException虽然名字很像,但前者是 Error,后者是 Exception。实际排查中,NoClassDefFoundError往往不是“类不存在”,而是“类在编译期存在、运行期加载失败”,典型的如静态初始化抛异常、依赖 jar 缺失。这种问题通过捕获它没有任何意义,要做的是检查 classpath、检查依赖版本、看ExceptionInInitializerError的堆栈。

4.3 日志记录与异常链条分析

很多线上问题难查的根因,其实是日志记得太糙。最常见的三个错误:

第一个错误,只记录e.getMessage()getMessage()可能返回 null,尤其是NullPointerException,它经常没有 message。正确的做法是把整个异常对象传给日志框架:

log.error("处理失败", e);

如果你用的是 SLF4J + Logback,这样写就能把完整堆栈打出来,附带参数占位符时也一样:

log.error("处理失败, orderId={}, userId={}", orderId, userId, e);

第二个错误,catch 里空着什么都不写。这就是所谓的“吞异常”,它比程序崩溃更危险,因为错误发生对你完全不可见。

catch (Exception e) { // 千万别这么写,坑人坑己 }

第三个错误,在 catch 里抛出一个新异常,把原始异常丢掉。比如:

catch (IOException e) { throw new BusinessException("文件读取失败"); }

除非你确定不需要把根因带给上层,否则一定要带上 cause:

throw new BusinessException("文件读取失败", e);

带 cause 的好处是可以保留完整的调用链,日志里能直接看到 Root Cause,排查效率高一个量级。关于异常链,Java 的getCause()方法就是干这个的,结合日志里的 “Caused by” 一段读,基本能快速定位到最初的问题源头。像我们线上遇到 “I/O exception (java.net.SocketException) caught when processing request” 这种网络层报错,如果不把 cause 链打出来,你可能看到的只是外层业务异常的一行标题,真正的连接重置原因全被藏住了。

5. 面试高频题与开发踩坑记录

5.1 try catch常见面试题速答

我在面试候选人时,只要对方写 Java,几乎必问异常处理相关的问题。下面整理了一批高频题和适合作为参考答案的要点:

1. Exception 和 Error 有什么区别?

Exception 是程序运行中可以恢复的异常,分为受检异常和非受检异常;Error 是 JVM 层面的严重错误,程序不应该尝试捕获和恢复。从继承关系上,二者都是 Throwable 的子类。

2. RuntimeException 和受检异常有什么区别?

受检异常在编译阶段强制处理,不处理过不了编译;RuntimeException 不强制处理,可以冒泡到最外层。设计受检异常是为了强制程序员处理可能失败的外部 IO 场景,但也容易造成接口签名污染。

3. catch (Throwable) 为什么不推荐?

因为会捕获 OutOfMemoryError、StackOverflowError 等严重错误,容易掩盖致命问题,让系统“带病运行”。阿里 Java 开发手册也明确规定不要捕获 Throwable。

4. try catch 里父类异常写在子类前面会发生什么?

编译报错。因为父类 catch 的捕获范围已经包含子类,后面的子类 catch 成了不可达代码。

5. finally 块里 return 会怎样?

try 块的返回值会被覆盖,而且会吞掉异常。强烈不建议在 finally 中 return。

6. try-with-resources 的实现原理是什么?

配合实现了AutoCloseable接口的资源类,编译器在字节码层帮你在 finally 里调用 close(),如果 try 块和 close() 都抛异常,后者会被抑制并放入e.getSuppressed(),保证原始异常不丢失。

7. NoClassDefFoundError 和 ClassNotFoundException 的区别?

前者是 Error,表示类在运行期动态链接失败;后者是 Exception,是类加载阶段找不到指定类。排查方向完全不同。

5.2 开发中遇到的典型异常问题与排查

日常开发中,异常相关的“怪现象”太多了,我从自己的踩坑经历里挑几个典型讲讲。

场景一:明明 catch 了,日志里却没有任何输出。这十有八九是空 catch 或者 catch 块里记录日志的方式不对。比如有人只写了System.out.println(e),在线上日志系统里根本捞不到。我见过一个系统,排查问题从下午查到晚上,最后发现是某个 catch 块里用了printStackTrace(),而生产环境根本没开 stdout 的日志采集。

场景二:错误被包了一层又一层,Root Cause 被淹没。这种问题最常见于跨模块调用。A 服务调 B 服务,B 抛异常,A 的 catch 里 new 了一个自己的异常抛出去,再被 C 服务接收又包一层,最后日志里全是中文提示信息,真正底层的 SocketTimeoutException 在 “Caused by” 第三层才露头。所以这时候,日志必须打完整堆栈,排查时从最内层 cause 开始往上看,效率最高。

场景三:捕获了受检异常,然后啥也没干。很多新同学在写一个不可能失败的操作时,为了通过编译强行 catch:

try { Thread.sleep(1000); } catch (InterruptedException e) { // 编译要求必须处理,但我知道这里不会中断 }

这种写法的问题在于,如果线程真的被中断,这个线程的中断状态已经被清除了,上层想感知中断就没戏了。正确的至少是恢复中断标志:

catch (InterruptedException e) { Thread.currentThread().interrupt(); // 再决定是退出还是忽略 }

这是并发编程里很容易忽视的细节,也是面试官很爱问的一个点。

场景四:异步任务里异常被吞。使用线程池时,execute()提交的任务如果内部抛异常,线程池默认会捕获并输出到Thread.getDefaultUncaughtExceptionHandler(),但如果你没配置这个 Handler,异常就悄无声息地丢了。相比之下,submit()返回的 Future 可以通过get()拿到 ExecutionException,但那也要求你去调用get()才会感知到异常。这也是“java线程等待都完成”类的需求里最容易踩的坑。

5.3 一套节选自查清单

最后我给你整理一份我每次 review 代码时都会用的自查清单,你可以直接存下来:

  • 最外层入口(Controller、MQ Consumer、定时任务入口)有catch (Exception)兜底,并在日志里完整打印堆栈。
  • 没有catch (Throwable),除非是记录日志并原样重抛的特殊场景。
  • 没有空 catch 或者只打印一行“出错了”但是不带异常的写法。
  • catch 块顺序:子类在前,父类在后。
  • finally 里没有 return,不主动吞异常。
  • 日志中打印异常时使用了log.error("描述", e)而不是e.getMessage()
  • 涉及 IO、资源流的场景优先使用 try-with-resources。
  • 多 catch 块里,相同处理逻辑的异常已经用 multi-catch 合并。
  • 异步任务内部异常已经考虑过,不会出现“静默失败”。

这份清单中的每一条,都是我实际踩过坑之后补充进去的。像“日志必须带完整异常对象”这一点,看起来简单,但真到线上查问题时就显得格外重要。尤其是在高并发生产环境下,一次错误日志如果只有一行 message 而没有堆栈,你要想还原调用路径,几乎等于大海捞针。

我个人现在写 catch 的习惯很简单:能多具体就多具体,该兜底才兜底,兜底也只用 Exception。想清楚 Throwable、Exception、Error 三者之间的关系之后,你会发现大部分异常问题都能在代码层预防掉。下次你再看到网上有人贴出一大段 catch (Throwable) 的代码时,至少可以一眼看出问题在哪,然后有理有据地给出修改建议。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询