深入理解 JVM 之 垃圾回收機制

TimberLiu發表於2019-02-25

雖然記憶體的分配和回收技術已相當成熟,但如果需要排查記憶體溢位、記憶體洩露問題,或者要求高併發、高效能時,就需要對垃圾的回收進行監控和調節,以更好優化系統提高效能。

物件存活判定

Java 記憶體結構中,程式計數器、虛擬機器棧、本地方法棧等隨著執行緒而生,隨執行緒而滅,不需要考慮記憶體回收問題。而 Java 堆和方法區則不同,它們的記憶體分配是動態的,只有在執行期間才能知道會建立哪些物件,垃圾回收關注的就是這兩部分。

垃圾回收首先需要判斷哪些物件還存活著,主要有引用計數和可達性分析兩種演算法。

引用計數演算法

它的原理如下:給物件新增一個引用計數器,每當有一個地方引用它時,計時器值就加 1;當引用失效時,計數器值就減 1;如果計數器為 0,物件就不可能再被使用。

引用計數演算法雖然實現簡單、判定效率較高。但它很難解決物件之間迴圈引用的問題。

例如兩個物件相互引用,實際上兩個物件都不會再訪問,但因為相互引用著對方,導致它們的計數器值都不為 0,於是引用技術演算法無法通過 GC 收集器回收它們。

可達性分析演算法

它的原理如下:通過一系列稱為 GC Roots 的物件作為起始點,從這些節點開始向下搜尋,搜尋所走過的路徑稱為引用鏈。當一個物件到 GC Roots 沒有任何引用鏈相連時,則證明物件是不可用的。

Java 中,可作為 GC Roots 的物件包括如下幾種:

  • 虛擬機器棧(棧幀中的本地變數表)中引用的物件;
  • 方法區中類靜態屬性引用的物件;
  • 方法區中常量引用的物件;
  • 本地方法棧中 JNI( Native 方法) 引用的物件。

引用型別

可以看到,物件回收判定演算法判斷物件是否存活都與引用有關。從 JDK1.2 開始,引用分為四種型別,用來實現不同的功能,它們的引用強度也依次遞減。

強引用(Strong Reference)

平時使用的引用就是強引用。只要強引用還存在,該物件永遠不會被回收。

可以通過將物件設定為 null,使其被回收。

軟引用(Soft Reference)

用於描述一些還有用但並非必需的物件。當系統記憶體空間不足時,會回收這些軟引用指向的物件。它通過 SoftReference 類來實現軟引用。

可以用來實現快取記憶體。

弱引用(Weak Reference)

用來描繪非必需物件。被弱引用指向的物件只能生存到下一次垃圾回收之前。只要垃圾收集器執行,弱引用指向的物件就會被回收。它通過 WeakReference 類來實現弱引用。

虛引用(Phantom Reference)

虛引用和沒有引用沒有任何區別。一個物件是否有虛引用,不會影響其生存時間,也無法通過虛引用獲取物件例項。它通過 PhantomReference 來實現虛引用。必須和引用佇列 ReferenceQueue 聯合使用。

為一個物件設定虛引用的唯一目的是該物件被垃圾收集器回收前會收到一條系統通知。

回收方法區

方法區,或者說 HotSpot 虛擬機器中的永久代,進行垃圾回收的效率一般比較低。回收主要包括兩部分內容:廢棄常量和無用的類。

判斷一個常量是否是廢棄常量比較簡單,與回收 Java 堆中的物件類似。而判定一個類是否是無用的類需要滿足三個條件:

  • 該類所有的例項都已經被回收;
  • 載入該類的 ClassLoader 已經被回收;
  • 該類物件的 java.lang.Class 物件沒有在任何地方被引用,無法在任何地方通過反射訪問該類的方法。

垃圾收集演算法

標記-清除演算法(Mark-Sweep)

標記-清除演算法分為兩個標記和清除階段:首先標記出所有需要回收的物件,在標記完成後統一回收所有被標記的物件。標記過程也就是物件存活判定演算法。

深入理解 JVM 之 垃圾回收機制

它是最基礎的收集演算法,主要有兩個缺點:

  • 效率問題:標記和清除兩個過程的效率都不高。
  • 空間問題:標記清除之後會產生大量不連續的記憶體碎片,空間碎片太多可能會導致以後在程式執行過程中需要分配較大的物件時,無法找到足夠的連續記憶體而不得不提前觸發另一次垃圾收集動作。

複製演算法(Copying)

複製演算法將可用記憶體分為大小相等的兩塊,每次只使用其中的一塊。在一塊記憶體用完後,將仍存活的物件賦值到另一塊上面,再把已使用過的記憶體一次清理掉。

深入理解 JVM 之 垃圾回收機制

複製演算法的優缺點如下:

  • 優點:每次對半個分割槽進行記憶體回收,記憶體分配時也不用考慮記憶體碎片等情況,實現簡單,執行高效。
  • 缺點:可使用的記憶體縮小為一半,代價較大。

標記-整理演算法(Mark-compact)

標記-整理演算法分為標記和整理兩個階段,標記階段和“標記-清除演算法”一樣,但在整理階段,不是直接對可回收物件進行清理,而是讓所有存活的物件都向一端移動,然後直接清理掉端邊界以外的記憶體。

深入理解 JVM 之 垃圾回收機制

標記-整理演算法的優缺點如下:

  • 避免了空間碎片,空間利用率較高。
  • 效率不高,標記和清除過程的效率較低。

分代演算法(Generational Collection)

分代演算法根據物件存活週期將記憶體劃分為幾塊。一般是將 Java 對分為新生代和老年代,根據各個年代的特點採用適當的收集演算法。

新生代中,每次垃圾收集時只有少量物件存活,選擇複製演算法;老年代中,物件存活率較高、沒有額外空間進行分配,使用“標記-清理”或“標記-整理”演算法。

為了對不同生命週期的物件採用不同的回收演算法,所以垃圾收集器都採用分代收集演算法,將堆分為新生代和老年代。

  深入理解 JVM 之 垃圾回收機制

記憶體分配和回收策略

新生代

新生代主要用來存放新建立的物件,一般佔堆 1/3 的空間。由於很多物件生命週期很短,每次 Minor GC 後只有少量物件存活,所以選用複製演算法。

新生代又被分為一塊較大的 Eden 區和兩塊較小的大小相等的 Survivor 區,使用 fromto 來分別指代兩個 Survivor 區。HotSpot 虛擬機器預設 Eden 和兩塊 Survivor 的大小比例為 8:1:1。每次只會使用 Eden 和其中一塊 Survivor 區為物件服務,所以總是有一塊 Survivor 區是空閒的,新生代實際可用的記憶體空間也就為 90%

通常,物件會分配在 Eden 區中,當 Eden 區無法在分配物件時,JVM 便會觸發一次 Minor GC,將存活下來的物件複製到 from 指向的 Survivor 區中。

from 指向的 Survivor 區也無法分配時,對 Edenfrom 指向的 Survivor 區執行 Minor GC,將存活下來的物件複製到 to 指向的 Survivor 區中,然後交換 fromto 指標,使 to 指向的 Survivor 區為空,以保證下次 Minor GC 有複製的空閒空間。

老年代

老年代用於存放大物件,或年齡超過一定程度的物件。一般佔據堆 2/3 的空間。

如果物件需要大量連續的記憶體空間,例如很長的字串及陣列,這些物件會直接分配在老年代,以避免在 Eden 區及兩個 Survivor 區之間發生大量的記憶體複製。

虛擬機器為每個物件定義了一個物件年齡計數器,如果物件分配在 Eden 區,在經過一次 Minor GC 後仍然存活,之後移動到 Survivor 空間中,將其年齡設定為 1。物件在 Survivor 區中每經過一次 Minor GC,年齡就增加一次,當它的年齡增加到一定程度(預設為 15)時,也會被晉升到老年代中。

如果在 Survivor 區中相同年齡所有物件大小的總和大於 Survivor 區的一半,年齡大於或等於該年齡的物件就可以直接進入老年代。

老年代的物件一般都比較穩定,Major GC 不會頻繁執行。Major GC 採用標記—清除演算法:首先掃描一次所有老年代,標記出存活的物件,然後回收沒有標記的物件。MajorGC 的耗時較長,而且會產生記憶體碎片。

三種清理方式

Minor GC(Young GC)

指發生在新生代的垃圾收集動作。當 Eden 區沒有足夠的空間分配時,就會觸發一次 Minor GC。由於 Java 物件大多生命週期較短,所以 Minor GC 非常頻繁,一般回收速度也比較快。

Major GC

指發生在老年代的垃圾收集動作,在進行 Major GC 前,一般都會進行至少一次 Minor GCMajor GC 的速度一般會比 Minor GC10 倍以上。

Full GC

指回收整個新生代和老年代的垃圾收集動作。成本較高,對系統效能產生影響。FULL GC 的時候會 STOP THE WORD

它的觸發條件主要有:

  • 在執行 Minor GC 之前,如果老年代最大可用的連續空間小於歷次晉升到老生代物件的平均大小,則觸發一次 Full GC
  • 大物件直接進入老年代,或從年輕代晉升上來的老物件,在老年代嘗試分配記憶體,但老年代記憶體空間不夠時。
  • 顯式呼叫 System.gc() 方法時。

參考資料

相關文章