尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Java异常处理:catch里写Throwable和Exception有什么区别?

Java异常处理:catch里写Throwable和Exception有什么区别? 写了好几年Javatry catch 这玩意儿几乎天天见但真要问一句“catch 里写 Throwable 和写 Exception 到底有什么区别”很多人会愣一下。毕竟两种写法都能通过编译业务代码跑起来好像也没什么不同。不过要是把这个坑留到线上代价通常不小——轻则异常被莫名吞掉重则 JVM 已经内存溢出了程序还在那“带病运行”日志里却什么都查不到。这篇文章就把这件事彻底讲透。我会从 Java 异常体系的继承结构说起结合 JVM 字节码层面的匹配机制再落到生产环境里到底该怎么选捕获粒度最后整理一批面试和实际开发里高频遇到的相关问题。无论你是刚学 Java 基础的新手还是准备 java 面试八股文的老兵都能从这里拿到可以直接用的结论和写法。1. 先把Java异常体系这张地图画清楚1.1 Throwable、Exception、Error到底谁是谁Java 里所有能被抛出、能被捕获的“东西”共同的祖宗只有一个就是java.lang.Throwable。它下面直接分了两大阵营Exception和Error。这个结构很多教程里都画过但真正理解它的人不多我先把这张图用文本方式放出来Throwable (java.lang) ├── Error │ ├── OutOfMemoryError │ ├── StackOverflowError │ ├── NoClassDefFoundError │ ├── LinkageError │ └── ... └── Exception ├── RuntimeException │ ├── NullPointerException │ ├── IllegalArgumentException │ ├── IndexOutOfBoundsException │ ├── ClassCastException │ └── ... └── 受检异常 (Checked Exception) ├── IOException ├── SQLException ├── FileNotFoundException ├── ClassNotFoundException └── ...Error这一支专门用来表示 JVM 层面的严重问题。OutOfMemoryError、StackOverflowError都属于这一类它们的特点是发生了之后JVM 本身可能已经处于不稳定的状态你再怎么 catch 也救不回来强行继续执行反而会让系统更糟糕。Exception才是我们日常打交道最多的类型它再往下分两类这个分法直接决定了代码能不能编译通过受检异常Checked Exception编译器强制要求你处理要么用 try catch 包住要么在方法签名上写 throws。IOException、SQLException就是典型。非受检异常Unchecked Exception也就是RuntimeException及其子类编译器不强制处理NullPointerException、ArrayIndexOutOfBoundsException每天都在制造麻烦。这里有个容易混淆的点RuntimeException本身也是Exception的子类所以从继承关系上看catch (Exception e)是能把RuntimeException底下的所有子孙全接住的。而Throwable是Exception和Error共同的父类所以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)能捕获IOException、NullPointerException、NumberFormatException等一切 Exception 子类catch (Throwable t)能捕获上面所有的再加上OutOfMemoryError、StackOverflowError、NoClassDefFoundError等 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请务必至少做到“日志完整 原样重抛”而不是默默吞掉。还有一个常见的认知误区NoClassDefFoundError和ClassNotFoundException虽然名字很像但前者是 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(文件读取失败); }除非你确定不需要把根因带给上层否则一定要带上 causethrow 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 开始往上看效率最高。场景三捕获了受检异常然后啥也没干。很多新同学在写一个不可能失败的操作时为了通过编译强行 catchtry { 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) 的代码时至少可以一眼看出问题在哪然后有理有据地给出修改建议。
返回列表