[Java] 喝杯咖啡,聊點 GC(一) – 基礎概念

今天我們要跟大家聊一點關於 Java 的神祕領域 – Garbage Collection (a.k.a. GC),亦即 “垃圾回收”,接觸過 Java 的人都知道,介紹 Java 物件的第一堂課,老師一定會跟你說「Java 跟 C 語言不一樣,它不需要自行解構,Java 語言會自動刪除不必要的物件」這時你一定會納悶,設計師沒有指定 delete/free 的時機 Java 又該如何知道時該刪除…

相信這個疑問很快會被席捲而來的新盈資訊所覆蓋,小獅就是其中之一,忙著享受 GC 帶來的好處,卻忘了要了解它。在最近小獅重新翻閱多篇文件之後,得出了這篇筆記…

那~就讓我們開始吧!進入 GC 的世界!

 

JVM 與 GC

首先,讓我們先從 Java 與 GC 的關係開始說起,當你啟動一支 Java 程式之後,系統做的事情其實並不是直接把程式的內容放進記憶體裡去執行,而是呼叫一支稱為 Bootstrap Loader 的程式,開始一連串的動作。

既然用到 Bootstrap Loader 這個詞,相信敏感的人應該會馬上覺得熟悉,這個詞一般會出現在計算機(computer)相關的地方,指的是電腦在開機時,第一隻載入的程式… 意思就是 – Java 其實是執行在一台特殊的電腦上,這樣的一台虛擬出來的電腦,我們稱之為 Java Virtual Machine,Java 虛擬機,常被縮寫成 JVM。

Bootstrap Loader 這個詞一般會出現在計算機(computer)相關的地方,指的是電腦在開機時,第一隻載入的程式,多半是固定且無法更改的,只會到固定的地方讀取資料並 載入資料;以一般的個人電腦為例,就是指 BIOS,在電源開啟之後,CPU 的機制會啟動 BIOS,自 ROM 中讀入並開始執行,接著依照設定執行 POST(Power-on self test 即 開機自檢測),完成之後就到非揮發性儲存裝置 – 一般多為硬碟,讀取 MBR(Master boot record 即 主開機紀錄),並依指示載入作業系統(如:Windows、Linux 等)

 

說到 JVM,我們先前就已經知道,每一次執行 Java 程式,就會開啟一部新的 JVM;也就是說,每一支 Java 程式都是執行在獨立的 JVM 中,那這部擁有多執行緒處理能力,卻只被用來執行單一支程式的,虛擬機器中的作業系統到底長甚麼樣子,大家應該會很想知道吧!

JVM System Threads

先前我們說過 JVM 具有同時注1做很多件事的能力(多執行緒),那就讓我們來看看 JVM 除了程式之外還執行了些什麼:

注 1:”同時” 指的是抽象觀念上的同時,實際執行時仍可能會因為 CPU 核心術 與 工作排程策略 而造成些微的差距,但人類無法察覺。

注 2:底下的表格依據 Java 7 Hotspot VM 撰寫,其他的 JVM 可能與之有所出入。

VM thread

這個執行續會不斷等待需要讓 JVM 進入 safe-point 的動作出現。它之所以被獨立成一個執行緒的原因是當 JVM 進入 safe-point 之後,所有的執行緒皆無法直接修改堆積區(heap area)的內容。這個直行緒的工作包含 “stop-the-world” garbage collections、thread stack dumps(執行緒堆疊傾印)、thread suspension、biased locking revocation。

This thread waits for operations to appear that require the JVM to reach a safe-point. The reason these operations have to happen on a separate thread is because they all require the JVM to be at a safe point where modifications to the heap can not occur. The type of operations performed by this thread are “stop-the-world” garbage collections, thread stack dumps, thread suspension and biased locking revocation.

Periodic task thread

這個執行續負責接收週期性執行動作的 timer 事件,或者該說 中斷。

This thread is responsible for timer events (i.e. interrupts) that are used to schedule execution of periodic operations

GC threads

這個執行緒可以支援 JVM 中多種不同的垃圾回收活動。

These threads support the different types of garbage collection activities that occur in the JVM

Compiler threads

這個執行緒會在執行程式時將 Bytecode 轉為原生指令(native code)。

These threads compile byte code to native code at runtime

Signal dispatcher thread

這個執行緒負責掌管 JVM 收到的訊息,並呼叫合適的 JVM 方法。

This thread receives signals sent to the JVM process and handle them inside the JVM by calling the appropriate JVM methods.

 

恩,好吧!我承認我還沒有辦法把它翻譯好,因為我對 JVM 的瞭解還不夠深。但是我要說的重點是,每一台 JVM 都配有一個 GC 執行緒,這代表 GC 的工作是在執行期間,因為某種條件觸發的。

若要了解觸發條件是什麼,我們就必須要先了解 Java 是如何管理物件的,既然無法手動釋放物件,那我們就可以大膽推測,Java 的物件生成和管理,是由 JVM 負責的,垃圾收集器(Garbage Collector)會自行尋找不必要的(垃圾)物件,並清除它。

 


了解 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 區,執行順序如下所示:

  1. 大多數的物件都被建立在 Eden 區。
  2. 在 Eden 區發生 GC 之後,”存活” 下來的物件會被移到 Survivor 區。
  3. 當 Eden 區再次發生 GC,物件會與上次的物件一起被堆放在 Survivor 區
  4. 當 Survivor 區滿了之後,會再一次進行 GC,並將生存下來的物件移到另一個 Survivor 區
  5. 當一個物件在上述步驟中生還,並多次逃過 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 的種類(譯註:應稱之為演算法):

  1. 循序式垃圾收集(Serial GC)
  2. 平行化垃圾收集(Parallel GC)
  3. 平行化精簡垃圾收集(Parallel Old GC, Parallel Compacting GC)
  4. 同步標記清除式垃圾收集(Concurrent Mark & Sweep GC, CMS)
  5. 垃圾優先行垃圾收集(Garbage First GC, G1 GC)

其中的 Serial GC 絕對不能用在工作伺服器上,這個方法是用在只有一個 CPU 核心的桌上型電腦(譯註:這是指相對於伺服器主機而言)。使用 Serial GC 會顯著降低應用程式的性能。

現在讓我們了解各個 GC 類型:

循序式垃圾收集(Serial GC)(-XX:+UseSerialGC)

在新世代中的發生的 GC 與我們先前介紹的相同,而舊世代則使用 “標記-清除-壓縮(mark-sweep-compact)” 演算法。

  1. 演算法的第一步是先標記舊世代中仍存活的物件
  2. 接下來再從頭檢查堆積區(heap)並清理(sweep)它,只留下仍存活的物件
  3. 在最後一步,它會移動所有剩餘物件,從頭開始連續擺放,直到堆積區(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 消耗得時間會比所有其他的垃圾收集方式要長得多,建議你多檢察系統以載麼樣的頻率,花費多少時間在完成壓縮的動作。

垃圾優先行垃圾收集(G1 GC)(XX:+UseG1GC

最後,讓我們一起學習垃圾優先型垃圾收集。

若你想學會 G1 GC,你必須先將先前所學,所有有關新舊世代的東西通通拋諸腦後。如同你在上圖中看到的,每一個物件都被分別放在格子中,當一個格子不夠大時,會再飛配一個格子給它。我們先前所熟悉的,新世代中三個分區的資料移動、舊世代區,在 G1 GC 中都不存在。這個方法是用來取代 CMS GC 的,因為後者在長期來看會造成許多的問題和壓縮的必須性。

G1 GC 的最大優勢是效能,它比任何我們到目前為止討論到的垃圾收集方式都要來的快。但要記住,在 JDK 6 中這個方式仍屬於前期預覽(early access)的狀態,僅提供測試用途,在 JDK 7 時正式加入,但建議你先稍待一年,等其他廠商確認相容性後再行採用;另外,筆者也聽過部分在 JDK 6 環境中,因採用了 G1 GC 之後造成 JVM 崩潰(crash)的案例。

(譯註:JDK 7 以經面世好一段時間了,甚至連 JDK 8 都已經進入到 u54,G1 GC 技術理應當已經成熟,這段建議就當作JDK 版本轉換時的提醒吧!)

在下一章節中,我們將會討論到如何調教垃圾收集,但在那之前,我想先問你一個問題:弱勢應用程式所建立的所有物件大小都是已知的,所有用在網頁應用程式伺服器的 GC 選項都是相同的,但大小和生命週期卻又因服務而有所不同,且伺服器的機種又有所差異,該怎麼辦?換句話說,不會因為某個服務使用 “A” 選項跑得最快,套用在其他的服務上就會得到最好的結果,最佳化的設定是必須要去不斷測試和找尋的,因此需要不斷的監控和調教。這並非我個人的一己之見,而是在 JavaOne 2010 中,開發出 Oracle JVM 的工程師們的討論結果。

因為篇幅的限制,在本篇文章中僅能帶大家匆匆一瞥,請期待我們的下一篇文章,我將會討論到 如何監控 和 調教 Java 的垃圾收集

 


 

哇~ 終於翻玩了,第一次挑戰長文翻譯,翻得不好的地方也請大家見諒,另外,本文也將同步刊載在 Team-BoB 網站,和 MyCraft 粉絲團,請大家多多抔捧場喔!

後記:下一篇嘛…. 這… 就要再看看大家的反應了

 

 

參考資料:

垃圾回收 | Openhome.cc

2 responses to “[Java] 喝杯咖啡,聊點 GC(一) – 基礎概念

發表迴響