寫個續集,填坑來了!關於“Thread.sleep(0)這一行‘看似無用’的代碼”裏面留下的坑。

語言: CN / TW / HK

你好呀,我是居家十三天只出了一次小區門的歪歪。

這篇文章是來填坑的,我以前寫文章的時候也會去填之前的一些坑,但是由於拖延症,大多都會隔上上幾個月。

這次這個坑比較新鮮,就是之前發佈的 《沒有二十年功力,寫不出這一行“看似無用”的代碼!》 這篇文章,太多太多的朋友看完之後問出了一個相同的問題:

首先非常感謝閲讀我文章的朋友,同時也特別感謝閲讀的過程中帶着自己的思考,提出有價值的問題的朋友,這對我而言是一種正反饋。

我當時寫的時候確實沒有想到這個問題,所以當突然問起的時候我大概知道原因,由於未做驗證,所以也不敢貿然回答。

於是我尋找了這個問題的答案,所以先説結論:

就是和 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 我通過查閲資料得出了這樣的兩個結論:

  • 1.正在執行 native 函數的線程可以看作“已經進入了safepoint”。
  • 2.由於 sleep 方法是 native 的,所以調用 sleep 方法的線程會進入 Safepoint。

論點清晰、論據合理、推理完美、事實清楚,所以上集演到這裏就結束了...

直到,有很多朋友問出了這個問題:

可是 num.getAndAdd 底層也是 native 方法調用啊?

對啊,和 sleep 方法一樣,這也是 native 方法調用啊,完全符合前面的結論啊,它為什麼不進入安全點呢,為什麼要搞差別對待呢?

大膽假設

看到問題的時候,我的第一反應就是先把鍋甩給 JIT 吧,畢竟除了它,其他的我也實在想(不)不(了)到(解)。

為什麼我會直接想到 JIT 呢?

因為循環中的這一行的代碼屬於典型的熱點代碼:

num.getAndAdd(1);

引用《深入理解JVM虛擬機》裏面的描述,熱點代碼,主要是分為兩類:

  • 被多次調用的方法。
  • 被多次執行的循環體。

前者很好理解,一個方法被調用得多了,方法體內代碼執行的次數自然就多,它成為“熱點代碼”是理所當然的。

而後者則是為了解決當一個方法只被調用過一次或少量的幾次,但是方法體內部存在循環次數較多的循環體,這樣循環體的代碼也被重複執行多次,因此這些代碼也應該認為是“熱點代碼”。很明顯,我們的示例代碼就屬於這種情況。

在我們的示例代碼中,循環體觸發了熱點代碼的編譯動作,而循環體只是方法的一部分,但編譯器依然必須以整個方法作為編譯對象。

因為編譯的目標對象都是整個方法體,不會是單獨的循環體。

既然兩種類型都是“整個方法體”,那麼區別在於什麼地方?

區別就在於執行入口(從方法第幾條字節碼指令開始執行)會稍有不同,編譯時會傳入執行入口點字節碼序號(Byte Code Index,BCI)。

這種編譯方式因為編譯發生在方法執行的過程中,因此被很形象地稱為“棧上替換”(On Stack Replacement,OSR),即方法的棧幀還在棧上,方法就被替換了。

説到 OSR 你就稍微耳熟了一點,是不是?畢竟它也偶現於面試環節中,作為一些高(裝)階(逼)面試題存在。

其實也就這麼回事。

好,概念就先説到這裏,剩下的如果你想要詳細瞭解,可以去翻閲書裏面的“編譯對象與觸發條件”小節。

我主要是為了引出虛擬機針對熱點代碼搞了一些優化這個點。

基於前面的鋪墊,我完全可以假設如下兩點:

  • 1.由於 num.getAndAdd 底層也是 native 方法調用,所以肯定有安全點的產生。
  • 2.由於虛擬機判定 num.getAndAdd 是熱點代碼,就來了一波優化。優化之後,把本來應該存在的安全點給乾沒了。

小心求證

其實驗證起來非常簡單,前面不是説了嗎,是 JIT 優化搞的鬼,那我直接關閉 JIT 功能,再跑一次,不就知道結論了嗎?

如果關閉 JIT 功能後,主線程在睡眠 1000ms 之後繼續執行,説明什麼?

説明循環體裏面可以進入 safepoint,程序執行結果符合預期。

所以結果是怎麼樣的呢?

我可以用下面的這個參數關閉 JIT:

-Djava.compiler=NONE

然後再次運行程序:

可以看到關閉 JIT 之後,主線程並沒有等待子線程運行結束後才輸出 num。效果等同於前面説的把 int 修改為 long,或者加入 Thread.sleep(0) 這樣的代碼。

因此我前面的那兩點假設是不是就成立了?

好,那麼問題就來了,説好的是小心求證,但是我這裏只是用了一個參數關閉了 JIT,雖然看到了效果,但是總感覺中間還缺點東西。

缺什麼東西呢?

前面的程序我已經驗證了:經過 JIT 優化之後,把本來應該存在的安全點給乾沒了。

但是這句話其實還是太籠統了,經過 JIT 優化之前和之後,分別是長什麼樣子的呢,能不能從什麼地方看出來安全點確實是沒了?

不能我説沒了就沒了,這得眼見為實才行。

誒,你説巧不巧。

我剛好知道有個東西怎麼去看這個“優化之前和之後“。

有個工具叫做 JITWatch,它就能幹這個事兒。

https://github.com/AdoptOpenJ...

如果你之前沒用過這個工具的話,可以去查查教程。不是本文重點,我就不教了,一個工具而已,不復雜的。

我把代碼貼到 JITWatch 的沙箱裏面:

然後點擊運行,最後就能得到這樣的一個界面。

左邊是 Java 源碼,中間是 Java 字節碼,右邊是 JIT 之後的彙編指令:

我框起來的部分就是 JIT 分層編譯後的不同的彙編指令。

其中 C2 編譯就是經過充分編譯之後的高性能指令,它於 C1 編譯後的彙編代碼有很多不同的地方。

這一部分如果之前沒接觸過,看不懂沒關係,也很正常,畢竟面試也不會考。

我給你截這幾張的意思就是表明,你只要知道,我現在已經可以拿到優化之前和之後的彙編指令了,但是他們自己的差異點很多,那麼我應該關注的差異點是什麼呢?

就像是給你兩個文本,讓你找出差異點,很容易。但是在眾多差異點中,哪個是我們關心的呢?

這個才是關鍵問題。

我也不知道,但是我找到了下面這一篇文章,帶領我走向了真相。

關鍵文章

好了,前面都是一些不痛不癢的東西,這裏的這篇文章才是關鍵點:

http://psy-lob-saw.blogspot.c...

因為我在這個文章中,找到了 JIT 優化之後,應該關注的“差異點”是什麼。

這篇文章的標題叫做《安全點的意義、副作用以及開銷》:

作者是一位叫做 nitsanw 的大佬,從他博客裏面的文章看,在 JVM 和性能優化方面有着很深的造詣,上面的文章就發佈於他的博客。

這是他的 github 地址:

https://github.com/nitsanw

用的頭像是一頭犛牛,那我就叫他牛哥吧,畢竟是真的牛。

同時牛哥就職於 Azul 公司,和 R 大是同事:

他這篇文章算是把安全點扒了個乾淨,但是內容非常多,我不可能面面俱到,只能挑和本文相關度非常大的地方進行簡述,但是真的強烈建議你讀讀原文。文章也分為了上下兩集,這是下集的地址:

http://psy-lob-saw.blogspot.c...

看完之後,你就知道,什麼叫做透徹,什麼叫做:

在牛哥的文章中分為了下面這幾個小節:

  • What's a Safepoint?(啥是安全點?)
  • When is my thread at a safepoint?(線程啥時候處於安全點?)
  • Bringing a Java Thread to a Safepoint。(將一個Java線程帶到一個安全點)
  • All Together Now。(搞幾個例子跑跑)
  • Final Summary And Testament。(總結和囑咐)

和本文重點相關的是“將一個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

然後在輸出裏面找下面的關鍵詞:

  • 如果是 OpenJDK,就找 {poll} 或 {poll return} ,這就是對應的安全點指令。
  • 如果是 Zing,就找 tls.pls_self_suspend 指令

實操一把就是這樣的:

確實找到了類似的關鍵字,但是在控制枱輸出的彙編太多了,根本沒法分析。

沒關係,這不重要,重要的是我到了這個關鍵的指令:{poll}

也就是説,如果在初始的彙編中有 {poll} 指令,但是在經過 JIT 充分優化之後的代碼,也就是前面説的 C2 階段的彙編指令裏面,找不到 {poll} 這個指令,就説明安全點確實是被幹掉了。

所以,在 JITWatch 裏面,當我選擇查看 for 循環(熱點代碼)在 C1 階段的編譯結果的時候,可以看看有 {poll} 指令:

但是,當我選擇 C2 階段的編譯結果的時候,{poll} 指令確實都找不到了:

接着,如果我把代碼修改為這樣,也就是前面説的會正常結束的代碼:

正常結束,説明循環體內可以進入安全點,也就是説明有 {poll} 指令。

所以,再次通過 JITWarch 查看 C2 的彙編,果然看到了它:

為什麼呢?

從最終輸出的彙編上來看,因為 Thread.sleep(0) 這行代碼的存在,阻止了 JIT 做過於激進的優化。

那麼為什麼 sleep 會阻止 JIT 做過於激進的優化呢?

好了,

別問了,

就到這吧,

再問,

就不禮貌了。

牛哥的案例

牛哥的文章中給了下面五個案例,每個案例都有對應的代碼:

  • Example 0: Long TTSP Hangs Application
  • Example 1: More Running Threads -> Longer TTSP, Higher Pause Times
  • Example 2: Long TTSP has Unfair Impact
  • Example 3: Safepoint Operation Cost Scale
  • Example 4: Adding Safepoint Polls Stops Optimization

我主要帶大家看看第 0 個和第 4 個,老有意思了。

第 0 個案例

它的代碼是這樣的:

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.c...

簡單來説就是他是在 Eclipse 裏面跑的,而 Eclipse 並不是用的 javac 來編譯,而是用的自己的編譯器。

編譯器差異導致字節碼的差異,從而導致運行結果的差異:

然後牛哥通過一頓分析,給出了這樣的一段代碼,

和之前的代碼唯一不一樣的地方,就是在子線程裏面調用 countOdds 方法之前,在主線程裏面先進行了 10w 次的運行調用。

這樣改造之後代碼運行起來就不會在 5 秒之後停止了,必須要強行 kill 才行。

為什麼呢?

別問,問就是答案就在他的下集裏面,自己去翻,寫的非常詳細。

同時在下集中,牛哥還非常貼心的給你貼出了他總結的六種循環的寫法,那些算是“counted Loops”,建議仔細辨別:

第 4 個案例

這個案例是一個基準測試,牛哥説它是來自 Netty 的一個 issue:

這裏怎麼突然提到 Netty 了呢?

牛哥給了一個超鏈接:

https://github.com/netty/nett...

這個 pr 裏面討論的內容非常的多,其中一個爭論的點就是循環到底用 int 還是 long。

這個哥們寫了一個基準測試,測試結果顯示用 int 和 long 似乎沒啥差別:

需要説明的是,為了截圖方便,我截圖的時候把這個老哥的基準測試刪除了。如果你想看他的基準測試代碼,可以通過前面説的鏈接去找到。

然後這個看起來頭髮就很茂盛的老哥直接用召喚術召喚了牛哥:

等了一天之後,牛哥寫了一個非常非常詳細的回覆,我還是隻截取其中一部分:

他上來就説前面的老哥的基準測試寫的有點毛病,所以看起來差不多。你看看我寫的基準測試跑出來的分,差距就很大了。

牛哥這裏提到的基準測試,就是我們的第四個案例。

所以也可以結合着 Netty 的這個特別長的 pr 去看這個案例,看看什麼叫做專業。

最後,再説一次,文中提到的牛哥的兩篇文章,建議仔細閲讀。

另外,關於安全點的源碼,我之前也分享過這篇文章,建議一起食用,味道更佳: 《關於安全點的那點破事兒》

我只是給你指個路,剩下的路就要你自己走了,天黑路滑,燈火昏暗,小心腳下,不要深究,及時回頭,阿彌陀佛!

最後,感謝你閲讀我的文章。歡迎關注公眾號【why技術】,文章全網首發哦。