做者:海纳
https://zhuanlan.zhihu.com/p/...
今天,分享一个JDK中使人惊讶的BUG,这个BUG的神奇之处在于,复现它的用例太简单了,人肉眼就能回答的问题,JDK中却存在了十几年。通过测试,咱们发现从JDK8到14都存在这个问题。java
你们能够在本身的开发平台上试试这段代码:git
public class Hello { public void test() { int i = 8; while ((i -= 3) > 0); System.out.println("i = " + i); } public static void main(String[] args) { Hello hello = new Hello(); for (int i = 0; i < 50_000; i++) { hello.test(); } } }
再使用如下命令执行: java Hello
面试
而后,就会看到这样的输出: 后端
固然,在程序的开始阶段,仍是能打印出正确的"i = -1"。微信
这个问题最终Huawei JDK的两名同事解决掉了,而且回合到社区。我这里大概讲一下分析的思路。关注微信公众号:Java技术栈,在后台回复:java,能够获取我整理的 N 篇最新 Java 教程,都是干货。多线程
首先,使用解释执行能够发现,结果都是正确的,这就说明,这基本上是JIT编译器的问题,而后经过-XX:-TieredCompilation关闭C1编译,问题一样复现,可是使用-XX:TieredStopAtLevel=3将JIT编译停留在C阶段,问题就不复现,这能够肯定是C2的问题了。架构
接下来,一名同事当即猜测到这个"/"实际上是('0'-1),恰好是字符零的ascii码减掉1。嗯,熟记ascii码表的重要性就体现出来了。接下来,就是找到c2中 int 转字符的地方。关键点,就在于这个字符'0',固然这里要对C2有足够的了解,立刻就找到c2中字符转化的方法(具体的代码 ,请参考OpenJDK社区):工具
void PhaseStringOpts::int_getChars(GraphKit& kit, Node* arg, Node* char_array, Node* start, Node* end) { // ...... // char sign = 0; Node* i = arg; Node* sign = __ intcon(0); // if (i < 0) { // sign = '-'; // i = -i; // } { IfNode* iff = kit.create_and_map_if(kit.control(), __ Bool(__ CmpI(arg, __ intcon(0)), BoolTest::lt), PROB_FAIR, COUNT_UNKNOWN); RegionNode *merge = 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-(q*10) ... // 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在这里:
https://bugs.openjdk.java.net...
JBS系统上没有详细的分析过程,只有最后的patch,因此我把这个问题写了个总结发在这里。能够看到,即便是很简单的测试用例,在编译器内部也会经历各类复杂的变换和优化。而后一些阶段的优化可能会影响后一个阶段的,因此编译器的BUG也每每晦涩。但反过来讲,也颇有意思。
推荐去个人博客阅读更多:
2.Spring MVC、Spring Boot、Spring Cloud 系列教程
3.Maven、Git、Eclipse、Intellij IDEA 系列工具教程
生活很美好,明天见~