前兩天一個小伙伴突然找我求助,說準備換個坑,最近在系統復習多線程知識,但遇到了一個刷新認知的問題……
小伙伴:Effective JAVA 里的并發章節里,有一段關于可見性的描述。下面這段代碼會出現死循環,這個我能理解,JMM 內存模型嘛,JMM 不保證 stopRequested 的修改能被及時的觀測到。
static boolean stopRequested = false;public static void main(String[] args) throws InterruptedException { Thread backgroundThread = new Thread(() -> { int i = 0; while (!stopRequested) { i++; } }) ; backgroundThread.start(); TimeUnit.MICROSECONDS.sleep(10); stopRequested = true ;}
但奇怪的是在我加了一行打印之后,就不會出現死循環了!難道我一行 println 能比 volatile 還好使啊?這倆也沒關系啊
static boolean stopRequested = false;public static void main(String[] args) throws InterruptedException { Thread backgroundThread = new Thread(() -> { int i = 0; while (!stopRequested) { // 加上一行打印,循環就能退出了! System.out.println(i++); } }) ; backgroundThread.start(); TimeUnit.MICROSECONDS.sleep(10); stopRequested = true ;}
我:小伙子八股文背的挺熟啊,JMM 張口就來。
我:這個……其實是 JIT 干的好事,導致你的循環無法退出。JMM 只是一個邏輯上的內存模型規范,JIT可以根據JMM的規范來進行優化。
比如你第一個例子里,你用-Xint禁用 JIT,就可以退出死循環了,不信你試試?
小伙伴:WK,真的可以,加上 -Xint 循環就退出了,好神奇!JIT 是個啥啊?還能有這種功效?
眾所周知,JAVA 為了實現跨平臺,增加了一層 JVM,不同平臺的 JVM 負責解釋執行字節碼文件。雖然有一層解釋會影響效率,但好處是跨平臺,字節碼文件是平臺無關的。
在 JAVA 1.2 之后,增加了 即時編譯(Just-in-Time Compilation,簡稱 JIT) 的機制,在運行時可以將執行次數較多的熱點代碼編譯為機器碼,這樣就不需要 JVM 再解釋一遍了,可以直接執行,增加運行效率。
但 JIT 編譯器在編譯字節碼時,可不僅僅是簡單的直接將字節碼翻譯成機器碼,它在編譯的同時還會做很多優化,比如循環展開、方法內聯等等……
這個問題出現的原因,就是因為 JIT 編譯器的優化技術之一 - 表達式提升(expression hoisting) 導致的。
先來看個例子,在這個 hoisting 方法中,for 循環里每次都會定義一個變量 y,然后通過將 x*y 的結果存儲在一個 result 變量中,然后使用這個變量進行各種操作
public void hoisting(int x) { for (int i = 0; i < 1000; i = i + 1) { // 循環不變的計算 int y = 654; int result = x * y; // ...... 基于這個 result 變量的各種操作 }}
但是這個例子里,result 的結果是固定的,并不會跟著循環而更新。所以完全可以將 result 的計算提取到循環之外,這樣就不用每次計算了。JIT 分析后會對這段代碼進行優化,進行表達式提升的操作:
public void hoisting(int x) { int y = 654; int result = x * y; for (int i = 0; i < 1000; i = i + 1) { // ...... 基于這個 result 變量的各種操作 }}
這樣一來,result 不用每次計算了,而且也完全不影響執行結果,大大提升了執行效率。
注意,編譯器更喜歡局部變量,而不是靜態變量或者成員變量;因為靜態變量是“逃逸在外的”,多個線程都可以訪問到,而局部變量是線程私有的,不會被其他線程訪問和修改。
編譯器在處理靜態變量/成員變量時,會比較保守,不會輕易優化。
像你問題里的這個例子中,stopRequested就是個靜態變量,編譯器本不應該對其進行優化處理;
static boolean stopRequested = false;// 靜態變量public static void main(String[] args) throws InterruptedException { Thread backgroundThread = new Thread(() -> { int i = 0; while (!stopRequested) { // leaf method i++; } }) ; backgroundThread.start(); TimeUnit.MICROSECONDS.sleep(10); stopRequested = true ;}
但由于你這個循環是個 leaf method,即沒有調用任何方法,所以在循環之中不會有其他線程會觀測到stopRequested值的變化。那么編譯器就冒進的進行了表達式提升的操作,將stopRequested提升到表達式之外,作為循環不變量(loop invariant)處理:
int i = 0;boolean hoistedStopRequested = stopRequested;// 將stopRequested 提升為局部變量while (!hoistedStopRequested) { i++;}
這樣一來,最后將 stopRequested賦值為 true 的操作,影響不了提升的hoistedStopRequested的值,自然就無法影響循環的執行了,最終導致無法退出。
至于你增加了 println 之后,循環就可以退出的問題。是因為你這行 println 代碼影響了編譯器的優化。println 方法由于最終會調用
FileOutputStream.writeBytes 這個 native 方法,所以無法被內聯優化(inling)。而未被內斂的方法調用從編譯器的角度看是一個“full memory kill”,也就是說 副作用不明 、必須對內存的讀寫操作做保守處理。
在這個例子里,下一輪循環的 stopRequested 讀取操作按順序要發生在上一輪循環的 println 之后。這里“保守處理”為:就算上一輪我已經讀取了 stopRequested 的值,由于經過了一個副作用不明的地方,再到下一次訪問就必須重新讀取了。
所以在你增加了 prinltln 之后,JIT 由于要保守處理,重新讀取,自然就不能做上面的表達式提升優化了。
以上對表達式提升的解釋,總結摘抄自 R大的知乎回答。R大,行走的 JVM Wiki!
我:“這下明白了吧,這都是 JIT 干的好事,你要是禁用 JIT 就沒這問題了”
小伙伴:“WK,一個簡單的 for 循環也太多機制了,沒想到 JIT 這么智能,也沒想到 R 大這么”
小伙伴:“那 JIT 一定很多優化機制吧,除了這個表達式提升還有啥?”
我:我也不是搞編譯器的……哪了解這么多,就知道一些常用的,簡單給你說說吧
和表達式提升類似的,還有個表達式下沉的優化,比如下面這段代碼:
public void sinking(int i) { int result = 543 * i; if (i % 2 == 0) { // 使用 result 值的一些邏輯代碼 } else { // 一些不使用 result 的值的邏輯代碼 }}
由于在 else 分支里,并沒有使用 result 的值,可每次不管什么分支都會先計算 result,這就沒必要了。JIT 會把 result 的計算表達式移動到 if 分支里,這樣就避免了每次對 result 的計算,這個操作就叫表達式下沉:
public void sinking(int i) { if (i % 2 == 0) { int result = 543 * i; // 使用 result 值的一些邏輯代碼 } else { // 一些不使用 result 的值的邏輯代碼 }}
除了上面介紹的表達式提升/表達式下沉以外,還有一些常見的編譯器優化機制。
下面這個 for 循環,一共要循環 10w 次,每次都需要檢查條件。
for (int i = 0; i < 100000; i++) { delete(i);}
在編譯器的優化后,會刪除一定的循環次數,從而降低索引遞增和條件檢查操作而引起的開銷:
for (int i = 0; i < 20000; i+=5) { delete(i); delete(i + 1); delete(i + 2); delete(i + 3); delete(i + 4);}
除了循環展開,循環還有一些優化機制,比如循環剝離、循環交換、循環分裂、循環合并……
JVM 的方法調用是個棧的模型,每次方法調用都需要一個壓棧(push)和出棧(pop)的操作,編譯器也會對調用模型進行優化,將一些方法的調用進行內聯。
內聯就是抽取要調用的方法體代碼,到當前方法中直接執行,這樣就可以避免一次壓棧出棧的操作,提升執行效率。比如下面這個方法:
public void inline(){ int a = 5; int b = 10; int c = calculate(a, b); // 使用 c 處理……}public int calculate(int a, int b){ return a + b;}
在編譯器內聯優化后,會將 calculate 的方法體抽取到 inline 方法中,直接執行,而不用進行方法調用:
public void inline(){ int a = 5; int b = 10; int c = a + b; // 使用 c 處理……}
不過這個內聯優化是有一些限制的,比如 native 的方法就不能內聯優化
來先看一個例子,在這個例子中 was finalized! 會在 done.之前輸出,這個也是因為 JIT 的優化導致的。
class A { // 對象被回收前,會觸發 finalize @Override protected void finalize() { System.out.println(this + " was finalized!"); } public static void main(String[] args) throws InterruptedException { A a = new A(); System.out.println("Created " + a); for (int i = 0; i < 1_000_000_000; i++) { if (i % 1_000_00 == 0) System.gc(); } System.out.println("done."); }}//打印結果Created A@1be6f5c3A@1be6f5c3 was finalized!//finalize方法輸出done.
從例子中可以看到,如果 a 在循環完成后已經不再使用了,則會出現先執行finalize的情況;雖然從對象作用域來說,方法沒有執行完,棧幀并沒有出棧,但是還是會被提前執行。
這就是因為 JIT 認為 a 對象在循環內和循環后都不會在使用,所以提前給它置空了,幫助 GC 回收;如果禁用 JIT,那就不會出現這個問題。
這個提前回收的機制,還是有點風險的,在某些場景下可能會引起 BUG……
上面只是介紹了幾個簡單常用的編譯優化機制,JVM JIT 更多的優化機制可以參考下面這個圖。這是 OpenJDK 文檔中提供的一個 pdf 材料,里面列出了 HotSpot JVM 的各種優化機制,相當多……
小伙伴:“JIT 這么多優化機制,很容易出問題啊,我平時寫代碼要怎么避開這些呢”
平時在編碼的時候,不用刻意的去關心 JIT 的優化,就比如上面那個 println 問題,JMM 本來就不保證修改對其他線程可見,如果按照規范去加鎖或者用 volatile 修飾,根本就不會有這種問題。
而那個提前置空導致的問題,出現的幾率也很低,只要你規范寫代碼基本不會遇到的。
我:所以,這不是 JIT 的鍋,是你的……
小伙伴:“懂了,你這是說我菜,說我代碼寫的屎啊……”
在日常編碼過程中,不用刻意的猜測 JIT 的優化機制,JVM 也不會完整的告訴你所有的優化。而且這種東西不同版本效果不一樣,就算搞明白了一個機制,可能到下個版本就會完全不一樣。
所以,如果不是搞編譯器開發的話,JIT 相關的編譯知識,作為一個知識儲備就好。
也不用去猜測 JIT 到底會怎么優化你的代碼,你(可能)猜不準……
本故事純屬瞎編,請勿隨意對號入座
可能部分讀者大佬們會認為是 sync 導致的問題,下面是稍加改造后的 sync 例子,結果是仍然無法退出死循環……
public class HoistingTest { static boolean stopRequested = false; public static void main(String[] args) throws InterruptedException { Thread backgroundThread = new Thread(() -> { int i = 0; while (!stopRequested) { // 加上一行打印,循環就能退出了!// System.out.println(i++); new HoistingTest().test(); } }) ; backgroundThread.start(); TimeUnit.SECONDS.sleep(5); stopRequested = true ; } Object lock = new Object(); private void test(){ synchronized (lock){} }}
再升級下,把 test 方法,也加上 sync,結果還是無法退出死循環……
Object lock = new Object();private synchronized void test(){ synchronized (lock){}}
但我只是想說,這個問題的關鍵是 jit 的優化導致的問題。jmm 只是規范,而 jit 的優化機制,也會遵循 jmm 的規范。
不過 jmm 并沒有說 sync 會影響 jit 之類的,可就算 sync 會影響那又怎么樣呢……并不是關鍵點
結合 R大 的解釋,編譯器對靜態變量更敏感,如果把上面的 lock 對象修改成 static 的,循環又可以退出了……
那如果不加 static ,把 sync 換成 unsafe.pageSize()呢?結果是循環還是可以退出……
所以,本文的重點是描述 jit 的影響,而不是各種會影響 jit 的動作。影響 jit 的可能性會非常多,而且不同的vm甚至不同的版本表現都會有所不同,我們并不需要去摸清這個機制,也沒法摸清(畢竟不是做編譯器的,就是是做編譯器,也不一定是 HotSpot……)
作者:京東保險 蔣信
來源:京東云開發者社區
本文鏈接:http://www.tebozhan.com/showinfo-26-12432-0.html一個 println 竟然比 volatile 還好使?
聲明:本網頁內容旨在傳播知識,若有侵權等問題請及時與本網聯系,我們將在第一時間刪除處理。郵件:2376512515@qq.com
上一篇: 圖形編輯器開發:快捷鍵的管理
下一篇: 如何實現并部署自己的Npm解析服務