理解 Java 垃圾回收機制

henry發表於2014-12-03

理解java垃圾回收機制有什麼好處呢?作為一個軟體工程師,滿足自己的好奇心將是一個很好的理由,不過更重要的是,理解GC工作機制可以幫助你寫出更好的Java應用程式。

這是我個人的主觀觀點,但我相信一個人精通了GC,往往會是一個更好的Java程式設計師。如果你對GC感興趣,那就意味著你有一定大規模應用開發的經驗。如果你已經仔細過考慮選擇合適的GC演算法,這意味著你完全理解你開發的應用程式的功能。當然,這可能不是一個優秀開發者共同標準。然而,很少有人會反對我說的:理解GC是成為一個偉大的Java開發人員的要求。

這是“成為一個Java GC專家”系列文章的第一部分。這次我將講述GC簡介,而在下一篇文章中,我將討論分析GC狀態和來自NHN的GC調優的例子。

本文的目的是用簡單的方式向你介紹GC。我希望這篇文章被證明是非常有幫助的。事實上,我的同事已經發表了一些關於Java內部機制的大文章,它們已經在推特變得很流行。你可以去看看。

回到垃圾收集器,在學習GC前,你應該知道一個技術名詞:這個詞是“stop-the-world。“ 無論你選擇哪種GC演算法,Stop-the-world都會發生。Stop-the-world意味著JVM停止應用程式,而去進行垃圾回收。當stop-the-world發生時,除了進行垃圾回收的執行緒,其他所有執行緒都將停止執行。被中斷的任務將在GC任務完成後恢復執行。GC調優往往意味著減少stop-the-world的時間。

分代垃圾收集

在Java程式碼中,Java語言沒有顯式的提供分配記憶體和刪除記憶體的方法。一些開發人員將引用物件設定為null或者呼叫System.gc()來釋放記憶體。將引用物件設定為null沒有什麼大問題,但是呼叫system.gc()方法會大大的影響系統效能,絕對不能這個幹。(謝天謝地,我還沒看到任何NHN開發者呼叫這個方法。)

在Java中,由於開發人員沒有在程式碼中顯式刪除記憶體,所以垃圾收集器會去發現不需要(垃圾)的物件,然後刪除它們,釋放記憶體。這款垃圾收集器是基於以下兩個假設而建立的。(稱他們為前提條件更好,而不是假設。)

  • 絕大多數物件在短時間內變得不可達
  • 只有少量年老物件引用年輕物件.

這些假設被稱為“弱代假說”。為了發揮這一假設的優勢,在HotSpot虛擬機器中,物理的將記憶體分為兩個—年輕代(young generation)年代(old generation)

年輕代:新建立的物件都存放在這裡。因為大多數物件很快變得不可達,所以大多數物件在年輕代中建立,然後消失。當物件從這塊記憶體區域消失時,我們說發生了一次“minor GC”。

年代:沒有變得不可達,存活下來的年輕代物件被複制到這裡。這塊記憶體區域一般大於年輕代。因為它更大的規模,GC發生的次數比在年輕代的少。物件從老年代消失時,我們說“major GC”(或“full GC”)發生了。

我們看一下這幅圖。

圖 1: GC區 & 資料流

上圖中的永久代(permanent generation)也稱為“方法區(method area)”,他儲存class物件和字串常量。所以這塊記憶體區域絕對不是永久的存放從老年代存活下來的物件的。在這塊記憶體中有可能發生垃圾回收。發生在這裡垃圾回收也被稱為major GC。

一些人可能想知道:

一個老年代的物件需要引用年輕代的物件,該怎麼辦?

為了解決這些問題,老年代中有一個被稱為“卡表(card table)”的東西,它是一個512 byte大小的塊。每當老年代的物件引用年輕代物件時,這種引用會被記錄在這張表格中。當垃圾回收發生在年輕代時,只需對這張表進行搜尋以確定是否需要進行垃圾回收,而不是檢查老年代中的所有物件引用。這張表格用一個叫做“寫閘(write barrier)”的東西進行管理。“寫閘”是一種裝置,對minor GC有更好效能。雖然因為這種機制,會產生一些時間效能開銷,但降低了整體的GC時間。

圖2: Card Table結構

年輕代組成部分

為了理解GC,我們學習一下年輕代,物件第一次建立發生在這塊記憶體區域。年輕代分為3塊。

  • Eden區
  • 2個Survivor

年輕代總共有3塊空間,其中2塊為Survivor區。各個空間的執行順序如下:

  1. 絕大多數新建立的物件分配在Eden區。
  2. 在Eden區發生一次GC後,存活的物件移到其中一個Survivor區。
  3. 在Eden區發生一次GC後,物件是存放到Survivor區,這個Survivor區已經存在其他存活的物件。
  4. 一旦一個Survivor區已滿,存活的物件移動到另外一個Survivor區。然後之前那個空間已滿Survivor區將置為空,沒有任何資料。
  5. 經過重複多次這樣的步驟後依舊存活的物件將被移到老年代。

通過檢查這些步驟,如你看到的樣子,其中一個Survivor區必須保持空。如果資料存在於兩個Survivor區,或兩個都沒使用,你可以將這個情況作為系統錯誤的一個標誌。

經過多次minor GC,資料被轉移到老年代過程如下面的圖表所示:

圖3: GC前和GC後

請注意,在HotSpot虛擬機器中,使用兩種技術加快記憶體的分配。一個被稱為“指標碰撞(bump-the-pointer)”,另外一個被稱為“TLABs(執行緒本地分配緩衝)”。

指標碰撞技術跟蹤分配給Eden區上最新的物件。該物件將位於Eden 區的頂部。如果之後有一個物件被建立,只需檢查Eden區是否有足夠大的空間存放該物件。如果空間夠用,它將被放置在Eden區,存放在空間的頂部。因此,在建立新物件時,只需檢查最後被新增物件,看是否還有更多的記憶體空間允許分配。然而,如果考慮多執行緒的環境,則是另外一種情況。為了實現多執行緒環境下,在Eden 區執行緒安全的去建立儲存物件,那麼必須加鎖,因此效能會下降。在HotSpot虛擬機器中TLABs能夠解決這一問題。它允許每個執行緒在Eden區有自己的一小塊私有空間。因為每一個執行緒只能訪問自己的TLAB,所以在這個區域甚至可以使用無鎖的指標碰撞技術進行記憶體分配。

我們已經對年輕代有了一個快速的瀏覽。你不需要要記住我剛才提到的兩種技術。即便你不知道他們,也不會怎麼樣。但請務必記住:物件第一次被建立發生在Eden區,長期存活的物件被移動到老年代的Survivor區。

老年代GC

當老年代資料滿時,基本上會執行一次GC。執行程式根據不同GC型別而變化,所以如果你知道不同型別的垃圾收集器,會更容易理解垃圾回收過程。

在JDK7中,有5種垃圾收集器:

  1. Serial收集器
  2. Parallel收集器
  3. Parallel Old收集器 (Parallel Compacting GC)收集器
  4. Concurrent Mark & Sweep GC  (or “CMS”)收集器
  5. Garbage First (G1) 收集器

其中,serial 收集器一定不能用於伺服器端。這個收集器型別僅應用於單核CPU桌面電腦。使用serial收集器會顯著降低應用程式的效能。

現在讓我們來了解每個收集器型別。

Serial 收集器 (-XX:+UseSerialGC)

我們在前一段的解釋了在年輕代發生的垃圾回收演算法型別。在老年代的GC使用演算法被稱為“標記-清除-整理”。

  1. 該演算法的第一步是在老年代標記存活的物件。
  2. 從頭開始檢查堆記憶體空間,並且只留下依然倖存的物件(清除)。
  3. 最後一步,從頭開始,順序地填滿堆記憶體空間,將存活的物件連續存放在一起,這樣堆分成兩部分:一邊有存放的物件,一邊沒有物件(整理)。

serial收集器應用於小的儲存器和少量的CPU。

 

Parallel收集器(-XX:+UseParallelGC)

圖4: Serial收集器 和 Parallel收集器的差異 

從這幅圖中,你可以很容易看到Serial收集器 和 Parallel收集器的差異。serial收集器只使用一個執行緒來處理的GC,而parallel收集器使用多執行緒並行處理GC,因此更快。當有足夠大的記憶體和大量芯數時,parallel收集器是有用的。它也被稱為“吞吐量優先垃圾收集器。”

Parallel Old 垃圾收集器(-XX:+UseParallelOldGC)

Parallel Old收集器是自JDK 5開始支援的。相比於parallel收集器,他們的唯一區別就是在老年代所執行的GC演算法的不同。它執行三個步驟:標記-彙總-壓縮(mark – summary – compaction)。彙總步驟與清理的不同之處在於,其將依然倖存的物件分發到GC預先處理好的不同區域,演算法相對清理來說略微複雜一點。

CMS GC (-XX:+UseConcMarkSweepGC)

圖5: Serial GC & CMS GC

 

CMS垃圾收集器(-XX:+UseConcMarkSweepGC)

如你在上圖看到的那樣, CMS垃圾收集器比之前我解釋的各種演算法都要複雜很多。初始標記(initial mark) 比較簡單。這一步驟只是查詢距離類載入器最近的倖存物件。所以停頓時間非常短。之後的併發標記步驟,所有被倖存物件引用的物件會被確認是否已經被追蹤檢查。這一步的不同之處在於,在標記的過程中,其他的執行緒依然在執行。在重新標記步驟會修正那些在併發標記步驟中,因新增或者刪除物件而導致變動的那部分標記記錄。最後,在併發清除步驟,垃圾收集器執行。垃圾收集器進行垃圾收集時,其他執行緒的依舊在工作。一旦採取了這種GC型別,由於垃圾回收導致的停頓時間會極其短暫。CMS 收集器也被稱為低延遲垃圾收集器。它經常被用在那些對於響應時間要求十分苛刻的應用上。

當然,這種GC型別在擁有stop-the-world時間很短的優點的同時,也有如下缺點:

  •  它會比其他GC型別佔用更多的記憶體和CPU
  •  預設情況下不支援壓縮步驟

在使用這個GC型別之前你需要慎重考慮。如果因為記憶體碎片過多而導致壓縮任務不得不執行,那麼stop-the-world的時間要比其他任何GC型別都長,你需要考慮壓縮任務的發生頻率以及執行時間。

G1 GC

最後,我們來學習一下G1型別。

圖6: Layout of G1 GC

如果你想要理解G1收集器,首先你要忘記你所理解的新生代和老年代。正如你在上圖所看到的,每個物件被分配到不同的網格中,隨後執行垃圾回收。當一個區域填滿之後,物件被轉移到另一個區域,並再執行一次垃圾回收。在這種垃圾回收演算法中,不再有從新生代移動到老年代的三部曲。這個型別的垃圾收集演算法是為了替代CMS 收集器而被建立的,因為CMS 收集器在長時間持續執行時會產生很多問題。

G1最大的好處是他的效能,他比我們在上面討論過的任何一種GC都要快。但是在JDK 6中,他還只是一個早期試用版本。在JDK7之後才由官方正式釋出。就我個人看來,NHN在將JDK 7正式投入商用之前需要很長的一段測試期(至少一年)。因此你可能需要再等一段時間。並且,我也聽過幾次使用了JDK 6中的G1而導致Java虛擬機器當機的事件。請耐心的等待它更穩定吧。

下一次我將討論GC優化相關問題,但是在此之前我要先明確一件事情。假如應用中建立的所有物件的大小和型別都是統一的,在我們公司,這種情下使用的WAS的GC引數 可以是相同的。但是WAS所建立物件的大小和生命週期根據服務以及硬體的不同而不同。換句話說,不能因為某個應用使用的GC引數“A”,就說明同樣的引數 也能給其他服務帶來最佳效果。而是要因地制宜,有的放矢。我們需要找到適合每個WAS執行緒的最佳引數,並且持續的監控和優化每個裝置上的WAS例項。這並不是我的一家之談,而是負責Oracle Java虛擬機器研發的工程師在 JavaOne 2010上已經討論過的。

本文中我們簡略的介紹了Java的垃圾回收機制,請繼續關注我們的後續文章,我們將會討論如何監控Java GC狀態以及GC調優。

另外,我特別推薦一本2011年12月釋出的《Java效能》(Amazon,也可以通過safari線上閱讀),還有在Oracle官網釋出的白皮書《Java HotSpotTM虛擬機器記憶體管理》(這本書與Java效能優化不是同一本)

作者Sangmin Lee, NHN公司,效能設計實驗室高階工程師。

相關文章