引言

在漫长的Java开发生涯中,你或多或少接触过各种开发规范,其中肯定有几条是关于异常类的使用相关的,比如规范会说“不要使用异常类用作流程控制,条件控制”,“不要在高并发场景频繁创建异常类”等等,这些规范都指向了同一个问题,就是异常类的使用对性能有一定的影响,需要尽量规避掉异常类以免对程序产生性能问题。那么异常类的使用到底是怎么影响性能的?我们从异常类的创建以及捕获两个方面来分析其性能问题。

一、异常类的创建

异常类梳理

我们先按照异常类的检测触发规则对其进行梳理,这里先吐槽一下,由于要实现解释执行和JIT分层编译,JVM的异常类处理机制非常繁杂,触发的入口非常多,而底层却趋于收敛,整个异常处理流程就像一颗枝繁叶茂的大树一样,所以梳理起来有一定的难度。

1. explicit exception显式检测异常

  • athrow字节码
    • 解释执行: 跳转到TemplateInterpreter::_throw_exception_entry异常处理例程
    • c1编译: 跳转到RuntimeStub - handle_exception异常处理例程
    • c2编译: 跳转到RuntimeStub - _rethrow_Java异常处理例程
  • test/comp等check指令 + jmp跳转指令 —— 显式检查后跳转到对应的异常处理例程
    • 解释执行: 跳转到TemplateInterpreter::_throw_XXXException_entry触发各种异常处理例程
    • c1编译: 跳转到RuntimeStub - throw_xxx_exception触发各种异常处理例程
    • c2编译: 跳转到UncommonTrapBlob触发退优化后切换为跳转到RuntimeStub - _rethrow_Java异常处理例程 (如果异常触发频繁还会使用预先创建的Exception实例,可以-XX:-OmitStackTraceInFastThrow关掉此功能)

2. implicit exception隐式检测异常

  • 主要通过内存寻址指令或div指令等触发系统中断信号
    • 解释执行: 通过MacroAssembler::null_check()植入隐式null检查
    • c1编译: 通过各种implicit check开关植入各种隐式检查
    • c2编译: 通过PhaseCFG::implicit_null_check()植入隐式null检查

为什么在有test + jmp指令进行显式的异常检测处理之外,还要存在通过内存寻址或算术除法指令来隐式地触发异常的机制?因为test + jmp指令整体实际执行时的cpu延迟比内存寻址的延迟更高:

  1. jmp指令依赖test指令的结果,无法利用超标量流水线进行高效并行;
  2. 同时在分支预测错误的情况下存在预测惩罚,会大大降低指令的执行效率。

所以频繁地执行test + jmp会明显降低cpu的吞吐。但是如果隐式异常检测引发系统中断信号(比如SIGSEGV),中断信号的处理代价也非常高,所以在C2编译阶段,一旦隐式触发异常就会进行退优化处理,转而使用显式检测来代替隐式检测,避免常规的异常检测频繁地触发中断信号处理。
这种基于运行时采集的数据进行激进投机的优化并且在优化失效后退回到常规处理手段的机制,正是JVM性能优化的经典设计哲学。(系统中断信号的处理代码入口在JVM_handle_linux_signal, 这里不再展开,有兴趣的话自行深入研究)

备注:有的书籍和文章可能会把athrow字节码抛出的异常定义为显式检测异常,因为这是在Java代码里面程序员手写的主动检测异常,而把JVM自己进行检测抛出的异常定义为隐式检测异常。

异常的创建开销

除了特殊情况下使用预创建的异常实例(这些实例不带方法栈信息),几乎每次触发异常时都需要进行异常类实例的创建,而所有的异常类都是继承自java.lang.Throwable父类,在不复写父类实现前,其构造时会调用父类的fillInStackTrace方法:

public synchronized Throwable fillInStackTrace() {
        if (stackTrace != null ||
            backtrace != null /* Out of protocol state */ ) {
            fillInStackTrace(0);
            stackTrace = UNASSIGNED_STACK;
        }
        return this;
    }

    private native Throwable fillInStackTrace(int dummy);

而异常类实例构造的开销主要就集中在native方法fillInStackTrace里:

1、整体总览

fillInStackTrace函数主要目的是为了构造方法调用栈,其中一个关键的设计思想:只保存轻量元数据(Method类 的 idnum、bci、mirror、name),不生成任何字符串和格式相关的信息。真正的格式化(类名、行号、文件名)推迟到 getStackTrace()printStackTrace() 函数被调用时才做,这叫做"惰性栈解析"(lazy stack trace),是 JVM上异常开销的主要优化点。


2、数据结构:backtrace 的"数组 + 链表分块"布局

在构造方法调用栈时使用了 BacktraceBuilder ,其揭示了 Throwable.backtrace 字段的真正内存布局——它不是 StackTraceElement[]数组,而是一串"数组分块链表":

Throwable.backtrace (Object)
    │
    ▼
┌────────── chunk #0 (objArray, trace_size=6) ──────────┐
│ [0] methods  -> short[32]   (method idnum)            │
│ [1] bcis     -> int[32]     (bci | version<<20)       │
│ [2] mirrors  -> Object[32]  (Class 类实例)   			│
│ [3] names    -> Symbol[32]  (方法名,防重定义)           │
│ [4] next     -> chunk #1 ─┐                           │
│ [5] hidden   -> 标志位     │                           │
└───────────────────────────┼───────────────────────────┘
                            ▼
                         chunk #1 → next → ...
  • 每个 chunk 存 trace_chunk_size = 32 方法栈帧,超过32就 expand() 新开一个 chunk 并把前一个的next指针指向新开的一个chunk。
  • 每个方法栈帧只占 short (2B) + int (4B) + oop(4B or 8B) + Symbol*(8B),不保存字符串、不保存文件名、不保存行号——这些都是 printStackTrace() 时通过method idnum去反查的。

这就是 Throwable 构造比想象中"快捷"的关键所在,这种数据结构既利用了数组的连续性以提高数据读写,又利用链表以避免长数组造成内存分配效率低下的问题,这种设计正是性能和内存使用效率进行折中博弈的结果。


3、核心代码逻辑

阶段 1:准备与快速短路判断
if (!StackTraceInThrowable) return;             // 全局关闭直接返回
ResourceMark rm(THREAD);
set_backtrace(throwable(), NULL);               // 先清空,防 OOM 后遗留残缺数据
clear_stacktrace(throwable());                  // 清 Java 层 lazy stacktrace
BacktraceBuilder bt(CHECK);                     // 分配第一个 chunk

if (!thread->has_last_Java_frame()) {
    // 极端情况:当前线程没有任何 Java 栈帧(纯 native 路径)
    // 只把传入的 method 放进去,bci=0,直接结束
    if (max_depth >= 1 && method() != NULL) {
        bt.push(method(), 0, CHECK);
        set_depth(throwable(), 1);
        set_backtrace(throwable(), bt.backtrace());
    }
    return;
}
阶段 2:栈回溯主循环

这里 没有使用 vframeStream(vframeStream 主要功能就是把虚拟帧栈回溯,其功能完整但慢),而是手写栈遍历,并为编译帧做了特殊的 scope-decode 内联展开处理。

关键变量:

变量 含义
decode_offset 非 0 表示当前 nmethod 还有内联帧要展开
nm 当前所在的 CompiledMethod(记忆给下次 scope 解码用)
skip_fillInStackTrace_check 跳过顶部若干 Throwable.fillInStackTrace 自身帧
skip_throwableInit_check 再跳过若干 <init> 构造函数帧(异常类及其父类)
skip_hidden 跳过 @Hidden 注解的帧(JSR 292/lambda 生成)

帧处理分三种情况:

(a) 解压内联帧(decode_offset != 0):

DebugInfoReadStream stream(nm, decode_offset);
decode_offset = stream.read_int();      // 下一层内联帧的偏移
method = (Method*)nm->metadata_at(stream.read_int());
bci = stream.read_bci();

一条物理帧可能展开成几十条逻辑帧——这是内联(inlining)给方法帧栈溯源带来的"苦果"。每次循环只弹一层,直到 decode_offset == 0 再移动物理帧。

(b) 解释帧:

address bcp = fr.interpreter_frame_bcp();
method = fr.interpreter_frame_method();
bci = method->bci_from(bcp);
fr = fr.sender(&map);                    // 物理往上一帧

© 编译帧(首次遇到):

CodeBlob* cb = fr.cb();
fr = fr.sender(&map);                    // 物理立刻往上
if (cb == NULL || !cb->is_compiled()) 
		continue;
nm = cb->as_compiled_method();
if (nm->method()->is_native()) { 
		method = nm->method(); bci = 0; }
else {
    PcDesc* pd = nm->pc_desc_at(pc);
    decode_offset = pd->scope_decode_offset();   // 激活内联帧解压
    continue;                                     // 下次循环从 (a) 分支开始弹
}

比如以下的例子(把PcDesc打印出来):

pc-bytecode offsets:
PcDesc(pc=0x00007f2a94ad05df offset=ffffffff bits=0):
PcDesc(pc=0x00007f2a94ad05ec offset=c bits=0):   demo.testExceptionWithProfile::test@-1 (line 33)
PcDesc(pc=0x00007f2a94ad05f8 offset=18 bits=0):   demo.testExceptionWithProfile::inner@3 (line 49)
   demo.testExceptionWithProfile::test@1 (line 33)
PcDesc(pc=0x00007f2a94ad0606 offset=26 bits=0):   demo.testExceptionWithProfile::inner@17 (line 53)
   demo.testExceptionWithProfile::test@1 (line 33)
PcDesc(pc=0x00007f2a94ad060b offset=2b bits=0):   demo.testExceptionWithProfile::inner@35 (line 57)
   demo.testExceptionWithProfile::test@1 (line 33)
PcDesc(pc=0x00007f2a94ad062c offset=4c bits=0):   demo.testExceptionWithProfile::test@-1 (line 33)
PcDesc(pc=0x00007f2a94ad0659 offset=79 bits=0):   demo.testExceptionWithProfile::inner@35 (line 57)
   demo.testExceptionWithProfile::test@1 (line 33)
PcDesc(pc=0x00007f2a94ad066a offset=8a bits=0):   demo.testExceptionWithProfile::test@1 (line 33)

pc=0x00007f2a94ad05f8地址处就有对应两条帧栈记录,分别是调用者test函数和被调用者inner函数。
其对应的Java代码示例如下:

		public static int test(int i){
        try {
            return inner(i);//JIT编译后函数已经被内联
        } catch (ArithmeticException e) {
            ...
        }
    }
阶段 3:过滤 + 入库

按顺序跳过:

  1. 顶层若干 fillInStackTrace 帧(异常类自己调的那几层)
  2. 紧接着的若干 <init> 帧(new Exception(...) 构造函数)
  3. @Hidden 的帧(可选,通过 -XX:+ShowHiddenFrames 打开)

然后 bt.push(method, bci) 记录这一帧。push 内部仅对4 个“数组”中分别写入一个元素,非常轻量。

最后把构造好的方法栈数据赋值给Throwable.backtrace字段:

set_backtrace(throwable(), bt.backtrace());
set_depth(throwable(), total_count);

fillInStackTrace函数的实现我们可以看出,如果异常触发时的帧栈非常深,那么构造异常类实例的整体耗时就会比较大,即使使用了延迟解析的技术,其耗时也无法被轻而易举地忽略掉,这就是异常的创建开销所在。

二、异常类的捕获

聊完了异常类的创建,我们紧接着聊聊异常类的捕获问题。当触发一个异常被抛出时,需要分两种情况来处理:

  1. 异常抛出的函数方法具有异常表时,函数执行会跳转到异常表处理的例程,这时肯定需要用异常去匹配异常表,如果能找到对应的异常处理器则会跳转到异常处理器代码的入口执行对应的代码逻辑;如果无法匹配异常表成功,则执行异常抛出的例程,此时参考情况2
  2. 异常抛出的函数方法没有异常表时,函数执行会跳转到异常抛出(forward exception)的例程,最终调用SharedRuntime::exception_handler_for_return_address去获取上一层帧栈对应函数的异常表(如有);如果上一层帧栈没有异常表或无法匹配异常则继续异常抛出的例程直到再无上一层帧栈,这个过程就叫做帧栈展开(exception unwinding)

如果是第一种场景同时能幸运地匹配上异常处理器,那么基本无需考虑异常类的捕获开销,但如果是第二种场景并且帧栈比较深的情况下就需要把异常类的捕获开销考虑进来,而异常类的捕获过程中的性能开销主要就集中在帧栈展开中。

我们以C1编译为例来说明帧栈展开的过程,因为C1编译下的异常处理比较经典(C2编译的异常处理涉及退优化等比较复杂的机制),它含有两条异常处理路径,理解它们的区别是理解帧栈展开的关键:

路径 入口 Stub 场景
当前帧处理 handle_exception 当前编译帧内有 catch 块,直接在本帧处理
帧栈展开(unwinding) unwind_exception 当前帧没有 catch 块,需要弹出当前帧,交给调用者处理

unwind_exception 完整流程(x86-64)

寄存器约定

rax  = exception_oop          (入参:异常对象)
rdx  = exception_pc           (抛出点 PC,即当前帧的返回地址)
rbx  = handler_addr           (查找到的处理器地址)
r14  = exception_oop_callee_saved  (跨 call 保存 exception_oop)
r15  = thread                 (JavaThread指针)

执行步骤详解

┌─────────────────────────────┐
| 调用者帧                     |
│  ...                        │
│  return address             │  ← 这就是 exception_pc(调用者的返回地址)
├─────────────────────────────┤
│当前帧(已执行 leave,rbp已恢复)│
└─────────────────────────────┘

Step 1:断言检查(DEBUG 模式)

// 确保 JavaThread 中的 exception_oop / exception_pc 字段为空
// 这两个字段是 handle_exception 路径使用的
__ cmpptr(Address(thread, JavaThread::exception_oop_offset()), 0);
__ jcc(Assembler::equal, oop_empty);
__ stop("exception oop must be empty");

这个断言说明:unwind_exception 被调用时,线程的 exception 字段必须是干净的,它走的是完全不同于 handle_exception 的路径

Step 2:清空 FPU 栈

__ empty_FPU_stack();

防止 FPU 状态污染后续帧, FPU是float point unit,用于浮点运算。

Step 3:保存 exception_oop 到 callee-saved 寄存器

__ movptr(exception_oop_callee_saved, exception_oop);  // r14 = rax

因为接下来要调用 C++ 函数,rax 会被破坏,所以先保存到 callee-saved 的 r14。关于caller-saved和callee-saved寄存器分类这里不展开详述。

Step 4:获取返回地址(= 抛出点 PC)

__ movptr(exception_pc, Address(rsp, 0));  // rdx = [rsp]

此时 rsp 指向的是当前帧的返回地址(即调用者调用当前方法所使用的 call 指令的下一条指令地址)。这个地址用于在调用者帧中查找异常处理器。关于C方法调用的帧栈约定请参考对应的ABI(x86-64)。

Step 5:调用运行时查找异常处理器

__ call_VM_leaf(CAST_FROM_FN_PTR(address,
    SharedRuntime::exception_handler_for_return_address),
    thread, exception_pc);
// 返回值 rax = 调用者帧中的异常处理器地址

SharedRuntime::exception_handler_for_return_address 的逻辑:

根据 return_address 找到对应的 CodeBlob:
  ├── 是 nmethod(JIT 编译方法)?
  │     ├── 是 deopt PC(C2退优化)?	→ 返回 deopt_blob->unpack_with_exception()
  │     └── 否?         		 	→ 返回 nm->exception_begin()
  ├── 是 call stub?     			→ 返回 StubRoutines::catch_exception_entry()
  └── 是解释器?         				→ 返回 Interpreter::rethrow_exception_entry()

SharedRuntime::exception_handler_for_return_address 为上层异常触发提供了异常处理器查找的共同底层实现,无论上层是解释执行或者C1、C2编译执行,无论其触发的场景是哪种,最终都会收敛到此处,就像异常处理流程中的树根一样。

Step 6:恢复寄存器

__ movptr(handler_addr, rax);           // rbx = 处理器地址(rbx寄存器用于保存处理器地址,因为 rax 要被 exception_oop 占用)
__ movptr(exception_oop, exception_oop_callee_saved);  // rax = 异常对象(从 r14 恢复)

此处变量对应的寄存器可以参考篇首的寄存器约定

Step 7:弹出返回地址(模拟 ret 效果)

__ pop(exception_pc);  // rdx = 返回地址,同时 rsp += 8(弹出栈顶)

这一步非常关键:

  • 将栈顶的返回地址弹出并赋值给 rdx(即 exception_pc
  • rsp 向上移动,相当于弹出了当前帧的返回地址,模拟了 ret 的效果

Step 8:跳转到异常处理器

__ jmp(handler_addr);  // jmp rbx

此时寄存器状态满足异常处理器的调用约定:

  • rax = exception_oop(异常对象)
  • rdx = exception_pc(抛出点 PC)
  • rsp 已经弹出返回地址,指向调用者帧

handle_exception 的对比

我们可以把异常处理和异常展开的流程作一下对比:

C1 编译代码抛出异常

当前帧有 catch 块?

handle_exception stub

unwind_exception stub

保存所有寄存器

调用 exception_handler_for_pc
在当前帧查找 handler

修改返回地址为 handler 地址

恢复寄存器,ret 回到 handler

保存 exception_oop 到 r14

读取栈顶返回地址作为 exception_pc

调用 exception_handler_for_return_address
在调用者帧查找 handler

pop 返回地址到 rdx
模拟 ret 弹出当前帧

jmp 到调用者帧的 handler

特性 handle_exception unwind_exception
查找范围 当前帧exception_handler_for_pc 调用者帧exception_handler_for_return_address
帧是否弹出 ,通过修改返回地址跳转 ,通过 pop 模拟 ret 弹出当前帧
寄存器保存 保存所有活跃寄存器 只保存 exception_oop 到 callee-saved寄存器
JavaThread 字段 写入 exception_oop/exception_pc 字段 不写入,断言这两个字段必须为空
返回方式 ret(通过修改栈上返回地址) jmp handler_addr(直接跳转)

其中异常展开对比异常处理最大的差异点就是不停地向上跳到调用者帧栈的handler去处理异常,一旦在整个展开的过程中异常都无法匹配到合适的异常处理器就会经历一次全链路的帧栈展开,当调用帧栈比较深的时候就会造成比较可观的性能损耗。

结论

我们通过分析异常类的创建和捕获,从两个方面分别说明了异常类性能开销的主要原因:

  1. 异常实例构造时需要对异常栈进行构造
  2. 异常处理时需要对调用栈进行遍历展开

所以我们在处理异常场景时,应该尽量避免频繁触发异常实例的构造,同时避免异常抛出时无法被捕获而遭受帧栈展开的损耗。对于某些场景可以预构造异常实例进行缓存反复使用的,用空间换时间的策略提高异常处理的效率。当然更激进的优化是直接复写父类fillInStackTrace()的实现避免调用native方法进行帧栈构造,但是这会丢失异常帧栈,对追溯异常场景有一定的损失。

更多推荐