沒有二十年功力,寫不出Thread.sleep(0)這一行「看似無用」的程式碼!

2022-09-05 15:00:42

你好呀,我是喜提七天居家隔離的歪歪。

這篇文章要從一個奇怪的註釋說起,就是下面這張圖:

我們可以不用管具體的程式碼邏輯,只是單單看這個 for 迴圈。

在迴圈裡面,專門有個變數 j,來記錄當前迴圈次數。

第一次迴圈以及往後每 1000 次迴圈之後,進入一個 if 邏輯。

在這個 if 邏輯之上,標註了一個註釋:prevent gc.

prevent,這個單詞如果不認識的同學記一下,考試肯定要考的:

這個註釋翻譯一下就是:防止 GC 執行緒進行垃圾回收。

具體的實現邏輯是這樣的:

核心邏輯其實就是這樣一行程式碼:

Thread.sleep(0);

這樣就能實現 prevent gc 了?

懵逼嗎?

懵逼就對了,懵逼就說明值得把玩把玩。

這個程式碼片段,其實是出自 RocketMQ 的原始碼:

org.apache.rocketmq.store.logfile.DefaultMappedFile#warmMappedFile

事先需要說明的是,我並沒有找到寫這個程式碼的人問他的意圖是什麼,所以我只有基於自己的理解去推測他的意圖。如果推測的不對,還請多多指教。

雖然這是 RocketMQ 的原始碼,但是基於我的理解,這個小技巧和 RocketMQ 框架沒有任何關係,完全可以脫離於框架存在。

我給出的修改意見是這樣的:

把 int 修改為 long,然後就可以直接把 for 迴圈裡面的 if 邏輯刪除掉了。

這樣一看是不是更加懵逼了?

不要慌,接下來,我給你抽絲剝個繭。

另外,在「剝繭」之前,我先說一下結論:

  • 提出這個修改方案的理論立足點是 Java 的安全點相關的知識,也就是 safepoint。
  • 官方最後沒有采納這個修改方案。
  • 官方採沒采納不重要,重要的是我高低得給你「剝個繭」。

探索

當我知道這個程式碼片段是屬於 RocketMQ 的時候,我想到的第一個點就是從程式碼提交記錄中尋找答案。

看提交者是否在提交程式碼的時候說明了自己的意圖。

於是我把程式碼拉了下來,一看提交記錄是這樣的:

我就知道這裡不會有答案了。

因為這個類第一次提交的時候就已經包含了這個邏輯,而且對應這次提交的程式碼也非常多,並沒有特別說明對應的功能。

從提交記錄上沒有獲得什麼有用的資訊。

於是我把目光轉向了 github 的 issue,拿著關鍵詞 prevent gc 搜尋了一番。

除了第一個連結之外,沒有找到什麼有用的資訊:

而第一個連結對應的 issues 是這個:

https://github.com/apache/rocketmq/issues/4902

這個 issues 其實就是我們在討論這個問題的過程中提出來的,也就是前面出現的修改方案:

也就是說,我想通過原始碼或者 github 找到這個問題權威的回答,是找不到了。

於是我又去了這個神奇的網站,在裡面找到了這個 2018 年提出的問題:

https://stackoverflow.com/questions/53284031/why-thread-sleep0-can-prevent-gc-in-rocketmq

問題和我們的問題一模一樣,但是這個問題下面就這一個回答:

這個回答並不好,因為我覺得沒答到點上,但是沒關係,我剛好可以把這個回答作為抓手,把差的這一點拉通對齊一下,給它賦能。

先看這個回答的第一句話:It does not(它沒有)。

問題就來了:「它」是誰?「沒有」什麼?

「它」,指的就是我們前面出現的程式碼。

「沒有」,是說沒有防止 GC 執行緒進行垃圾回收。

這個的回答說:通過呼叫 Thread.sleep(0) 的目的是為了讓 GC 執行緒有機會被作業系統選中,從而進行垃圾清理的工作。它的副作用是,可能會更頻繁地執行 GC,畢竟你每 1000 次迭代就有一次執行 GC 的機會,但是好處是可以防止長時間的垃圾收集。

換句話說,這個程式碼是想要「觸發」GC,而不是「避免」GC,或者說是「避免」時間很長的 GC。從這個角度來說,程式裡面的註釋其實是在撒謊或者沒寫完整。

不是 prevent gc,而是對 gc 採取了「打散執行,削峰填谷」的思想,從而 prevent long time gc。

但是你想想,我們自己程式設計的時候,正常情況下從來也沒冒出過「這個地方應該觸發一下 GC」這樣想法吧?

因為我們知道,Java 程式設計師來說,虛擬機器器有自己的 GC 機制,我們不需要像寫 C 或者 C++ 那樣得自己管理記憶體,只要關注於業務程式碼即可,並沒有特別注意 GC 機制。

那麼本文中最關鍵的一個問題就來了:為什麼這裡要在程式碼裡面特別注意 GC,想要嘗試「觸發」GC 呢?

先說答案:safepoint,安全點。

關於安全點的描述,我們可以看看《深入理解JVM虛擬機器器(第三版)》的 3.4.2 小節:

注意書裡面的描述:

有了安全點的設定,也就決定了使用者程式執行時並非在程式碼指令流的任意位置都能夠停頓下來開始垃圾收集,而是強制要求必須執行到達安全點後才能夠暫停。

換言之:沒有到安全點,是不能 STW,從而進行 GC 的。

如果在你的認知裡面 GC 執行緒是隨時都可以執行的。那麼就需要重新整理一下認知了。

接著,讓我們把目光放到書的 5.2.8 小節:由安全點導致長時間停頓。

裡面有這樣一段話:

我把劃線的部分單獨拿出來,你仔細讀一遍:

是HotSpot虛擬機器器為了避免安全點過多帶來過重的負擔,對迴圈還有一項優化措施,認為迴圈次數較少的話,執行時間應該也不會太長,所以使用int型別或範圍更小的資料型別作為索引值的迴圈預設是不會被放置安全點的。這種迴圈被稱為可數迴圈(Counted Loop),相對應地,使用long或者範圍更大的資料型別作為索引值的迴圈就被稱為不可數迴圈(Uncounted Loop),將會被放置安全點。

意思就是在可數迴圈(Counted Loop)的情況下,HotSpot 虛擬機器器搞了一個優化,就是等迴圈結束之後,執行緒才會進入安全點。

反過來說就是:迴圈如果沒有結束,執行緒不會進入安全點,GC 執行緒就得等著當前的執行緒迴圈結束,進入安全點,才能開始工作。

什麼是可數迴圈(Counted Loop)?

書裡面的這個案例來自於這個連結:

https://juejin.cn/post/6844903878765314061
HBase實戰:記一次Safepoint導致長時間STW的踩坑之旅

如果你有時間,我建議你把這個案例完整的看一下,我只擷取問題解決的部分:

截圖中的 while(i < end) 就是一個可數迴圈,由於執行這個迴圈的執行緒需要在迴圈結束後才進入 Safepoint,所以先進入 Safepoint 的執行緒需要等待它。從而影響到 GC 執行緒的執行。

所以,修改方案就是把 int 修改為 long。

原理就是讓其變為不可數迴圈(Uncounted Loop),從而不用等迴圈結束,在迴圈期間就能進入 Safepoint。

接著我們再把目光拉回到這裡:

這個迴圈也是一個可數迴圈。

Thread.sleep(0) 這個程式碼看起來莫名其妙,但是我是不是可以大膽的猜測一下:故意寫這個程式碼的人,是不是為了在這裡放置一個 Safepoint 呢,以達到避免 GC 執行緒長時間等待,從而加長 stop the world 的時間的目的?

所以,我接下來只需要找到 sleep 會進入 Safepoint 的證據,就能證明我的猜想。

你猜怎麼著?

本來是想去看一下原始碼,結果啪的一下,在原始碼的註釋裡面,直接找到了:

https://hg.openjdk.java.net/jdk8u/jdk8u/hotspot/file/tip/src/share/vm/runtime/safepoint.cpp

註釋裡面說,在程式進入 Safepoint 的時候, Java 執行緒可能正處於框起來的五種不同的狀態,針對不同的狀態有不同的處理方案。

本來我想一個個的翻譯的,但是資訊量太大,我消化起來有點費勁兒,所以就不亂說了。

主要聚焦於和本文相關的第二點:Running in native code。

When returning from the native code, a Java thread must check the safepoint _state to see if we must block.

第一句話,就是答案,意思就是一個執行緒在執行 native 方法後,返回到 Java 執行緒後,必須進行一次 safepoint 的檢測。

同時我在知乎看到了 R 大的這個回答,裡面有這樣一句,也印證了這個點:

https://www.zhihu.com/question/29268019/answer/43762165

那麼接下來,就是見證奇蹟的時刻了:

根據 R 大的說法:正在執行 native 函數的執行緒看作「已經進入了safepoint」,或者把這種情況叫做「在safe-region裡」。

sleep 方法就是一個 native 方法,你說巧不巧?

所以,到這裡我們可以確定的是:呼叫 sleep 方法的執行緒會進入 Safepoint。

另外,我還找到了一個 2013 年的 R 大關於類似問題討論的貼文:

https://hllvm-group.iteye.com/group/topic/38232?page=2

這裡就直接點名道姓的指出了:Thread.sleep(0).

這讓我想起以前有個面試題問:Thread.sleep(0) 有什麼用。

當時我就想:這題真難(S)啊(B)。現在發現原來是我道行不夠,小丑竟是我自己。

還真的是有用。

實踐

前面其實說的都是理論。

這一部分我們來拿程式碼實踐跑上一把,就拿我之前分享過的《真是絕了!這段被JVM動了手腳的程式碼!》文章裡面的案例。

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);
    }
}

這個程式碼,你直接粘到你的 idea 裡面去就能跑。

按照程式碼來看,主執行緒休眠 1000ms 後就會輸出結果,但是實際情況卻是主執行緒一直在等待 t1,t2 執行結束才繼續執行。

這個迴圈就屬於前面說的可數迴圈(Counted Loop)。

這個程式發生了什麼事情呢?

  • 1.啟動了兩個長的、不間斷的迴圈(內部沒有安全點檢查)。
  • 2.主執行緒進入睡眠狀態 1 秒鐘。
  • 3.在1000 ms之後,JVM嘗試在Safepoint停止,以便Java執行緒進行定期清理,但是直到可數迴圈完成後才能執行此操作。
  • 4.主執行緒的 Thread.sleep 方法從 native 返回,發現安全點操作正在進行中,於是把自己掛起,直到操作結束。

所以,當我們把 int 修改為 long 後,程式就表現正常了:

受到 RocketMQ 原始碼的啟示,我們還可以直接把它的程式碼拿過來:

這樣,即使 for 迴圈的物件是 int 型別,也可以按照預期執行。因為我們相當於在迴圈體中插入了 Safepoint。

另外,我通過不嚴謹的方式測試了一下兩個方案的耗時:

在我的機器上執行了幾次,時間上都差距不大。

但是要論逼格的話,還得是右邊的 prevent gc 的寫法。沒有二十年功力,寫不出這一行「看似無用」的程式碼!

額外提一句

再說一個也是由前面的 RocketMQ 的原始碼引起的一個思考:

這個方法是在幹啥?

預熱檔案,按照 4K 的大小往 byteBuffer 放 0,對檔案進行預熱。

byteBuffer.put(i, (byte) 0);

為什麼我會對這個 4k 的預熱比較敏感呢?

去年的天池大賽有這樣的一個賽道:

https://tianchi.aliyun.com/competition/entrance/531922/information

其中有兩個參賽選大佬都提到了「檔案預熱」的思路。

我把連結放在下面了,有興趣的可以去細讀一下:

https://tianchi.aliyun.com/forum/postDetail?spm=5176.12586969.0.0.13714154spKjib&postId=300892

https://tianchi.aliyun.com/forum/postDetail?spm=5176.21852664.0.0.4c353a5a06PzVZ&postId=313716

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