
本日,分享一个JDK中令人惊异的BUG,这个BUG的神奇之处在于,复现它的用例太大略了,人肉眼就能回答的问题,JDK中却存在了十几年。经由测试,我们创造从JDK8到14都存在这个问题。
大家可以在自己的开拓平台上试试这段代码:
publicclassHello{publicvoidtest(){int i=8;while ((i-=3)>0);System.out.println("i="+i);}publicstaticvoidmain(String[]args){Hellohello=newHello();for(int i=0;i<50_000;i++){hello.test();}}}
再利用以下命令实行:java Hello
然后,就会看到这样的输出:
当然,在程序的开始阶段,还是能打印出精确的"i = -1"。
这个问题终极Huawei JDK的两名同事办理掉了,并且回合到社区。我这里大概讲一下剖析的思路。
首先,利用阐明实行可以创造,结果都是精确的,这就解释,这基本上是JIT编译器的问题,然后通过-XX:-TieredCompilation关闭C1编译,问题同样复现,但是利用-XX:TieredStopAtLevel=3将JIT编译勾留在C阶段,问题就不复现,这可以确定是C2的问题了。
接下来,一名同事立即猜想到这个"/"实在是('0'-1),刚好是字符零的ascii码减掉1。嗯,熟记ascii码表的主要性就表示出来了。接下来,便是找到c2中 int 转字符的地方。关键点,就在于这个字符'0',当然这里要对C2有足够的理解,立时就找到c2中字符转化的方法(详细的代码 ,请参考OpenJDK社区):
voidPhaseStringOpts::int_getChars(GraphKit&kit,Nodearg,Nodechar_array,Nodestart,Nodeend){//......//charsign=0;Nodei=arg;Nodesign=__intcon(0);//if(i<0){//sign='-';//i=-i;//}{IfNodeiff=kit.create_and_map_if(kit.control(),__Bool(__CmpI(arg,__intcon(0)),BoolTest::lt),PROB_FAIR,COUNT_UNKNOWN);RegionNodemerge=new(C)RegionNode(3);kit.gvn().set_type(merge,Type::CONTROL);i=new(C)PhiNode(merge,TypeInt::INT);kit.gvn().set_type(i,TypeInt::INT);sign=new(C)PhiNode(merge,TypeInt::INT);kit.gvn().set_type(sign,TypeInt::INT);merge->init_req(1,__IfTrue(iff));i->init_req(1,__SubI(__intcon(0),arg));sign->init_req(1,__intcon('-'));merge->init_req(2,__IfFalse(iff));i->init_req(2,arg);sign->init_req(2,__intcon(0));kit.set_control(merge);C->record_for_igvn(merge);C->record_for_igvn(i);C->record_for_igvn(sign);}//for(;;){//q=i/10;//r=i-((q<<3)+(q<<1));//r=i-(q10)...//buf[--charPos]=digits[r];//i=q;//if(i==0)break;//}{//略去和这个循环相对应的代码}//略去很多代码}
可以看到,这里在中间表示阶段引入了一个“i < 0"的判断。紧张便是那个CmpI结点,看起来这里的逻辑走错了,导致 i 明明小于0,结果却走到了大于0的分支,这样,直接拿字符'0'与i求和的结果,便是错的了。
那这个CmpI为什么会错呢?利用c2visualizer工具可以看到,在GVN阶段,上面循环中的CmpI和这里引入的CmpI被合并了。GVN的全称是Global Value Numbering,名字很高大上,实在便是表达式去重。例如:
上面的例子中,两个 CmpI 的输入参数是完备相同的。都是变量 i 和整数 0,那么,这两个CmpI 结点实在便是完备相同的。这样的话,编译器在做中间优化的时候就会把这两个CmpI结点合并成一个。
到这里为止,实在还是没问题的。但接下来,编译器会对空的循环体做一些特殊的变换,编译器能直接打算出空循环体结束往后,i 的值是 -1,又创造空循环体什么都不做,以是,它干脆把CmpI的两个参数都换成了 -1,以便于让循环走不进来——而且,编译器再做一次常量传播就可以把这个CmpI彻底干掉了。但是,这里CmpI就有问题了,这里强行搞成 False 让循环不实行,并且把 i 的值也直接变成循环结束的那个值。但刚才合并的那个CmpI 也被吃掉了。
这就导致,直接拿着 i = -1 这个值进到了 i >= 0 的分支里了。以是修正也很大略,那便是在对CmpI变换的时候,看看它还有没有其他的out,如果有,就复制一份出来。
这个BUG的干系issue和patch在这里:
bugs.openjdk.java.net/projects/JDK/issues/JDK-8231988?filter=allissues
JBS系统上没有详细的剖析过程,只有末了的patch,以是我把这个问题写了个总结发在这里。可以看到,纵然是很大略的测试用例,在编译器内部也会经历各种繁芜的变换和优化。然后一些阶段的优化可能会影响后一个阶段的,以是编译器的BUG也每每晦涩。但反过来说,也很故意思