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

java領域佼佼者 發佈 2020-02-24T03:05:32+00:00

今天,分享一個JDK 中令人驚訝的 BUG,這個 BUG 的神奇之處在於,復現它的用例太簡單了,人肉眼就能回答的問題,JDK 中卻存在了十幾年。 public void test() { int i = 8; System.out.printl


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

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

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 = /
i = /
i = /
i = /
i = /
i = /
i = /
i = /
i = /
i = /
i = /
i = /

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

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,如果有,就複製一份出來。

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

關鍵字: