
八股文系列Java中的异常和错误这次聊聊Java面试里永远绕不开的异常和错误。很多同学背了一大堆“Error和Exception的区别”真到了线上遇到java.lang.OutOfMemoryError: insufficient memory或者代码里冒出ArrayIndexOutOfBoundsException的时候还是会慌。我见过不少工作三五年的人能说出Error不用catch这种话但一问到“finally里return能不能覆盖try里的return”“try-with-resources的suppressed异常是怎么产生的”就卡壳了。这篇文章把异常体系、底层原理、常见面试题、生产排障一次讲透。适合准备Java面试的人也适合那些写了几年Java但一直没系统梳理过异常这块的开发者。不整那些虚的直接上干货从架构到字节码从面试题到线上案例全部过一遍。1. 先搞清楚异常和错误到底哪里不一样1.1 Throwable一家三口的定位谈异常之前得先明确一个概念Java里所有的异常和错误共同父类都是Throwable再往下分成两个大类Error和Exception。很多人一上来就背“错误是JVM出的问题异常是程序出的问题”这种说法方向对但不够准确。Error表示的是程序无法处理的严重问题比如OutOfMemoryError堆内存耗尽、StackOverflowError栈溢出、NoClassDefFoundError类定义找不到。这些问题的共同特点是通常不是代码逻辑能挽救的就算你catch住了应用大概率也处于不健康状态继续跑下去可能产生更严重的后果。Exception则分成两类一类是受检异常Checked Exception比如IOException、SQLException编译器强制你处理不写try-catch或者不往上throws编译直接失败。另一类是运行时异常RuntimeException也叫非受检异常Unchecked Exception比如NullPointerException、IllegalArgumentException、ArrayIndexOutOfBoundsException编译期不强制处理但运行时随时可能蹦出来。这里值得多说一句判断一个类属于哪一类本质取决于它继承的是Exception还是RuntimeException。比如你自己的业务异常YqException如果继承Exception那就是受检异常调用方必须处理如果继承RuntimeException那编译期管不着你。这个设计选择在后面的业务代码里非常关键。1.2 受检异常与非受检异常是故意折腾你还是有用意不少初学者对受检异常深恶痛绝觉得“我调一个读文件的方法凭什么必须处理IOException我把它throws掉不就行了”你先别急着吐槽受检异常的出现是有历史背景的。Java早期设计者认为某些异常是“可预见但容易忽略的”比如文件不存在、网络连接断开、数据库连不上。如果他们不强制你处理很多人会忘记判断这些情况程序静默失败后面排查起来更痛苦。运行时异常则对应的是“程序逻辑本身有Bug”。比如数组越界这属于代码写错了不该把这种错误处理职责强加给每一层调用方而应该让它在运行时暴露出来配合异常栈快速定位修复。所以真正规范的写法是外部环境因素导致的、调用方有可能恢复或替代处理的场景用受检异常代码缺陷、参数不合法、状态错误这类调用方通常无法恢复的场景用运行时异常。但现代Java开发里受检异常的使用比例在下降Spring等主流框架更倾向于非受检异常因为受检异常在Service层和Lambda表达式里非常碍手碍脚。这个在本文第4节会展开说。2. try-catch-finally这些老熟人你真的会用吗2.1 finally块里的return为什么是经典面试题先看段代码很多人第一眼觉得知道答案但真到了面试官追问每个细节时就会露馅public static int test() { int a 10; try { return a; } finally { a 20; } }这个方法返回值是多少10。很多人记过这个结论但不知道底层逻辑。Java在处理finally和return时会先把return后面的值计算出来放到一个局部变量或操作数栈里然后去执行finally块最后才把之前保存的那个值返回。也就是说finally修改a的值不会影响已经保存的返回值。但如果finally里直接写了return a那返回值就会变成20并且会吞掉try里原本要返回的结果。这个机制直接引出了生产环境的一个大坑永远不要在finally里写return。一旦finally里有returntry里catch到的异常会被完全吞掉调用方看到的是一段正常的返回值但实际业务逻辑已经出错了。这种问题非常隐蔽日志里什么都查不到只能靠代码review发现。我处理过一次线上事故某个定时任务每天凌晨悄无声息地失败排查了半天最后发现就是finally里有个return false把异常盖掉了。同样容易吞异常的还有finally块本身抛出异常。如果try块里已经抛了一个异常finally块里又抛了另一个新的异常会覆盖旧的原来的异常栈彻底丢失。这个问题JDK 7之前没有太好的解法只能靠你手动把两个异常拼接起来JDK 7引入try-with-resources时的suppressed异常机制才算从语言层面解决了它。2.2 try-with-resources正确用法的三个细节说起suppressed异常就必须聊try-with-resources。try (FileInputStream fin new FileInputStream(/tmp/a.txt); FileOutputStream fout new FileOutputStream(/tmp/b.txt)) { // 业务代码 }这个写法的好处是无论正常结束还是异常退出try块里的资源都会被自动close。它等价于一个隐式的finally close调用但比手动写finally更安全因为如果业务代码抛异常、close又抛异常两个异常不会互相覆盖——后抛出的close异常会被记录为“被抑制的异常”挂在主异常上。这里有一个细节值得注意资源关闭顺序是逆序的也就是后声明的先关闭。另一个容易被忽略的点是只有实现了java.lang.AutoCloseable接口的类才能放在try后面的括号里。还有一点如果资源类的close方法内部有逻辑依赖某种状态你在使用资源的过程中用完了它后续代码又继续用同一个对象不会自动帮你“变成新资源”该需要手动把资源置空还是得置空。很多老项目里ResourceLeak就是因为手动close时漏了分支。比如从数据库连接池拿了个Connection业务代码里套了两层try-catch内层return了外层的finally没走到close。换成try-with-resources之后这种问题迎刃而解。所以我的建议是只要Java版本允许所有带close方法的资源统一用try-with-resources不要再写finally close了。2.3 catch顺序规则与写异常处理的基本思路catch的匹配规则是自上而下的并且一旦匹配成功后面的catch就不再执行。所以父类异常不能放在子类异常前面。如果把Exception写在最前面后面的IOException、SQLException都是不可达代码编译直接报错。实际操作中还有一个常见误区把catch写的很大直接catch(Exception e)然后打一行日志完事。这种写法会让代码看起来“稳定”但掩盖了真实问题。尤其在一些框架封装层粗粒度的catch会把业务异常、框架异常、系统异常全部混在一起后面定位问题时非常痛苦。正确做法是能精确到具体异常类型的就精确到具体类型别图省事用 Exception 兜底。如果是自己封装的Service层尽量统一抛业务异常继承RuntimeException在全局异常处理器中统一捕获避免每个Controller都写一堆try-catch。捕获异常后要么处理、要么抛出去不要catch了却什么都不做。你哪怕打一条warn级别日志都比静默吞掉好得多。还有个老生常谈的点catch块里不要用email发送异常栈不要在循环里做耗时操作也不要把异常栈打印到控制台却不进日志文件。线上排查主要靠日志printStackTrace在标准输出里配置不好的项目根本看不到。3. Error家族不只是outofmemoryerror3.1 OOM的五种形态与排查方向一说到错误大多数人第一个想到的就是OutOfMemoryError。但OOM其实不是一个单一场景JVM在不同的内存区域抛出OOM排查方向完全不同。java.lang.OutOfMemoryError: Java heap space最常见堆内存不够。可能是大对象太多、内存泄漏、GC回收不掉。排查方向是先看JVM堆参数-Xmx是否合理再dump堆快照分析对象引用。java.lang.OutOfMemoryError: GC overhead limit exceededGC回收效果差一直在Full GC但堆始终无法释放JVM的保护机制直接抛错。这种情况基本就是堆太小或者内存泄漏严重。java.lang.OutOfMemoryError: Metaspace元空间不够。常见于动态生成类、CGLIB代理大量使用、热部署不当。调大-XX:MaxMetaspaceSize只能缓解根治要看类加载器的生命周期。java.lang.OutOfMemoryError: unable to create new native thread操作系统线程数上限被耗尽。这个和JVM堆大小无关往往是线程池无限创建、或者操作系统ulimit限制。java.lang.OutOfMemoryError: Direct buffer memory直接内存堆外内存不够典型场景是Netty用了很多DirectByteBuffer。有人说“OOM不能catch”这个说法其实不准确严格说OOM是可以catch住的但catch住通常没意义。JVM已经到了内存耗尽的地步你catch住后立刻去申请内存大概率还是失败反而可能让应用陷入半死不活的状态连日志都打不出来。生产环境处理OOM的正确思路是提前设置JVM参数-XX:HeapDumpOnOutOfMemoryError让JVM在OOM发生时保留现场然后对dump文件做分析。我见过一个很有意思的误判案例某服务报java.lang.OutOfMemoryError: insufficient memory第一反应是加内存结果加了之后依然报错。后来发现原因是这台机器本身swap分区已满操作系统无法给JVM分配更多内存根本不是堆参数的问题。这提醒我们看到OOM先别急着调堆先看是哪个内存区域报的错再看操作系统层面的可用内存。3.2 StackOverflowError是递归的“礼物”StackOverflowError也是高频出现的错误。大多数情况下是无限递归导致的比如一个方法没有正确的退出条件或者递归深度超过了JVM默认的栈容量。这里有个细节值得想一下为什么是Error不是Exception因为线程栈溢出往往说明代码有严重缺陷线程栈都已经不够用了继续执行任何操作都可能失败。虽然你也能catch住它但几乎没有实际意义正确做法是修代码。我实际排查过的一个案例一个树形结构的递归转JSON接口树深度超过一万层直接StackOverflowError。后来把递归改成显式栈Stack对象加迭代问题就解决了。这不是JVM参数能解决的问题调-Xss只能延后崩溃点并不能根治。调大-Xss值有时也是必要的比如某些深度递归算法本身就是合理的但一定得先评估线程数量和总内存开销。每个线程都会占用栈内存如果-Xss设得过大线程数一多反而会引发其他OOM。生产环境一般1MB左右就够了特殊场景再针对性调大。3.3 Error到底要不要捕获老规矩不要求捕获但也不是绝对禁止。在极少数场景下Error确实值得处理。比如某些Third-party组件在初始化时可能抛NoClassDefFoundError而你的服务有降级逻辑可以catch住后走备用方案。另一个例子是LinkageError版本冲突时可能出现你在框架里做兜底时可能想catch住打印详细日志方便排查。但要注意的是如果你决定catch Error或Throwable务必打全异常栈并且要让监控系统感知到。静默处理Error是很危险的事情因为程序可能已经处于数据不一致的边界状态。4. 异常在JVM和编译器层面是怎么运作的4.1 用javap看异常表try-catch的底层真相很多Java工程师写了几年代码从来没看过编译后的字节码。这部分我们花点时间看看try-catch在JVM里到底是怎么实现的。写一段简单的代码public class ExceptionDemo { public void test() { try { Thread.sleep(1000); } catch (InterruptedException e) { System.out.println(interrupted); } } }编译后用javap -v查看你会看到方法里除了字节码指令之外还有一个Exception table异常表。它记录了每个try-catch块的起止字节码偏移、异常类型处理器偏移。JVM在执行期间并不像我们想象的那样“逐条扫描catch”而是靠异常表做快速匹配。这意味着什么如果try块内没有抛出异常catch逻辑完全不参与执行性能开销几乎可以忽略。所以不要再说什么“用try-catch会影响性能”就不敢用。真正影响性能的是异常对象的创建过程也就是fillInStackTrace填栈时需要快照当前线程的调用栈这一步非常耗时。这也是为什么有人在高性能场景下用“异常代替流程控制”会导致性能雪崩。4.2 受检异常和Lambda的别扭关系CompletableFuture与LombokJava的受检异常在设计上有个尴尬之处很多函数式接口比如Function、Consumer的方法声明里根本没有throws子句这意味着你不能在Lambda里直接抛受检异常。举个例子CompletableFuture.supplyAsync(() - { // 调一个声明了throws IOException的方法 // 编译不过 });这正是热搜词里“CompletableFuture异常后不在执行其他的异步任务”背后的痛点之一。很多人会用一个try-catch把受检异常包成RuntimeException再抛出去。但这样处理之后异常信息要等到调用future.get()时才会被抛出而且如果你没对CompletableFuture做exceptionally处理异常会被静默埋掉整个异步链路就断了。我个人推荐的做法是在异步任务的最外层统一包try-catch显式记录日志并做补偿。不要指望异常能自动传播到主线程。CompletableFuture提供了exceptionally和handle方法但很多人压根不用导致异步任务失败后完全无感知这是非常危险的。另一个和受检异常较劲的方案是Lombok的SneakyThrows。它能在编译期把受检异常“偷渡”出去让你不用写catch或者throws。看起来很方便但风险也在这里热词里提到的“you arent using a compiler supported by lombok”就是版本不匹配导致的编译异常本质是Lombok依赖了内部编译器APIJDK版本升级后可能失效。我建议团队里少用SneakyThrows它会绕过编译器的受检异常检查让调用方失去对异常风险的感知。4.3 异常的性能损耗到底在哪很多人说“Java异常性能差”但其实要分开看。正常流程下try-catch几乎零开销。如果代码里没有异常抛出JVM不会进入异常处理流程。真正的性能杀手是异常对象创建的时间成本。每一次new异常对象都会自动填充堆栈轨迹StackTraceElement数组的长度取决于调用栈深度。有人统计过一个包含几十层调用栈的异常创建耗时可能是普通对象创建的几十到几百倍。所以在高并发场景下如果你用异常来控制业务流程比如“查到数据为空就抛一个NoDataException然后catch住做兜底”这种写法非常危险一旦QPS上去异常对象创建会瞬间占满CPU。正确的做法业务分支用if判断断言用assert或者显式检查参数校验返回错误码不要用异常参与正常流程。但反过来说如果异常出现的频率本来就不高比如每千次请求只会出现一两次那么使用异常完全没问题可读性还更好。我自己写的代码状态判断用if真正无法继续执行的场景才抛异常。5. 面试高频题与生产排障实录5.1 深度八股六道必问面试题以下是面试官最爱问的异常相关问题我按常见程度排个序顺便把考察点标出来。第一题Error和Exception的区别是什么考察的是你对Throwable体系的理解以及面试官是否引导你聊受检/非受检异常。第二题finally块一定会执行吗答案是不一定。如果JVM退出System.exit、线程被终止、或者try块里发生了无限循环让程序卡死finally不一定会执行。这里的“不一定”考验的是你对JVM执行模型的认知而不是背结论。第三题try里的return和finally里的return哪个生效这个上文已经解释过注意把“return表达式先求值再执行finally”这个底层流程讲清楚。第四题try-with-resources里suppressed异常是怎么产生的如何获取你需要说出来如果try块抛出主异常同时close方法也抛异常close的异常会被标记为suppressed附加在主异常上通过Throwable.getSuppressed()可以拿到它。第五题说一下常见的运行时异常有哪些ClassCastException、NullPointerException、IndexOutOfBoundsException、IllegalArgumentException、UnsupportedOperationException、ConcurrentModificationException都要能举出实际触发场景。第六题是否捕获过OOM如何排查这道题考察的是实战经验。你要答出HeapDumpOnOutOfMemoryError、jmap、jstack、MAT、GC日志等工具的使用不是背参数而是要讲出排查路径。还有一道比较阴的题catch块里打印异常日志为何要避免e.printStackTrace()因为printStackTrace打印到标准错误输出普通日志框架不一定能采集到生产环境经常丢日志。正确做法是用日志框架的error(..., e)方法这样exception对象和堆栈能完整保留在日志文件里。5.2 看异常日志的正确姿势cause by、suppressed、幂等重试生产环境里最常见的问题不是没打日志而是不会看日志。异常栈非常长很多人只看第一行就发群里求助忽略了下层cause by所揭示的真因。这里教大家一个心法出现异常信息先从头读到尾找出被Caused by引导的部分。cause by的后续堆栈往往才是异常真正的根源。比如你看到一个InvocationTargetException表面上是反射调用异常往下翻Cause by才发现里面是一个NullPointerException。反射包了一层导致根因被藏住了。另一个值得强调的是suppressed异常。它出现在主异常后面的Suppressed:标记里很多人误把它当普通堆栈忽略掉但它在资源关闭场景里经常是定位问题的关键信息。遇到Suppressed要知道它是被抑制掉的辅助异常不能忽略。线上排查还离不开幂等重试机制。很多从异常中恢复的场景比如网络抖动、数据库连接超时、第三方接口暂时不可用需要重试。但重试一定要有策略固定次数重试超过次数后直接失败别无限循环。重试间隔要有退避别在服务端已经过载的情况下盲打。读操作可以放心重试写操作必须保证幂等性否则重复提交会造成脏数据。比如用Spring Retry或者Resilience4j把重试、熔断、降级都配好。异常处理不等于“catch一下算了”而是要设计一套从异常发生到恢复的完整策略。5.3 和生产事故赛跑从异常信息快速定位到代码行我最后分享一个排查异常的思路保证每一步都有明确产出。第一步看异常类型。比如ArrayIndexOutOfBoundsException先确认是操作数组还是List再定位到具体文件行号。如果日志被异步刷盘或者线程池吞掉没有行号那就得靠线程名和业务标识去回溯。第二步看异常信息中的关键ID。很多时候异常本身不足以定位问题但异常信息里带着订单号、用户ID、traceId通过这些标识去链路追踪系统比如SkyWalking、Zipkin里查完整调用链。第三步结合上下文日志。异常前后的INFO日志经常会给你线索不要只看错误那一行。第四步如果异常可复现尽量本地跑一个最小复现Demo比对着生产环境猜要快得多。我见过太多人一收到异常报警就上服务器dump线程、杀进程、加内存搞了半小时发现只是某个字段为空导致NPE。冷静分析异常栈比盲目扩大资源更有效率。写在最后的一点个人体会异常和错误这块内容看起来就是几个关键字的事真正用好了能避免生产环境非常多的隐蔽故障。我自己的习惯是代码里绝不吞异常拿不准的时候就往日志里打catch的粒度宁小勿大所有的网络调用和资源操作都考虑好失败后的补偿路径每个异步任务都有兜底日志。做到这几点你线上代码的稳定性会比很多人强出一截。如果你正在准备面试希望这篇能帮你把零散的知识点串成体系。面试官不会只问你“Error和Exception的区别”他更想看到你能从字节码层面讲异常表、从生产环境讲排查路径、从设计原则讲受检与非受检的取舍。把这几层打通了异常相关的八股文就不只是背出来的答案而是你实实在在的经验。