02.24 一個在Java中已經存在了十幾年的一個bug...


一個在Java中已經存在了十幾年的一個bug...

今天,分享一個 JDK 中令人驚訝的 BUG,這個 BUG 的神奇之處在於,復現它的用例太簡單了,人肉眼就能回答的問題,JDK 中卻存在了十幾年。經過測試,我們發現從 JDK8 到 14 都存在這個問題。

大家可以在自己的開發平臺上試試這段代碼:

<code>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();        }    }}/<code>

再使用以下命令執行:

java Hello然後,就會看到這樣的輸出:

<code>i = /i = /i = /i = /i = /i = /i = /i = /i = /i = /i = /i = //<code>

當然,在程序的開始階段,還是能打印出正確的"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 社區):

<code>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;  // }  {   // 略去和這個循環相對應的代碼  }  // 略去很多代碼}/<code>

可以看到,這裡在中間表示階段引入了一個“i < 0"的判斷。主要就是那個 CmpI 結點,看起來這裡的邏輯走錯了,導致 i 明明小於 0,結果卻走到了大於 0 的分支,這樣,直接拿字符'0'與 i 求和的結果,就是錯的了。

那這個 CmpI 為什麼會錯呢?使用 c2visualizer 工具可以看到,在 GVN 階段,上面循環中的 CmpI 和這裡引入的 CmpI 被合併了。GVN 的全稱是 Global Value Numbering,名字很高大上,其實就是表達式去重。例如:


一個在Java中已經存在了十幾年的一個bug...


上面的例子中,兩個 CmpI 的輸入參數是完全相同的。都是變量 i 和整數 0,那麼,這兩個 CmpI 結點其實就是完全相同的。這樣的話,編譯器在做中間優化的時候就會把這兩個 CmpI 結點合併成一個。

到這裡為止,其實還是沒問題的。但接下來,編譯器會對空的循環體做一些特別的變換,編譯器能直接計算出空循環體結束以後,i 的值是 -1,又發現空循環體什麼都不做,所以,它乾脆把 CmpI 的兩個參數都換成了 -1,以便於讓循環走不進來——而且,編譯器再做一次常量傳播就可以把這個 CmpI 徹底幹掉了。但是,這裡 CmpI 就有問題了,這裡強行搞成 False 讓循環不執行,並且把 i 的值也直接變成循環結束的那個值。但剛才合併的那個 CmpI 也被吃掉了。

這就導致,直接拿著 i = -1 這個值進到了 i >= 0 的分支裡了。所以修改也很簡單,那就是在對 CmpI 變換的時候,看看它還有沒有其他的 out,如果有,就複製一份出來。

JBS 系統上沒有詳細的分析過程,只有最後的 patch,所以我把這個問題寫了個總結髮在這裡。可以看到,即使是很簡單的測試用例,在編譯器內部也會經歷各種複雜的變換和優化。然後一些階段的優化可能會影響後一個階段的,所以編譯器的 BUG 也往往晦澀。但反過來說,也很有意思。


分享到:


相關文章: