你好呀,我是居家十三天只出了一次小區門的歪歪。
這篇文章是來填坑的,我以前寫文章的時候也會去填之前的一些坑,但是由於拖延症,大多都會隔上上幾個月。
這次這個坑比較新鮮,就是之前釋出的《沒有二十年功力,寫不出這一行「看似無用」的程式碼!》這篇文章,太多太多的朋友看完之後問出了一個相同的問題:
首先非常感謝閱讀我文章的朋友,同時也特別感謝閱讀的過程中帶著自己的思考,提出有價值的問題的朋友,這對我而言是一種正反饋。
我當時寫的時候確實沒有想到這個問題,所以當突然問起的時候我大概知道原因,由於未做驗證,所以也不敢貿然回答。
於是我尋找了這個問題的答案,所以先說結論:
就是和 JIT 編譯器有關。由於迴圈體中的程式碼被判定為熱點程式碼,所以經過 JIT 編譯後 getAndAdd 方法的進入安全點的機會被優化掉了,所以執行緒不能在迴圈體能進入安全點。
是的,被優化了,我打這個詞都感覺很殘忍。
接下來我準備寫個「下集」,告訴你我是怎麼得到這個結論的。但是為了讓你絲滑入戲,我先帶你簡單的回顧一下「上集」。
另外,先把話說在前面,這知識點吧,屬於可能一輩子都遇不到的那種。因此我把它劃分到我寫的「沒有卵用系列」,看著也就圖一樂。
好了,在之前的那篇文章中,我給出了這樣的一個測試用例:
public class MainTest {
public static AtomicInteger num = new AtomicInteger(0);
public static void main(String[] args) throws InterruptedException {
Runnable runnable=()->{
for (int i = 0; i < 1000000000; i++) {
num.getAndAdd(1);
}
System.out.println(Thread.currentThread().getName()+"執行結束!");
};
Thread t1 = new Thread(runnable);
Thread t2 = new Thread(runnable);
t1.start();
t2.start();
Thread.sleep(1000);
System.out.println("num = " + num);
}
}
按照程式碼來看,主執行緒休眠 1000ms 後就會輸出結果,但是實際情況卻是主執行緒一直在等待 t1,t2 執行結束才繼續執行。
執行結果是這樣的:
其實我在這裡埋了「彩蛋」,這個程式碼雖然你直接貼上過去就能跑,但是如果你的 JDK 版本高於 10,那麼執行結果就和我前面說的不一樣了。
從結果來看,還是有不少人挖掘到了這個「彩蛋」:
所以看文章的時候,有機會自己親自驗證一下,說不定會有意外收穫的。
針對程式表現和預期不一致的問題,第一個解決方案是這樣的:
把 int 修改為 long 就搞定了。至於為什麼,之前的文章中已經說明了,這裡就不贅述了。
關鍵的是下面這個解決方案,所有的爭議都圍繞著它展開。
受到 RocketMQ 原始碼的啟示,我把程式碼修改為了這樣:
從執行結果上來看 ,即使 for 迴圈的物件是 int 型別,也可以按照預期執行。
為什麼呢?
因為在上集中關於 sleep 我通過查閱資料得出了這樣的兩個結論:
論點清晰、論據合理、推理完美、事實清楚,所以上集演到這裡就結束了...
直到,有很多朋友問出了這個問題:
可是 num.getAndAdd 底層也是 native 方法呼叫啊?
對啊,和 sleep 方法一樣,這也是 native 方法呼叫啊,完全符合前面的結論啊,它為什麼不進入安全點呢,為什麼要搞差別對待呢?
看到問題的時候,我的第一反應就是先把鍋甩給 JIT 吧,畢竟除了它,其他的我也實在想(不)不(了)到(解)。
為什麼我會直接想到 JIT 呢?
因為迴圈中的這一行的程式碼屬於典型的熱點程式碼:
num.getAndAdd(1);
參照《深入理解JVM虛擬機器器》裡面的描述,熱點程式碼,主要是分為兩類:
前者很好理解,一個方法被呼叫得多了,方法體內程式碼執行的次數自然就多,它成為「熱點程式碼」是理所當然的。
而後者則是為了解決當一個方法只被呼叫過一次或少量的幾次,但是方法體內部存在迴圈次數較多的迴圈體,這樣迴圈體的程式碼也被重複執行多次,因此這些程式碼也應該認為是「熱點程式碼」。很明顯,我們的範例程式碼就屬於這種情況。
在我們的範例程式碼中,迴圈體觸發了熱點程式碼的編譯動作,而回圈體只是方法的一部分,但編譯器依然必須以整個方法作為編譯物件。
因為編譯的目標物件都是整個方法體,不會是單獨的迴圈體。
既然兩種型別都是「整個方法體」,那麼區別在於什麼地方?
區別就在於執行入口(從方法第幾條位元組碼指令開始執行)會稍有不同,編譯時會傳入執行入口點位元組碼序號(Byte Code Index,BCI)。
這種編譯方式因為編譯發生在方法執行的過程中,因此被很形象地稱為「棧上替換」(On Stack Replacement,OSR),即方法的棧幀還在棧上,方法就被替換了。
說到 OSR 你就稍微耳熟了一點,是不是?畢竟它也偶現於面試環節中,作為一些高(裝)階(逼)面試題存在。
其實也就這麼回事。
好,概念就先說到這裡,剩下的如果你想要詳細瞭解,可以去翻閱書裡面的「編譯物件與觸發條件」小節。
我主要是為了引出虛擬機器器針對熱點程式碼搞了一些優化這個點。
基於前面的鋪墊,我完全可以假設如下兩點:
其實驗證起來非常簡單,前面不是說了嗎,是 JIT 優化搞的鬼,那我直接關閉 JIT 功能,再跑一次,不就知道結論了嗎?
如果關閉 JIT 功能後,主執行緒在睡眠 1000ms 之後繼續執行,說明什麼?
說明迴圈體裡面可以進入 safepoint,程式執行結果符合預期。
所以結果是怎麼樣的呢?
我可以用下面的這個引數關閉 JIT:
-Djava.compiler=NONE
然後再次執行程式:
可以看到關閉 JIT 之後,主執行緒並沒有等待子執行緒執行結束後才輸出 num。效果等同於前面說的把 int 修改為 long,或者加入 Thread.sleep(0) 這樣的程式碼。
因此我前面的那兩點假設是不是就成立了?
好,那麼問題就來了,說好的是小心求證,但是我這裡只是用了一個引數關閉了 JIT,雖然看到了效果,但是總感覺中間還缺點東西。
缺什麼東西呢?
前面的程式我已經驗證了:經過 JIT 優化之後,把本來應該存在的安全點給乾沒了。
但是這句話其實還是太籠統了,經過 JIT 優化之前和之後,分別是長什麼樣子的呢,能不能從什麼地方看出來安全點確實是沒了?
不能我說沒了就沒了,這得眼見為實才行。
誒,你說巧不巧。
我剛好知道有個東西怎麼去看這個「優化之前和之後「。
有個工具叫做 JITWatch,它就能幹這個事兒。
https://github.com/AdoptOpenJDK/jitwatch
如果你之前沒用過這個工具的話,可以去查查教學。不是本文重點,我就不教了,一個工具而已,不復雜的。
我把程式碼貼到 JITWatch 的沙箱裡面:
然後點選執行,最後就能得到這樣的一個介面。
左邊是 Java 原始碼,中間是 Java 位元組碼,右邊是 JIT 之後的組合指令:
我框起來的部分就是 JIT 分層編譯後的不同的組合指令。
其中 C2 編譯就是經過充分編譯之後的高效能指令,它於 C1 編譯後的組合程式碼有很多不同的地方。
這一部分如果之前沒接觸過,看不懂沒關係,也很正常,畢竟面試也不會考。
我給你截這幾張的意思就是表明,你只要知道,我現在已經可以拿到優化之前和之後的組合指令了,但是他們自己的差異點很多,那麼我應該關注的差異點是什麼呢?
就像是給你兩個文字,讓你找出差異點,很容易。但是在眾多差異點中,哪個是我們關心的呢?
這個才是關鍵問題。
我也不知道,但是我找到了下面這一篇文章,帶領我走向了真相。
好了,前面都是一些不痛不癢的東西,這裡的這篇文章才是關鍵點:
http://psy-lob-saw.blogspot.com/2015/12/safepoints.html
因為我在這個文章中,找到了 JIT 優化之後,應該關注的「差異點」是什麼。
這篇文章的標題叫做《安全點的意義、副作用以及開銷》:
作者是一位叫做 nitsanw 的大佬,從他部落格里面的文章看,在 JVM 和效能優化方面有著很深的造詣,上面的文章就釋出於他的部落格。
這是他的 github 地址:
https://github.com/nitsanw
用的頭像是一頭犛牛,那我就叫他牛哥吧,畢竟是真的牛。
同時牛哥就職於 Azul 公司,和 R 大是同事:
他這篇文章算是把安全點扒了個乾淨,但是內容非常多,我不可能面面俱到,只能挑和本文相關度非常大的地方進行簡述,但是真的強烈建議你讀讀原文。文章也分為了上下兩集,這是下集的地址:
http://psy-lob-saw.blogspot.com/2016/02/wait-for-it-counteduncounted-loops.html
看完之後,你就知道,什麼叫做透徹,什麼叫做:
在牛哥的文章中分為了下面這幾個小節:
和本文重點相關的是「將一個Java執行緒帶到一個安全點」這個部分。
我給你解析一下:
這一段主要在說 Java 執行緒需要每隔一個時間間隔去輪詢一個「安全點標識」,如果這個標識告訴執行緒「請前往安全點」,那麼它就進入到安全點的狀態。
但是這個輪詢是有一定的消耗的,所以需要 keep safepoint polls to a minimum,也就是說要減少安全點的輪詢。因此,關於安全點輪詢觸發的時間就很有講究。
既然這裡提到輪詢了,那麼就得說一下我們範例程式碼裡面的這個 sleep 時間了:
有的讀者把時間改的短了一點,比如 500ms,700ms 之類的,發現程式正常結束了?
為什麼?
因為輪詢的時間由 -XX:GuaranteedSafepointInterval 選項控制,該選項預設為 1000ms:
所以,當你的睡眠時間比 1000ms 小太多的時候,安全點的輪詢還沒開始,你就 sleep 結束了,當然觀察不到主執行緒等待的現象了。
好了,這個只是隨口提一句,回到牛哥的文章中,他說綜合各種因素,關於安全點的輪詢,可以在以下地方進行:
第一個地方:
Between any 2 bytecodes while running in the interpreter (effectively)
在直譯器模式下執行時,在任何 2 個位元組碼之間都可以進行安全點的輪詢。
要理解這句話,就需要了解直譯器模式了,上個圖:
從圖上可以知道,直譯器和編譯器之間是相輔相成的關係。
另外,可以使用 -Xint 啟動引數,強制虛擬機器器執行於「解釋模式」:
我們完全可以試一試這個引數嘛:
程式正常停下來了,為什麼?
剛剛才說了:
在直譯器模式下執行時,在任何 2 個位元組碼之間都可以進行安全點的輪詢。
第二個地方:
On 'non-counted' loop back edge in C1/C2 compiled code
在 C1/C2 編譯程式碼中的 "非計數 "迴圈的每次迴圈體結束之後。
關於這個「計數迴圈」和「非計算迴圈」我在上集裡面已經說過了,也演示過了,就是把 int 修改為 long,讓「計數迴圈」變成「非計算迴圈」,就不贅述了。
反正我們知道這裡說的沒毛病就行。
第三個地方:
這是前半句:Method entry/exit (entry for Zing, exit for OpenJDK) in C1/C2 compiled code.
在 C1/C2 編譯程式碼中的方法入口或者出口處(Zing 為入口,OpenJDK 為出口)。
前半句很好理解,對於我們常用的 OpenJDK 來說,即使經過了 JIT 優化,但是在方法的入口處還是設定了一個可以進行安全點輪詢的地方。
主要是關注後半句:
Note that the compiler will remove these safepoint polls when methods are inlined.
當方法被內聯時編譯器會刪除這些安全點輪詢。
這不就是我們範例程式碼的情況嗎?
本來有安全點,但是被優化沒了。說明這種情況是真實存在的。
然後我們接著往下看,就能看到我一直在找的「差異點」了:
牛哥說,如果有人想看到安全點輪詢,那麼可以加上這個啟動引數:
-XX:+PrintAssembly
然後在輸出裡面找下面的關鍵詞:
實操一把就是這樣的:
確實找到了類似的關鍵字,但是在控制檯輸出的組合太多了,根本沒法分析。
沒關係,這不重要,重要的是我到了這個關鍵的指令:{poll}
也就是說,如果在初始的組合中有 {poll} 指令,但是在經過 JIT 充分優化之後的程式碼,也就是前面說的 C2 階段的組合指令裡面,找不到 {poll} 這個指令,就說明安全點確實是被幹掉了。
所以,在 JITWatch 裡面,當我選擇檢視 for 迴圈(熱點程式碼)在 C1 階段的編譯結果的時候,可以看看有 {poll} 指令:
但是,當我選擇 C2 階段的編譯結果的時候,{poll} 指令確實都找不到了:
接著,如果我把程式碼修改為這樣,也就是前面說的會正常結束的程式碼:
正常結束,說明迴圈體內可以進入安全點,也就是說明有 {poll} 指令。
所以,再次通過 JITWarch 檢視 C2 的組合,果然看到了它:
為什麼呢?
從最終輸出的組合上來看,因為 Thread.sleep(0) 這行程式碼的存在,阻止了 JIT 做過於激進的優化。
那麼為什麼 sleep 會阻止 JIT 做過於激進的優化呢?
好了,
別問了,
就到這吧,
再問,
就不禮貌了。
牛哥的文章中給了下面五個案例,每個案例都有對應的程式碼:
我主要帶大家看看第 0 個和第 4 個,老有意思了。
它的程式碼是這樣的:
public class WhenWillItExit {
public static void main(String[] argc) throws InterruptedException {
Thread t = new Thread(() -> {
long l = 0;
for (int i = 0; i < Integer.MAX_VALUE; i++) {
for (int j = 0; j < Integer.MAX_VALUE; j++) {
if ((j & 1) == 1)
l++;
}
}
System.out.println("How Odd:" + l);
});
t.setDaemon(true);
t.start();
Thread.sleep(5000);
}
}
牛哥是這樣描述這個程式碼的:
他說這個程式碼應該是在 5 秒之後結束,但是實際上它會一直執行下去,除非你用 kill -9 命令強行停止它。
但是當我把程式碼貼上到 IDEA 裡面執行起來,5 秒之後,程式停了,就略顯尷尬。
我建議你也粘出來跑一下。
這裡為什麼和牛哥說的執行結果不一樣呢?
評論區也有人問出了這個問題:
於是牛哥又寫了一篇下集,詳細的解釋了為什麼:
http://psy-lob-saw.blogspot.co.za/2016/02/wait-for-it-counteduncounted-loops.html
簡單來說就是他是在 Eclipse 裡面跑的,而 Eclipse 並不是用的 javac 來編譯,而是用的自己的編譯器。
編譯器差異導致位元組碼的差異,從而導致執行結果的差異:
然後牛哥通過一頓分析,給出了這樣的一段程式碼,
和之前的程式碼唯一不一樣的地方,就是在子執行緒裡面呼叫 countOdds 方法之前,在主執行緒裡面先進行了 10w 次的執行呼叫。
這樣改造之後程式碼執行起來就不會在 5 秒之後停止了,必須要強行 kill 才行。
為什麼呢?
別問,問就是答案就在他的下集裡面,自己去翻,寫的非常詳細。
同時在下集中,牛哥還非常貼心的給你貼出了他總結的六種迴圈的寫法,那些算是「counted Loops」,建議仔細辨別:
這個案例是一個基準測試,牛哥說它是來自 Netty 的一個 issue:
這裡怎麼突然提到 Netty 了呢?
牛哥給了一個超連結:
https://github.com/netty/netty/pull/3969#issuecomment-132559757
這個 pr 裡面討論的內容非常的多,其中一個爭論的點就是迴圈到底用 int 還是 long。
這個哥們寫了一個基準測試,測試結果顯示用 int 和 long 似乎沒啥差別:
需要說明的是,為了截圖方便,我截圖的時候把這個老哥的基準測試刪除了。如果你想看他的基準測試程式碼,可以通過前面說的連結去找到。
然後這個看起來頭髮就很茂盛的老哥直接用召喚術召喚了牛哥:
等了一天之後,牛哥寫了一個非常非常詳細的回覆,我還是隻擷取其中一部分:
他上來就說前面的老哥的基準測試寫的有點毛病,所以看起來差不多。你看看我寫的基準測試跑出來的分,差距就很大了。
牛哥這裡提到的基準測試,就是我們的第四個案例。
所以也可以結合著 Netty 的這個特別長的 pr 去看這個案例,看看什麼叫做專業。
最後,再說一次,文中提到的牛哥的兩篇文章,建議仔細閱讀。
另外,關於安全點的原始碼,我之前也分享過這篇文章,建議一起食用,味道更佳:《關於安全點的那點破事兒》
我只是給你指個路,剩下的路就要你自己走了,天黑路滑,燈火昏暗,小心腳下,不要深究,及時回頭,阿彌陀佛!
最後,感謝你閱讀我的文章。歡迎關注公眾號【why技術】,文章全網首發哦。