前言
本文主要分析一下异常机制对Java程序性能的影响,从而警惕对异常机制的滥用。
核心:异常机制的设计初衷是处理程序错误,而非控制正常流程。滥用异常会显著降低系统性能。
异常可能慢的原因
异常处理主要可以分为两大部分:异常创建、异常抛出和异常捕获
构造异常对象的昂贵开销 (new Exception()):
fillInStackTrace()方法 :这是罪魁祸首。当你创建一个异常对象(如new Exception())时,其构造函数默认会调用fillInStackTrace()这个 native 方法。- 快照收集:JVM 需要遍历当前线程的调用栈,从当前方法一直追溯到线程的入口。
- 信息记录:它必须抓取每一层栈帧的类名、方法名、文件名、源代码行号等信息,并封装在对象中。
抛出与捕获异常的流程开销 (throw + catch):
即使异常对象已经创建好,执行 throw 和 catch 的过程依然比普通的条件判断慢得多:
- 异常表查找:JVM 需要在当前栈帧中查找异常表,这是一个线性扫描过程(虽然异常表通常很短,但仍有开销)。
- 栈回溯 :如果当前方法没有
catch处理,JVM 需要弹出当前栈帧,回到调用者,继续查找。这涉及复杂的栈帧操作和上下文恢复。 - 上下文切换与清理 :跳转到
catch块时,JVM 需要清理操作数栈,重新设置程序计数器(PC),这比简单的if (e != null)分支预测要复杂得多。
基准比较
基准代码比较如下:
csharp
package com.exception;
import java.util.ArrayList;
import java.util.List;
public class ExceptionTest {
private int testTimes;
// 定义一个 volatile 黑洞,防止 JIT 编译器优化掉测试对象的创建和使用
// volatile 保证每次写入都必须刷回主存,防止编译器认为赋值是无意义的死代码
private static volatile Object BLACK_HOLE;
public ExceptionTest(int testTimes) {
this.testTimes = testTimes;
}
public void newObject() {
long l = System.nanoTime();
List<Object> list = new ArrayList<>(testTimes);
Object obj = null;
for (int i = 0; i < testTimes; i++) {
obj = new Object();
list.add(obj);
}
// 必须使用对象,否则 JVM 会优化掉整个循环内的 new 操作
BLACK_HOLE = obj;
System.out.println("1. 建立普通对象:" + (System.nanoTime() - l) + " ns");
}
public void newException() {
List<Exception> list = new ArrayList<>(testTimes);
long l = System.nanoTime();
for (int i = 0; i < testTimes; i++) {
// 仅创建,不抛出
list.add(new Exception());
}
BLACK_HOLE = list; // 强制保留所有对象,防止 GC 或优化
System.out.println("2. 仅建立异常对象:" + (System.nanoTime() - l) + " ns");
}
public void catchException() {
List<Exception> list = new ArrayList<>(testTimes);
long l = System.nanoTime();
for (int i = 0; i < testTimes; i++) {
try {
// 抛出异常
throw new Exception();
} catch (Exception e) {
// 关键:将每个捕获的对象都存起来
// 这逼迫 JVM 必须完整执行 new 和 throw 流程,不能优化掉任何一个
list.add(e);
}
}
BLACK_HOLE = list; // 强制保留所有对象
System.out.println("3. 建立、抛出并接住异常对象:" + (System.nanoTime() - l) + " ns");
}
public static void main(String[] args) {
// 建议稍微增加次数,或者在 main 方法外层套一个循环预热 JVM,
// 否则第一次运行的数据可能会包含类加载和解释执行的时间,不够准确
ExceptionTest test = new ExceptionTest(100_000);
// 预热:让 JIT 编译器介入,把代码编译成机器码
System.out.println("--- 预热中 ---");
for(int i=0; i<10000; i++) {
try { throw new Exception(); } catch (Exception e) {}
}
// 稍微延迟,确保编译完成
try { Thread.sleep(100); } catch (InterruptedException e) {}
System.out.println("=== 开始性能测试 ===");
test.newObject();
test.newException();
test.catchException();
}
}
本地执行结果如下:
markdown
--- 预热中 ---
=== 开始性能测试 ===
1. 建立普通对象:7371385 ns
2. 仅建立异常对象:105856936 ns
3. 建立、抛出并接住异常对象:147632209 ns
建立一个异常对象,是建立一个普通Object耗时的约10倍,而抛出、接住一个异常对象,所花费时间大约是建立异常对象的1.5倍。
那占用时间的"大头":抛出、接住异常,系统到底做了什么事情?大致做如下内容:
- 检查栈顶异常对象类型
- 把异常对象的引用出栈
- 搜索异常表,找到匹配的异常handler
- 重置PC寄存器状态
- 清理操作栈
- 把异常对象的引用入栈
- 把异常方法的栈帧逐个出栈(这里的栈是VM栈)
- 残忍地终止掉当前线程。
详情请参考这篇文章:www.iteye.com/blog/icyfen...