JVM詳解2.垃圾收集與內存分配


博客地址:https://spiderlucas.github.io
備用地址:http://spiderlucas.coding.me

Java與C++之間有一堵由內存動態分配和垃圾收集技術所圍成的“高牆”,牆外的人想進來,牆裏面的人卻想出來。

2.1 對象是否需要回收

2.1.1 引用計數法算法

原理:給對象中添加一個引用計數器,每當有一個地方引用它時,計數器值加1;當引用失效時,計數器減1,任何時刻計數器都爲0的對象就是不可能再被使用的。
優點:實現原理簡單,而且判定效率很高。
缺點:很難解決對象之間相互循環引用的問題。

2.1.2 可達性分析算法

原理:通過一系列名爲“GC Roots”的對象作爲起始點,從這些節點開始向下搜索,搜索所走過的路徑稱爲引用鏈(Reference Chain)。當一個對象到GC Roots沒有任何引用鏈相連時,則證明此對象是不可用的。

Java中的GC Roots對象
  1. 虛擬機棧(棧楨中的本地變量表)中的引用的對象
  2. 本地方法棧中JNI(一般說的Native方法)的引用的對象
  3. 方法區中的類靜態屬性引用的對象
  4. 方法區中的常量引用的對象

2.1.3 什麼是引用

無論是通過引用計數算法判斷對象的引用數量,還是通過根搜索算法判斷對象的引用鏈是否可達,判定對象是否存活都與“引用”有關。

JDK 1.2 之前

在JDK1.2之前,Java中的引用的定義很傳統:如果reference類型的數據中存儲的數值代表的是另外一塊內存的起始地址,就稱這塊內存代表着一個引用
缺點:一個對象在這種定義下只有被引用或者沒有被引用兩種狀態,我們希望能描述這樣一類對象——當內存空間還足夠時,則能保留在內存之中;如果內存在GC之後還是非常緊張,則可以拋棄這些對象(如緩存)。

JDK 1.2 之後

在 JDK 1.2 之後,Java對引用的概念進行了擴充,將引用分爲強引用(Strong Reference)、軟引用(Soft Reference)、弱引用(Weak Reference)、虛引用(Phantom Reference),這四種引用強度依次逐漸減弱。

  1. 強引用:就是指在程序代碼之中普遍存在,類似“Object obj = new Object()”這類的引用,只要強引用還存在,垃圾收集器永遠不會回收掉被引用的對象。
  2. 軟引用:用來描述一些還有用但並非必需的對象。對於軟引用關聯着的對象,在系統將要發生內存溢出異常之前,將會把這些對象列進回收範圍之中並進行第二次回收。如果這次回收還是沒有足夠的內存,纔會拋出內存溢出異常。在JDK1.2之後提供了SoftReference類來實現軟引用。
  3. 弱引用:也是用來描述非必需對象的,但是它的強度比軟引用更弱一些,被弱引用關聯的對象只能生存到下一次垃圾收集發生之前。當垃圾收集器工作時,無論當前內存是否足夠,都會回收掉只被弱引用關聯的的對象。在JDK1.2之後提供了WeakReference類來實現弱引用。
  4. 虛引用(幽靈引用、幻影引用):是最弱的一種引用關係。一個對象是否有虛引用的存在,完全不會對其生存時間構成影響,也無法通過虛引用來取得一個對象實例。爲一個對象設置虛引用關聯的唯一目的就是希望能在這個對象被收集器回收時收到一個系統通知。在JDK1.2之後,提供了PhantomReference類來實現虛引用。

更多資料:深入探討 java.lang.ref 包慎用java.lang.ref.SoftReference實現緩存

2.1.4 finalize()

兩次標記過程

即使在可達性分析算法中不可達的對象,也並非是“非死不可”的,這時候它們暫時處於“緩刑”階段,要真正宣告一個對象死亡,至少要經歷兩次標記過程:

  1. 對象在進行可達性分析後發現沒有與GC Roots相連接的引用鏈,那它將會被第一次標記並且進行一次篩選。篩選的條件是此對象是否有必要執行finalize()方法。當對象沒有覆蓋finalize方法,或者finalize()方法已經被虛擬機調用過,虛擬機將這兩種情況都視爲“沒有必要執行”,對象被回收。
  2. 如果這個對象有必要執行finalize()方法,那麼這個對象將會被放置在一個名爲F-Queue的隊列之中,並在稍後由一條虛擬機自動建立的、低優先級的Finalizer線程去執行。這裏所謂的“執行”是指虛擬機會觸發這個方法,但並不承諾會等待它運行結束。這樣做的原因是,如果一個對象finalize()方法中執行緩慢,或者發生死循環,將很可能會導致F-Queue隊列中的其他對象永久處於等待狀態,甚至導致整個內存回收系統崩潰。
  3. Finalize()方法是對象脫逃死亡命運的最後一次機會,稍後GC將對F-Queue中的對象進行第二次小規模標記。如果對象要在Finalize()中成功拯救自己——只要重新與引用鏈上的任何的一個對象建立關聯即可,那在第二次標記時它將移除出“即將回收”的集合。如果對象這時候還沒逃脫,那基本上它就真的被回收了。
使用finalize()自我救贖
public class FinalizeEscapeGC {
    public static FinalizeEscapeGC SAVE_HOOK = null;

    public void isAlive() {
        System.out.println("yes, I am still alive");
    }

    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("finalize method executed!");
        FinalizeEscapeGC.SAVE_HOOK = this;
    }

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

        // 對象第一次成功拯救自己
        SAVE_HOOK = null;
        System.gc();

        // 因爲finalize方法優先級很低,所有暫停0.5秒以等待它
        Thread.sleep(500);
        if (SAVE_HOOK != null) {
            SAVE_HOOK.isAlive();
        } else {
            System.out.println("no ,I am dead QAQ!");
        }
 
        // 以上代碼與上面的完全相同,但這次自救卻失敗了!!!
        SAVE_HOOK = null;
        System.gc();

        //因爲finalize方法優先級很低,所有暫停0.5秒以等待它
        Thread.sleep(500);
        if (SAVE_HOOK != null) {
            SAVE_HOOK.isAlive();
        } else {
            System.out.println("no ,I am dead QAQ!");
        }
    }
}
總結
  • System.gc()底層調用的是Runtime.getRuntime().gc();,該方法的Java doc裏邊寫的是調用此方法suggestsJVM進行GC,即無法保證對垃圾收集器的調用。
  • finalize()方法至多由GC執行一次,用戶當然可以手動調用對象的finalize方法,但並不影響GC對finalize()的行爲。
  • 雖然可以在finalize()方法完成很多操作如關閉外部資源,但更好的方式應該是try-finally
  • finalize()運行代價高昂,不確定大,無法保證各個對象的調用順序。
  • 最好的方法就是忘掉有這個方法!

2.1.5 回收方法區

Java虛擬機規範不要求虛擬機在方法區實現垃圾收集;方法區的GC性價比一般比較低。
方法區的GC主要是回收兩部分內容:廢棄常量和無用的類。

廢棄常量

判斷常量是否廢棄跟對象是一樣。常量池中的其他類、接口、方法、字段的符號引用也是如此。

無用的類(必須同時滿足以下三個條件)
  1. 該類所有的實例都已經被回收,也就是Java堆中不存在該類的任何實例;
  2. 加載該類的ClassLoader已經被回收;
  3. 該類對應的Java.lang.Class對象沒有在任何地方被引用,無法在任何地方通過反射訪問該類的方法。
類是否回收
  • 滿足上述3個條件的類只是被判定爲可以被虛擬機回收,而不是和對象一樣,不使用了基於就必然會回收。是否對類進行回收,還需要對虛擬機進行相應的參數設置。
  • 在HotSpot中,虛擬機提供-Xnoclassgc參數進行控制,還可以使用-verbose:class以及-XX:+TraceClassLoading-XX:+TraceClassUnLoading查看類加載和卸載信息,其中-verbose:class-XX:+TraceClassLoading可以在Product版的虛擬機中使用,-XX:+TraceClassUnLoading參數需要FastDebug版的虛擬機支持。
  • 在大量使用反射、動態代理、CGLib等ByteCode框架、動態生成JSP以及OSGI這類頻繁自定義ClassLoader的場景都需要虛擬機具備類卸載功能,以保證永久代不會溢出。

2.2 垃圾收集算法

2.2.1 標記-清除算法

定義:標記-清除(Mark-Sweep)算法分爲標記和清除兩個階段,首先標記出需要回收的對象,標記完成之後統一清除對象。
缺點:效率問題,標記和清除過程效率不高;標記清除之後會產生大量不連續的內存碎片。

2.2.2 複製算法

定義:複製(Copying)算法它將可用內存容量劃分爲大小相等的兩塊,每次只使用其中的一塊。當這一塊用完之後,就將還存活的對象複製到另外一塊上面,然後在把已使用過的內存空間一次理掉。
優點:這樣使得每次都是對其中的一塊進行內存回收,不會產生碎片等情況,只要移動堆訂的指針,按順序分配內存即可,實現簡單,運行高效。
缺點:內存縮小爲原來的一半。
使用情況:現在的商業虛擬機都採用這種收集算法來回收新生代,新生代中的對象98%都是“朝生夕死”的,所以並不需要按照1:1的比例來劃分內存空間,而是將內存分爲一塊比較大的Eden空間和兩塊較小的Survivor空間,每次使用Eden和其中一塊Survivor。當回收時,將Eden和Survivor中還存活着的對象一次性地複製到另外一塊Survivor空間上,最後清理掉Eden和剛纔用過的Survivor空間。
HotSpot虛擬機:默認Eden和Survivor的大小比例是8:1,也就是說,每次新生代中可用內存空間爲整個新生代容量的90%(80%+10%),只有10%的空間會被浪費。

2.2.3 標記-整理算法

定義:標記-整理算法的標記過程與標記清除算法一樣,但後續步驟不是直接對可回收對象進行清理,而是對所有存活的對象都向一端移動,然後清理掉邊界以外的內存。
優點:解決了複製算法在對象存活率較高情況下需要大量複製導致的效率問題,而且不會縮小內存。

2.2.4 分代收集算法

定義:根據對象存活週期的不同將內存分爲幾塊,一般是把Java堆分爲新生代和老年代,根據各個年代的特點採用最適用的算法。
新生代:每次收集都會有大批對象死去,只有少量存活,採用複製算法。
老年代:對象存活率較高、沒有額外空間對它進行分配擔保,採用標記-清除或標記-整理算法。

2.3 HotSpot算法實現

2.3.1 枚舉根節點

可達性分析的效率問題:可作爲GC Roots的節點主要在全局性的引用(常量或類的靜態屬性)與執行上下文(如棧幀的本地變量表)中,現在很多應用僅僅方法區就有數百兆,如果逐個檢查引用必然會消耗很多時間。
GC停頓:可達性分析在分析期間整個執行系統看起來就像被凍結在某個時間點上,不可以出現分析過程中對象引用關係還在不斷變化的情況,這就是導致GC進行時必須停頓所有Java執行線程(Sun將這件事情成爲“Stop The World”)的一個重要原因,即使在號稱(幾乎)不會發生停頓的CMS收集器中,枚舉跟結點也是必須要暫停的。
準確是GC:主流JVM都使用的是準確式GC,即JVM知道內存中某位置的數據類型什麼,所以當執行系統停下來的時候,不需要一個不漏的檢查完所有執行上下文和全局的引用位置,虛擬機可以有辦法知道哪些地方存放着對象的引用。
HotSpot的OOPMap:在類加載完成的時候,HotSpot就把對象內什麼偏移量上是什麼類型的數據計算出來;在JIT編譯過程中,也會在特定的位置記錄下棧和寄存器中哪些位置是引用。這樣GC在掃描的時候就可以直接獲得這些信息。

2.3.2 安全點

爲什麼需要安全點:有了OOPMap,HotSpot可以快而準的完成GC Roots的查找,但如果爲每一行代碼的指令都生成OOPMap,這樣將佔用大量的空間。所以HotSpot並沒有這麼做!
安全點:HotSpot只在特定的位置記錄了OOPMap,這些位置稱爲安全點(Safe Point),即程序不能在任意地方都可以停下來進行GC,只有到達安全點時才能暫停進行GC。

安全點的選擇

安全點的選定基本上是以“是否具有讓程序長時間執行的特徵”進行選定的,既不能選擇太少以致於讓GC等待太久,與不能太頻繁以致於增大系統負荷。具體的安全點有

  1. 循環的末尾
  2. 方法返回前
  3. 調用方法的call之後
  4. 拋出異常的位置
GC時讓所有線程停下來
  • 搶先式中斷:不需要線程的執行代碼主動配合,在GC時先把所有線程中斷,然後如果有線程沒有運行到安全點,則恢復線程讓他們運行到安全點。幾乎沒有JVM採用這種方式
  • 主動式中斷:當GC需要中斷線程的時候,不直接對線程操作而是設置一個標誌,各個線程執行時主動輪詢這個標誌,發現中斷標誌爲真時就自己中斷掛起。輪詢標誌的地方和安全點是重合的,另外再加上創建對象需要分配內存的地方。

2.3.3 安全區域

安全點的不足:安全點機制保證了程序執行時,在較短的時間就會遇到可以進入GC的安全點,但如果程序處於不執行狀態(如Sleep狀態或者Blocked狀態),這時候線程無法相應JVM的中斷請求,無法運行到安全點去中斷掛起,JVM也不會等待線程重新被分配CPU時間。
安全區域:安全區域(Safe Region)是指在一段代碼片段之中,引用關係不會發生變化,這個區域的任何地方GC都是安全的。可以把安全區域看成是擴展了的安全點。

安全區域工作原理
  1. 在線程執行到安全區域中的代碼時,首先標識自己已經進入了安全區域,那樣,當在這段時間裏JVM要發起GC時,就不用管標識自己爲安全區域狀態的線程了。
  2. 在線程要離開安全區域時,它要檢查系統是否已經完成了根節點枚舉,如果完成了,那線程就繼續執行,否則它就必須等待直到收到可以安全離開安全區域的信號爲止。

3.4 垃圾收集器

這裏討論的收集器基於JDK 7 Update14的HotSpot虛擬機,這個版本中正式提供了商用的G1收集器。下圖展示了HotSpot虛擬機的垃圾收集器,如果兩個收集器存在連線,說明可以搭配使用。
HotSpot虛擬機的垃圾收集器

3.4.1 Serial

簡介:最基本、最悠久、單線程
缺點:只會使用一條線程完成GC工作,而且在工作時必須暫停其他所有工作線程。
優點:簡單而高效(與其他收集器的單線程比),是JVM運行在Client模式下的默認新生代收集器。
Serial/Serial Old收集器運行示意圖

使用方式

-XX:+UseSerialGC,設置之後默認使用Serial(年輕代)+Serial Old(老年代) 組合進行GC。

3.4.2 ParNew

簡介:Serial的多線程版本,其餘行爲包括Serial的所有控制參數、收集算法、Stop The World、對象分配規則、回收策略等都與Serial完全一樣,默認開啓的收集線程數與CPU數量相同。
優點:多線程收集、能與CMS配合工作(這也是它是許多Server模式下虛擬機中首選的原因)
缺點:單線程效率不及Serial。
ParNew/Serial Old收集器運行示意圖

使用方式
  1. 設置-XX:+UseConcMarkSweepGC的默認收集器
  2. 設置-XX:+UseConcMarkSweepGC強制指定
  3. 設置-XX:ParallelGCThreads參數來限制垃圾收集的線程數。

3.4.3 Parallel Scavenge

簡介:新生代收集器、採用複製算法、並行多線程收集器、關注的目標是達到一個可控制的吞吐量而非儘可能的縮短GC時用戶線程的停頓時間。
吞吐量:CPU用於運行用戶代碼的時間和CPU總消耗時間的比值,即吞吐量=運行用戶代碼時間/(運行用戶代碼時間+垃圾收集時間)。停頓時間越短適合與用戶交互的程序,良好的相應速度能提升用戶體驗;而高吞吐量可以高效利用CPU時間,適合後臺運算。

使用方式
  1. -XX:MaxGCPauseMillis:控制最大垃圾收集停頓時間,是一個大於0的毫秒數
  2. -XX:GCTimeRatio:直接設置吞吐量大小,是一個大於0且小於100的整數,默認值是99,就是允許最大1%即(1/(1+99))的垃圾收集時間。
  3. -XX:+UseAdaptiveSizePolicy:如果設置此參數,就不需要手工設定新生代的大小、Eden於Survivor區的比例、晉升老年代對象年齡等細節參數來,虛擬機會動態調整。

3.4.4 Serial Old收集器

簡介:Serial的老年代版本、單線程、使用標記整理算法
用途:主要是爲Client模式下的虛擬機使用;在Server模式下有兩大用途,一是在JDK 5及之前版本中配合Parallel Scavenge收集器一起使用,而是作爲CMS的後備預案,在併發收集發生Concurrent Mode Failure時使用。

3.4.5 Parallel Old收集器

簡介:Parallel Scavenge的老年代版本、多線程、標記整理算法、JDK 6中才出現
用途:直到Parallel Old收集器出現後,“吞吐量優先”收集器終於有了比較名副其實的應用組合,在注重吞吐量以及CPU資源敏感的場合,可以使用Parallel Scavenge和Parallel Old的組合。
Parallel Scavenge和Parallel Old的組合

3.4.6 CMS

簡介:CMS(Concurrent Mark Sweep)以最短回收停頓時間爲目標、適合B/S系統的服務端、基於標記清除算法
優點:併發收集、低停頓

工作流程
  1. 初始標記——需要Stop The World,僅僅標記一下GC Roots能直接關聯對象,速度很快
  2. 併發標記——進行GC Roots Tracing
  3. 重新標記——需要Stop The World,爲了修正併發標記期間因用戶程序繼續運作而導致標記產生變動的那一部分對象的標記記錄,速度很快
  4. 併發清除

CMS收集器

缺點
  1. 對CPU資源非常敏感,在併發階段它雖然不會導致用戶線程停頓,但是會因爲佔用一部分線程(CPU資源)導致程序變慢
  2. CMS無法處理“浮動垃圾”——浮動垃圾是在併發清理階段用戶線程產生的新的垃圾,所以可能出現“Concurrent Mode Failure”失敗而導致另一次Full GC的產生。
  3. 由於CMS在垃圾收集階段用戶線程還需要執行,所以不能像其他收集器那樣等老年代幾乎填滿了再進行收集,所以需要預留一部分空間給用戶線程。CMS運行期間如果預留的內存無法滿足程序需要,就會出現“Concurrent Mode Failure”失敗,此時虛擬機將會臨時啓用Serial Old收集器來進行老年代的垃圾收集,導致長時間停頓。
  4. 由於CMS基於標記清除算法,所以會導致內存碎片。

3.4.7 G1收集器

原理
  1. 堆內存劃分:G1收集器將整個Java堆劃分爲多個大小相等的獨立區域(Region),雖然還保留有新生代和老年代的概念,但新生代和老年代不再是物理隔離的了,它們都是一部分Region(不需要連續)的集合。
  2. 收集策略:G1跟蹤各個Region裏面的垃圾堆積的價值大小(回收所獲得的空間大小以及回收所需時間的經驗值),在後臺維護一個優先列表,每次根據允許的收集時間,優先回價值最大的Region(這也就是Garbage-First名稱的來由),有計劃地避免在整個Java堆中進行全區域的垃圾收集。
  3. Region不可能是孤立的:把Java堆分爲多個Region後,垃圾收集是否就真的能以Region爲單位進行了?仔細想想就很容易發現問題所在:Region不可能是孤立的。一個對象分配在某個Region中,它並非只能被本Region中的其他對象引用,而是可以與整個Java堆任意的對象發生引用關係。那在做可達性判定確定對象是否存活的時候,豈不是還得掃描整個Java堆才能保障準確性?這個問題其實並非在G1中才有,只是在G1中更加突出了而已。在以前的分代收集中,新生代的規模一般都比老年代要小許多,新生代的收集也比老年代要頻繁許多,那回收新生代中的對象也面臨過相同的問題,如果回收新生代時也不得不同時掃描老年代的話,Minor GC的效率可能下降不少。
  4. 使用Remembered Set來避免全堆掃描:在G1收集器中Region之間的對象引用以及其他收集器中的新生代與老年代之間的對象引用,虛擬機都是使用Remembered Set來避免全堆掃描的。G1中每個Region都有一個與之對應的Remembered Set,虛擬機發現程序在對Reference類型的數據進行寫操作時,會產生一個Write Barrier暫時中斷寫操作,檢查Reference引用的對象是否處於不同的Region之中(在分代的例子中就是檢查引是否老年代中的對象引用了新生代中的對象),如果是,便通過CardTable把相關引用信息記錄到被引用對象所屬的Region的Remembered Set之中。當進行內存回收時,GC根節點的枚舉範圍中加入Remembered Set即可保證不對全堆掃描也不會有遺漏。
優點
  1. 並行與併發:G1能充分使用多CPU、多核來縮短Stop The World的停頓,部分其他收集器需要停頓Java線程執行的GC動作,G1仍然可以通過併發的方式讓Java線程繼續運行。
  2. 分代收集:保留了分代收集的概念,而且不需要其他收集器配合能獨立管理整個堆。
  3. 空間整合:G1從整體看來是基於“標記-整理”算法實現的,從局部(兩個Region之間)是基於複製算法實現的,不會產生空間碎片。
  4. 可預測的停頓:G1能讓使用者明確制定在長度爲M毫秒內,消耗在GC上的時間不得超過N毫秒,這幾乎是實時Java(RTJS)的垃圾收集器的特徵了。
運作流程

G1運行流程

如果不計算維護Remembered Set的操作,G1收集器的運作大致可劃分爲以下幾個步驟:

  • 初始標記(Initial Marking)——標記一下GC Roots能直接關聯到的對象,並且修改TAMS(Next Top at Mark Start)的值,讓下一階段用戶程序併發運行時,能在正確可用的Region中創建新對象,這階段需要停頓線程,但耗時很短。
  • 併發標記(Concurrent Marking)——從GC Root開始對堆中對象進行可達性分析,找出存活的對象,這階段耗時較長,但可與用戶程序併發執行。
  • 最終標記(Final Marking)——爲了修正併發標記期間,因用戶程序繼續運作而導致標記產生變動的那一部分標記記錄,虛擬機將這段時間對象變化記錄在線程Remembered Set Logs裏面,最終標記階段需要把Remembered Set Logs的數據合併到Remembered Set中,這階段需要停頓線程,但是可並行執行。
  • 篩選回收(Live Data Counting and Evacuation)——首先對各個Region的回收價值和成本進行排序,根據用戶所期望的GC停頓時間來制定回收計劃,這個階段其實也可以做到與用戶程序一起併發執行,但是因爲只回收一部分Region,時間是用戶可控制的,而且停頓用戶線程將大幅提高收集效率。

3.4.8 GC參數總結

參數 描述
UseSerialGC 虛擬機運行在Client模式下的默認值,打開此開關後,使用 Serial+Serial Old 的收集器組合進行內存回收
UseParNewGC 打開此開關後,使用 ParNew + Serial Old 的收集器組合進行內存回收
UseConcMarkSweepGC 打開此開關後,使用 ParNew + CMS + Serial Old 的收集器組合進行內存回收。Serial Old 收集器將作爲 CMS 收集器出現 Concurrent Mode Failure 失敗後的後備收集器使用
UseParallelGC 虛擬機運行在 Server 模式下的默認值,打開此開關後,使用 Parallel Scavenge + Serial Old(PS MarkSweep) 的收集器組合進行內存回收
UseParallelOldGC 打開此開關後,使用 Parallel Scavenge + Parallel Old 的收集器組合進行內存回收
SurvivorRatio 新生代中 Eden 區域與 Survivor 區域的容量比值,默認爲8,代表 Eden : Survivor = 8 : 1
PretenureSizeThreshold 直接晉升到老年代的對象大小,設置這個參數後,大於這個參數的對象將直接在老年代分配
MaxTenuringThreshold 晉升到老年代的對象年齡,每個對象在堅持過一次 Minor GC 之後,年齡就增加1,當超過這個參數值時就進入老年代
UseAdaptiveSizePolicy 動態調整 Java 堆中各個區域的大小以及進入老年代的年齡
HandlePromotionFailure 是否允許分配擔保失敗,即老年代的剩餘空間不足以應付新生代的整個 Eden 和 Survivor 區的所有對象都存活的極端情況
ParallelGCThreads 設置並行GC時進行內存回收的線程數
GCTimeRatio GC 時間佔總時間的比率,默認值爲99,即允許 1% 的GC時間,僅在使用 Parallel Scavenge 收集器生效
MaxGCPauseMillis 設置 GC 的最大停頓時間,僅在使用 Parallel Scavenge 收集器時生效
CMSInitiatingOccupancyFraction 設置 CMS 收集器在老年代空間被使用多少後觸發垃圾收集,默認值爲 68%,僅在使用 CMS 收集器時生效
UseCMSCompactAtFullCollection 設置 CMS 收集器在完成垃圾收集後是否要進行一次內存碎片整理,僅在使用 CMS 收集器時生效
CMSFullGCsBeforeCompaction 設置 CMS 收集器在進行若干次垃圾收集後再啓動一次內存碎片整理,僅在使用 CMS 收集器時生效

3.5 理解GC日誌

每一種收集器的日誌形式都是由它們自身的實現所決定的,換言之每個收集器的日誌格式都可以不一樣。但虛擬機設計者爲了方便用戶閱讀,將各個收集器的日誌都維持一定的共性,例如以下兩段典型的GC日誌:

33.125:[GC[DefNew:3324K->152K(3712K),0.0025925secs]3324K->152K(11904K),0.0031680 secs]

100.667:[FullGC[Tenured:0K->210K(10240K),0.0149142secs]4603K->210K(19456K),[Perm:2999K->2999K(21248K)],0.0150007 secs][Times:user=0.01 sys=0.00,real=0.02 secs]
  1. 前面的數字(33.125、100.667):代表GC發生的時間,即從JVM啓動以來經過的秒數
  2. [GC或[FullGC:代表這次GC的停頓類型,如果有“Full”說明這次GC是發生了Stop-The-World的。新生代也會出現“[Full GC”,這一般是因爲出現了分配擔保失敗之類的問題,所以才導致STW)。
  3. [GC (System.gc())或[Full GC (System.gc()):說明是調用System.gc()方法所觸發的收集。
  4. [DefNew、[Tenured、[Perm等:表示GC發生的區域,這裏顯示的區域名稱與使用的GC收集是密切相關的——上面樣例所使用的Serial收集器中的新生代名爲“Default New Generation”,所以顯示的是“[DefNew”;如果是ParNew收集器,新生代名稱就會變爲“[ParNew”,意爲“Parallel New Generation”;如果採用Parallel Scavenge收集器,那它配套的新生代稱爲“PSYoungGen”;老年代和永久代同理,名稱也是由收集器決定的。
  5. 內部方括號中的3324K->152K(11904K):GC前該內存區域已使用容量 -> GC後該內存區域已使用容量(該內存區域總容量)。
  6. 外部方括號中的3324K->152K(11904K):表示GC前Java堆已使用容量->GC後Java堆已使用容量(Java堆總容量)。
  7. 0.0025925secs:該內存區域GC所佔用的時間,單位是秒。
  8. [Times:user=0.01 sys=0.00,real=0.02 secs]:user、sys和real與Linux的time命令所輸出的時間含義一致,分別代表用戶態消耗的CPU時間、內核態消耗的CPU事件和操作從開始到結束所經過的牆鍾時間(Wall Clock Time)。CPU時間與牆鍾時間的區別是,牆鍾時間包括各種非運算的等待耗時,例如等待磁盤I/O、等待線程阻塞,而CPU時間不包括這些耗時,但當系統有多CPU或者多核的話,多線程操作會疊加這些CPU時間,所以讀者看到user或sys時間超過real時間是完全正常的。詳細參見:Linux用戶態程序計時方式詳解

3.6 內存分配與回收策略

對象的內存分配總的來說,就是在堆上分配(但也可能經過JIT編譯後被拆散爲標量類型並間接地棧上分配);對象主要分配在新生代的Eden區上;如果啓動了本地線程分配緩衝,將按線程優先在TLAB上分配;少數情況下也可能會直接分配在老年代中。分配的規則並不是百分之百固定的,其細節取決於當前使用的是哪一種垃圾收集器組合,還有虛擬機中與內存相關的參數的設置。

3.6.1 Minor和Full GC

  • 新生代GC(Minor GC):指發生在新生代的垃圾收集動作,因爲Java對象大多都具備朝生夕死的特性,所以Minor GC非常頻繁,一般回收速度也比較快。
  • 老年代GC(Major GC/Full GC):指發生在老年代的GC,出現Major GC,經常會伴隨至少一次的Minor GC(但並非絕對的,在Parallel Scavenge收集器的收集策略裏就有直接進行Major GC的策略選擇過程)。Major GC的速度一般會比Minor GC慢10倍以上。

3.6.2 對象優先在Eden分配

大多數情況下,對象在新生代Eden區中分配。當Eden區沒有足夠空間進行分配時,虛擬機將發起一次Minor GC。

/**
 * -Xms20M -Xmx20M -Xmn10M -XX:+UseSerialGC -XX:+PrintGCDetails -XX:SurvivorRatio=8
 */
public class Allocation {
    private static final int _1MB = 1024 * 1024;

    public static void main(String[] args) {
        byte[] allocation1, allocation2, allocation3, allocation4;
        allocation1 = new byte[2 * _1MB];
        allocation2 = new byte[2 * _1MB];
        allocation3 = new byte[2 * _1MB];
        allocation4 = new byte[4 * _1MB]; // Minor GC
    }
}
[GC (Allocation Failure) [DefNew: 7482K->380K(9216K), 0.0061982 secs] 7482K->6524K(19456K), 0.0062260 secs] [Times: user=0.00 sys=0.01, real=0.01 secs]
Heap
  def new generation   total 9216K, used 4641K [0x00000007bec00000, 0x00000007bf600000, 0x00000007bf600000)
   eden space 8192K,  52% used [0x00000007bec00000, 0x00000007bf0290f0, 0x00000007bf400000)
   from space 1024K,  37% used [0x00000007bf500000, 0x00000007bf55f318, 0x00000007bf600000)
   to   space 1024K,   0% used [0x00000007bf400000, 0x00000007bf400000, 0x00000007bf500000)
  tenured generation   total 10240K, used 6144K [0x00000007bf600000, 0x00000007c0000000, 0x00000007c0000000)
   the space 10240K,  60% used [0x00000007bf600000, 0x00000007bfc00030, 0x00000007bfc00200, 0x00000007c0000000)
  Metaspace       used 2968K, capacity 4496K, committed 4864K, reserved 1056768K
   class space    used 327K, capacity 388K, committed 512K, reserved 1048576K
  1. -Xms20M、-Xmx20M、-Xmn10M、-XX:SurvivorRatio=8四個參數保證了整個Java堆大小爲20M,新生代10M(eden space 8192K、from space 1024K、to space 1024K)、老年代10M。
  2. 在給allocation4分配空間的時候會發生一次Minor GC,這次GC發生的原因是給allocation4分配所需的4MB內存時,發現Eden區已經被佔用了6MB,剩餘空間不足以分配 4MB,因此發生Minor GC。
  3. [GC (Allocation Failure) :表示因爲向Eden給新對象申請空間,但是Eden剩餘的合適空間不夠所需的大小導致的Minor GC。
  4. GC期間虛擬機又發現已有的3個2MB對象無法全部放入Survivor空間(Survivor只有1MB),所以只好通過分配擔保機制提前轉移到老年代。
  5. 這次GC結束後,4MB的allocation4對象被順利分配到Eden中。因此程序執行完的結果是Eden佔用4MB(被allocation4佔用),Survivor空閒,老年代被佔用6MB(allocation1,2,3佔用)。

3.6.3 大對象直接進入老年代

什麼是大對象:大對象就是指需要大量連續內存空間的Java對象,最典型的大對象就是那種很長的字符串及數組(byte[]數組就是典型的大對象)。
大對象的影響:大對象對虛擬機的內存分配來說就是一個壞消息(更加壞的情況就是遇到一羣朝生夕死的短命 對象,寫程序時應該避免),經常出現大對象容易導致內存還有不少空間時就提前觸發垃圾收集以獲取足夠的連續空間來安置大對象。
設置大對象的參數:可以通過-XX:PretenureSizeThreshold參數設置使得大於這個設置值的對象直接在老年代分配,避免在Eden區及兩個Survivor區之間發生大量的內存拷貝。

/**
 * -Xms20M -Xmx20M -Xmn10M -XX:+UseSerialGC -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:PretenureSizeThreshold=3145728(3M)
 */
public class PretenureSizeThreshold {
    private static final int _1MB = 1024 * 1024;

    public static void main(String[] args) {
        byte[] allocation = new byte[4 * _1MB];
    }
}
Heap
 def new generation   total 9216K, used 1502K [0x00000007bec00000, 0x00000007bf600000, 0x00000007bf600000)
  eden space 8192K,  18% used [0x00000007bec00000, 0x00000007bed778d8, 0x00000007bf400000)
  from space 1024K,   0% used [0x00000007bf400000, 0x00000007bf400000, 0x00000007bf500000)
  to   space 1024K,   0% used [0x00000007bf500000, 0x00000007bf500000, 0x00000007bf600000)
 tenured generation   total 10240K, used 4096K [0x00000007bf600000, 0x00000007c0000000, 0x00000007c0000000)
   the space 10240K,  40% used [0x00000007bf600000, 0x00000007bfa00010, 0x00000007bfa00200, 0x00000007c0000000)
 Metaspace       used 2931K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 321K, capacity 388K, committed 512K, reserved 1048576K
  1. 我們可以看到Eden空間幾乎沒有被利用,而老年代10MB空間被使用40%,也就是4MB的allocation對象被直接分配到老年代中,這是因爲PretenureSizeThreshold被設置爲3MB,因此超過3MB的對象都會直接在老年代中進行分配。
  2. PretenureSizeThreshold參數只對Serial和ParNew兩款收集器有效,Parallel Scavenge收集器不認識這個參數,Parallel Scavenge收集器一般並不需要設置。如果遇到必須使用此參數的場合,可以考慮ParNew加CMS的收集器組合。

3.6.4 長期存活對對象將進入老年代

對象年齡:虛擬機給每個對象定義了一個對象年齡(Age)計數器。如果對象在Eden出生並經過第一次Minor GC後仍然存活,並且能被Survivor容納的話,將被移動到Survivor空間中,並且對象年齡設爲1。對象在Survivor區中每“熬過”一次Minor GC,年齡就增加1歲,當它的年齡增加到一定程度(默認爲15歲),就將會被晉升到老年代中。
設置對象晉升年齡:通過參數-XX:MaxTenuringThreshold來設置。

/**
 * -Xms20M -Xmx20M -Xmn10M -XX:+UseSerialGC -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=1
 */
public class MaxTenuringThreshold {
    private static final int _1MB = 1024 * 1024;

    public static void main(String[] args) {
        byte[] allocation1, allocation2, allocation3;
        allocation1 = new byte[_1MB / 4];
        allocation2 = new byte[4 * _1MB];
        allocation3 = new byte[4 * _1MB]; // Eden空間不足GC,allocation1進入Survivor
        allocation3 = null;
        allocation3 = new byte[4 * _1MB]; // Eden空間不足第二次GC
    }
}
[GC (Allocation Failure) [DefNew: 5690K->624K(9216K), 0.0052742 secs] 5690K->4720K(19456K), 0.0053049 secs] [Times: user=0.00 sys=0.01, real=0.01 secs] 
[GC (Allocation Failure) [DefNew: 4720K->0K(9216K), 0.0009947 secs] 8816K->4709K(19456K), 0.0010106 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
Heap
 def new generation   total 9216K, used 4260K [0x00000007bec00000, 0x00000007bf600000, 0x00000007bf600000)
  eden space 8192K,  52% used [0x00000007bec00000, 0x00000007bf0290f0, 0x00000007bf400000)
  from space 1024K,   0% used [0x00000007bf400000, 0x00000007bf400000, 0x00000007bf500000)
  to   space 1024K,   0% used [0x00000007bf500000, 0x00000007bf500000, 0x00000007bf600000)
 tenured generation   total 10240K, used 4709K [0x00000007bf600000, 0x00000007c0000000, 0x00000007c0000000)
   the space 10240K,  45% used [0x00000007bf600000, 0x00000007bfa99570, 0x00000007bfa99600, 0x00000007c0000000)
 Metaspace       used 2953K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 327K, capacity 388K, committed 512K, reserved 1048576K

此方法中allocation1對象需要256KB的內存空間,Survivor空間可以容納。當MaxTenuringThreshold=1時,allocation1對象在第二次GC發生時進入老年代,新生代已使用的內存GC後會非常乾淨地變成0KB。而 MaxTenuringThreshold=15時,第二次GC發生後,allocation1對象則還留在新生代Survivor空間,這時候新生代仍然有410KB的空間被佔用。

3.6.5 動態對象年齡判定

爲了能更好地適應不同程序的內存狀況,虛擬機並不總是要求對象的年齡必須達到MaxTenuringThreshold才能晉升到老年代,如果在 Survivor空間中相同年齡所有對象大小的綜合大於Survivor空間的一半,年齡大於或等於該年齡的對象就可以直接進入老年代,無須等到MaxTenuringThreshold中要求的年齡。

/**
 * -Xms20M -Xmx20M -Xmn10M -XX:+UseSerialGC -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=15
 */
public class Main {
    private static final int _1MB = 1024 * 1024;

    public static void main(String[] args) {
        byte[] allocation1, allocation2, allocation3, allocation4;
        allocation1 = new byte[_1MB / 4];
        allocation2 = new byte[_1MB / 4];
        allocation3 = new byte[4 * _1MB];
        allocation4 = new byte[4 * _1MB]; // 第一次GC
        allocation4 = null;
        allocation4 = new byte[4 * _1MB]; // 第二次GC
    }
}
[GC (Allocation Failure) [DefNew: 5946K->880K(9216K), 0.0045988 secs] 5946K->4976K(19456K), 0.0046307 secs] [Times: user=0.00 sys=0.00, real=0.01 secs] 
[GC (Allocation Failure) [DefNew: 5058K->0K(9216K), 0.0012867 secs] 9154K->4965K(19456K), 0.0013125 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
Heap
 def new generation   total 9216K, used 4315K [0x00000007bec00000, 0x00000007bf600000, 0x00000007bf600000)
  eden space 8192K,  52% used [0x00000007bec00000, 0x00000007bf036ce8, 0x00000007bf400000)
  from space 1024K,   0% used [0x00000007bf400000, 0x00000007bf4000e0, 0x00000007bf500000)
  to   space 1024K,   0% used [0x00000007bf500000, 0x00000007bf500000, 0x00000007bf600000)
 tenured generation   total 10240K, used 4965K [0x00000007bf600000, 0x00000007c0000000, 0x00000007c0000000)
   the space 10240K,  48% used [0x00000007bf600000, 0x00000007bfad9500, 0x00000007bfad9600, 0x00000007c0000000)
 Metaspace       used 2957K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 327K, capacity 388K, committed 512K, reserved 1048576K

發現運行結果中Survivor佔用仍然爲0%,而老年代比預期增加了,也就是說allocation1,allocation2對象都直接進入了老年代,而沒有等到15歲的臨界年齡。因爲這兩個對象加起來達到了512KB,並且它們是同年的,滿足同年對象達到Survivor空間的一半規則。 我們只要註釋一個對象的new操作,就會發現另外一個不會晉升到老年代了。

3.6.5 空間分配擔保

  • Minor GC流程:在發生Minor GC之前,虛擬機會先檢查老年代最大可用的連續空間是否大於新生代所有對象總空間,如果這個條件成立,那麼Minor GC可以確保是安全的。如果不成立,則虛擬機會查看HandlePromotionFailure設置值是否允許擔保失敗。如果允許,那麼會繼續檢查老年代最大可用的連續空間是否大於歷次晉升到老年代對象的平均大小:如果大於,將嘗試着進行一次Minor GC,儘管這次Minor GC是有風險的;如果小於,或者HandlePromotionFailure設置不允許冒險,那這時將進行一次Full GC。
  • 空間分配擔保:出現大量對象在Minor GC後仍然存活的情況時,就需要老年代進行分配擔保,讓Survivor無法容納的對象直接進入老年代。老年代要進行這樣的擔保,前提是老年代本身還有容納這些對象的剩餘空間。一共有多少對象會活下去,在實際完成內存回收之前是無法明確知道的,所以只好取之前每一次回收晉升到老年代對象容量的平均大小值作爲經驗,與老年代的剩餘空間進行對比,決定是否進行Full GC來讓老年代騰出更多空間。
  • 擔保失敗的解決辦法:取平均值進行比較其實仍然是一種動態概率的手段,如果某次Minor GC存活後的對象突增以致於遠遠高於平均值時,依然會導致擔保失敗(Handle Promotion Failure)。如果出現HandlePromotionFailure失敗,那就只好在失敗後重新發起一次Full GC。雖然擔保失敗時繞的圈子是最大的,但大部分情況下都還是會將HandlePromotionFailure開關打開,避免Full GC過於頻繁。
JDK 6 Update 24之後,HandlePromotionFailure參數不會再影響到虛擬機的空間分配擔保策略,只要老年代的連續空間大於新生代對象總大小或者歷次晉升的平均大小就會進行Minor GC,否則將進行Full GC。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章