☰
Java异常影响性能?JVM异常表与JMH基准测试拆解真实开销
2026/10/2 3:47:39 网站建设 项目流程

开头段落先讲个我实际遇到的场景。去年做代码评审,看到一位同事把一段业务逻辑从 try/catch 写法改成了返回值码 + if 判断,理由是“异常会影响性能”。我当时让他别急着交,先回答一个问题:你优化的是 try/catch 本身,还是异常对象创建和栈填充的成本?他没答上来。后来我们用 JMH 和线上数据把这事彻底测了一遍,结论比他预想的有意思得多。这个话题也是我每次做 Java 性能排查、给团队做分享时必聊的内容,甚至好几回被问到“Java 异常到底影响性能吗”,基本上属于 Java 进阶绕不开的基础问题。

这篇文章不打算给你一个“有异常就慢、没异常就快”的简单结论。我会从 JVM 异常处理的底层机制讲起,然后给出可复现的基准测试思路和结果,再结合业务场景说清楚什么时候该担心异常开销、什么时候完全不用操心,最后聊一些实际工程里的调优经验。

1. 先搞清楚网上流传的“异常性能差”到底指什么

1.1 try-catch 语句本身贵不贵

很多 Java 开发者第一次接触性能优化时,都会听到类似“不要用 try/catch”“异常会拖慢系统”的说法。这个结论不能说是错的,但被严重简化了。最关键的是,它把两件事混在了一起:一个是 try-catch 这个语法结构本身的开销,另一个是“创建一个异常对象并抛出”的开销。这两者在 JVM 里的成本差异是数量级的。

先看 try-catch 本身。Java 编译器处理 try-catch 时,并不会在进入 try 块之前做任何特殊的“注册”或“备份”操作。它在字节码层面做的事,主要是生成一个异常表,然后正常执行 try 块的字节码。异常表里记录的是“哪段字节码范围对应哪个 catch 块”,类似一张查询清单:如果字节码执行到某个位置抛出了特定类型的异常,JVM 就去查这张表,找到对应的 handler 跳转执行。

所以,只要 try 块里没有真正抛出异常,catch 块就不会参与执行。现代 JVM 的 JIT 编译器会把这层逻辑优化得很干净。我之前做过一次很粗糙的循环测试,单个方法里用 try-catch 包住一段正常的加减运算,和不加 try-catch 直接运行,性能差距几乎落在噪声范围内。后来用 JMH 更严格地跑,结论也一样:try-catch 的正常路径,开销趋近于零。

1.2 昂贵的是 “创建并抛出异常” 的那一下

既然 try-catch 本身不贵,那“异常影响性能”这句话到底在说什么?真正贵的是throw new XxxException(...)这个动作,尤其是异常生命周期里要做的事。

Java 的异常对象本质上是一个普通对象,但它和普通对象有一个关键不同:构造时默认会调用fillInStackTrace()。这个方法会从当前线程的栈帧开始挨个遍历,把方法名、类名、文件名、行号等信息收集起来,形成一串栈回溯。这个东西就是我们平时在日志里看到的那一段at com.xxx.service.Yyy.method(Yyy.java:123)。收集这串信息需要访问栈帧,涉及底层 JVM 的栈遍历逻辑,代价比普通对象分配要高得多。

更麻烦的是,throw出去之后,JVM 还要做异常处理器查找。如果同一个方法里就有匹配的 catch 块,那还好;如果当前方法没有,就得一层一层往上展开栈,逐帧查找,直到找到处理器或者把异常抛到线程边界。栈展开过程中,已经内联过的 JIT 代码可能需要解除内联,编译器做过的某些优化也可能失效。这意味着,异常一旦真正抛出来,可能带来一连串连锁反应。

所以我一般跟团队说一句话:try-catch 是买保险,不真正赔付的时候几乎不花钱;throw new 是出险,你报一次案,保险公司要跑一堆现场、做一堆调查,成本取决于案情复杂度和出险频率。

2. JVM 处理异常的真实路径:异常表、方法内联和栈深度

2.1 从字节码角度理解 JVM 怎么找到 catch 块

把一个简单的 try-catch 方法用javap -c反编译,你会看到方法属性里有一个ExceptionTable。它长得很像这样:

public static void demo() { try { maybeThrow(); } catch (IllegalStateException e) { handle(); } }

反编译后的核心结构大概是:

Exception table: from to target type 0 5 8 Class java/lang/IllegalStateException

这里的from、to表示 try 块对应的字节码偏移范围,target是 catch 块的入口,type是捕获的异常类型。JVM 在运行期遇到异常抛出时,会带着异常类型去查这个方法对应的异常表。如果当前字节码偏移落在某个from到to的区间里,而且异常类型匹配,就直接跳到目标位置执行 catch 逻辑。

如果当前方法没有匹配的条目,JVM 会弹出当前栈帧,回到调用者,继续查调用者的异常表。这个往上寻找的过程就是“栈展开”。注意,栈展开不是简单地读几个字段,它需要维护执行状态的一致性,尤其是 JIT 编译过的方法,可能还需要回退到解释执行状态。这也是为什么“异常抛出”和“普通返回值判断”之间存在本质差异:普通 if/return 走的是普通的执行流,而异常走的是 JVM 专门为“意外情况”设计的一条旁路。

2.2 栈深度会放大异常开销

理解了栈展开的逻辑,就能明白一个很重要的结论:异常的开销和调用栈深度强相关。

fillInStackTrace()要遍历当前的栈帧,栈越深,要记录的帧越多,构造异常对象就越慢。栈展开也一样,方法调用链越长,JVM 要逐层往上找处理器的次数就越多。我在实际代码里经常看到的一种“性能炸弹”是:一个很深的业务调用链,底层某个工具方法里频繁 throw 异常,然后被最外层统一 catch。单个异常可能花几微秒甚至几十微秒,表面上看起来不多,但如果是用户请求打进一个热点路径,每秒触发几千次、上万次,这些开销会立刻变成可观测的性能问题。

这里有一个生活化的类比。普通返回码就像你给同事递了一张纸条:“今天这事没成,原因码是 7”。异常则像你用力拍了一下桌子,整个办公室的人都转头看你,然后你还要把前因后果跟领导讲一遍。偶尔拍一次桌子没关系,但如果你把拍桌子当成正常沟通方式,那管理成本就失控了。线程栈就是你的“办公室规模”,办公室越大,围观群众越多,讲因果就越费劲。

3. 用 JMH 实测异常开销:别凭感觉下结论

3.1 为什么性能问题要靠基准测试而不是拍脑袋

很多人争论异常性能问题时,靠的是臆测。有人觉得“既然异常要遍历栈,那肯定慢了十倍”,有人觉得“JVM 会优化,根本不影响”。要打破这种争论,唯一可靠的方式是测。但因为异常涉及 JIT 编译、预热、栈深度、异常对象的创建时机,普通 main 方法循环测很容易被 JIT 的“首次很奇怪”现象污染,比如你测出来第一次抛异常很慢,后面几千次又变快了,你都不知道该信哪个。

所以我建议直接用 JMH 做微基准。JMH 是 OpenJDK 官方出的微基准测试框架,它会帮你处理预热、fork、黑盒优化消除等问题。下面这组测试我是在 JDK 17、JMH 1.37 环境跑过多次的,不同机器数值会不一样,但趋势基本一致。

3.2 四组对照实验的设计与结果

我设计了四个场景来拆解异常的性能影响:

  • A:普通方法返回,用 if 判断错误码,完全不走异常。
  • B:try-catch 包裹正常路径,但内部不抛异常。
  • C:浅栈方法里抛出异常并立即捕获,栈深度只有两层。
  • D:深层递归方法里抛出异常,栈深度约 50 层,再捕获。

下面是我用的测试方法核心代码,简化掉了 JMH 的注解和预热配置,不过思路很清楚:

@Benchmark @BenchmarkMode(Mode.AverageTime) @OutputTimeUnit(TimeUnit.NANOSECONDS) public int normalReturn() { int code = doWork(); if (code != 0) { return -1; } return code; } @Benchmark @BenchmarkMode(Mode.AverageTime) @OutputTimeUnit(TimeUnit.NANOSECONDS) public int tryCatchNormal() { try { return doWork(); } catch (IllegalStateException e) { return -1; } } @Benchmark @BenchmarkMode(Mode.AverageTime) @OutputTimeUnit(TimeUnit.NANOSECONDS) public int shallowThrow() { try { throw new IllegalStateException(); } catch (IllegalStateException e) { return -1; } } @Benchmark public int deepThrow() { try { return recursiveThrow(50); } catch (IllegalStateException e) { return -1; } } private int recursiveThrow(int depth) { if (depth == 0) { throw new IllegalStateException(); } return recursiveThrow(depth - 1); }

为了防止 JIT 把异常创建直接优化掉,我在基准方法上通常会加一点黑盒技巧,比如把异常对象里的栈信息用Blackhole.consume消费掉。下面是我机器上测出来的典型趋势,时间越小越好:

场景典型耗时相对差距
普通返回 + if 判断约 2-4 ns基准
try-catch 正常路径约 3-5 ns几乎无差别
浅栈抛出并捕获约 300-600 ns慢百倍左右
深栈抛出并捕获约 10-50 us 甚至更高慢数千倍

这个结果可以说是对“异常性能”最直接的解释。try-catch 本身不是瓶颈,真正慢的是“创建 + 抛出 + 栈展开”,而且栈越深越离谱。

3.3 实测中发现的反直觉现象

跑 JMH 的过程中有几个现象值得单独拿出来说,因为它们几乎改变了我在团队里定下的异常使用规范。

第一个现象是 HotSpot 的OmitStackTraceInFastThrow优化。这个优化默认是开启的,当 JIT 发现同一个代码点频繁抛出同一类异常时,它可能不再为后续异常填充完整的栈跟踪。所以你会在实际运行中看到一种奇怪表现:某个异常第一次出现时日志里带完整堆栈,后面再刷屏时堆栈变成了一段很短的话,比如只写了java.lang.IllegalStateException,下面没有at行。很多人以为日志框架丢了堆栈,其实是 JVM 为了性能主动把栈跟踪省略了。

第二个现象是浅栈异常的开销并没有想象中那么可怕。几百纳秒这个级别,在低频业务里根本不需要关心。比如一个接口每秒调用 10 次,偶发一次异常,即使每次多花 500 纳秒,也完全不会形成性能瓶颈。很多“异常性能问题”其实是被日志输出、磁盘 IO 和 GC 放大出来的,不是异常本身。

第三个现象是 JIT 层面可能对不逃逸的异常对象做优化。如果异常对象没被存到字段里、没被返回、没被写进日志,只是单纯地创建一次然后 catch 住,编译器理论上可能省略掉一些栈跟踪填充。这也是为什么实际业务里异常开销往往比微基准里看到的要复杂:真实代码里的异常对象经常要记录下来,逃逸分析没法帮你省掉成本。

4. 业务代码里真正让异常拖垮性能的三种模式

4.1 把异常当成正常业务分支用

这是我在各种 Java 项目里最常见的性能问题根源。最典型的就是用NumberFormatException判断字符串是否是数字,或者用数组越界异常控制边界,甚至有人用ClassCastException做类型判断。这类代码在功能上能跑,在性能上则非常不划算。

举个例子,判断一个字符串能不能解析成整数。用异常实现的代码很容易写:

public static boolean isInteger(String s) { try { Integer.parseInt(s); return true; } catch (NumberFormatException e) { return false; } }

如果这个方法在一个高吞吐的解析模块里被调用,比如每秒钟处理几万条订单数据,而其中大量输入是非法格式,那么 JVM 会疯狂地创建异常对象、填充栈跟踪、捕获,仅仅是为了得到一个 boolean。改成手写字符遍历或者用一个正则预编译判断,性能差距会非常明显。

这类问题的特点不是“不能用异常”,而是“异常发生概率过高且频繁被触发”。我的习惯是:如果某个异常在正常业务流里的触发率超过 1%,那就要怀疑是不是拿异常当分支了。正确做法是把这个分支显式化,比如返回 Optional、返回错误码、用枚举状态,或者用正则提前校验。

4.2 热点循环和锁内抛异常

第二种容易踩坑的场景是循环体内抛异常。假如在一个 for 循环里处理一万条记录,每条记录都可能触发一次异常,那这一万次异常的成本就是线性叠加。再加上循环内部如果还有深层调用,栈越深,单次异常就越贵。这种代码往往在压测时数据一上来就立刻暴露问题。

相比之下,把异常处理从循环里挪到循环外,只能省掉重复的异常表查找成本,但没法省掉异常创建成本。更推荐的做法是:在循环内先做低成本的显式校验,把确实异常的、极低概率的情况才交给异常处理。也就是说,异常机制是给“意外”留的通道,而不是给“常态”留的通道。

锁内抛异常是另一个容易被忽略的问题。异常抛出的瞬间,如果当前线程持有了锁,它会带着锁进入异常处理流程。如果 catch 块里又做了较重的逻辑,锁的持有时间会被明显拉长,其他线程都在等锁,吞吐量自然掉下去。即便 catch 块很轻,异常栈遍历也仍然发生在持锁状态下。所以高并发共享资源临界区里,我建议尽量不用异常来做业务状态流转。

4.3 捕获后打印栈或把异常当数据到处传递

第三种问题看起来不是性能问题,但在线上往往比异常本身更难处理:捕获异常之后打印堆栈。

很多人习惯在 catch 块里直接e.printStackTrace()或者LOG.error("xxx", e)。如果这个异常只是偶发,那没问题,日志记录本身就是必要的。但当一个异常在热点路径上高频出现时,全量堆栈输出会带来几个连锁问题:

  • 日志文件体积暴涨,磁盘 IO 被拖慢。
  • 日志采集程序要不断解析、传输、入库,网络和 CPU 都有额外开销。
  • 栈跟踪字符串频繁创建,产生大量短生命周期对象,推高 GC 压力。
  • 如果异常对象本身被存到 List、Map 或者消息队列里继续传递,那些栈引用会一直存活,导致内存占用升高。

我之前排查过一个线上故障:一台服务 CPU 不算高,但 GC 非常频繁。后来用内存分析工具一看,堆里有大量异常对象,原因是某条风控规则在每次请求进来时都抛一次BizException,然后把异常连同完整堆栈一起塞进了一个待处理队列,后续模块又从队列里取出来逐条打印。功能是通的,但整体开销比直接返回结果对象高了几个数量级。

后来怎么改的?把这条规则改成返回一个状态码,真实需要告警的失败分支才抛出异常,并且只记录异常类型和信息,不再记录完整堆栈。改造后 GC 频率直接下降了一个级别。

5. Java 异常性能优化实操清单和边界

5.1 低频异常别乱动,保留语义更重要

先给一个反直觉的建议:如果你的业务代码里异常触发率很低,比如一个月也就几次,那不要为了性能去改造它。异常在这里提供了非常清晰的控制流和错误语义,调试时看一眼堆栈就知道问题在哪。为了“可能存在的性能问题”把它改成错误码体系,反而会降低代码可读性,甚至引入更隐蔽的 bug。

性能优化的第一原则是测量,第二原则是只优化真正的热点。如果一个异常路径既不是热点,也没有明显性能问题,那它就不是问题。我在评审代码时看到有人把正常的业务异常从Exception改成RuntimeException,或者把自定义异常改成返回 null,理由是网上说“运行时异常性能更好”。这种说法没有实际依据,异常类型和性能之间没有直接关系。

5.2 热点路径上如果必须抛异常,可以怎么做

有些场景确实没办法彻底避免异常,比如某个第三方库底层就是用异常做错误通知。这时候能做的是把异常尽量控制在浅栈位置,也就是尽早 catch,不要让异常一路穿过多层业务方法。

我可以接受下面这种写法:底层接口方法声明了throws XxxException,但是调用方立刻在同一层 catch,转换成返回值或者业务状态。这种做法的额外成本主要是异常创建和栈填充,但栈深度浅,开销可控。相比之下,我在一些老代码里看到过异常从 DAO 层一路抛到 Controller 层,中间经过五六次包装,每次包装还addSuppressed或者把原始异常继续往上抛,等它到达全局异常处理器时,栈跟踪已经非常长。这既影响性能,也让问题定位变困难。

另外,如果确认某个异常会高频出现,而且不需要栈跟踪,可以考虑关闭OmitStackTraceInFastThrow吗?我的建议是保持默认开启。这个参数不是用来“提升性能”的开关,而是 HotSpot 默认的一个保护性优化。生产环境如果特别依赖完整异常栈来排查问题,又发现异常出现得极其频繁,更应该做的是降低异常频率,而不是为了拿完整栈这个结果去牺牲全局性能。

5.3 关于日志、全局异常兜底和异步线程的注意点

工程上还有一个不成文的约定:业务方法里的异常,能往外层抛就往相对统一的地方抛,不要在每一层都打印一遍。全局异常处理器负责统一记录日志、转换响应,这样日志不会重复,开销也可控。但全局兜底的地方,日志级别建议区分错误级别,别把所有异常都打成 ERROR。一些可预期的业务失败,用 INFO/WARN 记录就够,完整堆栈可以在 DEBUG 级别保留。这个看起来是日志规范,实际上对异常性能影响很大,因为堆栈字符串生成和落盘成本是实打实的。

如果你在异步线程池里抛异常,还要注意线程栈可能不浅。比如某个任务是在一个很深的事务链里异步触发的,栈深度可能比普通 web 请求更深。这时候异常对象的栈跟踪收集成本会更高。所以异步任务入口通常会做一层明确的 catch,先把结果落成状态对象,再决定是否需要抛出。

提示:JVM 参数-XX:-OmitStackTraceInFastThrow可以关闭“快速异常抛出”优化。生产环境默认不建议动它。如果你真的需要在异常刷屏时看到完整堆栈,优先做异常降频,而不是改全局 JVM 参数。

6. 最后再聊聊代码评审和面试里的异常性能问题

这个话题几乎每次做 Java 面试复盘时都会被翻出来。面试官如果问“Java 的异常影响性能吗”,一份比较完整的回答思路是:先区分语法开销和异常抛出开销,再讲 try-catch 正常路径几乎零成本,真正的成本集中在异常对象创建、栈填充、栈展开上,然后补充栈深度和触发频率的影响,最后提一下 HotSpot 的OmitStackTraceInFastThrow优化。如果还能补一句“所以高频业务路径上不要把异常当正常分支用,低频异常路径完全不需要担心”,那基本就是面试官想听到的权衡思维。

代码评审里碰到类似代码时,我现在会先问三个问题:这个异常在这个路径上的触发概率是多少?调用深度是多少?我们真的测过这个位置是热点吗?如果三个问题都答不上来,那就别动它。我见过最尴尬的一次“优化”,是把一段低频异常处理改成了繁琐的错误码判断,结果代码复杂了一倍,线上性能没有一点变化。后来压测数据出来,那个方法的吞吐瓶颈根本不在异常处理上,而在下游数据库查询。

反过来说,我也见过真正需要优化异常性能的场景,比如支付回调处理里,一次批量任务会对同一批畸形数据反复触发异常校验,那个模块每处理一批数据都会抛几万个异常。这类问题只要把“业务状态判断”从“异常机制”里拆出来,性能立刻就能看到改善。所以这不是一个“用不用异常”的立场问题,而是一个“异常用在什么位置、以什么频率触发”的工程判断问题。

我个人现在的原则很简单:第一,正常业务流里,异常频率控制在极低水平;第二,如果某个异常出现的次数高到让你想去优化它,说明这个异常已经不算“异常”了,它是业务分支,应该用显式判断;第三,在性能和其他工程属性冲突的时候,优先用数据说话。把这三条想明白,异常到底影响不影响性能,这个问题你就有了自己的答案。

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

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

立即咨询