Java中已经存在了十几年的一个bug...
點擊上方“朱小廝的博客”,選擇“設為星標”
后臺回復”加群“獲取公眾號專屬群聊入口
來源:rrd.me/gfgTx
今天,分享一個 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,如果有,就復制一份出來。
這個 BUG 的相關 issue 和 patch 在這里:https://bugs.openjdk.java.net/projects/JDK/issues/JDK-8231988?filter=allissues
JBS 系統上沒有詳細的分析過程,只有最后的 patch,所以我把這個問題寫了個總結發在這里。可以看到,即使是很簡單的測試用例,在編譯器內部也會經歷各種復雜的變換和優化。然后一些階段的優化可能會影響后一個階段的,所以編譯器的 BUG 也往往晦澀。但反過來說,也很有意思。
想知道更多?掃描下面的二維碼關注我
后臺回復”加群“獲取公眾號專屬群聊入口
【精彩推薦】
一文講透微服務下如何保證事務的一致性
如何理解Linux中的零拷貝技術
干貨!Java字節碼增強探秘
Java Agent初探
IO多路復用是什么意思
當我們在談論內存的時候,我們在談論什么 | 干貨
分布式文件系統設計,該從哪些方面考慮
咱們從頭到尾說一次Java垃圾回收
朕已閱?
總結
以上是生活随笔為你收集整理的Java中已经存在了十几年的一个bug...的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: Java agent初探
- 下一篇: 重新复习一下JDK14的9大重磅特性