垃圾收集器與內存分配策略

垃圾收集器與內存分配策略

概述

GC要完成3件事:

  1. 哪些內存需要回收?
  2. 什麼時候回收?
  3. 如何回收?

Java內存運行時區域的各部分,其中程序計數器、虛擬機棧、本地方法棧3個區域隨線程而生,隨線程而滅;棧中的棧幀隨着方法的進入和退出而有條不紊地執行着入棧和出棧操作。每一個棧幀中分配多少內存基本上是在類結構確定下來時就已知的,因此這幾個區域的內存分配和回收都具備確定性,在這幾個區域內就不需要過多考慮回收的問題,因爲方法結束或者線程結束,內存自然就跟隨着回收了。

而Java堆和方法區則不一樣,一個接口中的多個實現類需要的內存可能不一樣,一個方法中的多個實現類需要的內存可能不一樣,一個方法中的多個分支需要的內存也可能不一樣,只有在程序處於運行期間時才能知道會創建哪些對象,這部分內存的分配和回收是動態的,垃圾收集器所關注的是這部分的內存。

對象已死嗎

引用計數算法

給對象添加一個引用計數器,每當有一個地方引用它的地方,計數器值+1;當引用失效,計數器值就減1;任何時候計數器爲0,對象就不可能再被引用了。

它很難解決對象之間相互循環引用的問題。

可達性分析算法

在主流的商用語言(Java、C#)中都使用可達性分析(Reachability Analysis)來判定對象是否存活的。

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

可達性分析

object5 6 7 對於GC Roots是不可達的,所以會被判定爲回收對象

在Java語言中,可以作爲Gc Roots的對象包括下面幾種:

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

再談引用

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

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

生存還是死亡?

即使在可達性分析算法中不可達的對象,也並非是“非死不可”的,這時候它們暫時處於“緩刑”階段,要真正宣告一個對象死亡,至少要經歷兩次標記過程:如果對象在進行可達性分析後發現沒有與GC Roots相連接的引用鏈,那它將會被第一次標記並且進行一次篩選,篩選的條件是此對象是否有必要執行finalize()方法。當對象沒有覆蓋finalize()方法,或者finalize()方法已經被虛擬機調用過,虛擬機將這兩種情況都視爲“沒有必要執行”。

如果這個對象被判定爲有必要執行finalize()方法,那麼這個對象將會放置在一個叫做F-Queue的隊列之中,並在稍後由一個由虛擬機自動建立的、低優先級的Finalizer線程去執行它。這裏所謂的“執行”是指虛擬機會觸發這個方法,但並不承諾會等待它運行結束,這樣做的原因是,如果一個對象在finalize()方法中執行緩慢,或者發生了死循環(更極端的情況),將很可能會導致F-Queue隊列中其他對象永久處於等待,甚至導致整個內存回收系統崩潰。finalize()方法是對象逃脫死亡命運的最後一次機會,稍後GC將對F-Queue中的對象進行第二次小規模的標記,如果對象要在finalize()中成功拯救自己——只要重新與引用鏈上的任何一個對象建立關聯即可,譬如把自己(this關鍵字)賦值給某個類變量或者對象的成員變量,那在第二次標記時它將被移除出“即將回收”的集合;如果對象這時候還沒有逃脫,那基本上它就真的被回收了。下面例子可以看出finalize()被執行,但是它仍然可以存活。

package cc.wsyw126.java.garbageCollection;

public class FinalizeEscapeGC {
    public static FinalizeEscapeGC SAVE_HOOK = null;

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

    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("finalize method executed!");
        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 :(");
        }


        //代碼和上面的一樣 但是這次自救失敗
        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 :(");
        }
    }
}

運行結果:

finalize method executed!
yes, I am still alive :)
no, i am dead :(

一樣的代碼,一次逃脫,一次失敗。因爲對象的finalize()只能被系統執行一次。

建議大家儘量避免使用它,因爲它不是C/C++中的析構函數,而是Java剛誕生時爲了使C/C++程序員更容易接受它所做出的一個妥協。它的運行代價高昂,不確定性大,無法保證各個對象的調用順序。

回收方法區

在堆中,尤其是在新生代中,常規應用進行一次垃圾收集一般可以回收70%~95%的空間,而永久代的垃圾收集效率遠低於此。

永久代的垃圾回收主要回收兩部分內容:廢棄常量和無用的類。“廢棄常量”判斷比較簡單,但是“無用的類”的判斷複雜一些,需要滿足下面3個條件:

  1. 該類所有的實例都已經被回收,也就是Java堆中不存在該類的任何實例。
  2. 加載該類的ClassLoader已經被回收。
  3. 該類對應的java.lang.Class對象沒有在任何地方被引用,無法在任何地方通過反射訪問該類的方法。

是否對類進行回收,HotSpot虛擬機提供了-Xnoclassgc參數進行控制,還可以使用-verbose:class以及-XX:+TraceClassLoading, -XX:+TraceClassUnLoading查看類架子啊和卸載信息,其中-verbose:class和-XX:+TraceClassLoading可以在Product版的虛擬機中使用,-XX:+TraceClassUnLoading參數需要FastDebug版的虛擬機支持。

在大量使用反射、動態代理、Cglib等ByteCode框架、動態生成JSP以及OSGI這類頻繁自定義ClassLoader的場景都需要虛擬機具備類卸載的功能,以保證永久代不會溢出。

垃圾收集算法

標記-清除算法

如同它的名字一樣,算法分爲“標記”和“清除”兩個階段:首先標記出所有需要回收的對象,在標記完成後統一回收所有被標記的對象。它的主要不足有兩個:一個是效率問題,標記和清除兩個過程的效率都不高;另一個是空間問題,標記清除之後會產生大量不連續的內存碎片,空間碎片太多可能會導致以後在程序運行過程中需要分配較大對象時,無法找到足夠的連續內存而不得不提前觸發另一次垃圾收集動作。標記—清除算法的執行過程如下圖所示:

標記-清除算法

複製算法

爲了解決效率問題,一種稱爲“複製”(Copying)的收集算法出現了,它將可用內存按容量劃分爲大小相等的兩塊,每次只使用其中的一塊。當這一塊的內存用完了,就將還存活着的對象複製到另外一塊上面,然後再把已使用過的內存空間一次清理掉。這樣使得每次都是對整個半區進行內存回收,內存分配時也就不用考慮內存碎片等複雜情況,只要移動堆頂指針,按順序分配內存即可,實現簡單,運行高效。只是這種算法的代價是將內存縮小爲了原來的一半,未免太高了一點。複製算法的執行過程如下圖所示:

複製算法

現在的商業虛擬機都採用這種收集算法來回收新生代,IBM公司的專門研究表明,新生代中的對象98%是“朝生夕死”的,所以並不需要按照1:1的比例來劃分內存空間,而是將內存分爲一塊較大的Eden空間和兩塊較小的Survivor空間,每次使用Eden和其中一塊Survivor。當回收時,將Eden和Survivor中還存活着的對象一次性地複製到另外一塊Survivor空間上,最後清理掉Eden和剛纔用過的Survivor空間。HotSpot虛擬機默認Eden和Survivor的大小比例是8:1,也就是每次新生代中可用內存空間爲整個新生代容量的90%(80%+10%),只有10%的內存會被“浪費”。當然,98%的對象可回收只是一般場景下的數據,我們沒有辦法保證每次回收都只有不多於10%的對象存活,當Survivor空間不夠用時,需要依賴其他內存(這裏指老年代)進行分配擔保(Handle Promotion)

分配擔保:如果另外一塊Survivor空間沒有足夠空間存放上一次新生代收集下來的存活對象時,這些對象將直接通過分配擔保機制進入老年代。

標記-整理算法

標記過程仍然與“標記-清除”算法一樣,但後續步驟不是直接對可回收對象進行清理,而是讓所有存活的對象都向一端移動,然後直接清理掉端邊界以外的內存,“標記-整理”算法的示意圖如圖所示。

標記-整理算法

分代收集算法

根據對象存活週期的不同將內存分爲幾塊。一般把Java堆分爲新生代和老年代,根據各個年代的特點採用最合適的收集算法。在新生代中,每次垃圾收集時有大批對象死去,只有少量存活,可以選用複製算法。而老年代對象存活率高,使用標記清除或者標記整理算法。

HotSpot的算法實現

枚舉根節點

從可達性分析中從GC Roots節點找引用鏈這個操作爲例,可作爲GC Roots的節點主要在全局性的引用(例如常量或類靜態屬性)與執行上下文(例如幀棧中的本地變量表)中,現在很多應用僅僅方法區就有數百兆,如果要逐個檢查這裏面的引用,那麼必然會消耗很多時間。

另外,可達性分析對執行時間的敏感還體現在GC停頓上,因爲這項分析工作必須在一個能確保一致性的快照中進行–這裏“一致性”的意思是指在整個分析期間整個執行系統看起來就像被凍結在某個時間點上,不可以出現分析過程中對象引用關係還在不斷變化的情況,該點不滿足的話分析結果準確性就無法得到保證。這點是導致GC進行時必須停頓所有Java執行線程(Sun將這件事情稱爲“Stop The World”)的其中一個重要原因,即使是在號稱(幾乎)不會發生停頓的CMS收集器中,枚舉根節點也是必須要停頓的。

由於目前的主流Java虛擬機使用的都是準確式GC,所以當執行系統停頓下來後,並不需要一個不漏地檢查完所有執行上下文和全局的引用位置,虛擬機應當是有辦法直接得知哪些地方存放着對象的引用。在HotSpot的實現中,是使用一組稱爲OopMap的數據結構來達到這個目的的,在類加載完成的時候,HotSpot就把對象內什麼偏移量上是什麼類型的數據計算出來,在JIT編譯過程中,也會在特定的位置記錄下棧和寄存器中哪些位置是引用。這樣,GC在掃描時就可以直接得知這些信息了。

安全點

在OopMap的協助下,HotSpot可以快速且準確地完成GC Roots枚舉,但一個很現實的問題隨之而來:可能導致引用關係變化,或者說OopMap內容變化的指令非常多,如果爲每一條指令都生成對應的OopMap,那將會需要大量的額外空間,這樣GC的空間成本將會變得很高。

實際上,HotSpot也的確沒有爲每條指令都生成OopMap,前面已經提到,只是在“特定的位置”記錄了這些信息,這些位置稱爲安全點(Safepoint),即程序執行時並非在所有地方都能停頓下來開始GC,只有在到達安全點時才能暫停。Safepoint的選定即不能太少以至於讓GC等待時間太長,也不能過於頻繁以至於過分增大運行時負荷。所以,安全點的選定基本上是以程序“是否具有讓程序長時間執行的特徵”爲標準進行選定的–因爲每條指令執行的時間都非常短暫,程序不太可能因爲指令流長度太長這個原因而過長時間運行,“長時間執行”的最明顯特徵就是指令序列複用,例如方法調用、循環跳轉、異常跳轉等,所以具有這些功能的指令纔會產生Safepoint。

對於Safepoint,另一個需要考慮的問題是如何在GC發生時讓所以線程(這裏不包括執行JNI調用的線程)都“跑”到最近的安全點上再停頓下來。這裏有兩種方案可供選擇:

  1. 搶先式中斷(Preemptive Suspension)
  2. 主動式中斷(Voluntary Suspension)

其中搶先式中斷不需要線程的執行代碼主動去配合,在GC發生時,首先把所有線程全部中斷,如果發現有線程中斷的地方不在安全點上,就恢復線程,讓它“跑”到安全點上。現在幾乎沒有虛擬機實現採用搶先式中斷來暫停線程從而響應GC事件。

而主動式中斷的思想是當GC需要中斷線程的時候,不直接對線程操作,僅僅簡單地設置一個標誌,各個線程執行時主動去輪詢這個標誌,發現中斷標誌爲真時就自己中斷掛起。輪詢標誌的地方和安全點是重合的,另外再加上創建對象需要分配內存的地方。

安全區域

使用Safepoint似乎已經完美地解決了如何進入GC的問題,但實際情況卻並不一定。Safepoint機制保證了程序執行時,在不太長的時間內就會遇到可進入GC的Safepoint。但是,程序就”不執行“的時候呢?所謂的程序不執行就是沒有分配CPU時間,典型的例子就是線程處於Sleep狀態或者Blocked狀態,這時候線程無法響應JVM的中斷請求,”走“到安全的地方去中斷掛起,JVM也顯然不太可能等待線程重新被分配CPU時間。對於這種情況,就需要安全區域(Safe Region)來解決。

安全區域是指在一段代碼片段之中,引用關係不會發生變化。在這個區域中的任意地方開始GC都是安全的。我們也可以把Safe Region看做是被擴展了的Safepoint。

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

垃圾收集器

垃圾收集器

Serial Collecor

Serial收集器是單線程收集器,是分代收集器。它進行垃圾收集時,必須暫停其他所有的工作線程,直到它收集結束。

  1. 新生代:單線程複製收集算法;
  2. 老年代:單線程標記整理算法。

Serial一般在單核的機器上使用,是Java 5非服務端JVM的默認收集器,參數-XX:UseSerialGC設置使用。

ParNew收集器

ParNew收集器和Serial收集器的主要區別是

  1. 新生代的收集,一個是單線程一個是多線程。
  2. 老年代的收集和Serial收集器是一樣的。

實際上是Serial收集器的多線程版本,擁有可控制參數(如:-XX:SurvivorRatio, -XX:PretenureSizeThreshold, -XX:HandlePromotionFailure等),收集算法,停頓,對象分配規則,回收策略都和Serial收集器完全一樣。

ParNew收集器是許多運行在server模式下的虛擬機中首選的新生代收集器,一個重要的原因是,只有ParNew和Serial收集器能和CMS收集器共同工作。無法與JDK1.4中存在的新生代收集器Parallel Scavenge配合工作,所以在JDK1.5中使用CMS來收集老年代的時候,新生代只能選擇ParNew和Serial。

ParNew收集器是使用-XX:+UseConcMarkSweepGC選項的默認新生代收集器。也可以用-XX:+UseParNewGC選項來強制指定它。

ParNew收集器在單CPU環境中不比Serial效果好,甚至可能更差,兩個CPU也不一定跑的過,但隨着CPU數量的增加,性能會逐步增加。默認開啓的收集線程數與CPU數量相同。在CPU數量很多的情況下,可以使用-XX:ParallelGCThreads參數來限制線程數。

Parallel並行: 多垃圾收集線程並行工作,但用戶線程仍需等待

Concurrent併發:用戶線程和垃圾收集同時進行。

Parallel Scavenge收集器

同ParNew一樣是使用複製算法的新生代並行多線程收集器。

Parallel Scavenge的特點是它的關注點與其他收集器不同,CMS等收集器的關注點儘可能地縮短垃圾收集時用戶線程的停頓時間,而Parallel Scavenge收集器的目標則是達到一個可控制的吞吐量(Throughput)。所謂吞吐量就是CPU用於運行用戶代碼與CPU總消耗時間的比值。

高吞吐量和停頓時間短的策略相比,主要強調任務更快完成,而後者強調用戶交互體驗。

Parallel Scavenge提供兩個參數控制垃圾回收停頓時間:-XX:MaxGCPauseMillis和-XX:GCTimeRatio

  1. MaxGCPauseMillis允許的值是一個大於零的毫秒數,收集器將盡力保證內存回收話費的時間不超過設定值。GC停頓時間縮小是以犧牲吞吐量和新生代空間來換取的,也就是要使停頓時間更短,垃圾回收的頻率會增加。
  2. GCTimeRatio的值是一個大於0小於100的整數,也就是垃圾收集時間佔總時間的比率。設爲19,則允許最大GC時間就佔總時間的5%(1/(1+19)),默認99.

Parallel Scavenge收集器也被稱爲吞吐量優先收集器。

還有一個參數, -XX:+UseAdaptiveSizePolicy,是個開關參數,打開後會自動調整Eden/Survivor比例,老年代對象年齡,新生代大小等。這個參數也是Parallel Scavenge和ParNew的重要區別。

Serial Old收集器

是Serial的老年代版本,同樣是單線程收集器,使用標記-整理算法。主要是client模式下的虛擬機使用

兩大用途:

  1. 在JDK1.5及之前的版本中與Parallel Scavenge搭配使用。
  2. 作爲CMS收集器的後備預案。在併發收集發生Concurrent Mode Failure時使用。

Parallel Old收集器

是Parallel Scavenge收集器的老年代版本,使用多線程和標記-整理算法。在JDK1.6中才開始使用。由於之前的版本中,Parallel Scavenge只有使用Serial Old作爲老年代收集器,其吞吐量優先的設計思路不能被很好的貫徹,在Parallel Old收集器出現後,這兩者的配合主要用於貫徹這種思路。

CMS收集器

Concurrent Mark Sweep 以獲取最短回收停頓時間爲目標的收集器,比較理想的應用場景是B/S架構的服務器。

基於標記-清除算法實現,運行過程分成4個步驟:

  1. 初始標記(需要stop the world),標記一下GC Roots能直接關聯到的對象,速度很快。
  2. 併發標記,進行GC Roots Tracing的過程。
  3. 重新標記(需要stop the world),爲了修正併發標記時用戶繼續運行而產生的標記變化,停頓時間比初始標記長,遠比並發標記短。
  4. 併發清除

缺點:

  1. CMS收集器對CPU資源非常敏感。在併發階段,它雖然不會導致用戶線程停頓,但是因爲佔用了一部分CPU資源而導致應用程序變慢,總吞吐量就會降低。CMS默認啓動的回收線程數爲(CPU數量+3)/4。爲了解決這一情況,有一個變種i-CMS,但目前並不推薦使用。
  2. CMS收集器無法處理浮動垃圾(floating garbage).可能會出現concurrent mode failure導致另一次full gc的產生。在CMS的併發清理階段,由於程序還在運行,垃圾還會不斷產生,這一部分垃圾出現在標記過程之後,CMS無法在本次收集中處理掉它們,只好留到下一次GC再處理。這種垃圾稱爲浮動垃圾。同樣由於CMS GC階段用戶線程還需要運行,即還需要預留足夠的內存空間供用戶線程使用,因此CMS收集器不能像其他收集器那樣等到老年代幾乎完全被灌滿了再進行收集而需要預留一部分空間提供併發收集時的程序運作使用。默認設置下,CMS收集器在老年代使用了68%的空間後就會被激活。這個值可以用-XX:CMSInitiatingOccupancyFraction來設置。要是CMS運行期間預留的內存無法滿足程序需要,就會出現concurrent mode failure,這時候就會啓用Serial Old收集器作爲備用進行老年代的垃圾收集。
  3. 空間碎片過多(標記-清除算法的弊端),提供-XX:+UseCMSCompactAtFullCollection參數,應用於在FULL GC後再進行一個碎片整理過程。-XX:CMSFullGCsBeforeCompaction,多少次不壓縮的full gc後來一次帶壓縮的。

G1收集器

G1. Garbage first,尚在研發階段,使用標記-整理算法,精確控制停頓,極力避免全區域垃圾收集。前面的收集器進行的收集範圍都是整個新生代或老年代,而G1將整個JAVA堆劃分爲多個大小固定的獨立區域,跟蹤這些區域裏面的垃圾堆積程度,在後臺維護一個優先列表,每次在允許的收集時間裏,優先回收垃圾最多的區域。

理解GC日誌

[GC [PSYoungGen: 8987K->1016K(9216K)] 9984K->5056K(19456K), 0.0569611 secs] [Times: user=0.03 sys=0.02, real=0.06 secs] 
[GC [PSYoungGen: 8038K->1000K(9216K)] 12078K->10425K(19456K), 0.0709523 secs] [Times: user=0.05 sys=0.00, real=0.07 secs] 
[Full GC [PSYoungGen: 1000K->0K(9216K)] [ParOldGen: 9425K->8418K(10240K)] 10425K->8418K(19456K) [PSPermGen: 9678K->9675K(21504K)], 0.3152834 secs] [Times: user=0.39 sys=0.00, real=0.32 secs] 
[Full GC [PSYoungGen: 8192K->3583K(9216K)] [ParOldGen: 8418K->9508K(10240K)] 16610K->13092K(19456K) [PSPermGen: 9675K->9675K(22016K)], 0.1913859 secs] [Times: user=0.34 sys=0.00, real=0.19 secs] 
[Full GC [PSYoungGen: 7716K->7702K(9216K)] [ParOldGen: 9508K->9508K(10240K)] 17224K->17210K(19456K) [PSPermGen: 9675K->9675K(21504K)], 0.2769775 secs] [Times: user=0.52 sys=0.00, real=0.28 secs] 
[Full GC [PSYoungGen: 7702K->7702K(9216K)] [ParOldGen: 9508K->9409K(10240K)] 17210K->17111K(19456K) [PSPermGen: 9675K->9675K(21504K)], 0.2491993 secs] [Times: user=0.64 sys=0.00, real=0.25 secs]
  1. “[GC”和“[full DC”說明了這次垃圾回收的停頓類型。如果是調用System.gc()方法所觸發的收集,那麼這裏顯示“[Full DC(System)”.
  2. [DefNew、[Tenured、[Perm 表示GC發生的區域。如果是ParNew收集器,新生代名爲“[ParNew”.如果採用Parallel Scavenge收集器,那它配套的新生代名爲”[PSYoungGen”。對於老年代和永久代同理。
  3. [PSYoungGen: 8987K->1016K(9216K)] 9984K->5056K(19456K), 0.0569611 secs]中後面的數字含義是:GC前該內存區域已使用容量->GC後該內存區域已使用容量(該區域總容量)。而方括號之外的表示“GC前Java堆已經使用的容量 -> GC後Java堆已經使用的容量(Java堆總容量)”。後面的時間是該區域GC所佔用的時間,單位是秒。
  4. [Times: user=0.03 sys=0.02, real=0.06 secs]這裏的user、sys和real與Linux的time命令所輸出的時間含義一,分別代表用戶態消耗的CPU時間,內核態消耗的CPU時間和操作從開始到結束所經過的牆鍾時間。

垃圾收集器參數總結

參  數 描  述
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收集器時生效

內存分配與回收策略

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

接下來我們將會講解幾條最普遍的內存分配規則,並通過代碼去驗證這些規則。本節下面的代碼在測試時使用Client模式虛擬機運行,沒有手工指定收集器組合,換句話說,驗證的是在使用Serial / Serial Old收集器下(ParNew / Serial Old收集器組合的規則也基本一致)的內存分配和回收的策略。讀者不妨根據自己項目中使用的收集器寫一些程序去驗證一下使用其他幾種收集器的內存分配策略。

對象優先在Eden分配

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

虛擬機提供了-XX:+PrintGCDetails這個收集器日誌參數,告訴虛擬機在發生垃圾收集行爲時打印內存回收日誌,並且在進程退出的時候輸出當前的內存各區域分配情況。在實際應用中,內存回收日誌一般是打印到文件後通過日誌工具進行分析,不過本實驗的日誌並不多,直接閱讀就能看得很清楚。

下面代碼的testAllocation()方法中,嘗試分配3個2MB大小和1個4MB大小的對象,在運行時通過-Xms20M、 -Xmx20M、 -Xmn10M這3個參數限制了Java堆大小爲20MB,不可擴展,其中10MB分配給新生代,剩下的10MB分配給老年代。-XX:SurvivorRatio=8決定了新生代中Eden區與一個Survivor區的空間比例是8∶1,從輸出的結果也可以清晰地看到“eden space 8192K、from space 1024K、to space 1024K”的信息,新生代總可用空間爲9216KB(Eden區+1個Survivor區的總容量)。

執行testAllocation()中分配allocation4對象的語句時會發生一次Minor GC,這次GC的結果是新生代6651KB變爲148KB,而總內存佔用量則幾乎沒有減少(因爲allocation1、allocation2、allocation3三個對象都是存活的,虛擬機幾乎沒有找到可回收的對象)。這次GC發生的原因是給allocation4分配內存的時候,發現Eden已經被佔用了6MB,剩餘空間已不足以分配allocation4所需的4MB內存,因此發生Minor GC。GC期間虛擬機又發現已有的3個2MB大小的對象全部無法放入Survivor空間(Survivor空間只有1MB大小),所以只好通過分配擔保機制提前轉移到老年代去。

這次GC結束後,4MB的allocation4對象順利分配在Eden中,因此程序執行完的結果是Eden佔用4MB(被allocation4佔用),Survivor空閒,老年代被佔用6MB(被allocation1、allocation2、allocation3佔用)。通過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倍以上。

private static final int _1MB = 1024 * 1024;

/**
 * VM參數:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8
  */
public static void testAllocation() {
   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 [DefNew: 6651K->148K(9216K), 0.0070106 secs] 6651K->6292K(19456K), 0.0070426 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
Heap
 def new generation   total 9216K, used 4326K [0x029d0000, 0x033d0000, 0x033d0000)
  eden space 8192K,  51% used [0x029d0000, 0x02de4828, 0x031d0000)
  from space 1024K,  14% used [0x032d0000, 0x032f5370, 0x033d0000)
  to   space 1024K,   0% used [0x031d0000, 0x031d0000, 0x032d0000)
 tenured generation   total 10240K, used 6144K [0x033d0000, 0x03dd0000, 0x03dd0000)
   the space 10240K,  60% used [0x033d0000, 0x039d0030, 0x039d0200, 0x03dd0000)
 compacting perm gen  total 12288K, used 2114K [0x03dd0000, 0x049d0000, 0x07dd0000)
   the space 12288K,  17% used [0x03dd0000, 0x03fe0998, 0x03fe0a00, 0x049d0000)
No shared spaces configured.

大對象直接進入老年代

所謂的大對象是指,需要大量連續內存空間的Java對象,最典型的大對象就是那種很長的字符串以及數組(筆者列出的例子中的byte[]數組就是典型的大對象)。大對象對虛擬機的內存分配來說就是一個壞消息(替Java虛擬機抱怨一句,比遇到一個大對象更加壞的消息就是遇到一羣“朝生夕滅”的“短命大對象”,寫程序的時候應當避免),經常出現大對象容易導致內存還有不少空間時就提前觸發垃圾收集以獲取足夠的連續空間來“安置”它們。

虛擬機提供了一個-XX:PretenureSizeThreshold參數,令大於這個設置值的對象直接在老年代分配。這樣做的目的是避免在Eden區及兩個Survivor區之間發生大量的內存複製(複習一下:新生代採用複製算法收集內存)。

執行下面代碼中的testPretenureSizeThreshold()方法後,我們看到Eden空間幾乎沒有被使用,而老年代的10MB空間被使用了40%,也就是4MB的allocation對象直接就分配在老年代中,這是因爲PretenureSizeThreshold被設置爲3MB(就是3145728,這個參數不能像-Xmx之類的參數一樣直接寫3MB),因此超過3MB的對象都會直接在老年代進行分配。

注意 PretenureSizeThreshold參數只對Serial和ParNew兩款收集器有效,Parallel Scavenge收集器不認識這個參數,Parallel Scavenge收集器一般並不需要設置。如果遇到必須使用此參數的場合,可以考慮ParNew加CMS的收集器組合。

private static final int _1MB = 1024 * 1024;

/**
 * VM參數:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8
 * -XX:PretenureSizeThreshold=3145728
 */
public static void testPretenureSizeThreshold() {
  byte[] allocation;
  allocation = new byte[4 * _1MB];  //直接分配在老年代中
}

運行結果:

Heap
 def new generation   total 9216K, used 671K [0x029d0000, 0x033d0000, 0x033d0000)
  eden space 8192K,   8% used [0x029d0000, 0x02a77e98, 0x031d0000)
  from space 1024K,   0% used [0x031d0000, 0x031d0000, 0x032d0000)
  to   space 1024K,   0% used [0x032d0000, 0x032d0000, 0x033d0000)
 tenured generation   total 10240K, used 4096K [0x033d0000, 0x03dd0000, 0x03dd0000)
   the space 10240K,  40% used [0x033d0000, 0x037d0010, 0x037d0200, 0x03dd0000)
 compacting perm gen  total 12288K, used 2107K [0x03dd0000, 0x049d0000, 0x07dd0000)
   the space 12288K,  17% used [0x03dd0000, 0x03fdefd0, 0x03fdf000, 0x049d0000)
No shared spaces configured.

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

既然虛擬機採用了分代收集的思想來管理內存,那麼內存回收時就必須能識別哪些對象應放在新生代,哪些對象應放在老年代中。爲了做到這點,虛擬機給每個對象定義了一個對象年齡(Age)計數器。如果對象在Eden出生並經過第一次Minor GC後仍然存活,並且能被Survivor容納的話,將被移動到Survivor空間中,並且對象年齡設爲1。對象在Survivor區中每“熬過”一次Minor GC,年齡就增加1歲,當它的年齡增加到一定程度(默認爲15歲),就將會被晉升到老年代中。對象晉升老年代的年齡閾值,可以通過參數-XX:MaxTenuringThreshold設置。

讀者可以試試分別以-XX:MaxTenuringThreshold=1和-XX:MaxTenuringThreshold=15兩種設置來執行下面代碼中的testTenuringThreshold()方法,此方法中的allocation1對象需要256KB內存,Survivor空間可以容納。當MaxTenuringThreshold=1時,allocation1對象在第二次GC發生時進入老年代,新生代已使用的內存GC後非常乾淨地變成0KB。而MaxTenuringThreshold=15時,第二次GC發生後,allocation1對象則還留在新生代Survivor空間,這時新生代仍然有404KB被佔用。

private static final int _1MB = 1024 * 1024;

/**
 * VM參數:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=1
 * -XX:+PrintTenuringDistribution
 */
@SuppressWarnings("unused")
public static void testTenuringThreshold() {
  byte[] allocation1, allocation2, allocation3;
  allocation1 = new byte[_1MB / 4]; 
   // 什麼時候進入老年代取決於XX:MaxTenuringThreshold設置
  allocation2 = new byte[4 * _1MB];
  allocation3 = new byte[4 * _1MB];
  allocation3 = null;
  allocation3 = new byte[4 * _1MB];
}

以MaxTenuringThreshold=1參數來運行的結果:

[GC [DefNew
Desired Survivor size 524288 bytes, new threshold 1 (max 1)
- age   1:     414664 bytes,     414664 total
: 4859K->404K(9216K), 0.0065012 secs] 4859K->4500K(19456K), 0.0065283 secs] [Times: user=0.02 sys=0.00, real=0.02 secs]
[GC [DefNew
Desired Survivor size 524288 bytes, new threshold 1 (max 1)
: 4500K->0K(9216K), 0.0009253 secs] 8596K->4500K(19456K), 0.0009458 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
Heap
 def new generation   total 9216K, used 4178K [0x029d0000, 0x033d0000, 0x033d0000)
  eden space 8192K,  51% used [0x029d0000, 0x02de4828, 0x031d0000)
  from space 1024K,   0% used [0x031d0000, 0x031d0000, 0x032d0000)
  to   space 1024K,   0% used [0x032d0000, 0x032d0000, 0x033d0000)
 tenured generation   total 10240K, used 4500K [0x033d0000, 0x03dd0000, 0x03dd0000)
   the space 10240K,  43% used [0x033d0000, 0x03835348, 0x03835400, 0x03dd0000)
 compacting perm gen  total 12288K, used 2114K [0x03dd0000, 0x049d0000, 0x07dd0000)
   the space 12288K,  17% used [0x03dd0000, 0x03fe0998, 0x03fe0a00, 0x049d0000)
No shared spaces configured.

以MaxTenuringThreshold=15參數來運行的結果:

[GC [DefNew
Desired Survivor size 524288 bytes, new threshold 15 (max 15)
- age   1:     414664 bytes,     414664 total
: 4859K->404K(9216K), 0.0049637 secs] 4859K->4500K(19456K), 0.0049932 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[GC [DefNew
Desired Survivor size 524288 bytes, new threshold 15 (max 15)
- age   2:     414520 bytes,     414520 total
: 4500K->404K(9216K), 0.0008091 secs] 8596K->4500K(19456K), 0.0008305 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
Heap
 def new generation   total 9216K, used 4582K [0x029d0000, 0x033d0000, 0x033d0000)
  eden space 8192K,  51% used [0x029d0000, 0x02de4828, 0x031d0000)
  from space 1024K,  39% used [0x031d0000, 0x03235338, 0x032d0000)
  to   space 1024K,   0% used [0x032d0000, 0x032d0000, 0x033d0000)
 tenured generation   total 10240K, used 4096K [0x033d0000, 0x03dd0000, 0x03dd0000)
   the space 10240K,  40% used [0x033d0000, 0x037d0010, 0x037d0200, 0x03dd0000)
 compacting perm gen  total 12288K, used 2114K [0x03dd0000, 0x049d0000, 0x07dd0000)
   the space 12288K,  17% used [0x03dd0000, 0x03fe0998, 0x03fe0a00, 0x049d0000)
No shared spaces configured.

動態對象年齡判定

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

執行下面代碼中的testTenuringThreshold2()方法,並設置-XX:MaxTenuringThreshold=15,會發現運行結果中Survivor的空間佔用仍然爲0%,而老年代比預期增加了6%,也就是說,allocation1、allocation2對象都直接進入了老年代,而沒有等到15歲的臨界年齡。因爲這兩個對象加起來已經到達了512KB,並且它們是同年的,滿足同年對象達到Survivor空間的一半規則。我們只要註釋掉其中一個對象new操作,就會發現另外一個就不會晉升到老年代中去了。

private static final int _1MB = 1024 * 1024;

/**
 * VM參數:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=15
 * -XX:+PrintTenuringDistribution
 */
@SuppressWarnings("unused")
public static void testTenuringThreshold2() {
  byte[] allocation1, allocation2, allocation3, allocation4;
  allocation1 = new byte[_1MB / 4];
    // allocation1+allocation2大於survivo空間一半
  allocation2 = new byte[_1MB / 4]; 
  allocation3 = new byte[4 * _1MB];
  allocation4 = new byte[4 * _1MB];
  allocation4 = null;
  allocation4 = new byte[4 * _1MB];
}

運行結果:

[GC [DefNew
Desired Survivor size 524288 bytes, new threshold 1 (max 15)
- age   1:     676824 bytes,     676824 total
: 5115K->660K(9216K), 0.0050136 secs] 5115K->4756K(19456K), 0.0050443 secs] [Times: user=0.00 sys=0.01, real=0.01 secs]
[GC [DefNew
Desired Survivor size 524288 bytes, new threshold 15 (max 15)
: 4756K->0K(9216K), 0.0010571 secs] 8852K->4756K(19456K), 0.0011009 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
Heap
 def new generation   total 9216K, used 4178K [0x029d0000, 0x033d0000, 0x033d0000)
  eden space 8192K,  51% used [0x029d0000, 0x02de4828, 0x031d0000)
  from space 1024K,   0% used [0x031d0000, 0x031d0000, 0x032d0000)
  to   space 1024K,   0% used [0x032d0000, 0x032d0000, 0x033d0000)
 tenured generation   total 10240K, used 4756K [0x033d0000, 0x03dd0000, 0x03dd0000)
   the space 10240K,  46% used [0x033d0000, 0x038753e8, 0x03875400, 0x03dd0000)
 compacting perm gen  total 12288K, used 2114K [0x03dd0000, 0x049d0000, 0x07dd0000)
   the space 12288K,  17% used [0x03dd0000, 0x03fe09a0, 0x03fe0a00, 0x049d0000)
No shared spaces configured.

空間分配擔保

在發生Minor GC之前,虛擬機會先檢查老年代最大可用的連續空間是否大於新生代所有對象總空間,如果這個條件成立,那麼Minor GC可以確保是安全的。如果不成立,則虛擬機會查看HandlePromotionFailure設置值是否允許擔保失敗。如果允許,那麼會繼續檢查老年代最大可用的連續空間是否大於歷次晉升到老年代對象的平均大小,如果大於,將嘗試着進行一次Minor GC,儘管這次Minor GC是有風險的;如果小於,或者HandlePromotionFailure設置不允許冒險,那這時也要改爲進行一次Full GC。

下面解釋一下“冒險”是冒了什麼風險,前面提到過,新生代使用複製收集算法,但爲了內存利用率,只使用其中一個Survivor空間來作爲輪換備份,因此當出現大量對象在Minor GC後仍然存活的情況(最極端的情況就是內存回收後新生代中所有對象都存活),就需要老年代進行分配擔保,把Survivor無法容納的對象直接進入老年代。與生活中的貸款擔保類似,老年代要進行這樣的擔保,前提是老年代本身還有容納這些對象的剩餘空間,一共有多少對象會活下來在實際完成內存回收之前是無法明確知道的,所以只好取之前每一次回收晉升到老年代對象容量的平均大小值作爲經驗值,與老年代的剩餘空間進行比較,決定是否進行Full GC來讓老年代騰出更多空間。

取平均值進行比較其實仍然是一種動態概率的手段,也就是說,如果某次Minor GC存活後的對象突增,遠遠高於平均值的話,依然會導致擔保失敗(Handle Promotion Failure)。如果出現了HandlePromotionFailure失敗,那就只好在失敗後重新發起一次Full GC。雖然擔保失敗時繞的圈子是最大的,但大部分情況下都還是會將HandlePromotionFailure開關打開,避免Full GC過於頻繁,參見下面代碼,請在JDK 6 Update 24之前的版本中運行測試。

private static final int _1MB = 1024 * 1024;

/**
 * VM參數:-Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:-HandlePromotionFailure
 */
@SuppressWarnings("unused")
public static void testHandlePromotion() {
  byte[] allocation1, allocation2, allocation3, allocation4, allocation5, allocation6, allocation7;
  allocation1 = new byte[2 * _1MB];
  allocation2 = new byte[2 * _1MB];
  allocation3 = new byte[2 * _1MB];
  allocation1 = null;
  allocation4 = new byte[2 * _1MB];
  allocation5 = new byte[2 * _1MB];
  allocation6 = new byte[2 * _1MB];
  allocation4 = null;
  allocation5 = null;
  allocation6 = null;
  allocation7 = new byte[2 * _1MB];
}

以HandlePromotionFailure = false參數來運行的結果:

[GC [DefNew: 6651K->148K(9216K), 0.0078936 secs] 6651K->4244K(19456K), 0.0079192 secs] [Times: user=0.00 sys=0.02, real=0.02 secs]
[GC [DefNew: 6378K->6378K(9216K), 0.0000206 secs][Tenured: 4096K->4244K(10240K), 0.0042901 secs] 10474K->4244K(19456K), [Perm : 2104K->2104K(12288K)], 0.0043613 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]

以HandlePromotionFailure = true參數來運行的結果:
[GC [DefNew: 6651K->148K(9216K), 0.0054913 secs] 6651K->4244K(19456K), 0.0055327 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[GC [DefNew: 6378K->148K(9216K), 0.0006584 secs] 10474K->4244K(19456K), 0.0006857 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]

在JDK 6 Update 24之後,這個測試結果會有差異,HandlePromotionFailure參數不會再影響到虛擬機的空間分配擔保策略,觀察OpenJDK中的源碼變化(見下面代碼),雖然源碼中還定義了HandlePromotionFailure參數,但是在代碼中已經不會再使用它。JDK 6 Update 24之後的規則變爲只要老年代的連續空間大於新生代對象總大小或者歷次晉升的平均大小就會進行Minor GC,否則將進行Full GC。

bool TenuredGeneration::promotion_attempt_is_safe(size_t
max_promotion_in_bytes) const{
    // 老年代最大可用的連續空間
    size_t available = max_contiguous_available();
    // 每次晉升到老年代的平均大小
    size_t av_promo = (size_t) gc_stats()->avg_promoted()->padded_average();
    // 老年代可用空間是否大於平均晉升大小,或者老年代可用空間是否大於當此GC時新生代所有對象容量
    bool res = (available >= av_promo) || (available >=
            max_promotion_in_bytes);
    return res;
}

摘自:
深入理解Java虛擬機
備註:
轉載請註明出處:http://blog.csdn.net/wsyw126/article/details/62334387
作者:WSYW126

發佈了170 篇原創文章 · 獲贊 50 · 訪問量 28萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章