今天,分享一個 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 也往往晦澀。但反過來說,也很有意思。