原文:SIGSEGV as control flow - How the JVM optimizes your null checks
写过Java的人都一定写过空值检测。先不管好还是不好, if (variable == null)
这样的语句在Hadoop源码中出现了超过 6000 次 1,简直到处都是。很多情况下,这是纯粹的防御性代码,因为在正常流程下我们是不希望输入一个null的。在这篇文章中,我会简单介绍一下JVM在优化空值检测时使用的一个有趣的小把戏。
如果你感兴趣并想要亲自动手看下汇编代码的输出,欢迎阅读我的上一篇文章(译者注:原文中的链接)。
import java.util.Random;
public class Test {
static Random random = new Random();
public static int getLen(String s) {
if (s == null) {
return -1;
} else {
return s.length();
}
}
public static void main(String[] args) {
long res = 0;
for(int i = 0; i < 50000000; i++) {
res += getLen(Integer.toString(random.nextInt(1000)));
}
res += getLen(null);
System.out.println(res);
}
}
有一种办法可以更机智地处理掉上面代码中的空值判断。Hotspot在(对getLen方法即时编译生成的)汇编代码中是如何处理的呢?我们来看一下:
jackson@serv nullcheck $ java \
-XX:+UnlockDiagnosticVMOptions -XX:CompileCommand=print,Test.getLen \
-XX:-UseCompressedOops Test > asm.s
注1. 为了清理掉已有的JIT汇编,我强制关掉了压缩指针开关
有趣的是,(对getLen方法即时编译生成的)C2级别的优化代码看起来和之前多少有点相似:
# {method} 'getLen' '(Ljava/lang/String;)I' in 'Test'
# parm0: rsi:rsi = 'java/lang/String'
# [sp+0x30] (sp of caller)
mov %eax,-0x14000(%rsp)
push %rbp
sub $0x20,%rsp #*synchronization entry
# - Test::getLen@-1 (line 9)
mov 0x10(%rsi),%r10 #*getfield value
# - java.lang.String::length@1 (line 623)
# - Test::getLen@7 (line 12)
# implicit exception: dispatches to 0x00007ff0f48275b1
mov 0x10(%r10),%eax # implicit exception: dispatches to 0x00007ff0f48275a0
add $0x20,%rsp
pop %rbp
test %eax,0xa5b1a61(%rip) # 0x00007ff0fedd9000
# {poll_return}
retq
我们的空值检测变成了什么样子呢?Hotspot也不是万能的,它并不能提前知道我在main方法中前50000000次调用getLen方法的时候根本没有传null进去!但是注意这一句:implicit exception: dispatches to 0x00007eff39212231
,这说明了,在汇编代码执行到这一句的时候如果发生signalled exception(在这种情况下是SIGSEGV),会转而执行这一串内存地址所指向的代码。这里我们发现了JVM的小把戏:并没有真正地每次都去进行空值判断,而是就放任空值通过,让汇编代码报错,然后通过补救措施让代码回到优化之前的版本上去执行。
(译者注:空值检测的消除是Hotspot在运行时会对热点方法进行的一种比较激进的优化手段,如果根据过往的统计信息判断得知一处空值检测出现null的概率极低,就会直接消除掉这个空值检测,来减少性能消耗【要注意虽然一次空值检测消耗的性能不多,但是能够被Hotspot进行即时编译优化的一定是热点代码,是会被执行很多次的】。激进优化一定都有逆优化机制兜底,如果真的出现了null,那么汇编代码执行的时候会报错,那么这时候再转到优化之前的代码上去执行就是了。虽然回退操作会耗时,但是比起节省掉的时间,还是很划算的)
深化
Hotspot在linux x86 版本上使用的 signal handler大概是这样子的,我已经进行过提取和简化,可以去openJDK的os_linux_86.cpp中看完整代码。
extern "C" JNIEXPORT int
JVM_handle_linux_signal(int sig,
siginfo_t* info,
void* ucVoid,
int abort_if_unrecognized) {
ucontext_t* uc = (ucontext_t*) ucVoid;
...
pc = (address) os::Linux::ucontext_get_pc(uc);
...
} else if (sig == SIGSEGV &&
!MacroAssembler::needs_explicit_null_check((intptr_t)info->si_addr)) {
// Determination of interpreter/vtable stub/compiled code null exception
stub = SharedRuntime::continuation_for_implicit_exception(thread, pc,
SharedRuntime::IMPLICIT_NULL);
}
...
if (stub != NULL) {
// save all thread context in case we need to restore it
if (thread != NULL) thread->set_saved_exception_pc(pc);
uc->uc_mcontext.gregs[REG_PC] = (greg_t)stub;
return true;
}
这个handler使用到了mcontext_t
结构,并从中读取汇编语句的Segment Fault和对应跳转的代码地址信息。然后调用函数SharedRuntime::continuation_for_implicit_exception
查找当前正在运行的方法的信息,并会根据报错的代码位置去exception table中查找需要继续执行的代码位置。知道了接下来该去执行哪里的代码,这个handler就会把指令点存入mcontext
中并结束了。
目光回到我们即时编译过的代码中来,我们的方法getLen就遇到了所谓的“罕见陷阱”(Uncommon Trap),就是用来描述从编译执行逆优化回解释执行的过程的。这个过程会丢弃掉上次即时编译的代码(因为现在JVM知道了之前根据统计信息假设的不会出现null是明显错误的),并继续解释执行,不过当这个方法又被执行过很多次,再次触发即时编译的临界条件时,仍然会重新被编译。
如果你还是好奇,可以去lcm.cpp的PhaseCFG::implicit_null_check
函数里面观察空值检测消除的细节。不过不幸的是,空值检测消除的代码和大多数c2优化手段一样,有些粗糙,当检测到出现null的概率大于0.01%时,这段代码没起到任何作用。
实践
我们可以使用strace命令来观察我们的代码运行时发生了什么。如果不太熟悉也没关系,strace是Linux上的debug工具,使用strace运行程序(或者使用-p <pid>
绑定到已有进程)时,我们可以观察到进程所有的系统函数调用和返回信号。我们可以使用下面的命令来运行我们的程序:
strace -f -o outfile java Test
注1. -f:跟踪子进程
和预期的一样,我们看到了SIGSEGV,你还可能看到其他的段错误。事实上,Hotspot在很多时候使用段错误来进行流程控制。不同的发行版的输出格式可能不同,比如Ubuntu可以输出很有用的si_addr
,即导致此错误的内存地址。
25048 --- SIGSEGV {si_signo=SIGSEGV, si_code=SEGV_MAPERR, si_addr=0x10} ---
从这个结果中我们可以知道,在尝试读取0x10
的时候出现错误。
(Shameless plug: If you are, like me, crazy enough to find this sort of stuff interesting, follow me on twitter. I’m going to try and do several more posts on JVM internals in the next few months.)
1. 在trunk上执行git grep ' == null' | wc -l
,结果显示6197