[讨论] Java与C/C++的性能对比

IcyFenix 2011-02-13
  写这个主题是因为若干时间前一时头脑发热,写了这个帖子(http://www.iteye.com/topic/857722),现在看来这个帖子很幼稚,尤其是二楼= =#,后来被bugmenot同学抓到:

bugmenot 写道
javacoreSZ 写道
IcyFenix 写道
看看反应如何,好的话明天再写一篇来小淌一下“C/C++会比Java语言快多少多少倍”这浑水,也是这2天由那几张判断字符串能不能用异常的帖子中看到的观点。


这个最好还是别写,坑太大了。
而且他们性能之间的差距并不是由c++ 或 java语言本身所带来的,而是由于他们的实际编译器/虚拟机在做代码生成的时候,对代码优化的程度所带来的。

所以你要写的话,也是要深入各主流cpp编译器来写,写得不好就口水战了。

楼主大大这坑还跳么?小的们还等着学习呢
小的琢磨,“同等开发成本”为前提来比较才有意义。不然C/C++总能写出更高效的


  哼哼,不就踩个坑嘛,我抛块砖头,引点玉出来。请撒迦、bugmenot等同学继续来拍吧 ……

  ========== 上面是废话,下面是正文,我是分割线 ==========

  大多数程序员都认为C/C++会比Java语言快,甚至于觉得从Java语言诞生以来,“执行速度缓慢”的帽子就应当被扣在头顶,这种观点的出现是由于Java刚出现的时候JIT编译技术还不成熟,主要靠解释器执行的Java语言确实性能比较低下。但是在今天JIT编译技术已经发展成熟之后,Java语言有可能在速度上与C/C++争一日长短了吗?这个问题的答案,让我们从两者的编译器谈起。

  Java与C/C++的编译器对比实际上是代表了最经典的JIT编译器与静态编译器的对比,也很大程度上决定了Java与C/C++的性能对比的结果,因为无论是C/C++还是Java代码,最终编译之后被机器执行的都是本地机器码,哪种语言性能更高,除了它们自身的API库实现得好坏以外,其余的比较就成了一场“拼编译器”、“拼输出代码质量”的游戏。当然,这种比较也是剔除了开发效率的片面对比,语言间孰优孰劣,谁快谁慢的问题都是很难有结果的争论,下面我们就回到正题,看看这两种语言的编译器各有何优势。

  Java虚拟机的JIT编译器与C/C++的静态优化编译器相比,可能会由于下列这些原因导致输出的本地代码有一些劣势(下面列举的也包括一些虚拟机执行子系统的性能劣势):

  首先,因为JIT编译器运行占用的是用户程序运行时间,具有很大的时间压力,它能提供的优化手段也严重受制于编译成本。如果编译速度不能达到要求,那用户将在启动程序或程序的某部分察觉到重大延迟,这点使得JIT编译器不敢随便引入大规模的优化技术,而编译的时间成本在静态优化编译器中并不是主要的关注点。

  其次,Java语言是动态的类型安全语言,这意味着需要由虚拟机来确保程序不会违反语言语义或访问非结构化内存。在实现层面上看,这就意味着虚拟机必须频繁进行动态检查,如对象实例访问时检查空指针、数组元素访问时检查上下界范围、类型转换时检查继承关系等等。对于这类程序代码没有明确写出的检查行为,尽管编译器会努力进行优化,但是总体上仍然要消耗着不少的运行时间。

  Java语言中虽然没有virutal关键字,但是使用虚方法的频率却远远大于C/C++语言,这意味着运行时对方法接收者进行多态选择的频率要远远大于C/C++语言,也意味着JIT编译器在进行一些优化,如方法内联时难度要远大于C/C++的静态优化编译器。

  Java语言是可以动态扩展的语言,运行时加载新的类可能改变程序类型继承关系,这使得很多全局的优化都难以进行,因为编译器无法看见程序的全貌,许多全局优化措施都只能以激进优化的方式来完成,编译器不得不时刻注意并随着类型变化而在运行是撤消或重新进行一些优化。

  Java语言中的对象内存分配都是堆上进行,只有方法中的局部变量才在栈上分配。而C/C++的对象则有多种内存分配方式,既可能在堆上分配,也可能在栈上分配,如果可以把线程私有的对象在栈上分配,将可以减轻内存回收的压力,也不需要考虑内存屏障方面的问题。另外,C/C++中主要由用户程序代码来回收分配的内存,这就不存在无用对象筛选的过程,因此效率上(仅指运行效率,排除了开发效率)也垃圾收集机制要高。

  Java语言相对C/C++的劣势上面说了一大堆,倒不是说Java就真的不如C/C++了,相信大家也注意到了,Java语言的这些性能上的劣势都是为了换取开发效率上的优势而付出的代价,动态安全、动态扩展、垃圾回收这些“拖后腿”特性都为Java语言的开发效率作出了很大贡献。何况,也不见得就没有Java的JIT编译器能做,而C/C++的静态优化编译器不能做的优化:由于C/C++编译器的静态性,以运行期性能监控为基础的优化措施它都无法进行,如调用频率预测(Call Frequency Prediction)、分支频率预测(Branch Frequency Prediction)、裁剪未被选择的分支(Untaken Branch Pruning)等,这些都会形成一些Java语言独有的性能优势。
RednaxelaFX 2011-02-13
alright,这边慢慢聊~

“语言特性”本身是会对实现造成影响的。例如说语言规定了运行时要支持反射,那么运行时就必须要有足够多的数据放在那里摆着。所以有些语言天生确实就更有潜质变得“慢”一些,或者说为了要快起来要付出的代价会更高。

不过顶楼里举的很多例子都不太对咯。下面随便说说。

例如说Sun HotSpot在x86上做空指针检查的时候许多情况下都是零开销的。前提是被编译的代码自身要很少遇到NPE。
public class Foo {
  private int value;
  
  public static int bar(Foo foo) {
    return foo.value;
  }
  
  public static void main(String[] args) {
    bar(new Foo());
  }
}

像这样的一段代码,在bar()里本来应该要由JVM来做一次null检查对吧?要确认foo不为null才会真的执行.value的操作。但实际上呢?下面请楼主接上 ^_^

补充:后面的代码例子放在gist上了,联动 https://gist.github.com/825394
IcyFenix 2011-02-13
接楼上的,在楼上的例子中,bar()方法内联了之后虚拟机就能能保证foo肯定不是null,所以这个检查会被jit干掉,因此是没消耗的。

如果发散来讲,还有其他地方调用bar()方法,并且编译器没有办法保证bar()的参数是不是null,但实际上如果调用个N遍,foo都不是null的话,那这个检查也可能会被jit留个逃生门之后干掉。

不过……顶楼关于动态安全上写了这句:
引用
对于这类程序代码没有明确写出的检查行为,尽管编译器会努力进行优化,但是总体上仍然要消耗着不少的运行时间。

1.如果出一次null就要回到原始社会(当然,C/C++里面如果程序员没写判断,出一次null可能比回原始社会更严重),我们按不出意外的情况来讨论。

2.在不出意外的情况下,有另外一些检查是编译器不敢随便去掉的,例如:
public int bar(int[] arr,int n){
	return arr[n];
}
这段代码如果内联之后不能保证n是可控的范围(n在外面是常量?循环变量?),或者索性就不能内联,而且没有profile信息保障jit可以激进优化的话,它每次总要检查一下上下界。
RednaxelaFX 2011-02-13
IcyFenix 写道
这个例子中,bar()方法内联了之后虚拟机就能能保证foo肯定不是null,所以这个检查会被jit干掉,因此是没消耗的。

hmm,如果就这么强制编译的话确实会是那样呢。因为main()会内联掉getValue(),内联后就可以发现foo.value一定不会遇到null,自然就不需要生成任何null检查。

不过还有更有趣的。这个例子是用来说明别的问题的——隐式异常处理。
在隐式异常处理下,可以完全不检查也保证安全,而且不需要编译器的额外支持。

稍微把代码改造一下,变成这样(主要变动是在main里,为了正常触发getValue()方法的编译):
public class TestC2NullCheck {
  public static int getValue(Foo foo) {
    return foo.value;
  }
  
  public static void main(String[] args) throws Exception {
    for (int i = 0; i < 120000; i++) {
      getValue(new Foo());
    }
    Thread.sleep(2000);
  }
}

class Foo {
  public int value;
}


接下来用点魔法,只允许TestC2NullCheck.getValue方法得到JIT编译,而不允许其它方法被JIT编译。并观察编译生成的代码,
java -XX:+UnlockDiagnosticVMOptions -XX:CompileCommand='compileonly,TestC2NullCheck.getValue' -XX:-UseFastAccessorMethods -XX:+PrintAssembly TestC2NullCheck

可以看到类似这样的结果:(我是在32位x86,XUbuntu 10.10上用自己build的6u23来跑的,我的环境里默认用的是server模式)
Decoding compiled method 0xb487f408:
Code:
[Disassembling for mach='i386']
[Entry Point]
[Verified Entry Point]
  # {method} 'getValue' '(LFoo;)I' in 'TestC2NullCheck'
  # parm0:    ecx       = 'Foo'
  #           [sp+0x10]  (sp of caller)
  0xb487f500: mov    %eax,-0x4000(%esp)
  0xb487f507: push   %ebp
  0xb487f508: sub    $0x8,%esp          ;*synchronization entry
                                        ; - TestC2NullCheck::getValue@-1 (line 3)
  0xb487f50e: mov    0x8(%ecx),%eax     ;*getfield value
                                        ; - TestC2NullCheck::getValue@1 (line 3)
                                        ; implicit exception: dispatches to 0xb487f51c
  0xb487f511: add    $0x8,%esp
  0xb487f514: pop    %ebp
  0xb487f515: test   %eax,0xb77c8000    ;   {poll_return}
  0xb487f51b: ret    
  0xb487f51c: mov    $0xfffffff6,%ecx
  0xb487f521: nop    
  0xb487f522: nop    
  0xb487f523: call   0xb4863720         ; OopMap{off=40}
                                        ;*getfield value
                                        ; - TestC2NullCheck::getValue@1 (line 3)
                                        ;   {runtime_call}
  0xb487f528: call   0x018292a0         ;*getfield value
                                        ; - TestC2NullCheck::getValue@1 (line 3)
                                        ;   {runtime_call}
  0xb487f52d: hlt    
  0xb487f52e: hlt    
  0xb487f52f: hlt    
  0xb487f530: hlt    
  0xb487f531: hlt    
  0xb487f532: hlt    
  0xb487f533: hlt    
  0xb487f534: hlt    
  0xb487f535: hlt    
  0xb487f536: hlt    
  0xb487f537: hlt    
  0xb487f538: hlt    
  0xb487f539: hlt    
  0xb487f53a: hlt    
  0xb487f53b: hlt    
  0xb487f53c: hlt    
  0xb487f53d: hlt    
  0xb487f53e: hlt    
  0xb487f53f: hlt    
[Exception Handler]
[Stub Code]
  0xb487f540: jmp    0xb487dba0         ;   {no_reloc}
[Deopt Handler Code]
  0xb487f545: push   $0xb487f545        ;   {section_word}
  0xb487f54a: jmp    0xb4864c00         ;   {runtime_call}
[Constants]
  0xb487f54f: int3   


真正跑到了的是这一段:
  # {method} 'getValue' '(LFoo;)I' in 'TestC2NullCheck'
  # parm0:    ecx       = 'Foo'
  #           [sp+0x10]  (sp of caller)
  0xb487f500: mov    %eax,-0x4000(%esp)
  0xb487f507: push   %ebp
  0xb487f508: sub    $0x8,%esp          ;*synchronization entry
                                        ; - TestC2NullCheck::getValue@-1 (line 3)
  0xb487f50e: mov    0x8(%ecx),%eax     ;*getfield value
                                        ; - TestC2NullCheck::getValue@1 (line 3)
                                        ; implicit exception: dispatches to 0xb487f51c
  0xb487f511: add    $0x8,%esp
  0xb487f514: pop    %ebp
  0xb487f515: test   %eax,0xb77c8000    ;   {poll_return}
  0xb487f51b: ret


其中对应foo.value的就是一条x86指令:
 0xb487f50e: mov    0x8(%ecx),%eax     ;*getfield value
                                        ; - TestC2NullCheck::getValue@1 (line 3)
                                        ; implicit exception: dispatches to 0xb487f51c

就这么一句mov    0x8(%ecx),%eax。

接下来再把时间交给楼主~ 留意反汇编出来的代码后面的注释。
IcyFenix 2011-02-14
hmmmm……接楼上的……

用java伪代码来说就是下面那样……
try {
    return foo_without_null_check.value; // 优化后,正常访问时,使用不带空指针检查的代码版本直接访问value字段
} catch(IMPLICIT_NPE e) {   // 这里是伪代码,不是真的java catch
    return foo_with_null_check.value;    // 如果出了异常,使用带空指针检查的版本来返回value字段
}

那样的话,如果正常执行,确实是没有额外开销= =#

关于其他几点,请撒迦继续拍~~,把时间交回给撒迦同学
RednaxelaFX 2011-02-14
对了,附送上一组对比用的代码
struct Foo {
  int mark;
  void* klass;
  int value;
};

int __attribute__ ((fastcall, noinline)) getValue(struct Foo* foo) {
  return foo->value;
}

int main() {
  struct Foo foo;
  getValue(&foo);
  return 0;
}

拿这个在x86上用GCC 4.4.4来编译,带上优化参数(-O2),
rednaxelafx@vbox:~/experiment/test_null_check$ gcc -v
Using built-in specs.
Target: i686-linux-gnu
Configured with: ../src/configure -v --with-pkgversion='Ubuntu/Linaro 4.4.4-14ubuntu5' --with-bugurl=file:///usr/share/doc/gcc-4.4/README.Bugs --enable-languages=c,c++,fortran,objc,obj-c++ --prefix=/usr --program-suffix=-4.4 --enable-shared --enable-multiarch --enable-linker-build-id --with-system-zlib --libexecdir=/usr/lib --without-included-gettext --enable-threads=posix --with-gxx-include-dir=/usr/include/c++/4.4 --libdir=/usr/lib --enable-nls --with-sysroot=/ --enable-clocale=gnu --enable-libstdcxx-debug --enable-objc-gc --enable-targets=all --disable-werror --with-arch-32=i686 --with-tune=generic --enable-checking=release --build=i686-linux-gnu --host=i686-linux-gnu --target=i686-linux-gnu
Thread model: posix
gcc version 4.4.5 (Ubuntu/Linaro 4.4.4-14ubuntu5) 
rednaxelafx@vbox:~/experiment/test_null_check$ gcc -S -O2 test.c


得到的汇编如下:
	.file	"test.c"
	.text
	.p2align 4,,15
.globl getValue
	.type	getValue, @function
getValue:
	pushl	%ebp
	movl	8(%ecx), %eax
	movl	%esp, %ebp
	popl	%ebp
	ret
	.size	getValue, .-getValue
	.p2align 4,,15
.globl main
	.type	main, @function
main:
	pushl	%ebp
	xorl	%eax, %eax
	movl	%esp, %ebp
	popl	%ebp
	ret
	.size	main, .-main
	.ident	"GCC: (Ubuntu/Linaro 4.4.4-14ubuntu5) 4.4.5"
	.section	.note.GNU-stack,"",@progbits


留意了,getValue()函数的汇编是这样的:
	pushl	%ebp
	movl	8(%ecx), %eax
	movl	%esp, %ebp
	popl	%ebp
	ret


跟前面HotSpot编译出来的结果对比看看,感觉如何?
IcyFenix 2011-02-14
嗯,编译出来两者都是仅一条mov指令而已。

不过,下面代码GoodBoy.go()执行的时候速度有空指针检查和没有空指针检查是一致的

那BadBoy.go()执行的时候,难道不会比C++慢一些吗?遇到隐式异常,重新调用应该也要费点时间吧?

public class GoodBoy{

private Foo foo;  // 假设Foo有子类,无法内联。

public GoodBoy(Foo foo){
   this.foo = foo;
}

public void go(){
	for(int i=0;i<100000;i++){
    		foo.bar(foo);
	}
}

}

public class BadBoy extends GoodBoy{

	public void go(){
	try{
		for(int i=0;i<100000;i++){
	    		foo.bar(i%2==1?null:foo);
		}
	catch(Exception e){}
	}
}
RednaxelaFX 2011-02-14
嘿嘿,那楼主先读读我之前分享的资料,Java虚拟机分享演示稿2010-12-28版
211、218、252这几页
IcyFenix 2011-02-14
为了方便其他同学阅读,我把你PPT这几页贴出来:

引用

隐式异常处理
对空指针、栈溢出等异常情冴丌显式检查,而是直接生成不检查错误的代码
◦ 在正常执行时不会因为要检查异常条件而带来任何开销
◦ 在异常发生时速度比显式检查异常条件慢
◦ 若检测到异常经常发生则重新编译,生成显式检查异常条件的代码

JIT编译器生成的nmethod对象
// A nmethod contains:
//  - header                 (the nmethod structure)
//  [Relocation]
//  - relocation information
//  - constant part          (doubles, longs and floats used in nmethod)
// 
//  - code body
//  - exception handler
//  - stub code
//  [Debugging information]
//  - oop array
//  - data array
//  - pcs
//  [Exception handler table]
//  - handler entry point array
//  [Implicit Null Pointer exception table]
//  - implicit null table array

一些参数
 develop(intx, ImplicitNullCheckThreshold, 3,\
 "Don't do implicit null checks if NPE's in a method exceeds limit")



这么说来BadBoy.go()是会慢一些咯。然后我还有2个疑问
引用
若检测到异常经常发生则重新编译,生成显式检查异常条件的代码

1.为何不把显式检查异常条件的代码直接生成出来,放在正常不会被执行到的位置,如果出现异常跳转到该位置重新执行来代替重新编译?是因为出现异常后恢复现场不方便吗?

2.隐式异常表叫这名字的话……(Implicit Null Pointer exception table)
数组上下界检查、类型检查这些咋办?被优化器无情地抛弃了?他们虽然没对象访问频率高,但也算很经常遇到吧?

你这个PPT真是个好东西,我以前下载过6月份版本的,那时候还有个4小时的录像。我挺不住先去睡觉了,晚安~~提前多谢你解答上面2个问题:)
RednaxelaFX 2011-02-14
先把前面一些地方再细化一点点。
IcyFenix 写道
用java伪代码来说就是下面那样……
try {
    return foo_without_null_check.value; // 优化后,正常访问时,使用不带空指针检查的代码版本直接访问value字段
} catch(IMPLICIT_NPE e) {   // 这里是伪代码,不是真的java catch
    return foo_with_null_check.value;    // 如果出了异常,使用带空指针检查的版本来返回value字段
}

那样的话,如果正常执行,确实是没有额外开销= =#

这里,准确来说对应的Java伪代码是:
try {
  return foo.value;
} catch (segfault e) {
  uncommon_trap(); // reason='null_check' action='maybe_recompile'
}

如果真的遇到null的情况,会陷入这个所谓的uncommon trap中,回到VM内部;VM可以知道诱发该trap的原因是null检查,并且应该采取的行动是看看要不要重新编译该方法。

关于uncommon trap,在前面提到的我的演示稿的235和258页有提到。可以参考的论文是The Java HotSpot Server Compiler

然后,再把前面用的例子稍微修改:
public class TestC2NullCheck2 {
  public static int getValue(Foo foo) {
    return foo.value;
  }
  
  public static void main(String[] args) throws Exception {
    for (int i = 0; i < 120000; i++) {
      try { getValue(null); } catch (NullPointerException e) { /* ignore */ }
    }
    Thread.sleep(2000);
  }
}

class Foo {
  public int value;
}


这样跑:
java -XX:+UnlockDiagnosticVMOptions -XX:CompileCommand='compileonly,TestC2NullCheck2.getValue' -XX:-UseFastAccessorMethods -XX:+PrintAssembly TestC2NullCheck2


这样得到的getValue()对应的x86代码就会有明显的不同:
Decoding compiled method 0xb489d408:
Code:
[Disassembling for mach='i386']
[Entry Point]
[Verified Entry Point]
  # {method} 'getValue' '(LFoo;)I' in 'TestC2NullCheck2'
  # parm0:    ecx       = 'Foo'
  #           [sp+0x10]  (sp of caller)
  0xb489d500: push   %ebp
  0xb489d501: sub    $0x8,%esp          ;*synchronization entry
                                        ; - TestC2NullCheck2::getValue@-1 (line 3)
  0xb489d507: test   %ecx,%ecx
  0xb489d509: je     0xb489d519
  0xb489d50b: mov    0x8(%ecx),%eax
  0xb489d50e: add    $0x8,%esp
  0xb489d511: pop    %ebp
  0xb489d512: test   %eax,0xb77e6000    ;   {poll_return}
  0xb489d518: ret    
  0xb489d519: mov    $0x90f91d68,%ecx   ;   {oop(a 'java/lang/NullPointerException')}
  0xb489d51e: movl   $0x0,0xc(%ecx)     ;*getfield value
                                        ; - TestC2NullCheck2::getValue@1 (line 3)
  0xb489d525: add    $0x8,%esp
  0xb489d528: pop    %ebp
  0xb489d529: jmp    0xb489cf60         ;   {runtime_call}
  0xb489d52e: hlt    
  0xb489d52f: hlt    
  0xb489d530: hlt    
  0xb489d531: hlt    
  0xb489d532: hlt    
  0xb489d533: hlt    
  0xb489d534: hlt    
  0xb489d535: hlt    
  0xb489d536: hlt    
  0xb489d537: hlt    
  0xb489d538: hlt    
  0xb489d539: hlt    
  0xb489d53a: hlt    
  0xb489d53b: hlt    
  0xb489d53c: hlt    
  0xb489d53d: hlt    
  0xb489d53e: hlt    
  0xb489d53f: hlt    
[Exception Handler]
[Stub Code]
  0xb489d540: jmp    0xb489bba0         ;   {no_reloc}
[Deopt Handler Code]
  0xb489d545: push   $0xb489d545        ;   {section_word}
  0xb489d54a: jmp    0xb4882c00         ;   {runtime_call}
[Constants]
  0xb489d54f: int3   

这个版本与前一个版本的主要差异是这个版本给进的参数总是null而前一个版本总是不为null。可以看到,由此而带来的结果是,HotSpot的server编译器决定给这个版本生成显式的null检查(test %ecx,%ecx),并且也不再生成原本对应的uncommon trap,也就是说不会因为遇到null而重新编译。
这个行为可以由前面提到的ImplicitNullCheckThreshold参数来控制,是server模式专有的参数。client模式的处理稍微简单些,不涉及uncommon trap。

有些情况下,一个方法平时正常执行都好好的,突然抛个异常伴随着很高的代价,其中就很可能涉及到uncommon trap -> deoptimize (OSR) -> reinterpret/recompile。这也正好跟你在顶楼提到的老帖扯上关系了,呵呵。

接下来回答你的问题:
IcyFenix 写道
1.为何不把显式检查异常条件的代码直接生成出来,放在正常不会被执行到的位置,如果出现异常跳转到该位置重新执行来代替重新编译?是因为出现异常后恢复现场不方便吗?

想想看,一个不执行的“检查”有什么意义?如果能达到“正常不会执行”但“异常会执行”的效果,那中间必然还是经过了某种检查的。在HotSpot所使用的隐式异常处理中,NPE是靠硬件/操作系统来配合实现检查的——当通过null(也就是0附近的地址)访问数据时,硬件先会进入一个fault,然后操作系统会生成一个SIGSEGV信号(*-nix上)或抛出一个STATUS_ACCESS_VIOLATION结构化异常(Windows上的SEH),此时由HotSpot预先注册的处理器捕获到它们,然后再根据情况跳转到相应的处理代码去,接下来很可能会转换为抛出一个Java层面上的异常。在这里的例子中就是转为抛出NullPointerException了。

如果乐观的预计某段代码从来不会遇到NPE的话,那生成出平时不执行的null检查代码就是浪费空间。因为Java里语义上需要做null检查的地方很多,生成出没用的null检查就显得额外的浪费。
在server模式下,如果一个方法在被JIT编译之前就已经多次(>= ImplicitNullCheckThreshold)抛出过NPE,那么该方法最初被JIT编译的时候就已经会生成出带有显式null检查的代码了,看这个回复开头TestC2NullCheck2的例子。
带uncommon trap的隐式异常处理与一开始就生成显式null检查,这两种情况已经能覆盖住常见情况的颇大部分,所以也更加没必要“把显式检查异常条件的代码直接生成出来,放在正常不会被执行到的位置,如果出现异常跳转到该位置重新执行来代替重新编译”。

话说,既然隐式异常处理那么好,正常执行的时候是零开销的,为啥还会需要生成显式检查的版本呢?
这是因为隐式异常处理在真的遇到异常的时候需要付出的开销比显式检查大许多。硬件产生fault之后,程序需要从用户态切换到内核态去处理它,然后再切换回到用户态做进一步处理。这样的状态切换比起一直保持在用户态去做显式检查还要慢得多,所以如果异常情况经常发生的话用显式检查会更高效一些。

IcyFenix 写道
这么说来BadBoy.go()是会慢一些咯。

那得看跟什么比。不要陷入不公平比较的陷阱里去 

以前面TestC2NullCheck的例子看,它用了隐式异常处理,而它与对比用C代码所对应的x86代码(在foo.value这个点上)是完全一致的。但如果TestC2NullCheck.getValue()遇到null,我们可以得到带有完整stack trace的NullPointerException,而那个C的版本则只会扔个冷冰冰的segfault就退出了。

改造过TestC2NullCheck2的例子使用显式异常处理,所以它遇到NPE能毫无问题的正常运行过去。与前面对比组的C代码相比,带有显式异常处理自然是“慢”一些,但问题是那个C代码根本无法达到正常运行的目的。真要正常运行,我们还得给它手动加上NULL检查。这么一来,加上显式NULL检查的C代码也不会比同等条件的Java代码快多少。这才是所谓公平比较。

IcyFenix 写道
2.隐式异常表叫这名字的话……(Implicit Null Pointer exception table)
数组上下界检查、类型检查这些咋办?被优化器无情地抛弃了?他们虽然没对象访问频率高,但也算很经常遇到吧?

数组边界检查(array bounds check)与类型检查(subtype checking)在HotSpot里都不通过隐式异常的方式来实现。
在我知道的范围里,HotSpot只对null检查与除以0这两种情况使用隐式异常处理。
在SharedRuntime里有这么一个枚举类型:
  enum ImplicitExceptionKind {
    IMPLICIT_NULL,
    IMPLICIT_DIVIDE_BY_ZERO,
    STACK_OVERFLOW
  };

可以看到一共就三种可能。其中,栈溢出虽然也被列在里面了,虽然它产生异常用的方式跟隐式NPE非常类似(都是通过segfault)以及后续处理方式都非常类似,但毕竟HotSpot会为它显式生成代码(称为stack banging,在方法入口处的mov %eax,-0x4000(%esp)这种样子的代码就是),所以我习惯性把它归类为显式处理的。

高效的数组边界检查与快速子类型检查都是Java优化在学术研究上受到高度关注的方面。在这两方面,HotSpot的server模式编译器在优化的时候本来就已经会很努力的去消除不必要的检查,使得肯定不会抛异常的代码能尽可能快的跑。

例如说,在缺乏上下文信息的前提下,下面的代码必然要做数组边界检查:
// int i; int[] arr;
int j = arr[i];

但如果在有上下文的地方那就不一样了:
for (int i = 0; i < arr.length; i++) {
  int j = arr[i];
  // ...
}

首先HotSpot的server模式编译器通过模式匹配会发现这是一个简单的步进循环,步进单位是1。进而推断出i在循环中的值域。
因为在for循环中的i的值域只可能落在arr数组正常的索引范围内,所以可以保证数组边界检查必然通过,于是HotSpot的server编译器就在这里就不会为arr[i]生成边界检查代码,实际运行的时候就是零代价。
Global site tag (gtag.js) - Google Analytics