你好呀,我是why。html
不知道你們還有沒有印象,我曾經寫了這樣的一篇文章:《一個困擾我122天的技術問題,我好像知道答案了。》java
文章我給出了這樣的一個示例:linux
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
。架構
這都不是重點,重點是我接下來有幾處小改動,代碼的運行結果也是各不相同。併發
文章中的最後一節我是這樣說的:app
而圖片裏面提到的「關於Integer」的問題,就是文章說提到的「玄學」:less
是的,我回來填坑了。
其實讓我再次探索這個問題的原由是由於四月份的時候有人私信我,問我關於 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 關鍵字影響了程序的運行。
其實當時我得出這個結論的時候很是興奮,一個困擾我長達一年多的問題終於要被我親手解開神祕面紗了。
結論都有了,尋找推理過程還不是垂手可得的事情?
並且我知道去哪裏找答案,答案就藏在我桌子上的一本書裏面。
因而我翻開了《Java併發編程的藝術》,其中有一小節專門講到了 final 域的內存語義:
.png)
這一小節我印象但是太深入了,由於 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/j...
在這篇食譜裏面,有這樣的一個表格:
能夠看到,在 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 處理器中是空操做。
這特麼是玄學吧?
繞了一圈,怎麼又回去了啊。
這波,說真的,激怒我了,我花了這麼多時間,繞了一圈又回來了?
幹它。
通過前面的分析,留言中提到的結論是驗證不下去了。
可是我已經能夠很是明確的知道,確定是 final 關鍵字在做怪。
因而,我準備去 stackoverflow 上找一圈,看看會不會有意外發現。
果真,皇天不負有心人,我大概翻了幾百個帖子,就在準備放棄的邊緣,我翻到了一個讓我虎軀一震的帖子。
虎軀一震以後,又是倒吸一口涼氣:個人個娘,這是 JVM 的一個 BUG!?
這事先按下不表,我先說說我是怎麼在 stackoverflow 裏面搜索問題的。
首先,當前的這個狀況下,我能肯定的關鍵字就是 Java
,final
這兩個。
可是我拿着這兩個關鍵字去查的時候,查詢出來的結果太多了,翻了幾個以後我就發現這無疑是大海撈針。
因而我改變了策略,stackoverflow 上搜索是有 tag 即標籤功能的:
若是讓我把這個問題劃分一個標籤,標籤無非就是 Java
,JVM
,JMM
,JIT
。
因而,我在 java-memory-model
即 JMM 下挖到了一個寶藏:
就是這個寶藏問題,推進了接下來的劇情發展:
https://stackoverflow.com/que...
我知道你看到這裏的時候心裏毫無波瀾,聽到我虎軀一震,甚至還想笑。
可是我看到這個問題的時候,不誇張的說:手都在抖。
由於我知道,在這裏,就能解決這個玄學問題了。
而我倒吸一口涼氣的緣由是:這個問題裏面的示例代碼居然和個人代碼一模一樣,他代碼裏面的 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/...
專欄裏面的這篇文章,寶藏:
https://zhuanlan.zhihu.com/p/...
好比本文涉及到的棧上替換(OSR),R大就回答過:
直言,OSR 對於跑分頗有用,對於正常程序來講,用不上:
其中提到了這樣的一段話:
JIT 對代碼作了很是激進的優化。
其實回到咱們的文章中,final 關鍵字的加上與否,表象上看是生成了兩套不一樣的機器碼,而本質上仍是 final 關鍵字阻止了 JIT 進行激進的優化。