1. 程式人生 > 實用技巧 >JVM虛擬機器詳解

JVM虛擬機器詳解

JVM虛擬機器詳解

本文內容可能過多, 可下載pdf進行檢視 {點選下載}

jvm是java程式執行環境的虛擬機器, 編寫的java程式在不同作業系統安裝了對應的虛擬機器之後都可以執行

JVM原理

從最初的我們編寫的Java原始檔(.java檔案)是如何一步步執行的,如下圖所示,首先Java原始檔經過前端編譯器(javac或ECJ)將.java檔案編譯為Java位元組碼檔案,然後JRE載入Java位元組碼檔案,載入系統分配給JVM的記憶體區,然後執行引擎解釋或編譯類檔案,再由即時編譯器將位元組碼轉化為機器碼。主要介紹下圖中的類載入器和執行時資料區兩個部分。

JVM整體架構

從Java平臺的邏輯結構上來看,我們可以從下圖來了解JVM:

從上圖能清晰看到Java平臺包含的各個邏輯模組,也能瞭解到JDK與JRE的區別,對於JVM自身的物理結構,我們可以從下圖鳥瞰一下:

JVM的執行如下圖:



從圖中,可知記憶體分為執行緒私有和共享兩大類:

  1. 執行緒私有區,包含以下3類:

    • 程式計數器,記錄正在執行的虛擬機器位元組碼的地址;

    • 虛擬機器棧:方法執行的記憶體區,每個方法執行時會在虛擬機器棧中建立棧幀;

    • 本地方法棧:虛擬機器的Native方法執行的記憶體區;

  2. 執行緒共享區,包含以下2類

    • Java堆:物件分配記憶體的區域;

    • 方法區:存放類資訊、常量、靜態變數、編譯器編譯後的程式碼等資料;

      • 常量池:存放編譯器生成的各種字面量和符號引用,是方法區的一部分。

1. JVM-類載入子系統

類載入子系統是JVM裡面的一個重要的環節。與C/C++那些需要在編譯器期進行連線工作的語言不同,Java類的載入、連線和初始化都是在程式執行時完成的,只有在類被需要的時候才進行動態載入。

1.1 JVM何時載入類?

有且只有以下5種情況:

  • 建立新物件(new)、設定/讀取static欄位(putstatic/getstatic)或呼叫靜態方法(invokestatic)這四條指令時,如果該類沒有初始化,則初始化。
  • 使用java.lang.reflect包得方法進行反射呼叫的時候,如果該類沒有初始化,則初始化。
  • 當初始化一個類時,父類沒有初始化,則先初始化父類。
  • 當虛擬機器啟動,需要執行main()的主類,JVM首先初始化該類。
  • JDK 1.7的動態語言支援時,如果java.lang.invoke.MethodHandle例項最後的解析結果REF_getStatic、REF_putStatic、REF_invokeStatic的方法控制代碼,並且這個方法控制代碼所對應的類沒有進行過初始化,則初始化。

1.2 如何載入類?

類載入過程包括載入(Loading)、連結(Linking)和初始化(Initialization)三個過程。

載入(Loading)

  • 其中“根據全限定名獲取位元組流”的過程,可以有系統提供的類載入器完成,也可以由使用者自定義類載入器。
  • 對於HotSpot,Class物件雖然是物件,但仍然存放在方法區中。

連結-準備

  • 該階段值正式在方法區為類變數分配記憶體初始化類變數,
    public static int value = 123
    在準備階段初始化零值,而非123。初始化123的過程在初始化階段完成。

連結-解析

  • 將常量池中的符號引用替換成直接引用。其中對於非虛方法,在類載入階段就可以確定呼叫的版本,因此可以在此階段直接解析為直接引用;為對於虛方法(即支援多型),無法在此階段確定呼叫版本,虛方法的符號引用需等到程式執行到該符號引用的位元組碼時才能解析為直接引用。
  • 解析動作主要針對類或介面、欄位、類方法、介面方法、方法型別、方法型別等符號引用。
  • 執行讀寫欄位(getstatic、putstatic、getfield、putfield)、instanceof、方法呼叫(iinvokestatic、invokespecial、invokevirtual、invokedynamic)、new等操作符號引用的位元組碼之前,需要先進行符號引用解析。

初始化(Initialization)

  • 執行類構造器<clinit>:自動收集static變數和static{}塊,按原檔案出席西安的順序執行初始化。
  • <clinit>由編譯器自動生成,如果沒有static變數和static{}塊,就不會生成。

2. JVM-方法區

2.1 JVM記憶體模型

大多數JVM將記憶體分配為Method Area(方法區)、Heap(堆)、Program Counter Register(程式計數器)、JAVA Method Stack(JAVA方法棧)、Native Method Stack(本地方法棧)。

2.2 方法區(Method Area)

執行緒共享,儲存已經被虛擬機器載入的類資訊、常量、靜態變數、即時編譯器編譯後的程式碼等等。(HotSpot虛擬機器上開發部署人員更願意成為“永久代”,Permanent Generation)。示意圖如下,下面的圖片顯示的是JVM載入類的時候,方法區儲存的資訊:

型別資訊

  • 型別的全限定名
  • 超類的全限定名
  • 直接超介面的全限定名
  • 型別標誌(該類是類型別還是介面型別)
  • 類的訪問描述符(public、private、default、abstract、final、static)

型別的常量池

存放該型別所用到的常量的有序集合,包括直接常量(如字串、整數、浮點數的常量)和對其他型別、欄位、方法的符號引用。常量池中每一個儲存的常量都有一個索引,就像陣列中的欄位一樣。因為常量池中儲存中所有型別使用到的型別、欄位、方法的字元引用,所以它也是動態連線的主要物件(在動態連結中起到核心作用)。

欄位資訊

(該類宣告的所有欄位)

  • 欄位修飾符(public、protect、private、default)
  • 欄位的型別
  • 欄位名稱

方法資訊

方法資訊中包含類的所有方法,每個方法包含以下資訊:

  • 方法修飾符
  • 方法返回型別
  • 方法名
  • 方法引數個數、型別、順序等
  • 方法位元組碼
  • 運算元棧和該方法在棧幀中的區域性變數區大小
  • 異常表

類變數(靜態變數)

指該類所有物件共享的變數,即使沒有任何例項物件時,也可以訪問的類變數。它們與類進行繫結。

指向類載入器的引用

每一個被JVM載入的型別,都儲存這個類載入器的引用,類載入器動態連結時會用到。

指向Class例項的引用

類載入的過程中,虛擬機器會建立該型別的Class例項,方法區中必須儲存對該物件的引用。通過Class.forName(String className)來查詢獲得該例項的引用,然後建立該類的物件。

方法表

為了提高訪問效率,JVM可能會對每個裝載的非抽象類,都建立一個數組,陣列的每個元素是例項可能呼叫的方法的直接引用,包括父類中繼承過來的方法。這個表在抽象類或者介面中是沒有的,類似C++虛擬函式表vtbl。

執行時常量池

英文名: (Runtime Constant Pool)

Class檔案中除了有類的版本、欄位、方法、介面等描述資訊外,還有一項資訊是常量池,用於存放編譯器生成的各種字面常量和符號引用,這部分內容被類載入後進入方法區的執行時常量池中存放。

執行時常量池相對於Class檔案常量池的另外一個特徵具有動態性,可以在執行期間將新的常量放入池中(典型的如String類的intern()方法)。

(這個地方不太理解,網上找來的解釋不知道對否:執行時常量池是把Class檔案常量池載入進來,每個類有一個獨立的。剛開始時執行的時候常量池裡的連結都是符號連結(只用名字沒有實體),跟在Class檔案裡的一樣;邊執行邊把常量轉換成直接連結。例如說要Class A呼叫Foo.bar()方法,A.class檔案裡就會有對該方法的Method ref常量,是個符號連結(只有名字沒有實體),載入到執行時常量池也還是一樣是符號連結,等真的要呼叫該方法的時候該常量就會被resolve為一個直接連結(直接指向要呼叫的方法的實體))。

3. JVM-堆

Java 中的堆是 JVM 所管理的最大的一塊記憶體空間,主要用於存放各種類的例項物件。
在 Java 中,堆被劃分成兩個不同的區域:新生代 ( Young )、老年代 ( Old )。新生代 ( Young ) 又被劃分為三個區域:Eden、From Survivor、To Survivor。
這樣劃分的目的是為了使 JVM 能夠更好的管理堆記憶體中的物件,包括記憶體的分配以及回收。
堆的記憶體模型大致為:

從圖中可以看出: 堆大小 = 新生代 + 老年代。其中,堆的大小可以通過引數 –Xms、-Xmx 來指定。
本人使用的是 JDK1.6,以下涉及的 JVM 預設值均以該版本為準。
預設的,新生代 ( Young ) 與老年代 ( Old ) 的比例的值為 1:2 ( 該值可以通過引數 –XX:NewRatio 來指定 ),即:新生代 ( Young ) = 1/3 的堆空間大小。
老年代 ( Old ) = 2/3 的堆空間大小。其中,新生代 ( Young ) 被細分為 Eden 和 兩個 Survivor 區域,這兩個 Survivor 區域分別被命名為 from 和 to,以示區分。
預設的,Edem : from : to = 8 : 1 : 1 ( 可以通過引數 –XX:SurvivorRatio 來設定 ),即: Eden = 8/10 的新生代空間大小,from = to = 1/10 的新生代空間大小。

JVM 每次只會使用 Eden 和其中的一塊 Survivor 區域來為物件服務,所以無論什麼時候,總是有一塊 Survivor 區域是空閒著的。
因此,新生代實際可用的記憶體空間為 9/10 ( 即90% )的新生代空間。

3.1 java的堆和棧的結構:

java堆

根據垃圾回收機制的不同,java堆有可能擁有不同的結構,常見的java堆分為新生代和老年代。其中新生代存放剛建立的物件及年齡不大的物件,老年帶存放著在新生代中經歷過多次回收後還存在的物件。

物件晉升過程:

新生代分為eden區s0,s1區(from,to)。多數情況下物件首先分配在eden區,在一次新生代回收後,存活下來的物件存入s0或s1區。每經過一次新生代的回收,物件的年齡加1。預設情況下年齡達到15的物件將晉升至老年代。如果在第一次回收的時候,存活的物件大於s0(s1)空間,將直接晉升至老年代,如果在為物件第一次分配空間時,物件空間大於eden空間的話,物件也直接分配到老年代。

java棧

Java棧和資料結構中的棧有著類似的含義,先進後出,只支援入棧和出棧操作。Java棧中儲存的只要內容是棧幀,每一次進行函式呼叫,都會有一個對應的棧幀被壓入棧中,函式呼叫結束,都會有一個棧幀被彈出棧。

棧幀:

每一個棧幀中包含區域性變量表,運算元棧和幀資料區。

棧上分配:

棧上分配的基本思想,是將執行緒私有的物件,打散分配到棧上,分配在棧上的函式呼叫結束後物件會自行銷燬,不需要垃圾回收接入,從而提升效能。對於大量的零散小物件,棧上分配提供了一種很好的物件分配優化策略,但由於和堆空間相比,棧空間較小,因此大物件無法也不適合在棧上分配

棧上分配依賴逃逸分析和標量替換的實現,同時必須在server模式下才能啟用。引數-XX:+DoEscapeAnalysis啟用逃逸分析 -XX:+EliminateAllocations開啟標量替換(預設開啟).

例:-server -Xms 100m -Xmx 100m -XX:+DoEscapeAnalysis -XX:+EliminateAllocations

3.2 堆疊案例

案例1

第1步——執行int[] x=new int[3];
隱藏以下幾分支
JVM執行main()函式,在棧記憶體中開闢一個空間,存放x變數(x變數是區域性變數)。
同時,在堆記憶體中也開闢一個空間,存放new int[3]陣列,堆記憶體會自動記憶體首地址值,如0x0045。
陣列在棧記憶體中的地址值,會附給x,這樣x也有地址值。所以,x就指向(引用)了這個陣列。此時,所有元素均未附值,但都有預設初始化值0。

第2步——執行x[0]=20
即在堆記憶體中將20附給[0]這個陣列元素。這樣,陣列的三個元素值分別為20,0,0

示圖如下:

案例2

main()
int[] x=new int[3];
x[0]=20
x=null;

以上步驟執行步驟
第1、2步——與示例2完全一樣,略。

第3步——執行x=null;
null表示空值,即x的引用陣列記憶體地址0x0045被刪除了,則不再指向棧記憶體中的陣列。此時,堆中的陣列不再被x使用了,即被視為垃圾,JVM會啟動垃圾回收機制,不定時自動刪除。

示圖如下

案例3

main()
int[] x=new int[3];
int[] y=x;
y[1]=100
x=null;

以上步驟執行步驟

第1步——與示例2第1步一致,略。
第2步——執行int[] y=x,
在棧記憶體定義了新的陣列變數記憶體y,同時將x的值0x0045附給了y。所以,y也指向了堆記憶體中的同一個陣列。
第3步——執行y[1]=100
即在堆記憶體中將20附給[0]這個陣列元素。這樣,陣列的三個元素值分別為0,100,0
第4步——執行x=null
則變數x不再指向棧記憶體中的陣列了。但是,變數y仍然指向,所以陣列不消失。

示圖如下

案例4

Car c=new Car;
c.color="blue";
Car c1=new Car;
c1.num=5;

雖然是個物件都引用new Car,但是是兩個不同的物件。每一次new,都產生不同的實體

示例5

Car c=new Car;
c.num=5;
Car c1=c;
c1.color="green";
c.run();

Car c1=c,這句話相當於將物件複製一份出來,兩個物件的記憶體地址值一樣。所以指向同一個實體,對c1的屬性修改,相當於c的屬性也改了。

3.3 JVM 引數選項

jvm 可配置的引數選項可以參考 Oracle 官方網站給出的相關資訊:http://www.oracle.com/technetwork/java/javase/tech/vmoptions-jsp-140102.html
下面只列舉其中的幾個常用和容易掌握的配置選項:

引數 描述
-Xms 初始堆大小。如:-Xms256m
-Xmx 最大堆大小。如:-Xmx512m
-Xmn 新生代大小。通常為 Xmx 的 1/3 或 1/4。新生代 = Eden + 2 個 Survivor 空間。實際可用空間為 = Eden + 1 個 Survivor,即 90%
-Xss JDK1.5+ 每個執行緒堆疊大小為 1M,一般來說如果棧不是很深的話, 1M 是絕對夠用了的。
-XX:NewRatio 新生代與老年代的比例,如 –XX:NewRatio=2,則新生代佔整個堆空間的1/3,老年代佔2/3
-XX:SurvivorRatio 新生代中 Eden 與 Survivor 的比值。預設值為 8。即 Eden 佔新生代空間的 8/10,另外兩個 Survivor 各佔 1/10
-XX:PermSize 永久代(方法區)的初始大小
-XX:MaxPermSize 永久代(方法區)的最大值
-XX:+PrintGCDetails 列印 GC 資訊
-XX:+HeapDumpOnOutOfMemoryError 讓虛擬機器在發生記憶體溢位時 Dump 出當前的記憶體堆轉儲快照,以便分析用

可以參照一下下面程式碼:

/**
 -Xms60m
 -Xmx60m
 -Xmn20m
 -XX:NewRatio=2 ( 若 Xms = Xmx, 並且設定了 Xmn, 那麼該項配置就不需要配置了 )
 -XX:SurvivorRatio=8
 -XX:PermSize=30m
 -XX:MaxPermSize=30m
 -XX:+PrintGCDetails
 */
public static void main(String[] args) {
    new Test().doTest();
}

public void doTest(){
    Integer M = new Integer(1024 * 1024 * 1);  //單位, 兆(M)
    byte[] bytes = new byte[1 * M]; //申請 1M 大小的記憶體空間
    bytes = null;  //斷開引用鏈
    System.gc();   //通知 GC 收集垃圾
    System.out.println();
    bytes = new byte[1 * M];  //重新申請 1M 大小的記憶體空間
    bytes = new byte[1 * M];  //再次申請 1M 大小的記憶體空間
    System.gc();
    System.out.println();
}-

執行後的資訊如下:

[ GC [ PSYoungGen:  1351K -&gt; 288K (18432K) ]  1351K -&gt; 288K (59392K), 0.0012389 secs ]  [ Times: user=0.00 sys=0.00, real=0.00 secs ] 
[ Full GC (System)  [ PSYoungGen:  288K -&gt; 0K (18432K) ]  [ PSOldGen:  0K -&gt; 160K (40960K) ]  288K -&gt; 160K (59392K)  [ PSPermGen:  2942K -&gt; 2942K (30720K) ],  0.0057649 secs ] [ Times:  user=0.00  sys=0.00,  real=0.01 secs ] 
  
[ GC [ PSYoungGen:  2703K -&gt; 1056K (18432K) ]  2863K -&gt; 1216K(59392K),  0.0008206 secs ]  [ Times: user=0.00 sys=0.00, real=0.00 secs ] 
[ Full GC (System)  [ PSYoungGen:  1056K -&gt; 0K (18432K) ]  [ PSOldGen:  160K -&gt; 1184K (40960K) ]  1216K -&gt; 1184K (59392K)  [ PSPermGen:  2951K -&gt; 2951K (30720K) ], 0.0052445 secs ]  [ Times: user=0.02 sys=0.00, real=0.01 secs ] 
  
Heap
 PSYoungGen      total 18432K, used 327K [0x00000000fec00000, 0x0000000100000000, 0x0000000100000000)
  eden space 16384K, 2% used [0x00000000fec00000,0x00000000fec51f58,0x00000000ffc00000)
  from space 2048K, 0% used [0x00000000ffe00000,0x00000000ffe00000,0x0000000100000000)
  to   space 2048K, 0% used [0x00000000ffc00000,0x00000000ffc00000,0x00000000ffe00000)
 PSOldGen        total 40960K, used 1184K [0x00000000fc400000, 0x00000000fec00000, 0x00000000fec00000)
  object space 40960K, 2% used [0x00000000fc400000,0x00000000fc5281f8,0x00000000fec00000)
 PSPermGen       total 30720K, used 2959K [0x00000000fa600000, 0x00000000fc400000, 0x00000000fc400000)
  object space 30720K, 9% used [0x00000000fa600000,0x00000000fa8e3ce0,0x00000000fc400000)

從列印結果可以看出,堆中新生代的記憶體空間為 18432K ( 約 18M ),eden 的記憶體空間為 16384K ( 約 16M),from / to survivor 的記憶體空間為 2048K ( 約 2M)。
這裡所配置的 Xmn 為 20M,也就是指定了新生代的記憶體空間為 20M,可是從列印的堆資訊來看,新生代怎麼就只有 18M 呢? 另外的 2M 哪裡去了?
別急,是這樣的。新生代 = eden + from + to = 16 + 2 + 2 = 20M,可見新生代的記憶體空間確實是按 Xmn 引數分配得到的。
而且這裡指定了 SurvivorRatio = 8,因此,eden = 8/10 的新生代空間 = 8/10 * 20 = 16M。from = to = 1/10 的新生代空間 = 1/10 * 20 = 2M。
堆資訊中新生代的 total 18432K 是這樣來的: eden + 1 個 survivor = 16384K + 2048K = 18432K,即約為 18M。
因為 jvm 每次只是用新生代中的 eden 和 一個 survivor,因此新生代實際的可用記憶體空間大小為所指定的 90%。
因此可以知道,這裡新生代的記憶體空間指的是新生代可用的總的記憶體空間,而不是指整個新生代的空間大小。
另外,可以看出老年代的記憶體空間為 40960K ( 約 40M ),堆大小 = 新生代 + 老年代。因此在這裡,老年代 = 堆大小 – 新生代 = 60 – 20 = 40M。
最後,這裡還指定了 PermSize = 30m,PermGen 即永久代 ( 方法區 ),它還有一個名字,叫非堆,主要用來儲存由 jvm 載入的類檔案資訊、常量、靜態變數等。

4. 垃圾回收器

HotSpot虛擬機器中的7種垃圾收集器:Serial、ParNew、Parallel Scavenge、Serial Old、Parallel Old、CMS、G1,先介紹一些垃圾收集的相關概念,再介紹它們的主要特點、應用場景、以及一些設定引數和基本執行原理。

4.1 概述

垃圾收集器是垃圾回收演算法(標記-清除演算法、複製演算法、標記-整理演算法、火車演算法)的具體實現,不同商家、不同版本的JVM所提供的垃圾收集器可能會有很在差別,本文主要介紹HotSpot虛擬機器中的垃圾收集器。

4.2 垃圾收集器組合

JDK7/8後,HotSpot虛擬機器所有收集器及組合(連線),如下圖:

  1. 圖中展示了7種不同分代的收集器

    Serial、ParNew、Parallel Scavenge、Serial Old、Parallel Old、CMS、G1;

  2. 而它們所處區域,則表明其是屬於新生代收集器還是老年代收集器:

    新生代收集器:Serial、ParNew、Parallel Scavenge;

    老年代收集器:Serial Old、Parallel Old、CMS;

    整堆收集器:G1;

  3. 兩個收集器間有連線,表明它們可以搭配使用:

    Serial/Serial Old、Serial/CMS、ParNew/Serial Old、ParNew/CMS、Parallel Scavenge/Serial Old、Parallel Scavenge/Parallel Old、G1;

  4. 其中Serial Old作為CMS出現"Concurrent Mode Failure"失敗的後備預案(後面介紹);

4.2.1 Serial收集器

Serial(序列)垃圾收集器是最基本、發展歷史最悠久的收集器;

JDK1.3.1前是HotSpot新生代收集的唯一選擇;

特點

針對新生代;

採用複製演算法;

單執行緒收集;

進行垃圾收集時,必須暫停所有工作執行緒,直到完成;

即會"Stop The World";

Serial/Serial Old組合收集器執行示意圖如下:

應用場景

依然是HotSpot在Client模式下預設的新生代收集器;

也有優於其他收集器的地方:
簡單高效(與其他收集器的單執行緒相比);

對於限定單個CPU的環境來說,Serial收集器沒有執行緒互動(切換)開銷,可以獲得最高的單執行緒收集效率;

在使用者的桌面應用場景中,可用記憶體一般不大(幾十M至一兩百M),可以在較短時間內完成垃圾收集(幾十MS至一百多MS),只要不頻繁發生,這是可以接受的

設定引數

"-XX:+UseSerialGC":新增該引數來顯式的使用序列垃圾收集器;

Stop TheWorld說明

JVM在後臺自動發起和自動完成的,在使用者不可見的情況下,把使用者正常的工作執行緒全部停掉,即GC停頓;

會帶給使用者不良的體驗;
從JDK1.3到現在,從Serial收集器-》Parallel收集器-》CMS-》G1,使用者執行緒停頓時間不斷縮短,但仍然無法完全消除;

更多Serial收集器請參考:

《Memory Management in the Java HotSpot™ Virtual Machine》 4.3節 Serial Collector(記憶體管理白皮書):http://www.oracle.com/technetwork/java/javase/tech/memorymanagement-whitepaper-1-150020.pdf

《Java Platform, Standard Edition HotSpot Virtual Machine Garbage Collection Tuning Guide》 第5節 Available Collectors(官方的垃圾收集調優指南):http://docs.oracle.com/javase/8/docs/technotes/guides/vm/gctuning/collectors.html#sthref27

4.2.2 ParNew收集器

ParNew垃圾收集器是Serial收集器的多執行緒版本

特點

除了多執行緒外,其餘的行為、特點和Serial收集器一樣;

Serial收集器可用控制引數、收集演算法、Stop The World、記憶體分配規則、回收策略等;

兩個收集器共用了不少程式碼;

ParNew/Serial Old組合收集器執行示意圖如下:

應用場景

在Server模式下,ParNew收集器是一個非常重要的收集器,因為除Serial外,目前只有它能與CMS收集器配合工作

但在單個CPU環境中,不會比Serail收集器有更好的效果,因為存線上程互動開銷。

設定引數

"-XX:+UseConcMarkSweepGC":指定使用CMS後,會預設使用ParNew作為新生代收集器;

"-XX:+UseParNewGC":強制指定使用ParNew;

"-XX:ParallelGCThreads":指定垃圾收集的執行緒數量,ParNew預設開啟的收集執行緒與CPU的數量相同;

為什麼只有ParNew能與CMS收集器配合

CMS是HotSpot在JDK1.5推出的第一款真正意義上的併發(Concurrent)收集器,第一次實現了讓垃圾收集執行緒與使用者執行緒(基本上)同時工作;

CMS作為老年代收集器,但卻無法與JDK1.4已經存在的新生代收集器Parallel Scavenge配合工作;

因為Parallel Scavenge(以及G1)都沒有使用傳統的GC收集器程式碼框架,而另外獨立實現;而其餘幾種收集器則共用了部分的框架程式碼;

4.2.3 Parallel Scavenge收集器

Parallel Scavenge垃圾收集器因為與吞吐量關係密切,也稱為吞吐量收集器(Throughput Collector)。

特點
  1. 有一些特點與ParNew收集器相似

    新生代收集器;

    採用複製演算法;

    多執行緒收集;

  2. 主要特點是:它的關注點與其他收集器不同

    CMS等收集器的關注點是儘可能地縮短垃圾收集時使用者執行緒的停頓時間;

    而Parallel Scavenge收集器的目標則是達一個可控制的吞吐量(Throughput);

    關於吞吐量與收集器關注點說明詳見本節後面;

應用場景

高吞吐量為目標,即減少垃圾收集時間,讓使用者程式碼獲得更長的執行時間;

當應用程式執行在具有多個CPU上,對暫停時間沒有特別高的要求時,即程式主要在後臺進行計算,而不需要與使用者進行太多互動;

例如,那些執行批量處理、訂單處理、工資支付、科學計算的應用程式;

設定引數

Parallel Scavenge收集器提供兩個引數用於精確控制吞吐量:

(A)、"-XX:MaxGCPauseMillis"

控制最大垃圾收集停頓時間,大於0的毫秒數;

MaxGCPauseMillis設定得稍小,停頓時間可能會縮短,但也可能會使得吞吐量下降;

因為可能導致垃圾收集發生得更頻繁;

(B)、"-XX:GCTimeRatio"

設定垃圾收集時間佔總時間的比率,0<n<100的整數;

GCTimeRatio相當於設定吞吐量大小;

垃圾收集執行時間佔應用程式執行時間的比例的計算方法是:

1 / (1 + n)

例如,選項-XX:GCTimeRatio=19,設定了垃圾收集時間佔總時間的5%--1/(1+19);

預設值是1%--1/(1+99),即n=99;

垃圾收集所花費的時間是年輕一代和老年代收集的總時間;

如果沒有滿足吞吐量目標,則增加代的記憶體大小以儘量增加使用者程式執行的時間;

此外,還有一個值得關注的引數:

(C)、"-XX:+UseAdptiveSizePolicy"

開啟這個引數後,就不用手工指定一些細節引數,如:

新生代的大小(-Xmn)、Eden與Survivor區的比例(-XX:SurvivorRation)、晉升老年代的物件年齡(-XX:PretenureSizeThreshold)等;

JVM會根據當前系統執行情況收集效能監控資訊,動態調整這些引數,以提供最合適的停頓時間或最大的吞吐量,這種調節方式稱為GC自適應的調節策略(GC Ergonomiscs);

這是一種值得推薦的方式:

(1)、只需設定好記憶體資料大小(如"-Xmx"設定最大堆);

(2)、然後使用"-XX:MaxGCPauseMillis"或"-XX:GCTimeRatio"給JVM設定一個優化目標;

(3)、那些具體細節引數的調節就由JVM自適應完成;

這也是Parallel Scavenge收集器與ParNew收集器一個重要區別;

更多目標調優和GC自適應的調節策略說明請參考:

《Memory Management in the Java HotSpot™ Virtual Machine》 5節 Ergonomics -- Automatic Selections and Behavior Tuning:http://www.oracle.com/technetwork/java/javase/tech/memorymanagement-whitepaper-1-150020.pdf

《Java Platform, Standard Edition HotSpot Virtual Machine Garbage Collection Tuning Guide》 第2節 Ergonomics:http://docs.oracle.com/javase/8/docs/technotes/guides/vm/gctuning/ergonomics.html#ergonomics

吞吐量與收集器關注點說明
  1. 吞吐量(Throughput)

    CPU用於執行使用者程式碼的時間與CPU總消耗時間的比值;

    即吞吐量=執行使用者程式碼時間/(執行使用者程式碼時間+垃圾收集時間);

    高吞吐量即減少垃圾收集時間,讓使用者程式碼獲得更長的執行時間;

  2. 垃圾收集器期望的目標(關注點)

    (1)、停頓時間

    停頓時間越短就適合需要與使用者互動的程式;

    良好的響應速度能提升使用者體驗;

    (2)、吞吐量

    高吞吐量則可以高效率地利用CPU時間,儘快完成運算的任務;

    主要適合在後臺計算而不需要太多互動的任務;

    (3)、覆蓋區(Footprint)

    在達到前面兩個目標的情況下,儘量減少堆的記憶體空間;

    可以獲得更好的空間區域性性;

更多Parallel Scavenge收集器的資訊請參考:

官方的垃圾收集調優指南 第6節:http://docs.oracle.com/javase/8/docs/technotes/guides/vm/gctuning/parallel.html#parallel_collector

4.2.4 Serial Old收集器

Serial Old是 Serial收集器的老年代版本;

特點

針對老年代;

採用"標記-整理"演算法(還有壓縮,Mark-Sweep-Compact);

單執行緒收集;

Serial/Serial Old收集器執行示意圖如下:

應用場景

主要用於Client模式;

而在Server模式有兩大用途:

(A)、在JDK1.5及之前,與Parallel Scavenge收集器搭配使用(JDK1.6有Parallel Old收集器可搭配);

(B)、作為CMS收集器的後備預案,在併發收集發生Concurrent Mode Failure時使用(後面詳解);

更多Serial Old收集器資訊請參考:

記憶體管理白皮書 4.3.2節:http://www.oracle.com/technetwork/java/javase/tech/memorymanagement-whitepaper-1-150020.pdf

4.2.5 CMS收集器

併發標記清理(Concurrent Mark Sweep,CMS)收集器也稱為併發低停頓收集器(Concurrent Low Pause Collector)或低延遲(low-latency)垃圾收集器;

在前面ParNew收集器曾簡單介紹過其特點;

特點

針對老年代;

基於"標記-清除"演算法(不進行壓縮操作,產生記憶體碎片);

以獲取最短回收停頓時間為目標;

併發收集、低停頓;

需要更多的記憶體(看後面的缺點);

是HotSpot在JDK1.5推出的第一款真正意義上的併發(Concurrent)收集器;

第一次實現了讓垃圾收集執行緒與使用者執行緒(基本上)同時工作;

應用場景

與使用者互動較多的場景;

希望系統停頓時間最短,注重服務的響應速度;

以給使用者帶來較好的體驗;

如常見WEB、B/S系統的伺服器上的應用;

設定引數

"-XX:+UseConcMarkSweepGC":指定使用CMS收集器;

CMS收集器運作過程

比前面幾種收集器更復雜,可以分為4個步驟:

(A)、初始標記(CMS initial mark)

僅標記一下GC Roots能直接關聯到的物件;

速度很快;

但需要"Stop The World";

(B)、併發標記(CMS concurrent mark)

進行GC Roots Tracing的過程;

剛才產生的集合中標記出存活物件;

應用程式也在執行;

並不能保證可以標記出所有的存活物件;

(C)、重新標記(CMS remark)

為了修正併發標記期間因使用者程式繼續運作而導致標記變動的那一部分物件的標記記錄;

需要"Stop The World",且停頓時間比初始標記稍長,但遠比並發標記短;

採用多執行緒並行執行來提升效率;

(D)、併發清除(CMS concurrent sweep)

回收所有的垃圾物件;

整個過程中耗時最長的併發標記和併發清除都可以與使用者執行緒一起工作;

所以總體上說,CMS收集器的記憶體回收過程與使用者執行緒一起併發執行;

CMS收集器執行示意圖如下:

CMS收集器3個明顯的缺點

(A)、對CPU資源非常敏感
併發收集雖然不會暫停使用者執行緒,但因為佔用一部分CPU資源,還是會導致應用程式變慢,總吞吐量降低。

CMS的預設收集執行緒數量是=(CPU數量+3)/4;

當CPU數量多於4個,收集執行緒佔用的CPU資源多於25%,對使用者程式影響可能較大;不足4個時,影響更大,可能無法接受。

增量式併發收集器:

針對這種情況,曾出現了"增量式併發收集器"(Incremental Concurrent Mark Sweep/i-CMS);

類似使用搶佔式來模擬多工機制的思想,讓收集執行緒和使用者執行緒交替執行,減少收集執行緒執行時間;

但效果並不理想,JDK1.6後就官方不再提倡使用者使用。

更多請參考:

官方的《垃圾收集調優指南》8.8節 Incremental Mode:http://docs.oracle.com/javase/8/docs/technotes/guides/vm/gctuning/cms.html#CJAGIIEJ

《記憶體管理白皮書》 4.6.3節可以看到一些描述;

(B)、無法處理浮動垃圾,可能出現"Concurrent Mode Failure"失敗

(1)、浮動垃圾(Floating Garbage)

在併發清除時,使用者執行緒新產生的垃圾,稱為浮動垃圾;

這使得併發清除時需要預留一定的記憶體空間,不能像其他收集器在老年代幾乎填滿再進行收集;

也要可以認為CMS所需要的空間比其他垃圾收集器大;

"-XX:CMSInitiatingOccupancyFraction":設定CMS預留記憶體空間;

JDK1.5預設值為68%;

JDK1.6變為大約92%;

(2)、"Concurrent Mode Failure"失敗

如果CMS預留記憶體空間無法滿足程式需要,就會出現一次"Concurrent Mode Failure"失敗;

這時JVM啟用後備預案:臨時啟用Serail Old收集器,而導致另一次Full GC的產生;

這樣的代價是很大的,所以CMSInitiatingOccupancyFraction不能設定得太大。

(C)、產生大量記憶體碎片

由於CMS基於"標記-清除"演算法,清除後不進行壓縮操作;

前面《Java虛擬機器垃圾回收(二) 垃圾回收演算法》"標記-清除"演算法介紹時曾說過:

產生大量不連續的記憶體碎片會導致分配大記憶體物件時,無法找到足夠的連續記憶體,從而需要提前觸發另一次Full GC動作。

解決方法:

(1)、"-XX:+UseCMSCompactAtFullCollection"

使得CMS出現上面這種情況時不進行Full GC,而開啟記憶體碎片的合併整理過程;

但合併整理過程無法併發,停頓時間會變長;

預設開啟(但不會進行,結合下面的CMSFullGCsBeforeCompaction);

(2)、"-XX:+CMSFullGCsBeforeCompaction"

設定執行多少次不壓縮的Full GC後,來一次壓縮整理;

為減少合併整理過程的停頓時間;

預設為0,也就是說每次都執行Full GC,不會進行壓縮整理;

由於空間不再連續,CMS需要使用可用"空閒列表"記憶體分配方式,這比簡單實用"碰撞指標"分配記憶體消耗大;

更多關於記憶體分配方式請參考:《Java物件在Java虛擬機器中的建立過程》

總體來看,與Parallel Old垃圾收集器相比,CMS減少了執行老年代垃圾收集時應用暫停的時間;

但卻增加了新生代垃圾收集時應用暫停的時間、降低了吞吐量而且需要佔用更大的堆空間;

更多CMS收集器資訊請參考:

《垃圾收集調優指南》 8節 Concurrent Mark Sweep (CMS) Collector:http://docs.oracle.com/javase/8/docs/technotes/guides/vm/gctuning/cms.html#concurrent_mark_sweep_cms_collector

《記憶體管理白皮書》 4.6節 Concurrent Mark-Sweep (CMS) Collector:http://www.oracle.com/technetwork/java/javase/tech/memorymanagement-whitepaper-1-150020.pdf

4.2.6 Parallel Old收集器

Parallel Old垃圾收集器是Parallel Scavenge收集器的老年代版本;

JDK1.6中才開始提供;

特點

針對老年代;

採用"標記-整理"演算法;

多執行緒收集;

Parallel Scavenge/Parallel Old收集器執行示意圖如下:

應用場景

JDK1.6及之後用來代替老年代的Serial Old收集器;

特別是在Server模式,多CPU的情況下;

這樣在注重吞吐量以及CPU資源敏感的場景,就有了Parallel Scavenge加Parallel Old收集器的"給力"應用組合;

設定引數

"-XX:+UseParallelOldGC":指定使用Parallel Old收集器;

更多Parallel Old收集器收集過程介紹請參考:

《記憶體管理白皮書》 4.5.2節: http://www.oracle.com/technetwork/java/javase/tech/memorymanagement-whitepaper-1-150020.pdf

4.2.7 G1收集器

G1(Garbage-First)是JDK7-u4才推出商用的收集器;

特點

(A)、並行與併發

能充分利用多CPU、多核環境下的硬體優勢;

可以並行來縮短"Stop The World"停頓時間;

也可以併發讓垃圾收集與使用者程式同時進行;

(B)、分代收集,收集範圍包括新生代和老年代

能獨立管理整個GC堆(新生代和老年代),而不需要與其他收集器搭配;

能夠採用不同方式處理不同時期的物件;

雖然保留分代概念,但Java堆的記憶體佈局有很大差別;

將整個堆劃分為多個大小相等的獨立區域(Region);

新生代和老年代不再是物理隔離,它們都是一部分Region(不需要連續)的集合;

更多G1記憶體佈局資訊請參考:

《垃圾收集調優指南》 9節:http://docs.oracle.com/javase/8/docs/technotes/guides/vm/gctuning/g1_gc.html#garbage_first_garbage_collection

(C)、結合多種垃圾收集演算法,空間整合,不產生碎片

從整體看,是基於標記-整理演算法;

從區域性(兩個Region間)看,是基於複製演算法;

這是一種類似火車演算法的實現;

都不會產生記憶體碎片,有利於長時間執行;

(D)、可預測的停頓:低停頓的同時實現高吞吐量

G1除了追求低停頓處,還能建立可預測的停頓時間模型;

可以明確指定M毫秒時間片內,垃圾收集消耗的時間不超過N毫秒;

應用場景

面向服務端應用,針對具有大記憶體、多處理器的機器;

最主要的應用是為需要低GC延遲,並具有大堆的應用程式提供解決方案;

如:在堆大小約6GB或更大時,可預測的暫停時間可以低於0.5秒;

用來替換掉JDK1.5中的CMS收集器;

在下面的情況時,使用G1可能比CMS好:

(1)、超過50%的Java堆被活動資料佔用;

(2)、物件分配頻率或年代提升頻率變化很大;

(3)、GC停頓時間過長(長於0.5至1秒)。

是否一定採用G1呢?也未必:

如果現在採用的收集器沒有出現問題,不用急著去選擇G1;

如果應用程式追求低停頓,可以嘗試選擇G1;

是否代替CMS需要實際場景測試才知道。

設定引數

"-XX:+UseG1GC":指定使用G1收集器;

"-XX:InitiatingHeapOccupancyPercent":當整個Java堆的佔用率達到引數值時,開始併發標記階段;預設為45;

"-XX:MaxGCPauseMillis":為G1設定暫停時間目標,預設值為200毫秒;

"-XX:G1HeapRegionSize":設定每個Region大小,範圍1MB到32MB;目標是在最小Java堆時可以擁有約2048個Region;

更多關於G1引數設定請參考:

《垃圾收集調優指南》 10.5節:http://docs.oracle.com/javase/8/docs/technotes/guides/vm/gctuning/g1_gc_tuning.html#important_defaults

為什麼G1收集器可以實現可預測的停頓

G1可以建立可預測的停頓時間模型,是因為:

可以有計劃地避免在Java堆的進行全區域的垃圾收集;

G1跟蹤各個Region獲得其收集價值大小,在後臺維護一個優先列表;

每次根據允許的收集時間,優先回收價值最大的Region(名稱Garbage-First的由來);

這就保證了在有限的時間內可以獲取儘可能高的收集效率;

一個物件被不同區域引用的問題

一個Region不可能是孤立的,一個Region中的物件可能被其他任意Region中物件引用,判斷物件存活時,是否需要掃描整個Java堆才能保證準確?

在其他的分代收集器,也存在這樣的問題(而G1更突出):

回收新生代也不得不同時掃描老年代?

這樣的話會降低Minor GC的效率;

解決方法:

無論G1還是其他分代收集器,JVM都是使用Remembered Set來避免全域性掃描:

每個Region都有一個對應的Remembered Set;

每次Reference型別資料寫操作時,都會產生一個Write Barrier暫時中斷操作;

然後檢查將要寫入的引用指向的物件是否和該Reference型別資料在不同的Region(其他收集器:檢查老年代物件是否引用了新生代物件);

如果不同,通過CardTable把相關引用資訊記錄到引用指向物件的所在Region對應的Remembered Set中;

當進行垃圾收集時,在GC根節點的列舉範圍加入Remembered Set;

就可以保證不進行全域性掃描,也不會有遺漏。

G1收集器運作過程

不計算維護Remembered Set的操作,可以分為4個步驟(與CMS較為相似)。

(A)、初始標記(Initial Marking)

僅標記一下GC Roots能直接關聯到的物件;

且修改TAMS(Next Top at Mark Start),讓下一階段併發執行時,使用者程式能在正確可用的Region中建立新物件;

需要"Stop The World",但速度很快;

(B)、併發標記(Concurrent Marking)

進行GC Roots Tracing的過程;

剛才產生的集合中標記出存活物件;

耗時較長,但應用程式也在執行;

並不能保證可以標記出所有的存活物件;

(C)、最終標記(Final Marking)

為了修正併發標記期間因使用者程式繼續運作而導致標記變動的那一部分物件的標記記錄;

上一階段物件的變化記錄線上程的Remembered Set Log;

這裡把Remembered Set Log合併到Remembered Set中;

需要"Stop The World",且停頓時間比初始標記稍長,但遠比並發標記短;

採用多執行緒並行執行來提升效率;

(D)、篩選回收(Live Data Counting and Evacuation)

首先排序各個Region的回收價值和成本;

然後根據使用者期望的GC停頓時間來制定回收計劃;

最後按計劃回收一些價值高的Region中垃圾物件;

回收時採用"複製"演算法,從一個或多個Region複製存活物件到堆上的另一個空的Region,並且在此過程中壓縮和釋放記憶體;

可以併發進行,降低停頓時間,並增加吞吐量;

G1收集器執行示意圖如下:

更多G1收集器資訊請參考:

《垃圾收集調優指南》 9節 Garbage-First Garbage Collector:http://docs.oracle.com/javase/8/docs/technotes/guides/vm/gctuning/g1_gc.html#garbage_first_garbage_collection

《垃圾收集調優指南》 10節 Garbage-First Garbage Collector Tuning:http://docs.oracle.com/javase/8/docs/technotes/guides/vm/gctuning/g1_gc_tuning.html#g1_gc_tuning

4.3 新生代、老年代和永生代

4.3.1 為什麼JVM會有年輕代

我們先來屢屢,為什麼需要把堆分代?不分代不能完成他所做的事情麼?其實不分代完全可以,分代的唯一理由就是優化GC效能。你先想想,如果沒有分代,那我們所有的物件都在一塊,GC的時候我們要找到哪些物件沒用,這樣就會對堆的所有區域進行掃描。而我們的很多物件都是朝生夕死的,如果分代的話,我們把新建立的物件放到某一地方,當GC的時候先把這塊存“朝生夕死”物件的區域進行回收,這樣就會騰出很大的空間出來。

4.3.2 JVM年輕代中的GC

HotSpot JVM把年輕代分為了三部分:1個Eden區和2個Survivor區(分別叫from和to)。預設比例為8:1,為啥預設會是這個比例,接下來我們會聊到。一般情況下,新建立的物件都會被分配到Eden區(一些大物件特殊處理),這些物件經過第一次Minor GC後,如果仍然存活,將會被移到Survivor區。物件在Survivor區中每熬過一次Minor GC,年齡就會增加1歲,當它的年齡增加到一定程度時,就會被移動到年老代中。

因為年輕代中的物件基本都是朝生夕死的(80%以上),所以在年輕代的垃圾回收演算法使用的是複製演算法,複製演算法的基本思想就是將記憶體分為兩塊,每次只用其中一塊,當這一塊記憶體用完,就將還活著的物件複製到另外一塊上面。複製演算法不會產生記憶體碎片。

在GC開始的時候,物件只會存在於Eden區和名為“From”的Survivor區,Survivor區“To”是空的。緊接著進行GC,Eden區中所有存活的物件都會被複制到“To”,而在“From”區中,仍存活的物件會根據他們的年齡值來決定去向。年齡達到一定值(年齡閾值,可以通過-XX:MaxTenuringThreshold來設定)的物件會被移動到年老代中,沒有達到閾值的物件會被複制到“To”區域。經過這次GC後,Eden區和From區已經被清空。這個時候,“From”和“To”會交換他們的角色,也就是新的“To”就是上次GC前的“From”,新的“From”就是上次GC前的“To”。不管怎樣,都會保證名為To的Survivor區域是空的。Minor GC會一直重複這樣的過程,直到“To”區被填滿,“To”區被填滿之後,會將所有物件移動到年老代中。

4.3.3 JVM一個物件的這一輩子

我是一個普通的Java物件,我出生在Eden區,在Eden區我還看到和我長的很像的小兄弟,我們在Eden區中玩了挺長時間。有一天Eden區中的人實在是太多了,我就被迫去了Survivor區的“From”區,自從去了Survivor區,我就開始漂了,有時候在Survivor的“From”區,有時候在Survivor的“To”區,居無定所。直到我18歲的時候,爸爸說我成人了,該去社會上闖闖了。於是我就去了老年代那邊,老年代裡,人很多,並且年齡都挺大的,我在這裡也認識了很多人。在老年代裡,我生活了20年(每次GC加一歲),然後被回收。

4.3.4 有關年輕代的JVM引數

1)-XX:NewSize和-XX:MaxNewSize

用於設定年輕代的大小,建議設為整個堆大小的1/3或者1/4,兩個值設為一樣大。

2)-XX:SurvivorRatio

用於設定Eden和其中一個Survivor的比值,這個值也比較重要。

3)-XX:+PrintTenuringDistribution

這個引數用於顯示每次Minor GC時Survivor區中各個年齡段的物件的大小。

4).-XX:InitialTenuringThreshol和-XX:MaxTenuringThreshold

用於設定晉升到老年代的物件年齡的最小值和最大值,每個物件在堅持過一次Minor GC之後,年齡就加1。

4.3.5 jvm 中的 “永生代”

“方法區” 主要儲存的資訊包括:常量資訊,類資訊,方法資訊,而且是全域性共享的(多執行緒共享);

jvm 有多種實現方式(不同的廠商); 並不是所有的jvm 都有永生代的概念;

通常情況下, 很多人把 “方法區” 和“永生代” 對等; 換句話說,是利用“永生代”

去實現“方法區”, 這樣可能導致OOM (因為“永生代”的大小是可以通過-XX:PermSize -XX:MaxPermSize)

設定的; 但是在J9 和JRockit jvm中,方法區使用的記憶體上限是4G(32位系統可表示的最大範圍),不

會存在該問題。

如果利用“永生代”實現“方法區”的垃圾收集, 主要是收集常量池和對型別進行解除安裝; 通常情況下,這個區域的

收集效率比較低。

一: 常量的回收比較容易

二: 類資訊的回收需要滿足的條件:

1 . 該類所有的例項已經被回收,JVM中沒有任何類的例項;

\2. 載入該類的ClassLoader被回收;

\3. 該類對應的java.lang.class沒有地方引用

Note: 可以使用-verbose:class以及-XX:TraceClassLoading和-XX:TraceClassUnLoading來檢視類的載入和解除安裝情況。

4.4 併發垃圾收集和並行垃圾收集的區別

  1. 並行(Parallel)

    指多條垃圾收集執行緒並行工作,但此時使用者執行緒仍然處於等待狀態;

    如ParNew、Parallel Scavenge、Parallel Old;

  2. 併發(Concurrent)

    指使用者執行緒與垃圾收集執行緒同時執行(但不一定是並行的,可能會交替執行);

    使用者程式在繼續執行,而垃圾收集程式執行緒運行於另一個CPU上;

    如CMS、G1(也有並行);

4.5 Minor GC和Full GC的區別

  1. Minor GC

    又稱新生代GC,指發生在新生代的垃圾收集動作;

    因為Java物件大多是朝生夕滅,所以Minor GC非常頻繁,一般回收速度也比較快;

  2. Full GC

    又稱Major GC或老年代GC,指發生在老年代的GC;

    出現Full GC經常會伴隨至少一次的Minor GC(不是絕對,Parallel Sacvenge收集器就可以選擇設定Major GC策略);

Major GC速度一般比Minor GC慢10倍以上;

下面將介紹這些收集器的特性、基本原理和使用場景,並重點分析CMS和G1這兩款相對複雜的收集器;但需要明確一個觀點:

沒有最好的收集器,更沒有萬能的收集;

選擇的只能是適合具體應用場景的收集器。

5 什麼是記憶體洩漏?

記憶體洩漏的定義:物件已經沒有被應用程式使用,但是垃圾回收器沒辦法移除它們,因為還在被引用著。

要想理解這個定義,我們需要先了解一下物件在記憶體中的狀態。下面的這張圖就解釋了什麼是無用物件以及什麼是未被引用物件

上面圖中可以看出,裡面有被引用物件和未被引用物件。未被引用物件會被垃圾回收器回收,而被引用的物件卻不會。未被引用的物件當然是不再被使用的物件,因為沒有物件再引用它。然而無用物件卻不全是未被引用物件。其中還有被引用的。就是這種情況導致了記憶體洩漏。

5.1 為什麼會發生記憶體洩漏?

來先看看下面的例子,為什麼會發生記憶體洩漏。下面這個例子中,A物件引用B物件,A物件的生命週期(t1-t4)比B物件的生命週期(t2-t3)長的多。當B物件沒有被應用程式使用之後,A物件仍然在引用著B物件。這樣,垃圾回收器就沒辦法將B物件從記憶體中移除,從而導致記憶體問題,因為如果A引用更多這樣的物件,那將有更多的未被引用物件存在,並消耗記憶體空間。

B物件也可能會持有許多其他的物件,那這些物件同樣也不會被垃圾回收器回收。所有這些沒在使用的物件將持續的消耗之前分配的記憶體空間。

5.2 如何防止記憶體洩漏的發生?

下面是幾條容易上手的建議,來幫助你防止記憶體洩漏的發生。

  • 特別注意一些像HashMap、ArrayList的集合物件,它們經常會引發記憶體洩漏。當它們被宣告為static時,它們的生命週期就會和應用程式一樣長。
  • 特別注意事件監聽和回撥函式。當一個監聽器在使用的時候被註冊,但不再使用之後卻未被反註冊。
  • “如果一個類自己管理記憶體,那開發人員就得小心記憶體洩漏問題了。” 通常一些成員變數引用其他物件,初始化的時候需要置空。

5.3 如何用Java建立記憶體洩漏

為了更好地理解Java的記憶體洩漏,我們可以用Java建立記憶體洩漏。

我們知道Java有Java垃圾收集機制GC但是有些物件垃圾回收器沒辦法移除它們,因為他們還在被引用著,這就造成了Java的記憶體洩漏。為了更好地理解記憶體洩漏,我們來用Java建立記憶體洩漏.

以下是在Java中建立真正的記憶體洩漏(通過執行程式碼無法訪問但仍儲存在記憶體中的物件)的方法:

  1. 應用程式建立一個長時間執行的執行緒(或使用執行緒池來更快地洩漏)。
  2. 執行緒通過一個(可選的自定義)ClassLoader載入一個類。
  3. 該類分配一大塊記憶體(例如new byte[1000000]),在靜態欄位中儲存對它的強引用,然後將引用儲存在ThreadLocal中。分配額外的記憶體是可選的(洩露Class例項就足夠了),但它會使洩漏工作更快。
  4. 執行緒清除對自定義類或從其載入的ClassLoader的所有引用。
  5. 重複。

這是可行的,因為ThreadLocal保留對該物件的引用,該引用保持對其Class的引用,該引用繼而保持對其ClassLoader的引用。ClassLoader反過來保持對它所載入的所有類的引用。

(在許多JVM實現中,特別是在Java 7之前,這種情況更糟糕,因為Classes和ClassLoader被直接分配到了permgen中,並且根本沒有GC'd。但是,無論JVM如何處理類解除安裝,ThreadLocal仍然會阻止類物件被回收。)

這種模式的一個變種就是為什麼應用程式容器(如Tomcat)會像篩子一樣洩漏記憶體,如果您經常以任何方式重新部署恰巧使用ThreadLocals的應用程式。(由於應用程式容器使用了所描述的執行緒,並且每次重新部署應用程式時都會使用新的ClassLoader。)

import java.io.IOException;
import java.net.URLClassLoader;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.nio.file.Path;
 
/**
* Example demonstrating a ClassLoader leak.
*
* To see it in action, copy this file to a temp directory somewhere,
* and then run:
* {@code
* javac ClassLoaderLeakExample.java
* java -cp . ClassLoaderLeakExample
* }
*
* And watch the memory grow! On my system, using JDK 1.8.0_25, I start
* getting OutofMemoryErrors within just a few seconds.
*
* This class is implemented using some Java 8 features, mainly for
* convenience in doing I/O. The same basic mechanism works in any version
* of Java since 1.2.
*/
public final class ClassLoaderLeakExample {
 
    static volatile boolean running = true;

    public static void main(String[] args) throws Exception {
        Thread thread = new LongRunningThread();
        try {
            thread.start();
            System.out.println("Running, press any key to stop.");
            System.in.read();
        } finally {
            running = false;
            thread.join();
    	}
	}
 
    /**
    * Implementation of the thread. It just calls {@link #loadAndDiscard()}
    * in a loop.
    */
    static final class LongRunningThread extends Thread {
        @Override 
        public void run() {
            while(running) {
                try {
                    loadAndDiscard();
                } catch (Throwable ex) {
                    ex.printStackTrace();
                }
                try {
                    Thread.sleep(100);
                } catch (InterruptedException ex) {
                    System.out.println("Caught InterruptedException, shutting down.");
                    running = false;
                }
            }
        }
    }
 
    /**
    * A simple ClassLoader implementation that is only able to load one
    * class, the LoadedInChildClassLoader class. We have to jump through
    * some hoops here because we explicitly want to ensure we get a new
    * class each time (instead of reusing the class loaded by the system
    * class loader). If this child class were in a JAR file that wasn't
    * part of the system classpath, we wouldn't need this mechanism.
    */
    static final class ChildOnlyClassLoader extends ClassLoader {
        ChildOnlyClassLoader() {
        	super(ClassLoaderLeakExample.class.getClassLoader());
        }
 
		@Override 
        protected Class<?> loadClass(String name, boolean resolve)
				throws ClassNotFoundException {
            if (!LoadedInChildClassLoader.class.getName().equals(name)) {
            	return super.loadClass(name, resolve);
            }
            try {
            	Path path = Paths.get(LoadedInChildClassLoader.class.getName()
            		+ ".class");
            	byte[] classBytes = Files.readAllBytes(path);
            	Class<?> c = defineClass(name, classBytes, 0, classBytes.length);
                if (resolve) {
                	resolveClass(c);
                }
            	return c;
            } catch (IOException ex) {
            	throw new ClassNotFoundException("Could not load " + name, ex);
            }
        }
	}
 
    /**
    * Helper method that constructs a new ClassLoader, loads a single class,
    * and then discards any reference to them. Theoretically, there should
    * be no GC impact, since no references can escape this method! But in
    * practice this will leak memory like a sieve.
    */
    static void loadAndDiscard() throws Exception {
        ClassLoader childClassLoader = new ChildOnlyClassLoader();
        Class<?> childClass = Class.forName(
        		LoadedInChildClassLoader.class.getName(), true, childClassLoader);
        childClass.newInstance();
    // When this method returns, there will be no way to reference
    // childClassLoader or childClass at all, but they will still be
    // rooted for GC purposes!
    }
 
    /**
    * An innocuous-looking class. Doesn't do anything interesting.
    */
    public static final class LoadedInChildClassLoader {
        // Grab a bunch of bytes. This isn't necessary for the leak, it just
        // makes the effect visible more quickly.
        // Note that we're really leaking these bytes, since we're effectively
        // creating a new instance of this static final field on each iteration!
        static final byte[] moreBytesToLeak = new byte[1024 * 1024 * 10];

        private static final ThreadLocal<LoadedInChildClassLoader> threadLocal
                = new ThreadLocal<>();

        public LoadedInChildClassLoader() {
            // Stash a reference to this class in the ThreadLocal
            threadLocal.set(this);
        }
    }
}