JVM -- 垃圾回收器

Garbage Collection Concept

一個人活在世上,如果有人思念的線,牽引到這個人的身上,我們會說這個人是活的。如果從來沒人想起,我們會說這個人已死。

當物件不再被參考的時候,就是死亡(dead)的狀態,被稱之為垃圾(garbage)。而有一個流程,是將這些佔記憶體空間(heap)的垃圾清除,稱為垃圾回收(garbage collection),又稱為再改造(reclaiming)。

基本上垃圾回收是個耗時又耗力的動作(對清潔人員要心懷感恩),雖然不是全部,但確實解決了許多的記憶體配置問題。垃圾回收的時機點,很妙,基本上不由你,而是看垃圾回收器(garbage collector, 清潔人員)的心情。大致上就是垃圾滿了,或者垃圾比例達到一定的門檻,他就會發起佛心來幫我們打掃。

垃圾回收最大的挑戰就是:保持配置(allocation)及回收的效率,還要避免記憶空間碎片(fragmentation)。最怕垃圾回收有以下幾種情境:

  • 該清的不清
  • 不該清的一直清
  • 造成正常活動暫停的時間過長
  • 記憶空間碎片,導致無法容納大型有用的物件
    通常就是在「時間」、「空間」、「頻率」三者之間作取捨而已。空間比較小的時候,打掃動作比較快,但是相對垃圾累積也比較快滿,所以打掃需要比較頻繁。相反地,空間大,需要打掃的頻率比較低,但是一打掃起來就很費時。

垃圾回收的類型

序列(serial) vs. 並行(parallel)

所謂的序列式回收就是同一時間,只有一個在進行。並行式回收則是在多個 CPU 環境下,可以將任務切分多個部份同步進行。

同時行進(concurrency) vs. 世界暫停(stop-the-world)

垃圾回收時,如果需要程式先暫停,直到垃圾回收完成,我們稱為世界暫停。如果可以邊跑程式邊執行垃圾回收,就稱為同時行進。同時行進類型的垃圾回收器會需要多一點額外支出,以及 heap 空間。

壓縮(compacting) vs. 不壓縮(non-compacting) vs. 拷貝(copying)

壓縮式回收會把有用的物件集中在一起,然後騰出一大片空間來,這可以避免碎片問題,方便後續配置物件。另外一種不壓縮回收,顧名思義就是直接清除垃圾,就地正法(in-place),不會將有用的物件集中在一起,回收速度比較快,但是會有碎片問題。最後一種拷貝式回收,是將記憶體空間區分成多個空間,然後將活著的物件從這個空間(from)搬至另一個空間(to),則原空間(from)就可以視為清空,準備配置新物件使用,接著兩個空間互換身份,再重複 from -> to 的過程。最大的缺點大概就是需要一個額外的空間吧。

效能指標

要評估垃圾回收的效能主要是看以下幾個指標:

  • 程式產率(throughput) – 不是花在垃圾回收上的時間比率。
  • 垃圾回收支出(garbage collection overhead) – 花在垃圾回收上的時間比率,剛好是程式產率的相反。
  • 暫停時間(pause time) – 當垃圾回收時,暫停程式的時間長度。
  • 回收頻率(frequence of collection) – 有多頻繁執行垃圾回收
  • 台面面積(footprint) – heap 尺寸的衡量。
  • 敏捷(promptness) – 「物件變成垃圾」與「記憶體變成可用」間的時間。
    依照不同的系統,可能會有不同的評估順位,例如:互動式系統就會要求暫停時間不能太長。在小型個人電腦上跑得應用程式,小一點的台面面積可能就會是主要考量。

分代回收(generational collection)

分代回收是把不同年齡的物件,放置在不同區域的技術。最常見的方式是分成育幼院(young generation)和養老院(old generation)。年輕的物件一開始會放置在育幼院,隨著年齡增長,如果可以安然度過幼兒期還不死的話,就可以順利晉升到養老院養老或等待死亡。會分代主要是基於以下觀察到的現象,稱為弱分代假設(weak generational hypothesis):

  • 大部分的物件,年紀輕輕就會夭折。
  • 很少老物件會參考到年輕物件。
    不同的演算法,處理不同世代的回收,因為不同世代有著不同的基本特性。育幼院裡的垃圾回收頻率高,又快又有效率;養老院本身空間比較寬敞,久久才回收一次,但是回收一次就要久久。相較於育幼院來說,養老院是低垃圾密度(家有一老如有一寶)。

JVM 的垃圾回收

Java HotSpot virtual machine 的記憶體空間分成三種世代: 育幼院(young generation)、養老院(old generation)、永久代(permanent generation)。育幼院回收(young generation collection)又稱小型回收(minor collection),在育幼院滿的時候執行。養老院或永久代回收稱為完整回收(full collection)或主要回收(major collection)。正常來說都是先執行育幼院回收。

育幼院裡又分成兩種區域,一個叫做伊甸(eden),放置新生兒的地方,另外一個叫做存活區(survivor spaces),我個人又把他叫做晉升觀察區,是當執行垃圾回收時,還活著的物件就會放到這裡來。直到歷經幾次觀察,如果還是存活下來,物件就會晉升到養老院。

記憶體監控與分析

之所以要了解垃圾回收機制(Garbage Collection, GC)的運作,一切都是為了管理記憶體的使用。最終目的都是要讓程式能夠從 OutOfMemory 的泥淖掙脫。JDK 在安裝之後,其實內建了很多可以用來監控程式執行狀況的工具。

JVM 參數

我最常用的當推這三兄弟:

  • -XX:+PrintGCDetails
  • -Xloggc
  • -Xms, -Xmx
    -XX:+PrintGCDetails 可以用來印出 GC 的紀錄。通常搭配 -XX:+PrintGCDateStamps 使用,會順便印出時間點。指令如下:
    -XX:+PrintGCDetails -XX:+PrintGCDateStamps

    結果會像是這樣:
    2014-01-17T23:10:19.878+0800: [GC [PSYoungGen: 3565K->490K(4928K)] 3589K->1154K(10432K), 0.0047054 secs] [Times: user=0.03 sys=0.02, real=0.00 secs]
    2014-01-17T23:10:19.893+0800: [GC [PSYoungGen: 4906K->483K(4928K)] 5570K->2255K(10432K), 0.0019935 secs] [Times: user=0.05 sys=0.00, real=0.00 secs]
    2014-01-17T23:10:19.898+0800: [GC [PSYoungGen: 4899K->509K(4928K)] 6671K->3375K(10432K), 0.0051385 secs] [Times: user=0.00 sys=0.00, real=0.01 secs]
    2014-01-17T23:10:19.907+0800: [GC [PSYoungGen: 4925K->493K(4928K)] 7791K->4447K(10432K), 0.0049439 secs] [Times: user=0.05 sys=0.00, real=0.00 secs]
    2014-01-17T23:10:19.912+0800: [Full GC [PSYoungGen: 493K->0K(4928K)] [PSOldGen: 3953K->4434K(9664K)] 4447K->4434K(14592K) [PSPermGen: 6730K->6730K(21248K)], 0.0159012 secs] [Times: user=0.01 sys=0.00, real=0.02 secs]
    2014-01-17T23:10:19.933+0800: [GC [PSYoungGen: 4416K->504K(2368K)] 8850K->5771K(12032K), 0.0093172 secs] [Times: user=0.03 sys=0.00, real=0.01 secs]
    2014-01-17T23:10:19.944+0800: [GC [PSYoungGen: 2360K->1208K(3648K)] 7627K->6475K(13312K), 0.0053389 secs] [Times: user=0.00 sys=0.00, real=0.01 secs]
    2014-01-17T23:10:19.951+0800: [GC [PSYoungGen: 3064K->1765K(3648K)] 8331K->7279K(13312K), 0.0059091 secs] [Times: user=0.00 sys=0.00, real=0.01 secs]
    2014-01-17T23:10:19.958+0800: [GC [PSYoungGen: 3621K->1765K(3648K)] 9135K->8135K(13312K), 0.0041976 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
    2014-01-17T23:10:19.963+0800: [GC [PSYoungGen: 3621K->1760K(3648K)] 9991K->9031K(13312K), 0.0072420 secs] [Times: user=0.00 sys=0.00, real=0.01 secs]
    2014-01-17T23:10:19.972+0800: [GC [PSYoungGen: 3153K->704K(3648K)] 10424K->9743K(13312K), 0.0055995 secs] [Times: user=0.01 sys=0.01, real=0.01 secs]
    2014-01-17T23:10:19.977+0800: [Full GC [PSYoungGen: 704K->15K(3648K)] [PSOldGen: 9039K->9663K(10944K)] 9743K->9679K(14592K) [PSPermGen: 6730K->6730K(21248K)], 0.0204194 secs] [Times: user=0.02 sys=0.00, real=0.02 secs]
    2014-01-17T23:10:19.998+0800: [Full GC [PSYoungGen: 1856K->0K(3648K)] [PSOldGen: 9663K->10398K(10944K)] 11519K->10398K(14592K) [PSPermGen: 6730K->6730K(21248K)], 0.0238346 secs] [Times: user=0.03 sys=0.00, real=0.02 secs]

    可以看到 PSYoungGen: 3565K->490K(4928K) 這樣的句子,解讀方式是專屬育幼院的垃圾清掃大隊: 目前已用空間從 3565K 變成 490 K,育幼院總空間為 4928 K

Full GC 會執行三個世代空間的垃圾清除,除了 PSYoungGen,還有 PSOldGen 和 PSPermGen。仔細看以上的範例,會發現回收後的已用空間,有越來越多的趨勢,這代表有老不死的在累積,總有一天會爆 java.lang.OutOfMemoryError: Java heap space 給你看。

如果想要把 GC 的紀錄存至檔案,就要使用 -Xloggc。指令如下:

-Xloggc:wgc.log

檔案裡的東西跟 -XX:+PrintGCDetails -XX:+PrintGCDateStamps 差不多。

-Xms 和 -Xmx 其實是用來指定系統要配給 JVM 當 Heap 空間用的記憶體大小限制,不是監控參數。指令方式如下:

-Xms8m -Xmx16m

我個人的使用方式是這樣:程式開發過程先把記憶體調小,讓 Full GC 的執行頻率高一點,然後搭配 -XX:+PrintGCDetails 或者 -Xloggc ,來看看程式哪部份在 GC 過後記憶體量有逐漸累積的趨勢。

JConsole

一個內建的 GUI 工具,想要叫出他,Java 6 以後就直接輸入指令 jconsole。如果是 Java 5,還得加上 -Dcom.sun.management.jmxremote。

先執行程式,在輸入指令呼叫出 jconsole,然後選擇目前想要監控的程式。所以這代表程式要跑得夠久。
jconsole_connect

當程式跑的過程中,就會即時看到相關的數據以及圖線。相關使用請自行參考 jconsole
jconsole

JVisualVM

java 6 開始引進開源的 visualvm,被定位成第二代 jconsole,功能更強大。透過 sampler 的採樣,可以快速得知是哪一種類型的物件佔據記憶體的比例最大,進而可以更快知道程式的問題點在哪裡。相關使用請自行參考 jvisualvm
jvisualvm_sampler

另外 Eclipse 可以跟獨立運作的 VisualVM 作結合,可以參考 Eclipse launcher for VisualVM的設定說明。

其實 JDK 已經有很多方便的工具,好好善用,也可以寫出只有驚喜沒有驚嚇的好程式來。

註:20140117 舊文章。

參考