深入理解Java虛擬機器 - 垃圾收集器與記憶體分配策略

beyondlicg發表於2018-03-08

Java與C++間有一堵由動態記憶體分配和垃圾收集技術所圍成的牆,外面的人想進來,裡面的人卻想出去。

概述

  • GC需要完成的3件事情
  1. 哪些記憶體需要回收
  2. 什麼時候進行回收
  3. 怎麼進行回收
  • 意義 目前動態記憶體分配和垃圾手記技術已經很成熟,一切似乎已經進入自動化時代,為什麼我們還要去了解GC和動態記憶體分配呢?答案很簡單:當出現記憶體洩露、記憶體溢位問題時,當垃圾回收成為系統達到更高併發量的瓶頸時,瞭解這些自動化技術就顯得很有必要。

  • 前章回顧 前章介紹了Java執行時記憶體的各個區域,其中程式計數器、虛擬機器棧、本地方法棧都是隨執行緒而生,隨執行緒而滅,棧的棧幀隨方法的呼叫而入棧,隨方法的完成而出棧。每一個棧幀中分配的記憶體大小在編譯期就明確可知,因此這幾個區域的記憶體分配和回收都具有確定性,所以這幾個區域不需要過多考慮記憶體回收的問題,因為方法或執行緒結束時,記憶體也隨之跟著回收。而Java堆和方法區不一樣,因為只有程式處於執行期間才能知道會建立哪些物件,這部分記憶體的分配和回收是動態的。垃圾收集所關注的也是這部分記憶體,一下提到的記憶體都指這一部分記憶體。

物件已死嗎

引用計數法

給物件新增一個引用計數器,每當物件被引用時,計數器值加1,當引用失效時,計數器值減1,當計數器值為0時,說明物件沒有被其他地方引用,即物件已死。客觀地說,引用計數法(Reference Counting)的實現簡單,判斷效率也很高,但是,主流的Java虛擬機器都沒有采用引用計數法來判斷物件是否已死,因為它有一個致命問題-無法解決物件間相互引用的問題。
程式碼展示:


複製程式碼

可達性分析法

基本思路:通過一系列的稱為“GC Roots”的物件作為起始點,從這些起始點往下搜尋,搜尋走過的路徑稱為引用鏈,當一個物件和“GC Roots”沒有任何引用鏈時(即GC Roots到這個物件是不可達的),說明物件是無用的。

深入理解Java虛擬機器 - 垃圾收集器與記憶體分配策略
在Java中可作為GC Roots的物件有下面幾種:

  1. 虛擬機器棧(棧幀中的本地變數表)中引用的物件
  2. 方法區中類靜態屬性引用的物件
  3. 方法區中常量引用的物件
  4. 本地方法棧中引用的物件

引用的四種型別

  1. 強引用
  2. 軟引用
  3. 弱引用
  4. 虛引用

物件死亡的歷程

可達性分析法中不可達的物件也不是非死不可的,而是處於緩刑階段。要宣告一個物件的死亡至少要經過兩次標記過程:當經過可達性分析後發現物件與GC Roots不可達,那麼它會被第一次標記並且進行一次刷選,刷選的條件是此兌物件是否有必要執行finalize方法。當物件沒有覆蓋finalize方法或物件的finalize方法已經被虛擬機器執行過,這兩種情況都會被視為不需要執行finalize方法。
如果這個物件有必要執行finalize方法,那麼物件會被放在F-Queue的佇列中,並且會被由Java虛擬機器自動建立的、低優先順序的Finalizer執行緒去執行。finalize方法是物件最後一次逃脫死亡的機會,在finalize方法後,GC將會對物件進行第二次標記。如果物件在finalize方法中成功拯救自己,那麼在第二次標記時會被移出回收集合,否則就真的被回收了。
程式碼展示:

package com.whut.java;

/**
 * User:  Chunguang Li
 * Date:  2018/3/8
 * Email: 1192126986@foxmail.com
 */

/**
 * 程式碼演示了兩點:
 * 1. 物件可以在GC時自救
 * 2.自救的機會只有一次,因為一個物件的finalize方法只會被JVM呼叫一次
 */
public class FinalizeEscapeGC {
    public static FinalizeEscapeGC finalizeEscapeGC = null;

    public void isAlive(){
        System.out.println("i still alive...");
    }

    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("execute finalize method...");
    }

    public static void main(String[] args) throws InterruptedException {
        finalizeEscapeGC = new FinalizeEscapeGC();

        finalizeEscapeGC = null;
        // 顯示呼叫gc
        System.gc();
        // 第一次自救
        // 因為Finalizer執行緒優先順序很低,需要暫停0.5秒時間等待Finalizer執行緒執行物件的finalize方法
        Thread.sleep(500);

        if (finalizeEscapeGC != null){
            finalizeEscapeGC.isAlive();
        }else {
            System.out.println("i am dead...");
        }

        finalizeEscapeGC = null;
        System.gc();
        // 自救失敗
        Thread.sleep(500);

        if (finalizeEscapeGC != null){
            finalizeEscapeGC.isAlive();
        }else {
            System.out.println("i am dead...");
        }
    }
}

複製程式碼

回收方法區

很多人認為方法區(虛擬機器中的永久代)是沒有垃圾回收的,Java虛擬機器規範也確實說過不要求虛擬機器在方法區實現圾回收,因為方法區的垃圾收集效率很低。
方法區的垃圾收集主要回收兩部分內容:廢棄常量和無用的類。

  • 回收廢棄常量 回收廢棄常量與回收Java堆中的物件類似,以常量池中的字面量的回收為例:如果“abc”字串儲存在常量池中,其他地方沒有任何物件引用常量池中的“abc”常量,那麼進行垃圾回收時“abc”常量會被清理出常量池。常量池中的其他類(介面)、方法、欄位的符號引用也與此類似。

  • 無用的類 判斷無用的類比廢棄常量條件苛刻得多。必須滿足一下三個條件:

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

垃圾收集演算法

標記-清除演算法

最基礎的收集演算法。

  • 工作原理 演算法主要分為兩個階段-標記和清除:首先標記出所有需要回收的物件,標記完成後統一進行清除。

    深入理解Java虛擬機器 - 垃圾收集器與記憶體分配策略

  • 缺點

  1. 效率問題:標記和清除兩個過程效率都不高
  2. 空間問題:物件清除後會產生大量不連續的空間碎片,當需要分配給大物件分配較大的記憶體空間時會因為找不到足夠的連續空間而不得不提前觸發下一次垃圾收集。

複製演算法

為解決效率問題,複製演算法出現了:它將記憶體空間分為大小相等的兩塊區域,每次只使用其中一塊,當進行垃圾收集時,將這塊區域中還存活的物件複製到另一塊,然後將這一塊記憶體回收。這樣就不會產生記憶體碎片的問題。這種演算法實現簡單,執行高效,只是代價是每次只能使用記憶體的一半,代價過高。

深入理解Java虛擬機器 - 垃圾收集器與記憶體分配策略

現在的商用虛擬機器都採用這種收集演算法回收新生代記憶體。根IBM公司的研究表明,新生代中的記憶體物件98%是朝生夕死的,所以不需要按照1:1的比例來劃分記憶體空間,而是將記憶體分為一塊較大的Eden區域,兩塊較小的Survivor區域。每次只使用一塊Eden區域和一塊Survivor區域,當進行垃圾收集時,將Eden區域和Survivor區域仍然存活的物件複製到另一塊Survivor區域,然後將Eden區域和使用過的Survivor區域清除。HotSpot虛擬機器預設的Eden和Survivor區域大小比例為8:1,這樣只會浪費10%的記憶體。

標記-整理演算法

複製演算法在物件成活率較低的新生代比較適用,而對於物件成活率較高的老年代就需要進行較多的複製操作,效率明顯會減低。所以針對老年代的特點,提出了標記-整理演算法:標記清除過程仍然與標記清除演算法一樣,只是在清除後將存活的物件都向一端移動。

深入理解Java虛擬機器 - 垃圾收集器與記憶體分配策略

分代收集演算法

當前商業的虛擬機器的垃圾收集演算法都採用分代收集演算法:根據物件存活週期的不同將記憶體劃分為幾塊,一般把Java堆分為新生代和老年代,再根據各個年代的特點選擇合適的收集演算法。
在新生代中,物件存活率低,適合使用複製演算法,而老年代物件的存活率高,適合使用標記-清除演算法或標記-整理演算法。

垃圾收集器

收集演算法是記憶體回收的方法論,那麼收集器就是收集演算法的實現。

深入理解Java虛擬機器 - 垃圾收集器與記憶體分配策略

Serial 收集器 - 新生代收集器

Serial收集器是最基本、最悠久的收集器。這個收集器是一個單執行緒收集器,在它進行垃圾收集時,必須停掉所有其他的工作執行緒,然後以一條收集執行緒進行垃圾收集,直到收集工作結束,才可以恢復其他工作執行緒。這對於許多應用是難以接受的。但是對Client(客戶端)模式的虛擬機器來說,Serial收集器是一個不錯的選擇,因為在桌面端應用,分配給虛擬機器的記憶體不會太大,收集幾十兆到幾百兆的新生代記憶體停頓時間完全可以控制在幾十毫秒。

深入理解Java虛擬機器 - 垃圾收集器與記憶體分配策略

ParNew 收集器 - 新生代收集器

ParNew收集器其實就是Serial收集器的多執行緒版本,除了使用多協調執行緒進行垃圾收集外,其餘的Serial收集器完全一樣。ParNew收集器在單CPU或CPU數量少的環境中效能不會有比Serial收集器更好的結果,但是隨著CPU數量的增多,它GC時對CPU資源的的有效利用還是很有好處的,所以它是許多執行在Server模式下的虛擬機器的首先新生代收集器。

深入理解Java虛擬機器 - 垃圾收集器與記憶體分配策略

Parallel Scavenge 收集器 - 新生代收集器

它看上去似乎與ParNew一樣,但是它的目標是達到一個可控制的吞吐量(吞吐量 = 執行使用者程式碼時間 / (執行使用者程式碼時間 + GC時間))。停頓時間越短,就越適合與使用者互動的程式,因為良好的響應時間可以提高使用者的體驗,而吞吐量則可以高效利用CPU時間儘快完成程式的計算任務,主要適合在後臺運算而需要互動任務。
Parallel Scavenge 收集器提供了兩個引數用於控制吞吐量:

  1. 最大垃圾收集停頓時間:-XX:MAxGCPauseMillis
  2. 設定吞吐量大小:-XX:GCTimeRatio
    深入理解Java虛擬機器 - 垃圾收集器與記憶體分配策略

Serial Old 收集器 - 老年代收集器

Serial Old收集器是Serial收集器的老年代版本,同樣是一個單執行緒收集器,使用標記-整理演算法。

深入理解Java虛擬機器 - 垃圾收集器與記憶體分配策略

Parallel Old 收集器 - 老年代收集器

Parallel Old收集器是Parallel Scavenge收集器的老年代版本,使用多執行緒和標記-整理演算法,主要配合Parallel Scavenge收集器組成“吞吐量優先”組合。

深入理解Java虛擬機器 - 垃圾收集器與記憶體分配策略

CMS 收集器 - 老年代收集器

CMS(Concurrent Mark Sweep)是一款以獲取最短回收停頓時間為目的的收集器。CMS非常適合B/S系統服務端的Java應用,因為這類應用尤其注重服務的響應時間,希望系統的停頓時間越短。CMS是基於標記-清除演算法的運作流程分為4個部分:

  1. 初始標記:標記GC Roots能關聯到的物件,速度很快
  2. 併發標記:進行GC Roots Tracing
  3. 重新標記:為了修改併發標記期間因程式繼續執行而導致標記產生變動的物件的標記
  4. 併發清除 初始標記和重新標記仍需要Stop The World,而併發標記和併發清除可以與使用者執行緒一起併發工作。CMS的主要特點是:併發收集、低停頓。
    深入理解Java虛擬機器 - 垃圾收集器與記憶體分配策略

相關文章