先把结论丢前面:try…catch 本身几乎不怎么影响性能,真正贵的是“抛异常”这件事本身。
有一次做接口压测,接口逻辑其实不复杂,但 RT 一直比预期高一点。一起看代码的时候,有个同事指着一长串try…catch说:“你这个肯定慢啊,异常这么重,性能都吃这里了”。
当时我心里也有点打鼓: “难不成我这些防御性代码真该删了?要不要全改成 if…else 判断错误码?”
后来我专门花了点时间翻了下 JVM 文档,又写了几段小压测,才比较踏实:不抛异常的时候,try…catch 的开销可以忽略;真的扔异常的时候,才是地狱。
try…catch 不抛异常的时候,JVM 干了啥?
你先看一段最普通的代码:
public void foo() {
int a = 1;
int b = 2;
int c = a + b;
}
再看个加了异常处理版:
public void fooWithTryCatch() {
try {
int a = 1;
int b = 2;
int c = a + b;
} catch (Exception e) {
// 这里什么都不做
}
}
这两段在不抛异常的情况下,JVM 做的额外事情其实很少:
编译后的字节码会多一点,用来标记 try 块的开始和结束、异常表之类
运行的时候,只要没有异常,CPU 就沿着“正常路径”执行下去,不会真的一步步去“检查有没有异常”
更关键的是:JIT(即时编译器)会对热点代码做优化。如果发现你的这段代码跑了无数次,且几乎从不抛异常,很多额外的检查会被折叠、内联、优化掉。
所以,单纯在方法里包一个try { … } catch (Exception e),但正常情况下根本不抛异常,这个 try…catch 的开销,基本不用你操心。
真正拖垮性能的是“抛异常”这一瞬间
那什么时候会慢?就是你真的:
throw new RuntimeException("xxx");
的时候。
抛异常这件事,主要贵在几个点:
要 new 一个异常对象(对象分配 + GC 压力)
默认会填充栈轨迹(fillInStackTrace()):
JVM 要从当前方法一路往上走
把每一层方法、行号、类名都记下来
这就是你平时看到的那一长串stack trace
栈回滚(unwind):
当前方法直接中止
一层层往上找谁能接住这个异常(哪个 catch 匹配)
如果你还顺手来一句:
e.printStackTrace();
那 I/O 又是一笔不小的账。
所以抛一次异常的成本,大致可以理解成:
“分配一个不小的对象 + 遍历调用栈 + 额外逻辑 + 可能的 I/O”。
这跟你普通的if (x == null) { return; }完全不是一个量级。
简单写段小代码感受一下差距
下面这段代码你完全可以自己本机跑一下,感受个数量级就行,不要求绝对精确:
public class ExceptionPerfDemo {
privatestaticfinalint LOOP = 1_000_00; // 十万次
public static void main(String[] args) {
noException();
exceptionEveryTime();
}
private static void noException() {
long start = System.nanoTime();
for (int i = 0; i < LOOP; i++) {
try {
int x = i + 1; // 随便做点事
} catch (Exception e) {
// 这里基本不会进来
}
}
long end = System.nanoTime();
System.out.println("noException cost: " + (end - start) / 1_000_000.0 + " ms");
}
private static void exceptionEveryTime() {
long start = System.nanoTime();
for (int i = 0; i < LOOP; i++) {
try {
thrownew RuntimeException("test");
} catch (Exception e) {
// 吞掉
}
}
long end = System.nanoTime();
System.out.println("exceptionEveryTime cost: " + (end - start) / 1_000_000.0 + " ms");
}
}
一般你会看到类似这样的现象(大概的比例):
noException:几十毫秒甚至更少
exceptionEveryTime:可能直接上秒级,差一个数量级甚至两个数量级
注意两点:
这个测试本身不严谨(没有用 JMH、没有预热、还有编译优化等),只是帮你建立一个“数量级直觉”
差别真正来自“频繁抛异常”,不是来自“存在 try…catch”
那些真·坑性能的 “异常用法”
所以,我们要防的不是try…catch,而是一些非常常见的、但又很坑的写法。
1)用异常当 if 用
比如:
public int parseInt(String s) {
try {
return Integer.parseInt(s);
} catch (NumberFormatException e) {
// 当成正常分支
return 0;
}
}
如果这里 99.9% 的输入都是合法数字,偶尔来一两次错,这没什么问题。
但如果这玩意儿写在一个大循环里,大量输入都不合法,每次都走异常分支,那就等于你在高频路径上疯狂创建异常对象。
更极端一点的写法是这种:
// 千万别这样写
try {
// 先直接干
doSomething();
} catch (SomeException e) {
// 再考虑回滚或者换个方案
compensate();
}
如果SomeException变成了“正常的一种业务状态”,基本等于你在用异常做业务分支,这就有点危险了。
更好的思路是:能提前判断的,尽量用条件判断挡在前面。
2)在核心循环里频繁抛异常
比如,你在一个每秒要执行几十万次的统计循环里,写了类似:
for (Item item : items) {
try {
validate(item); // 里面一旦不合法就 throw
handle(item);
} catch (BizException e) {
// 记录一下日志算了
log.warn("bad item: {}", item.getId(), e);
}
}
如果数据基本没问题,偶发几次异常,这样 OK。 但如果一半以上的数据都是“不合法”的,那你这一圈跑下来 CPU 就会明显发热。
这里有两个优化思路:
validate能提前返回 false 的,就不要用抛异常来表达“失败”
真要抛异常,也尽量不要在每个 item 上都e.printStackTrace(),日志量会爆,I/O 也很重
3)重复包装异常,白白多 new 一堆
经常能看到类似这种:
try {
dao.save(user);
} catch (SQLException e) {
throw new RuntimeException("save user error", e);
}
这类写法本身没错,业务边界上做一层异常转换是很常见的。
但有的同学恨不得每一层都 new 一次:
catch (SQLException e) {
throw new RuntimeException("dao error", e);
}
catch (RuntimeException e) {
throw new ServiceException("service error", e);
}
catch (ServiceException e) {
throw new ControllerException("controller error", e);
}
调用栈一深,你会同时创建好几个异常对象,而且栈轨迹一层套一层,打印出来也很吓人。
能少包一层就少包一层,边界层把 checked 转 unchecked 一次就够了。
try…catch 会不会影响 JIT 优化?
很多人还有个担心: “加了 try…catch,会不会导致 JVM 不好优化,导致整个方法都跑不快?”
现实情况是,现代 JVM(比如 HotSpot)对异常的处理已经非常成熟了,几件事可以放心:
不抛异常的代码路径,JIT 是会重点优化的
try 块里的逻辑,该内联的内联,该循环展开的展开
不会因为你多写了几个catch,就突然把整个方法当“黑盒”了
真正让优化变差的往往是:
方法巨长,干了太多事
大量分支嵌套,逻辑复杂
内存分配频繁、逃逸分析无法消除
相比之下,多几个try…catch的影响真不大。
所以别为了“看起来高性能”,把所有异常处理都删了,出错还要靠日志和 if…else 拼凑。
举个常见场景,读配置文件:
public Properties loadConfig(String path) {
Properties properties = new Properties();
try (InputStream in = new FileInputStream(path)) {
properties.load(in);
return properties;
} catch (FileNotFoundException e) {
// 配置文件不存在
throw new IllegalStateException("config file not found: " + path, e);
} catch (IOException e) {
// IO 异常
throw new IllegalStateException("load config error: " + path, e);
}
}
这里有几个点:
try-with-resources 本身就是基于try…catch的语法糖
正常情况下,你的配置文件只在启动时读几次,根本不在性能热点上
真出异常了你肯定也不指望“性能好”,程序通常直接报错退出
这种地方你就可以大胆用异常,把代码写得可读、可靠一点,性能完全不用担心。
什么时候需要真正在意异常的性能?
总结一下,只有在这几种场景下,你才需要认真考虑“异常成本”:
某段代码是极高频调用路径
比如:每条日志、每个数据包、每条消息都要走
你又把“正常分支”写成了“抛异常”的形式
异常本身是“业务常态”
比如用NumberFormatException来表示“用户没填字段”
用NullPointerException来判空(别笑,是有人这么干)
在异常处理中做了很重的事情
比如每次异常都打完整 stack trace 到磁盘
还顺便查了几次数据库
如果不是以上这些情况,你远远更应该关心 SQL、网络、JSON 解析、序列化、锁竞争这些东西的性能,而不是在try…catch上纠结半天。
try…catch 存在本身,不是罪魁祸首
频繁抛异常、滥用异常当业务逻辑,才是真正的性能杀手
所以别再因为一句“异常很慢”就把代码写成一堆 if…else 返回错误码了,该用异常的地方还是要用,真的慢了,用工具量一量再下结论。