沒有二十年功力,寫不出Thread.sleep(0)這一行「看似無用」的代碼!
- 2022 年 9 月 5 日
- 筆記
你好呀,我是喜提七天居家隔離的歪歪。
這篇文章要從一個奇怪的注釋說起,就是下面這張圖:
我們可以不用管具體的代碼邏輯,只是單單看這個 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 是這個:
//github.com/apache/rocketmq/issues/4902
這個 issues 其實就是我們在討論這個問題的過程中提出來的,也就是前面出現的修改方案:
也就是說,我想通過源碼或者 github 找到這個問題權威的回答,是找不到了。
於是我又去了這個神奇的網站,在裏面找到了這個 2018 年提出的問題:
//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)?
書裏面的這個案例來自於這個鏈接:
//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 的證據,就能證明我的猜想。
你猜怎麼著?
本來是想去看一下源碼,結果啪的一下,在源碼的注釋裏面,直接找到了:
//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 大的這個回答,裏面有這樣一句,也印證了這個點:
//www.zhihu.com/question/29268019/answer/43762165
那麼接下來,就是見證奇蹟的時刻了:
根據 R 大的說法:正在執行 native 函數的線程看作「已經進入了safepoint」,或者把這種情況叫做「在safe-region里」。
sleep 方法就是一個 native 方法,你說巧不巧?
所以,到這裡我們可以確定的是:調用 sleep 方法的線程會進入 Safepoint。
另外,我還找到了一個 2013 年的 R 大關於類似問題討論的帖子:
//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 的預熱比較敏感呢?
去年的天池大賽有這樣的一個賽道:
//tianchi.aliyun.com/competition/entrance/531922/information
其中有兩個參賽選大佬都提到了「文件預熱」的思路。
我把鏈接放在下面了,有興趣的可以去細讀一下:
//tianchi.aliyun.com/forum/postDetail?spm=5176.12586969.0.0.13714154spKjib&postId=300892
//tianchi.aliyun.com/forum/postDetail?spm=5176.21852664.0.0.4c353a5a06PzVZ&postId=313716
最後,感謝你閱讀我的文章。歡迎關注公眾號【why技術】,文章全網首發哦。