414天前,我以為這是程式設計玄學...

why技術發表於2021-05-10

你好呀,我是why。

不知道大家還有沒有印象,我曾經寫了這樣的一篇文章:《一個困擾我122天的技術問題,我好像知道答案了。》

文章我給出了這樣的一個示例:

public class VolatileExample {

    private static boolean flag = false;
    private static int i = 0;
    public static void main(String[] args) {
        new Thread(() -> {
            try {
                TimeUnit.MILLISECONDS.sleep(100);
                flag = true;
                System.out.println("flag 被修改成 true");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }).start();
        while (!flag) {
            i++;
        }
        System.out.println("程式結束,i=" + i);
    }
}

上面這個程式是不會正常結束的,因為變數 flag 沒有被 volatile 修飾。

而在子執行緒休眠的 100ms 中, while 迴圈的 flag 一直為 false,迴圈到一定次數後,觸發了 jvm 的即時編譯功能(JIT),進行迴圈表示式外提(Loop Expression Hoisting),導致形成死迴圈。

而如果加了 volatile 去修飾 flag 變數,保證了 flag 的可見性,則不會進行提升。

驗證方案就是關閉 JIT 功能,對應的命令是 -Xint 或者 -Djava.compiler=NONE

這都不是重點,重點是我接下來有幾處小改動,程式碼的執行結果也是各不相同。

文章中的最後一節我是這樣說的:

而圖片裡面提到的“關於Integer”的問題,就是文章說提到的“玄學”:

是的,我回來填坑了。

再次探索

其實讓我再次探索這個問題的起因是因為四月份的時候有人私信我,問我關於 Integer 的玄學問題是否有了結論。

我只能說:

但是,後來我想到了這篇文章裡面的一個留言:

由於當時公眾號沒有留言功能,用的第三方小程式,所以我沒有太注意到留言提醒。

這位大佬留言之後,我隔了很長時間才看到,我還在留言後面回覆了一個:

謝謝大佬分析,有時間的時候我按照這個思路去分析分析。

但是後來我也擱置了,因為我感覺好像繼續在這裡面深究下去收益已經不大了。

沒想到,時隔這麼長時間,又有讀者來問了。

於是在五一期間我按照留言的說法,修改了一下程式,並進行了一波基於搜尋引擎的研究。

嘿,你猜怎麼著?

我還真的研究出了一點有意思的東西。

先說結論:final 關鍵字影響了程式的結果。

在上面這個案例中,final 關鍵字在哪呢?

當我們把程式裡面的 int 修改為 Integer 後,i++ 操作涉及到裝箱、拆箱的操作,這個過程中對應的原始碼是這裡:

而這裡的 new Interger(i) 裡面的 value 是 final,

程式能正常結束,確實是 final 關鍵字影響了程式的結果。

那麼final 到底是怎麼影響的呢?

這個地方我經過探索之後,發現和留言中說的有一定的偏差。

留言中說的是因為有 storestore 屏障加上 Happens-Before 關係得出 flag 會被刷到主記憶體中。

而我基於搜尋引擎的幫助,探索出來的結論是加上 final 和不加 final,生成的是兩套機器碼,導致執行結果不一致。

但是我這裡得加上一個前提:處理器是 x86 架構。

得出這個結論基於的測試案例如下,也是按照留言給的思路寫出來的:

Class 裡面包含一個 final 的屬性,在構造方法裡面給屬性賦值。然後在 while 迴圈裡面不斷 new 該物件:

我的執行環境是:

  • jdk1.8.0_271
  • win10
  • IntelliJ IDEA 2019.3.4

執行結果是:

  • 如果 age 屬性加上 final 修飾,程式則可以正常退出。
  • 如果 age 屬性去掉 final 修飾,程式則無限迴圈,不能退出。

動圖如下:

你也可以把我上面給的程式碼粘出來,跑一跑,看看是否和我說的執行結果一致。

說說 final

當我把程式改造成上面這個樣子之後,其實結論已經很明顯了,final 關鍵字影響了程式的執行。

其實當時我得出這個結論的時候非常興奮,一個困擾我長達一年多的問題終於要被我親手解開神祕面紗了。

結論都有了,尋找推理過程還不是輕而易舉的事情?

而且我知道去哪裡找答案,答案就藏在我桌子上的一本書裡面。

於是我翻開了《Java併發程式設計的藝術》,其中有一小節專門講到了 final 域的記憶體語義:

這一小節我印象可是太深刻了,因為 3.6.5 小節的“溢位”應該是“逸出”才對,早年間還基於此,寫了這篇文章:

《講真,我發現這本書有個地方寫錯了!》

所以我只要在這一個小節裡面找到證據,來證明留言裡面的“storestore 屏障加上 Happens-Before 關係得出 flag 會被刷到主記憶體中”這個論點就行了。

但是,事情遠遠沒有我想的這麼簡單,因為我發現,我在書裡面沒有找到能證明論點的證據,反而找到了推翻論點的證據。

書裡面的一大段內容我就不搬運過來了,僅僅關注 3.6.6 final語義在處理器中的實現這一小節的內容:

注意畫了下劃線這一句話:在 X86 處理器中,final 域的讀/寫不會插入任何記憶體屏障。

由於沒有任何記憶體屏障的存在,即“storestore 屏障”也是省略掉了。因此在 X86 處理器的前提下,final 域的記憶體語義帶來的 flag 重新整理是不存在的。

所以前面的論點是不正確的。

那麼這本書裡面的“在 X86 處理器中,final 域的讀/寫不會插入任何記憶體屏障”這個結論又是從哪裡來的呢?

這個說來就巧了,是我們的老朋友 Doug Lee 告訴作者的。

你看 3.6.7 小節提到了 JSR-133。而關於 JSR-133,老爺子寫過這樣的一篇文章:《The JSR-133 Cookbook for Compiler Writers》,直譯過來就是《編譯器編寫者的JSR-133食譜》

http://gee.cs.oswego.edu/dl/jmm/cookbook.html

在這篇食譜裡面,有這樣的一個表格:

可以看到,在 x86 處理器中,LoadStore、LoadLoad、StoreStore 都是 no-op,即無任何操作。

On x86, any lock-prefixed instruction can be used as a StoreLoad barrier. (The form used in linux kernels is the no-op lock; addl $0,0(%%esp).) Versions supporting the "SSE2" extensions (Pentium4 and later) support the mfence instruction which seems preferable unless a lock-prefixed instruction like CAS is needed anyway. The cpuid instruction also works but is slower.

翻譯過來就是:在 x86 上,任何帶 lock 字首的指令都可以用作一個 StoreLoad 屏障。 (在 Linux 核心中使用的形式是 no-op lock; addl $0,0(%%esp)。) 支援 "SSE2" 擴充套件的版本(Pentium4 和更高版本)支援 mfence 指令, 該指令似乎是更好的,除非無論如何都需要像 CAS 這樣的帶 lock 字首的指令。cpuid 指令也可以,但是速度較慢。

查到這裡的時候我都快懵逼了,好不容易整理出來的一點點思路就這樣再次被堵死了。

我給你捋一下啊。

我們是不是已經可以非常明確 final 帶來的屏障(StoreStore)在 X86 處理器中是空操作,並不能對記憶體可見性產生任何影響。

那麼為什麼程式加上 final 之後,停下來了?

程式停下來了,說明主執行緒一定是觀測到了 flag 的變化了?

那麼為什麼程式去掉 final 後,停不下來了?

程式沒有停了,說明主執行緒一定沒有觀測到 flag 的變化?

也就是說停不停下來,和有沒有 final 有直接的關係。

但是 final 域帶來的屏障在 X86 處理器中是空操作。

這特麼是玄學吧?

繞了一圈,怎麼又回去了啊。

這波,說真的,激怒我了,我花了這麼多時間,繞了一圈又回來了?

幹它。

stackoverflow

經過前面的分析,留言中提到的結論是驗證不下去了。

但是我已經可以非常明確的知道,肯定是 final 關鍵字在作怪。

於是,我準備去 stackoverflow 上找一圈,看看會不會有意外發現。

果然,皇天不負有心人,我大概翻了幾百個帖子,就在準備放棄的邊緣,我翻到了一個讓我虎軀一震的帖子。

虎軀一震之後,又是倒吸一口涼氣:我的個娘,這是 JVM 的一個 BUG!?

這事先按下不表,我先說說我是怎麼在 stackoverflow 裡面搜尋問題的。

首先,當前的這個情況下,我能確定的關鍵字就是 Java,final 這兩個。

但是我拿著這兩個關鍵字去查的時候,查詢出來的結果太多了,翻了幾個之後我就發現這無疑是大海撈針。

於是我改變了策略,stackoverflow 上搜尋是有 tag 即標籤功能的:

如果讓我把這個問題劃分一個標籤,標籤無非就是 Java,JVM,JMM,JIT

於是,我在 java-memory-model 即 JMM 下挖到了一個寶藏:

就是這個寶藏問題,推動了接下來的劇情發展:

https://stackoverflow.com/questions/57427531/in-java-what-operations-are-involved-in-the-final-field-assignment-in-the-cons

我知道你看到這裡的時候內心毫無波瀾,聽到我虎軀一震,甚至還想笑。

但是我看到這個問題的時候,不誇張的說:手都在抖。

因為我知道,在這裡,就能解決這個玄學問題了。

而我倒吸一口涼氣的原因是:這個問題裡面的示例程式碼竟然和我的程式碼如出一轍,他程式碼裡面的 Simple 就是對應著我程式碼裡面的 Why。想要驗證的問題,那就更是一模一樣了。

問題裡面的描述是這樣說的:

Actually, I know the storing "final" field would not emit any assembly instructions on x86 platform. But why this situation came out? Are there some particular operations I don't know ?

實際上,我知道“final”欄位不會在 x86 處理器上發出任何彙編指令。但為什麼會出現這種情況?有什麼特別的操作我不知道嗎?

真相

上面提到的 stackoverflow 問題下面有這樣的一個回答,這裡面就是玄學背後的科學:

我翻譯一下給你看:

老哥,我看到你問題裡面的截圖了,你查問題的姿勢沒對。

截圖是什麼呢?

就是提問者附在問題裡面的兩個截圖:

其中 final case 的截圖是這樣的:

non-final case 的截圖是這樣的:

順道說一句題外話,截圖來源就是 JITWatch 工具,一個很強大的工具。

從你的截圖來看,雖然 runMethod 都被編譯過了,但是並沒有被真正的執行過。你需要注意的是彙編輸出中有 % 標記的地方,它代表著 OSR(on-stack replacement)棧上替換。

如果你不清楚啥是 OSR 也先彆著急,一會說。

對於加和不加 final,最終得出的彙編程式碼是不一樣的,我編譯之後,僅保留相關部分如下:

從截圖中可以看出,沒有加 final 的時候,彙編程式碼其實就是一個死迴圈。而加上 final 之後,每次都會去載入 flag 欄位。

但是你看,這兩種情況,都沒有對 Simple 類進行例項分配,也沒有欄位的分配。

所以,這不是編譯器 final 欄位賦值的問題,而是編譯器的一種優化手段。

整個過程中完全沒有 Simple 類的事兒,也就更加沒有 final 欄位的事兒了。但是加上 final 之後確實影響了程式的結果。

這個問題在比較新的 JVM 版本中得到了修復(言外之意就是一個 BUG?)。

所以,如果你在 JDK 11 版本上執行相同的程式碼,無論加不加 final,程式都不會正常退出。

好了,上面說了這麼多,其實原因已經很清楚了。

根本原因是因為加不加 final 在我的示例環境中生成的是兩套不同的機器碼。

深層次的原因是 OSR 機制導致的。

驗證

經過前面的分析,現在新的排查方向又出來了。

我現在得去驗證一下回答問題這個哥們是不是在胡說。

於是我先去驗證了他的這句話:

If you run the same example on JDK 11, there will be an infinite loop in both cases, regardless of the final modifier.

用高版本的 JDK 分別執行加了 final 和不加 final 修飾符的情況。

程式確實是都陷入了死迴圈。

動圖如下,可以看到我的 JDK 版本是 15.0.1:

第一個點驗證完成。同樣的程式碼,JDK8 和 JDK15 執行起來結果不一致(其實JDK9執行就不一致了)。

我有理由相信,也許這是 JVM 的一個,不能說 BUG,應該說是缺陷吧。(等等...缺陷不就是 BUG 嗎?)

第二個驗證的點是他的這句話:

Instead, execution jumps from the interpreter to the OSR stub.

用 JDK8 跑出來結果不一樣是因為有棧上替換在搗鬼,那麼我可以用下面這個命令,把棧上替換給關閉了:

-XX:-UseOnStackReplacement

去掉 final 後,再次執行程式,程式停止了。

第二個點驗證完成。

第三個驗證的點是他的這個地方:

我也把我的彙編搞出來看看,有沒有類似這樣的地方。

怎麼搞彙編出來呢?

用下面這個命令:

-XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:+LogCompilation -XX:LogFile=jit.log

同時你還需要一個 hsdis 的 dll 檔案,網上有很多,一搜就能找到,我相信如果你也想親自驗證,那麼找這個檔案難不倒你。

沒有加 final 欄位的時候,彙編是這樣的:

jmp 指令是幹啥的?

無條件跳轉。

所以,這裡就是個死迴圈。

加上 final 欄位後,彙編是這樣的:

首先跳轉用的是 je 了,而不是 jmp 了。

je 的跳轉是有條件的,代表的是“等於則跳轉”。

而在 je 指令之前,還有 movzbl 指令,該操作就是在讀取 flag 變數的值。

所以,加了 final 語句之後,每次都會去讀取 flag 變數的值,因此 flag 值的變化能及時被主執行緒看到。

同時我也有 JITWatch 看了一下,對於迴圈中的 new Why(18) 語句,編譯器分析出來這句話並沒有什麼卵用,於是被優化掉了:

所以我們在彙編中沒有看到對 Why 物件進行分配的相關指令,也就是驗證了他的這句話:

You see, in both cases there is no Simple instance allocation at all, and no field assignment either.

自此,玄學問題得到了科學的解釋。

如果你堅持看到了這裡,那麼恭喜你,又學到了一個沒啥卵用的知識點。

如果你想要學點和本文相關的、有用的東西,那麼我建議看看這幾個地方:

  • 《Java併發程式設計的藝術》的3.6小節-final域的記憶體語義。
  • 《深入理解Java虛擬機器》的第四部分-程式編譯與程式碼優化。
  • 《深入解析Java虛擬機器HotSpot》的第7章-編譯概述,第8章-C1編譯器,第9章-C2編譯器。
  • 《Java效能優化實踐》的第10章-理解即時編譯

看完上面這些之後,你至少會比較清楚的瞭解到 Java 程式從原始碼編譯成位元組碼,再從位元組碼編譯成本地機器碼的這兩個過程。

能夠了解 JVM 的熱點程式碼探測方案、HotSpot 的即時編譯、編譯觸發條件,以及如何從 JVM 外部觀察和分析即使編譯的資料和結果。

還有會了解到一些編譯器的優化技術,比如:方法內聯、分層編譯、棧上替換、分支預測、逃逸分析、鎖消除、鎖膨脹...等等,這些基本上用不上,但是你知道了又顯得高大上的知識點。

另外,強推R大的這個專欄:

https://www.zhihu.com/column/hllvm

專欄裡面的這篇文章,寶藏:

https://zhuanlan.zhihu.com/p/25042028

比如本文涉及到的棧上替換(OSR),R大就回答過:

直言,OSR 對於跑分很有用,對於正常程式來說,用不上:

其中提到了這樣的一段話:

JIT 對程式碼做了非常激進的優化。

其實回到我們的文章中,final 關鍵字的加上與否,表象上看是生成了兩套不同的機器碼,而本質上還是 final 關鍵字阻止了 JIT 進行激進的優化。

相關文章