Java中,一個存在十幾年的bug...

做者:海納
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也每每晦澀。但反過來講,也頗有意思。

推薦去個人博客閱讀更多:

1.Java JVM、集合、多線程、新特性系列教程

2.Spring MVC、Spring Boot、Spring Cloud 系列教程

3.Maven、Git、Eclipse、Intellij IDEA 系列工具教程

4.Java、後端、架構、阿里巴巴等大廠最新面試題

生活很美好,明天見~

相關文章
相關標籤/搜索