[Java] 關於 Garbage Collection 的二三事(一)
《了解 Java 垃圾回收》
本段文章經作者同意後翻譯,來源:http://www.cubrid.org/blog/dev-platform/understanding-java-garbage-collection/
了解 Java 的垃圾回收(GC)有甚麼好處?滿足一個軟體工程師對知識的渴望當然是一個理由,但更實際的來說瞭解 GC 可以讓你寫出更好的 Java 程式。
這當然是我(Sangmin Lee)個人的主觀意見,但我相信熟悉 GC 的人往往更能成為一個優秀的 Java 程式設計師。若你對 GC 的過程感興趣,那代表著你已經參與過一定大小的 Java 軟體開發;若你還思考過要選擇哪個 GC 演算法,相信你已經完全了解你的程式功能。當然,這可能不是一個可以用來區分程式設計師優劣的標準,但若是說瞭解 GC 是成為偉大 Java 程式設計師的必備條件,想必很少人會反對。
本篇為 “Become a Java GC Expert” 系列的第一篇文章。在本篇文章中,我會介紹 GC 的基本觀念;在下一篇文章則會討論到 GC 狀態的分析 和 一些來自 NHN 的 GC 的調教範例。
回到垃圾回收,在開始了解 GC 之前,有一件事情你必須要先知道,那就是 “stop-the-world”。”stop-the-world” 代表 JVM 停下整個程式以進行 GC。當 “stop-the-world” 發生時,所有與垃圾回收無關的執行緒都會被停下,直到 GC 完成之後才會恢復執行,因此 GC 調教時 多半設法減少 stop-the-world 的時間。
世代演進式垃圾回收(Generational Garbage Collection)
Java 語言並不會在程式碼中指定記憶體或移除,所以有些人會透過將相應的物件(譯註:應為物件參照)設為 null,或透過呼叫 System.gc() 來達到效果。設定物件為 null 是沒什麼,但呼叫 System.gc() 可能會對系統效能造成劇烈的影響,因此建議千萬不要使用。(幸運的是,我沒有看過任何同事這麼做過)
在 Java 語言之中,尋找不需要的物件並移除他們是 垃圾回收器(Garbage Collector) 的工作。垃圾回收器是基於以下二個假設建立的(更準確地來說,是觀點或前提)
- 多數物件很快就會變得無法存取(unreachable)
- 舊世代 對 新世代 的參照僅少量存在
這些假設被稱為 弱世代假設(weak generational hypothesis)。為了強化這樣的假設,在 HotSpot VM(譯註:自 Java 1.3 之後的預設 JVM)中直接區分出 新世代(young generation) 與 舊世代(old generation)。
新世代(young generation):絕大多數新建立的物件都會被放在這裡。因為多數物件被建立之後很快就會變得無法存取,所以多數物件都會在這裡被建立,然後消失;當物件在這裡消失,我們會說 “輕度 GC(minor GC)” 已經發生了。
(譯註:原文的 minor 很妙,它除了有 比較小、比較輕微 的意思之外,還可以當作 年幼的 來使用,剛好呼應 young 年輕的意思)
舊世代(old generation):沒有變成無法存取,並活過年輕世代的物件會被移動到這裡。一般來說,舊世代的空間都會比新世代來的大,也因為如此,相對於新世代,這裡比較少發生 GC。當物件自舊世代消失時,我們稱之為 “重度 GC(major GC)” 或 “完整 GC(full GC)”。
讓我們看一下這個圖表
圖表 1:GC 區域 & 資料流
上表中的 永久世代(permanent generation)又被稱為”方法區(method area)” 其中儲存的是 類別(class)或 字串(character string),也就是說絕對不會發生物件活過舊世代然後進到這裡的情形。另外,這裡仍會發生 GC,而這裡發生的 GC 也屬於 完整 GC(major GC)。
有些人可能會有疑問
若一個舊世代的物件必須參照新世代的物件該怎們辦?
為了解決這樣的狀況,有一種叫做 “卡片表(card table)” 的東西存在於舊世代中,是一個 512 byte 的區塊。每當一個舊世代物件參照到新世代物件時,便將它記錄在這個區塊之中。如此一來,每當新世代發生 GC 時,就只需要搜尋這個表格即可,無須再一一檢查所有舊世代物件。而這個卡片使用 寫入屏障(write barrier) 進行管理,它可以讓完整 GC(major GC)的速度更快。縱然會造成一點效能損失,但整體的完整 GC(major GC)時間是縮短的。
圖表 2: 卡片表
新世代的構成(Composition of the Young Generation)
為了瞭解 Java 的垃圾回收機制,我們必須先了解新世代(Young Generation),也就是物件被創造出來的地方。新世代被分為 3 區塊:
- 一個 Eden(伊甸)區
- 二個 Survivor(生還者)區
在這 3 個區塊中,其中的 2 個被我們稱為 Servivor 區,執行順序如下所示:
- 大多數的物件都被建立在 Eden 區。
- 在 Eden 區發生 GC 之後,”存活” 下來的物件會被移到 Survivor 區。
- 當 Eden 區再次發生 GC,物件會與上次的物件一起被堆放在 Survivor 區
- 當 Survivor 區滿了之後,會再一次進行 GC,並將生存下來的物件移到另一個 Survivor 區
- 當一個物件在上述步驟中生還,並多次逃過 GC,就會被移動到舊世代
(譯註:小獅個人偏好將 Eden 和 Survivor 以原文方式呈現,因為對原意了解尚有不足)
如你所見,其中一個 Survivor 區必須是是空的,若是兩個 Survivor 區皆有資料 或 二個都是空的,那可能是你的系統已經發生問題的癥兆。
資料因 輕度GC(miner GC) 被堆置到舊世代的過程如下圖所示:
圖表 3:GC 前後
在 HotSpot VM 中,有 2 個技巧被用來加速記憶體取得(建立物件)的過程,其一被稱之為 “預訂指標(bump-the-pointer)”,另一個被稱之為 “TLABs(Thread-Local Allocation Buffers, 執行緒專有空間緩衝)”。
預訂指標(bump-the-pointer)技術會追蹤在 Eden 區 被建立的最新物件,並將它放在 Eden 區的頂部,若一個新物件在稍後被建立,JVM 會檢查 Eden 區的剩餘空間是否足夠容納;若足夠,則放置在頂端,也就是說當一個新物件被建立,僅有最後被新增的物件需要被檢查,記憶體空間的取得(allocation)速度也因此受益。
(譯註:放在頂部指的應該是 heap tree 的根,物件一般並不會被建立在 堆疊區 喔)
然而,若是我們考慮到多執行緒的環境,要儲存多執行緒使用的物件,同時又要保證執行緒安全(Thread-Safe),無法避免的卡死(lock)狀態就會出現,而效能表現也會因而大打折扣。
執行緒專有空間緩衝(TLABs) 就是為了解決這樣的問題而被加入 HotSpot VM,它允許每個執行緒在 Eden 區有一個小空間與之對應,而每個執行緒僅能存取自己的空間緩衝(TLAB),如此一來,就算採用 bump-the-pointer 也不會發生卡死的問題 。
到此,我們已經快速的檢視了新世代(young generation)中的 GC。你無須記住剛才提到的二個技術(譯註:即 bump-the-pointer 和 TLABs),就算看不懂也不會少一塊肉(譯註:原文為 go th jail 即坐牢的意思)。但請千萬要記住,物件被建立在 Eden 區之後,活過一系列流程的物件最後會經過 Survivor 區 來到 舊世代(old generation)。
舊世代中的垃圾回收(GC for Old Generation)
基本上來說,每當舊世代的空間滿了之後,就會進行垃圾收集的動作(GC)。隨著 GC 種類的不同,執行過程也存在著不等的差異,若能認識不同的 GC 類型,對了解這段話會有一定的幫助。
以 JDK 7 來說,存在著 5 種 GC 的種類(譯註:應稱之為演算法):
- 循序式垃圾收集(Serial GC)
- 平行化垃圾收集(Parallel GC)
- 平行化精簡垃圾收集(Parallel Old GC, Parallel Compacting GC)
- 同步標記清除式垃圾收集(Concurrent Mark & Sweep GC, CMS)
- 垃圾優先行垃圾收集(Garbage First GC, G1 GC)
其中的 Serial GC 絕對不能用在工作伺服器上,這個方法是用在只有一個 CPU 核心的桌上型電腦(譯註:這是指相對於伺服器主機而言)。使用 Serial GC 會顯著降低應用程式的性能。
現在讓我們了解各個 GC 類型:
循序式垃圾收集(Serial GC)(-XX:+UseSerialGC)
在新世代中的發生的 GC 與我們先前介紹的相同,而舊世代則使用 “標記-清除-壓縮(mark-sweep-compact)” 演算法。
- 演算法的第一步是先標記舊世代中仍存活的物件
- 接下來再從頭檢查堆積區(heap)並清理(sweep)它,只留下仍存活的物件
- 在最後一步,它會移動所有剩餘物件,從頭開始連續擺放,直到堆積區(heap)被分成二個部分,有物件的一邊,和空的一邊
循序式垃圾收集(Serial GC)適合用在較小記憶體和處理器數量的電腦。
平行化垃圾收集(Parallel GC)(-XX:+UseParallelGC)
圖表 4:Serial GC 與 Parallel GC 的差異
從上圖中,我們可以很輕易地發現循序式與平行化垃圾收集的差異,循序式垃圾收集僅使用 1 個執行緒來進行垃圾收集,而平行化垃圾收集則使用多個執行緒來進行同樣的動作,想當然爾會比較快。
這個方法適合使用在記憶體和處理器核心數都有餘裕的系統上,另外,它也常被稱為 “吞吐 GC(throughtput GC)”。
平行化精簡垃圾收集(Parallel Old GC, Parallel Compacting GC)(-XX:+UseParallelOldGC)
平行化精簡垃圾收集自 JDK 5 之後開始被支援,與平行化垃圾收集相比,唯一的不同處在於用在舊世代的垃圾收集演算法,其共分為三個階段,分別是:標記(mark)、總結(summary)、壓實(compaction)。在總結階段時,生存物件的判斷會與先前進行過垃圾收集的區域分開,這樣的不同使得步驟較為繁複。
同步標記清除式垃圾收集(Concurrent Mark & Sweep GC, CMS)(-XX:+UseConcMarkSweepGC)
就像你在圖片上看到的,同步標記清除式垃圾收集相對於,其他我們到目前為止認識的 GC 類型還要來的複雜許多。一開始的初始標記階段倒還簡單,僅有最接近 類別載入器(ClasssLoader) 的生存物件會被搜尋,因此暫停時間相當短暫(譯註:Stop-the-World)。在同步標記階段,會追蹤和檢查所有已知(譯註:由初始標記得知)生存物件參照到的物件,特別的是,這個步驟是在其他執行緒仍然在執行時完成的。在再標記階段,會再檢查是否有被 新增 或 解除參照關係 的物件會。最後的同步清除階段則是垃圾清理程序,它會與其他執行緒一同被執行。也因為這樣的垃圾收集方式,系統暫停的時間變得非常短,讓同步標記清除式垃圾收集也被稱做低延遲垃圾收集。一般被用在對應用程式回應時間相當敏感的場合。
縱然它有著 stop-the-world 較短的優點,但相對的它也有以下的缺點:
- 消耗較多的 記憶體 和 CPU 資源
- 壓縮的步驟預設並不存在
(譯註:壓縮指的是整理舊世代空間的行為)
你必須在選用這種垃圾收集方式之前先三思,因為若記憶體空間的碎片化造成壓縮的動作變得不得不做,Stop-the-World 消耗得時間會比所有其他的垃圾收集方式要長得多,建議你多檢查系統是以怎麼樣的頻率,花費多少時間完成壓縮的動作。