JVM虛擬機-GC 回收機制與分代回收策略

JVM虛擬機-GC 回收機制與分代回收策略

垃圾回收(Garbage Collection,簡寫爲GC)

Java語言開發者比C語言開發者幸福的地方就在於,我們不需要手動釋放對象的內存,JVM中的垃圾回收器(Garbage Collector)會爲我們自動回收。但是這種幸福是有代價的:一旦這種自動化機制出錯,我們又不得不深入理解GC回收機制,甚至需要對這些“自動化”的技術實施必要的監控和調節。

Java內存運行時區域的各個部分,其中程序計數器,虛擬機棧,本地方法棧3個區域隨線程而生,隨線程而滅;棧中的棧幀隨着方法的進入和退出而有條不紊地執行着出棧和入棧操作,這幾個區域內不需要過多考慮回收的問題。

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

什麼是垃圾

所謂垃圾就是內存中已經沒有用的對象。既然是“垃圾回收”,那就必須知道哪些對象是垃圾。Java虛擬機中使用一種叫作“可達性分析”的算法和“引用計數器”算法來決定對象是否可以被回收。

引用計數器算法

引用計數器算法簡單概括爲:給對象添加一個引用計數器,每當有一個地方引用該對象時,計數器+1,當引用失效時,計數器-1,任何時刻,當計數器爲0的時候,該對象不再被引用。

客觀的說,引用計數器的實現簡單,判定效率也高,大部分場景下是一個不錯的選擇。但是,當前主流的JVM均沒有采用標記清除算法,原因在於,它很難解決對象之間互相循環調用的情況。

可達性分析算法

可達性分析算法是從離散數學中的圖論引入的,JVM把內存中所有的對象之間的引用關係看作一張圖,通過一組名爲“GC Root”的對象作爲起始點,從這些節點開始向下搜索,搜索所走過的路徑稱爲引用鏈,最後通過判斷對象的引用鏈是否可達來決定對象是否可以被回收。如下圖所示:

img

比如上圖中,對象A/B/C/D/E與GC Root之間都存在一條直接或者間接的引用鏈,這也代表它們與GC Root之間是可達的,因此它們是不能被GC回收掉的。而對象M和K雖然被對象J引用到,但是並不存在一條引用鏈連接它們與GC Root,所以當GC進行垃圾回收時,只要遍歷到J/M/K這3個對象,就會將它們回收。

注意:上圖中圓形圖標雖然標記的是對象,但實際上代表的是此對象在內存中的引用。包括GC Root也是一組引用而非對象。

GC Root對象

在Java中,有以下幾種對象可以作爲GC Root:

  1. Java虛擬機棧(局部變量表)中的引用的對象
  2. 方法區中靜態引用指向的對象
  3. 仍處於存活狀態中的線程對象
  4. Native方法中JNI引用的對象

什麼時候回收

不同的虛擬機實現有着不同的GC實現機制,但是一般情況下每一種GC實現都會在以下兩種情況下觸發垃圾回收。

  1. Allocation Failure:在堆內存中分配時,如果因爲可用的剩餘空間不足導致對象內存分配失敗,這時系統會觸發一次GC。
  2. System.gc();:在應用層,Java開發工程師可以主動調用此API來請求一次GC。

代碼驗證GC Root的幾種情況

現在我們瞭解了Java中的GC Root,以及何時觸發GC,接下來就通過幾個案例來驗證GC Root的情況。

-Xms 初始分配JVM的運行時內存大小,如果不指定默認爲物理內存的1/64。

比如我們運行如下命令執行HelloWorld程序,從物理內存中分配出200M空間給JVM內存。

java -Xms200m HelloWorld

驗證虛擬機棧(棧幀中的局部變量)中引用的對象作爲GC Root

運行如下代碼:

public class GCRootLocalVariable {

	private int _10MB = 10 * 1024 * 1024;
	private byte[] memory = new byte[8* _10MB];

	public static void main(String args[]){
		System.out.println("start :");
		printMemory();
		method();
		System.gc();
		System.out.println("first GC :");
		printMemory();
	}
	
	public static void method(){
		GCRootLocalVariable g = new GCRootLocalVariable();
		System.gc();
		System.out.println("second GC :");
		printMemory();
	}

	public static void printMemory(){
		System.out.println("free is "+ Runtime.getRuntime().freeMemory()/1024/1024+"M,");
		System.out.println("total is "+ Runtime.getRuntime().totalMemory()/1024/1024+"M,");
		
	}

}

打印日誌:

start :
free is 237M,
total is 240M,
second GC :
free is 158M,
total is 240M,
first GC :
free is 238M,
total is 240M,

可以看出:

  • 當第一次GC時,g作爲局部變量,引用了new出的對象(80M),並且它作爲GC Root,在GC後並不會被回收。
  • 當第二次GC:method()方法執行完後,局部變量g跟隨方法消失,不再由引用類型指向該80M對象,所以第二次GC後此80M也會被回收。

**注意:**上面日誌後面的實例中,因爲有中間變量,所以會有1M左右的誤差,但不影響我們分析GC過程。

驗證方法區中的靜態變量引用的對象作爲GC Root

運行如下代碼:

public class GCRootStaticVariable{
	private static int _10MB = 10 * 1024 * 1024;
	private byte[] memory;
	private static GCRootStaticVariable staticVariable;
	public GCRootStaticVariable(int size){
		memory = new byte[size];	
	}

	public static void main(String args[]){
		System.out.println("start:");
		printMemory();
		GCRootStaticVariable g = new GCRootStaticVariable(4 * _10MB);
		g.staticVariable = new GCRootStaticVariable(8*_10MB);
		g = null;
		System.gc();
		System.out.println("GC Finished");
		printMemory();
	}

	public static void printMemory(){
		System.out.println("free is "+ Runtime.getRuntime().freeMemory()/1024/1024+"M,");
		System.out.println("total is "+ Runtime.getRuntime().totalMemory()/1024/1024+"M,");
		
	}
}

打印日誌:

start:
free is 237M,
total is 240M,
GC Finished
free is 158M,
total is 240M,

可以看出:

程序剛開始運行時內存爲237M,並分別創建了g對象(40M),同時也初始化g對象內部的靜態變量staticVariable對象(80M)。當調用GC時,只有g對象的40M被GC回收掉,而靜態變量staticVariable作爲GC Root,它引用的80M並不會被回收。

驗證活躍線程作爲GC Root

運行如下代碼:

public class GCRootThread{
	private int _10MB = 10 * 1024 * 1024;
	private byte[] memory = new byte[8*_10MB];

	public static void main(String args[]) throws Exception{
		System.out.println("start memory:");
		printMemory();
		AsyncTask at  = new AsyncTask(new GCRootThread());
		Thread thread = new Thread(at);
		thread.start();
		System.gc();
		System.out.println("main finished , GC finished");
		printMemory();
		thread.join();
		at = null;
		System.gc();
		System.out.println("thread finished , GC finished");
		printMemory();
	}

	public static void printMemory(){
		System.out.println("free is "+ Runtime.getRuntime().freeMemory()/1024/1024+"M,");
		System.out.println("total is "+ Runtime.getRuntime().totalMemory()/1024/1024+"M,");
	}

	private static class AsyncTask implements Runnable{
		private GCRootThread gcRootThread;

		public AsyncTask(GCRootThread gcRootThread){
			this.gcRootThread = gcRootThread;
		}

		public void run(){
			try{
				Thread.sleep(500);
			}catch(Exception e){}
		}
	}
}

打印日誌:

start memory:
free is 237M,
total is 240M,
main finished , GC finished
free is 158M,
total is 240M,
thread finished , GC finished
free is 238M,
total is 240M,

可以看出:

程序剛開始時是237M內存,當調用第一次GC時,線程並沒有執行結束,並且它作爲GC Root,所以它所引用的80M內存並不會被GC回收掉。thread.join();保證線程結束再調用後續代碼,所以當調用第二次GC時,線程已經執行完畢並被置爲null,這時線程已經被銷燬,所以之前它所引用的80M此時會被GC回收掉。

驗證成員變量是否可作爲GC Root

運行如下代碼:

public class GCRootClassVariable{

	private static int _10MB = 10*1024*1024;
	private byte[] memory;
	private GCRootClassVariable classVariable;
	
	public GCRootClassVariable(int size){
		memory = new byte[size];
	}	

	public static void main(String args[]){
		System.out.println("start:");
		printMemory();
		GCRootClassVariable g = new GCRootClassVariable(4 * _10MB);
		g.classVariable = new GCRootClassVariable(8*_10MB);
		g = null;
		System.gc();
		System.out.println("GC finished");
		printMemory();
	}

	public static void printMemory(){
		System.out.println("free is "+ Runtime.getRuntime().freeMemory()/1024/1024+"M,");
		System.out.println("total is "+ Runtime.getRuntime().totalMemory()/1024/1024+"M,");
	}
}

打印日誌:

start:
free is 237M,
total is 240M,
GC finished
free is 238M,
total is 240M,

可以看出當調用GC時,因爲g已經置爲null,因此g中的全局變量classVariable此時也不再被GC Root所引用。所以最後g(40M)和classVariable(80M)都會被回收掉。這也表名全局變量同靜態變量不同,它不會被當作GC Root。

如何回收垃圾

標記清楚算法(Mark and Sweep GC)

從“GC Roots” 集合開始,將內存整個遍歷一次,保留所有可以被GC Roots直接或間接引用到的對象,而剩下的對象都當作垃圾對待並回收,過程分爲兩步。

  1. Mark標記階段:找到內存中所有GC Root對象,只要是和GC Root對象直接或間接相連的則標記爲灰色(也就是存活對象),否則標記爲黑色(也就是垃圾對象)。
  2. Sweep清楚階段:當遍歷完所有的GC Root之後,則將標記爲垃圾的對象直接清楚。

如下圖所示:

img

  • 優點:實現簡單,不需要將其對象進行移動。
  • 缺點:這個算法需要中斷進程內其他組件的執行(stop the world),並且可能產生內存碎片,提高了垃圾回收的頻率。

複製算法(Copying)

將現有內存空間分爲兩塊,每次只使用其中一塊,在垃圾回收時將正在使用的內存中的存活對象複製到未被使用的內存塊中。之後,清楚正在使用的內存塊中的所有對象,交換兩個內存的角色,完成垃圾回收。

  1. 複製算法之前,內存分爲A/B兩塊,並且當前只是用內存A,內存的狀況如下圖所示:

    img

  2. 標記完之後,所有可達對象都被按次序複製到內存B中,並設置B爲當前使用中的內存。內存狀況如下圖所示:

    img

  • 優點:按順序分配內存即可,實現簡單,運行高效,不用考慮內存碎片。
  • 缺點:可用的內存大小縮小爲原來的一半,對象存活率高時會頻繁進行復制。

標記-壓縮算法(Mark-Compact)

需要先從根節點開始對所有的可達對象做一次標記,之後,它並不簡單地清理未標記的對象,而是將所有的存活對象壓縮到內存的一端。最後清理邊界外所有的空間。因此標記壓縮也分兩步完成。

  1. Mark標記階段:找到內存中所有的GC Root對象,只要是和GC Root直接或間接相連的則標記爲灰色,否則標記爲黑色。

  2. Compact壓縮階段:將剩餘存貨對象壓縮到內存的某一端。

    img

  • 優點:這種方法既避免了碎片的產生,又不需要兩塊相同的內存空間,因此,其性價比比較高。
  • 缺點:所謂壓縮操作,仍需要進行局部的對象移動,所以一定程度上還是降低了效率。

JVM分代回收策略

Java虛擬機根據對象存活的週期不同,把堆內存劃分爲幾塊,一般爲新生代,老年代,這就是JVM的內存分代策略。

分代回收的中心思想就是:對於新創建的對象會在新生代中分配內存,此區域的對象生命週期一般較短。如果經過多次回收仍然存活下來,則將它們轉移到老年代中。

年輕代(Young Generation)

新生成的對象優先存放在新生代中,新生代對象朝生夕死,存活率很低,在新生代中,常規應用進行一次垃圾收集一般可以回收70%~95%的空間,回收效率很高。新生代中因爲要進行一些複製操作,所以一般採用的GC回收算法是複製算法。

新生代又可以繼續細分爲3部分:Eden,Survivor0(簡稱S0),Survivor1(簡稱S1)。這三部分按照8:1:1的比例來劃分新生代。這三塊區域的內存分配過程如下:

絕大多數剛剛被創建的對象會存放在Eden區。如圖:

img

Eden區第一次滿的時候,會進行垃圾回收。首先將Eden區的垃圾對象回收清除,並將存活的對象複製到S0,此時S1是空的。如圖:

img

下一次Eden區滿時,再執行一次垃圾回收。此次會將EdenS0區中所有的垃圾對象清除,並將存活對象複製到S1,此時S0變爲空。如圖:

img

如此反覆在S0S1之間切換幾次(默認是15次)之後,如果還有存活對象。說明這些對象的生命週期較長,則將它們轉移到老年代中。如圖:

img

老年代(Old Generation)

一個對象如果在新生代存活了足夠長的時間而沒有被清理掉,則會被複制到老年代。老年代的內存大小一般比新生代大,能存放更多的對象。如果對象比較大(比如長字符串或者大數組),並且新生代的剩餘空間不足,則這個大對象會直接被分配到老年代上。

老年代因爲對象的生命週期較長,不需要過多的賦值操作,所以一般採用標記壓縮的回收算法。

注意:對於老年代可能存在這麼一種情況,老年代中的對象有時候會引用到新生代對象。這時如果要執行新生代GC,則可能需要查詢整個老年代上可能存在引用新生代的情況,這顯然是低效的。所以,老年代中維護了一個512byte的card table ,所有老年代對象引用新生代對象的信息都記錄在這裏。每當新生代發生GC時,只需要檢查這個card table 即可,大大提高了性能。

GC Log分析

爲了讓上層應用開發人員更加方便的調試Java程序,JVM提供了相應的GC日誌。在GC執行垃圾回收事件的過程中,會有各種相應的log被打印出來。其中新生代和老年代所打印的日誌是有區別的。

  • 新生代 GC:這一區域的GC叫做 Minor GC。因爲Java對象大多都具備朝夕生死的特性,所以Minor GC非常頻繁,一般回收速度也比較快。
  • 老年代 GC:發生在這一區域的GC叫做 Major GC 或者 Full GC 。當出現了Major GC,經常會伴隨至少一次Minor GC。

**注意:**在有些虛擬機實現中,Major GC和Full GC還是有一些區別的。Major GC只是代表回收老年代的內存,而Full GC則代表回收整個堆中的內存,也就是新生代+老年代。

接下來通過幾個案例來分析如何查看GC Log,分析這些GC Log的過程也能再加深對JVM分代策略的理解。

首先我們需要了解幾個Java命令的參數:

img

我們用如下代碼,在內存中創建4個byte類型數組來演示內存分配與GC的詳細過程。代碼如下:

/**
* VM agrs: -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails
* -XX:SurvivorRatio=8
*/

public class MinorGCTest {

    private static final int _1MB = 1024*1024;
    public static void main(String[] args) {
        testAllocation();
    }

    public static void testAllocation(){
        byte[] a1,a2,a3,a4;
        a1 = new byte[2 * _1MB];
        a2 = new byte[2 * _1MB];
        a3 = new byte[2 * _1MB];
        a4 = new byte[1 * _1MB];
    }

}

通過上面的參數,可以看出堆內存總大小爲20M,其中新生代佔10M,剩下的10M會自動分配給老年代。

idea參數設置

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-qKbCDlHl-1586180322594)(C:\Users\大狼狗skr~\AppData\Roaming\Typora\typora-user-images\1586174256464.png)]

執行上述代碼打印日誌如下:

Heap
 PSYoungGen      total 9216K, used 8192K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
  eden space 8192K, 100% used [0x00000000ff600000,0x00000000ffe00000,0x00000000ffe00000)
  from space 1024K, 0% used [0x00000000fff00000,0x00000000fff00000,0x0000000100000000)
  to   space 1024K, 0% used [0x00000000ffe00000,0x00000000ffe00000,0x00000000fff00000)
 ParOldGen       total 10240K, used 0K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
  object space 10240K, 0% used [0x00000000fec00000,0x00000000fec00000,0x00000000ff600000)
 Metaspace       used 2630K, capacity 4486K, committed 4864K, reserved 1056768K
  class space    used 282K, capacity 386K, committed 512K, reserved 1048576K

日誌中的各字段代表意義如下:

img

從日誌中可以看出:程序執行完後,a1,a2,a3,a4四個對象都被分配在了新生代的Eden區。

如果我們將測試代碼中的a4初始化改爲a4 = new byte[2*_1MB];則打印日誌如下:

Heap
 PSYoungGen      total 9216K, used 2130K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
  eden space 8192K, 26% used [0x00000000ff600000,0x00000000ff814930,0x00000000ffe00000)
  from space 1024K, 0% used [0x00000000ffe00000,0x00000000ffe00000,0x00000000fff00000)
  to   space 1024K, 0% used [0x00000000fff00000,0x00000000fff00000,0x0000000100000000)
 ParOldGen       total 10240K, used 6664K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
  object space 10240K, 65% used [0x00000000fec00000,0x00000000ff282390,0x00000000ff600000)
 Metaspace       used 2631K, capacity 4486K, committed 4864K, reserved 1056768K
  class space    used 282K, capacity 386K, committed 512K, reserved 1048576K

這是因爲在給a4分配內存之前,Eden區已經被佔用6M。已經無法再分配出2M來存儲a4對象。因此會執行一次MinorGC。並嘗試將存活的a1、a2、a3複製到S1區。但是S1區只有1M空間,所以沒有辦法存儲a1、a2、a3任意一個對象。在這種情況下a1、a2、a3將被轉移到老年代,最後將a4保存在Eden區。所以最終結果就是:Eden 區佔用 2M(a4),老年代佔用 6M(a1、a2、a3)。

引用

判斷對象是否存活我們是通過GC Roots的引用可達性來判斷的。但是JVM中的引用關係並不止一種,而是有四種,根據引用的強度由強到弱,它們分別是:強引用(String Reference),軟引用(Soft Reference),弱引用(Weak Reference),虛引用(Phantom Reference)

img

平時的項目中,尤其是Android項目,因爲有大量的圖像對象,使用軟引用的場景較多。所以重點看下軟引用SoftReference的使用,不當的使用軟引用有時也會導致系統異常。

軟引用常規使用

代碼如下:

/**
 *VM options: -Xmx200M
 */
public class SoftReferenceNormal {

    static class SoftObject{
        byte[] data = new byte[120 * 1024 * 1024];
    }

    public static void main(String[] args) {
        SoftReference<SoftObject>   cacheRef = new SoftReference<>(new SoftObject());

        System.out.println("第一次GC前,軟引用:"+cacheRef.get());
        System.gc();
        System.out.println("第一次GC後,軟引用:"+cacheRef.get());
        SoftObject newSo = new SoftObject();
        System.out.println("再次分配120M強引用對象後,軟引用:"+cacheRef.get());

    }
}

執行上述代碼,打印日誌如下:

第一次GC前,軟引用:SoftReferenceNormal$SoftObject@15db9742
第一次GC後,軟引用:SoftReferenceNormal$SoftObject@15db9742
再次分配120M強引用對象後,軟引用:null

首先通過-Xmx200M將堆內存的最大內存設置爲200M.從日誌中可以看出,當第一次GC時,內存中還有剩餘內存,所以軟引用並不會被GC回收。但是當我們再次創建一個120M的強引用時,JVM可用內存已經不夠,所以會嘗試將軟引用給回收掉。

軟引用隱藏問題

需要注意的是,被軟引用對象關聯的對象會自動被垃圾回收器回收,但是軟引用對象本身也是一個對象,這些創建的軟引用並不會自動被垃圾回收器回收掉。比如如下代碼:

/**
 * VM options:-Xms4M -Xmx4M -Xmn2M
 */
public class SoftReferenceTest {

    public static class SoftObject{
        byte[] data = new byte[1024];
    }

    public static int CACHE_INITIAL_CAPACITY = 100*1024;
    public static ReferenceQueue<SoftObject> referenceQueue = new ReferenceQueue<>();
    public static Set<SoftReference<SoftObject>> cache = new HashSet<>(CACHE_INITIAL_CAPACITY);

    public static void main(String[] args) {
        for (int i = 0;i<CACHE_INITIAL_CAPACITY;i++){
            SoftObject obj = new SoftObject();
            cache.add(new SoftReference<>(obj,referenceQueue));
            if (i%10000 == 0){
                System.out.println("size of cache:"+cache.size());
            }
        }
        System.out.println("End!");
    }
}


上述代碼,雖然每一個SoftObject都被一個軟引用所引用,在內存緊張時,GC會將SoftObject所佔用的1KB回收。但是每一個SoftReference又都被Set強引用。執行上述代碼結果如下:

size of cache:1
size of cache:10001
size of cache:20001
Exception in thread "main" java.lang.OutOfMemoryError: GC overhead limit exceeded

限制堆內存大小爲4M,最終導致程序崩潰,但是異常的原因並不是普通的堆內存溢出,而是“GC overhead”。之所以會拋出這個錯誤,是由於虛擬機一直在不斷的回收軟引用,回收的速度過快,佔用的cpu過大(超過98%),並且每次回收掉的內存過小(小於2%),導致最終拋出了這個錯誤。

這裏需要做優化,合適的處理方式時註冊一個引用隊列,每次循環之後將引用隊列中出現的軟引用從cache中移除。如下所示:

public class SoftReferenceTest {

    public static int removeRefs = 0;
    public static class SoftObject{
        byte[] data = new byte[1024];
    }

    public static int CACHE_INITIAL_CAPACITY = 100*1024;
    public static ReferenceQueue<SoftObject> referenceQueue = new ReferenceQueue<>();
    public static Set<SoftReference<SoftObject>> cache = new HashSet<>(CACHE_INITIAL_CAPACITY);

    public static void main(String[] args) {
        for (int i = 0;i<CACHE_INITIAL_CAPACITY;i++){
            SoftObject obj = new SoftObject();
            cache.add(new SoftReference<>(obj,referenceQueue));
            clearUselessReferences();
            if (i%10000 == 0){
                System.out.println("size of cache:"+cache.size());
            }
        }
        System.out.println("End! removed soft referneces="+removeRefs);
    }

    private static void clearUselessReferences() {
        Reference<? extends SoftObject> ref = referenceQueue.poll();
        while(ref!=null){
            if (cache.remove(ref)){
                removeRefs++;
            }
            ref = referenceQueue.poll();
        }
    }
}


再次運行後,結果如下:

size of cache:1
size of cache:484
size of cache:1184
size of cache:1514
size of cache:724
size of cache:1424
size of cache:1317
size of cache:964
size of cache:1664
size of cache:504
size of cache:1204
End! removed soft referneces=100657

可以看出優化後,程序可以正常執行完。並且在執行過程中會動態的將集合中的軟引用刪除。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章