JVM學習(4)——全面總結Java的GC算法和回收機制


俗話說,自己寫的代碼,6個月后也是別人的代碼……復習!復習!復習!涉及到的知識點總結如下:

  •  一些JVM的跟蹤參數的設置
  • Java堆的分配參數
  • -Xmx 和 –Xms 應該保持一個什么關系,可以讓系統的性能盡可能的好呢?是不是虛擬機內存越大越好?

  • Java 7之前和Java 8的堆內存結構
  • Java棧的分配參數
  • GC算法思想介紹
    –GC ROOT可達性算法
    –標記清除
    –標記壓縮
    –復制算法
  • 可觸及性含義和在Java中的體現
  • finalize方法理解
  • Java的強引用,軟引用,弱引用,虛引用
  • GC引起的Stop-The-World現象
  • 串行收集器
  • 並行收集器
  • CMS

  記得JVM學習1里總結了一個例子,就是使用 -XX:+printGC參數來使能JVM的GC日志打印,讓程序員可以追蹤GC的蹤跡。如例子:

 1 public class OnStackTest {
 2     /**
 3      * alloc方法內分配了兩個字節的內存空間
 4      */
 5     public static void alloc(){
 6         byte[] b = new byte[2];
 7         b[0] = 1;
 8     }
 9 
10     public static void main(String[] args) {
11         long b = System.currentTimeMillis();
12 
13         // 分配 100000000 個 alloc 分配的內存空間
14         for(int i = 0; i < 100000000; i++){
15             alloc();
16         }
17 
18         long e = System.currentTimeMillis();
19         System.out.println(e - b);
20     }
21 }
View Code

  配置參數-XX:+printGC,再次運行會打印GC日志,截取一句:

[GC (Allocation Failure)  4416K->716K(15872K), 0.0018384 secs]

  代表發生了GC,花費了多長時間,效果是GC之前為4M多,GC之后為716K,回收了將近4M內存空間,而堆的大小大約是16M(默認的)。

  如果還嫌這些信息不夠,JVM還提供了打印詳細GC日志的參數:-XX:+PrintGCDetails

[GC (Allocation Failure) [DefNew: 4480K->0K(4992K), 0.0001689 secs] 5209K->729K(15936K), 0.0001916 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 

會詳細顯示堆的各個代的GC信息,還詳細的給出了耗時信息:user代表用戶態cpu耗時,sys代表系統的cpu耗時,real代表實際經歷時間。除此之外,-XX:+PrintGCDetails,還會在JVM退出前打印堆的詳細信息:

Heap
 def new generation   total 4992K, used 4301K [0x03800000, 0x03d60000, 0x08d50000)
  eden space 4480K,  96% used [0x03800000, 0x03c33568, 0x03c60000)
  from space 512K,   0% used [0x03ce0000, 0x03ce0000, 0x03d60000)
  to   space 512K,   0% used [0x03c60000, 0x03c60000, 0x03ce0000)
 tenured generation   total 10944K, used 729K [0x08d50000, 0x09800000, 0x13800000)
   the space 10944K,   6% used [0x08d50000, 0x08e06700, 0x08e06800, 0x09800000)
 Metaspace       used 103K, capacity 2248K, committed 2368K, reserved 4480K
View Code
  經過分析得知,該堆的新生代有5M空間,使用了3M
def new generation   total 4992K, used 3226K [0x03800000, 0x03d60000, 0x08d50000)

  在對象出生的地方,也就是伊甸園,有4M空間,使用了72%

eden space 4480K,  72% used [0x03800000, 0x03b26830, 0x03c60000)

  還有幸存代,from和to,他倆一定是相等的。

from space 512K,   0% used [0x03ce0000, 0x03ce0000, 0x03d60000)
  to   space 512K,   0% used [0x03c60000, 0x03c60000, 0x03ce0000)

  最后還有一個老年代空間,總共有10M,使用了729K

tenured generation   total 10944K, used 729K [0x08d50000, 0x09800000, 0x13800000)

  最后是Java 8改進之后的元數據空間,其中還有些16進制數字,比如[0x08d50000, 0x09800000, 0x13800000),意思依次是低邊界,當前邊界,最高邊界,代表內存分配的初始位置,當前分配到的位置,和最終能分配到的位置。

 

  重定向GC日志的方法

  -Xloggc:log/gc.log,指定GC log的位置,把GC日志輸出到工作空間的log文件夾下的gc.log文件中,能更加方便的幫助開發人員分析問題。
 
  打印最詳細的GC堆的日志:  -XX:+PrintHeapAtGC
  意思是 每次記錄GC日志,前后都要打印Java堆的詳細信息。如下一次:
{Heap before GC invocations=0 (full 0):
 def new generation   total 4928K, used 4416K [0x03c00000, 0x04150000, 0x09150000)
  eden space 4416K, 100% used [0x03c00000, 0x04050000, 0x04050000)
  from space 512K,   0% used [0x04050000, 0x04050000, 0x040d0000)
  to   space 512K,   0% used [0x040d0000, 0x040d0000, 0x04150000)
 tenured generation   total 10944K, used 0K [0x09150000, 0x09c00000, 0x13c00000)
   the space 10944K,   0% used [0x09150000, 0x09150000, 0x09150200, 0x09c00000)
 Metaspace       used 1915K, capacity 2248K, committed 2368K, reserved 4480K
Heap after GC invocations=1 (full 0):
 def new generation   total 4928K, used 512K [0x03c00000, 0x04150000, 0x09150000)
  eden space 4416K,   0% used [0x03c00000, 0x03c00000, 0x04050000)
  from space 512K, 100% used [0x040d0000, 0x04150000, 0x04150000)
  to   space 512K,   0% used [0x04050000, 0x04050000, 0x040d0000)
 tenured generation   total 10944K, used 202K [0x09150000, 0x09c00000, 0x13c00000)
   the space 10944K,   1% used [0x09150000, 0x09182950, 0x09182a00, 0x09c00000)
 Metaspace       used 1915K, capacity 2248K, committed 2368K, reserved 4480K
}
View Code

 

  監控Java類的加載情況: -XX:+TraceClassLoading

  監控系統中每一個類的加載,每一行代表一個類,主要用於跟蹤調試程序。

 

  監控類的使用情況:-XX:+PrintClassHistogram

  在程序運行中,按下Ctrl+Break后,打印類的信息:截取發現程序使用了大量的hashmap:

 num     #instances         #bytes  class name
----------------------------------------------
   1:          2919         400528  [C
   2:           173          77072  [B
   3:           593          58016  java.lang.Class
   4:          2552          40832  java.lang.String
   5:           638          36280  [Ljava.lang.Object;
   6:           827          26464  java.util.TreeMap$Entry
View Code

分別顯示序號(按照空間占用大小排序)、實例數量、總大小、類型

 

  下面看看Java堆的分配參數,指定最大堆和最小堆 -Xmx –Xms

  -Xms 10m,表示JVM Heap(堆內存)最小尺寸10MB,最開始只有 -Xms 的參數,表示 `初始` memory size(m表示memory,s表示size),屬於初始分配10m,-Xms表示的 `初始` 內存也有一個 `最小` 內存的概念(其實常用的做法中初始內存采用的也就是最小內存)。

  -Xmx 10m,表示JVM Heap(堆內存)最大允許的尺寸10MB,按需分配。如果 -Xmx 不指定或者指定偏小,也許出現java.lang.OutOfMemory錯誤,此錯誤來自JVM不是Throwable的,無法用try...catch捕捉。

  看下對JVM設置:-Xmx20m -Xms5m

public class OnStackTest {
    /**
     * alloc方法內分配了兩個字節的內存空間
     */
    public static void alloc(){
        byte[] b = new byte[10];
        b[0] = 1;
    }

    public static void main(String[] args) {
        long b = System.currentTimeMillis();

        // 分配 100000000 個 alloc 分配的內存空間
        for(int i = 0; i < 100000000; i++){
            alloc();
        }

        System.out.print("Xmx =");
        System.out.println(Runtime.getRuntime().maxMemory() / 1024.0 / 1024 + "M");

        System.out.print("free mem =");
        System.out.println(Runtime.getRuntime().freeMemory() / 1024.0 / 1024 + "M");

        System.out.print("total mem =");
        System.out.println(Runtime.getRuntime().totalMemory() / 1024.0 / 1024 + "M");

        long e = System.currentTimeMillis();
        System.out.println(e - b);
    }
}
View Code

Xmx =19.375M
free mem =4.21685791015625M
total mem =5.875M
1032


  記住:Java會盡量的維持在最小堆運行,即使設置的最大值很大,只有當GC之后也無法滿足最小堆,才會去擴容。

 

  -Xmx 和 –Xms 應該保持一個什么關系,可以讓系統的性能盡可能的好呢?是不是虛擬機內存越大越好?

  占坑,后續的GC機制來補充回答這個問題。首先並不是虛擬機內存越大就越好,大概原因是因為:內存越大,JVM 進行 Full GC 所需的時間越久,由於 Full GC 時 stop whole world 特性,如果是用於響應HTTP 請求的服務器,這個時候就表現為停止響應,對於需要低延遲的應用來說,這是不可接受的。對於需要高吞吐量的應用來說,可以不在乎這種停頓,比如一些后台的應用之類的,那么內存可以適當調大一些。需要根據具體情況權衡。

  設置新生代大小,-Xmn參數,設置的是絕對值,30m就是30m,10m就是10m。還有一個參數 -XX:NewRatio,看名字就知道是按照比例來設置,意思是設置新生代(eden+2*s)和老年代(不包含永久區)的比值,比如-XX:NewRatio4 表示 新生代:老年代=1:4。

   設置兩個Survivor區(s0,s1或者from和to)和eden的比例 -XX:SurvivorRatio,比如-XX:SurvivorRatio8表示兩個Survivor : eden=2:8,即一個Survivor占年輕代的1/10。
 
  PS, 這里說下Java堆的內存結構,Java 7和Java 8略有不同。先看 7以及以前的:
  分為了eden伊甸園,兩個幸存代survivor,前三者也叫年輕代,其次是老年代old和永久代permanent。一個Java對象被創建,先是存在於eden,如果存活時間超過了兩個幸存代就轉移到老年代保存,而永久帶保存了對象的方法,變量等元數據,如果永久帶沒地方了就會發生內存泄漏異常錯誤OutOfMemeoryError:PermGen。
 
   Java 8的堆內存結構有變化,移除了永久帶,也就是不再有OutOfMemeoryError:PermGen錯誤了。新加了元數據區,和對應的參數-XX:MaxMetaspaceSize。
 
   OOM時導出堆到文件進行內存分析和問題排查
  -XX:+HeapDumpOnOutOfMemoryError, -XX:+HeapDumpPath 導出OOM的路徑,比如:
-Xmx20m -Xms5m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/a.dump
 
   堆的分配參數總結
  • 根據實際事情調整新生代和幸存代的大小
  • 官方推薦新生代占堆的3/8
  • 幸存代占新生代的1/10
  • 在OOM時,記得Dump出堆,確保可以排查現場問題
   永久區分配參數
  -XX:PermSize , -XX:MaxPermSize,設置永久區的初始空間和最大空間,他們表示,一個系統可以容納多少個類型。類似-Xms和-Xmx。當使用一些框架時,會產生大量的類,這樣的類越來越多,會可能擠爆永久區,導致OOM。也就是說如果堆沒有用完(實際堆的空間占用很少),也拋出了OOM錯誤,很有可能是永久區導致的OOM問題。
 
   棧大小分配 -Xss
  通常只有幾百K,一般很少調大,它的大小決定了函數調用的深度,之前也說了每個線程都有獨立的棧空間,保存了局部變量、參數等。如果想跑更多的線程,需要把棧用-Xss盡量調小,而不是變大!因為線程越多,每個線程都要分配內存空間,這樣每個棧的空間越大,占據的內存越多……
但是也得預防很深的函數調用可能導致棧內存溢出問題,比如不合適的遞歸調用。
 
   Garbage Collection 垃圾收集簡介
  Java中,GC的對象主要是堆空間和永久區,記得很多人都下意識的認為Java的GC使用的是引用計數法,好像地球人都知道,無需多言似的,比如Python就是使用的這個。其實這是誤導人的,Java可以說從來都沒有用過這個引用計數算法!這是一個非常古老的算法了,另外PS:Java也不是第一個使用GC機制的語言(1960年 List 使用了GC )。
 
  引用計數法,它的一個基本思想
  對於一個對象A,只要有任何一個對象引用了A,則A的引用計數器就加1,當引用失效時,引用計數器就減1。只要對象A的引用計數器的值為0,則對象A就不可能再被使用。就可以回收了。如圖有一個根對象,和一個可達的對象:
  Java為什么不用他呢,因為 引用計數法有很多缺點
  • 性能,每次引用和去引用都要加減
  • 循環引用問題
對象1沒辦法回收,但是確實沒有用了。
 
   現代Java的垃圾回收使用的基本的算法思想是標記-清除算法
  標記-清除算法是現代垃圾回收算法的思想基礎。將垃圾回收分為兩個階段:標記階段和清除階段。
  一種可行的實現是,在標記階段,首先通過根節點,標記所有從根節點開始的可達對象(從GC ROOT開始標記引用鏈—— 又叫可達性算法)。因此,未被標記的對象就是未被引用的垃圾對象。然后,在清除階段,清除所有未被標記的對象。這樣就不怕循環問題了。
 
   PS:Java中可以作為GC ROOT的對象有:
  • 靜態變量引用的對象
  • 常量引用的對象
  • 本地方法棧(JNI)引用的對象
  • Java棧中引用的對象
  如圖,從根節點能到達的都是不能回收的,是被引用的。標記下。而這種算法的缺點就是容易出現內存碎片。利用率不高。
 
 
  要知道, 現代的Java虛擬機都是使用的分代回收的設計,比如在標記-清除算法的基礎上做了一些優化的—— 標記-壓縮算法, 適合用於存活對象較多的場合,如老年代。
  和標記-清除算法一樣,標記-壓縮算法也首先需要從根節點開始,對所有可達對象做一次標記。但之后,它並不簡單的清理未標記的對象,而是將所有的存活對象壓縮到內存的一端。之后,清理邊界外所有的空間。有效解決內存碎片問題。
 
  還有一個算法, 針對新生代的回收,叫復制算法
   和標記-清除算法相比,復制算法是一種相對高效的回收方法,但是
不適用於存活對象較多的場合如老年代,使用在新生代,
原理是
將原有的內存空間分為兩塊, 兩塊空間完全相同,每次只用一塊,在垃圾回收時,將正在使用的內存中的存活對象復制到未使用的內存塊中,之后,清除正在使用的內存塊中的所有對象,交換兩個內存的角色,完成垃圾回收。同樣也沒有內存
碎片產生。
 
復制算法的缺點是內存的浪費,因為每次只是使用了一般的空間,  而大多數存活對象都在老年代,故復制算法不用在老年代,老年代是Java堆的空間的擔保地區。復制算法主要用在新生代。在垃圾回收的時候,大對象直接從新生代進入了老年代存放,大對象一般不使用復制算法,因為一是太大,復制效率低,二是過多的大對象,會使得小對象復制的時候無地方存放。還有被長期引用的對象也放在了老年代。
Java的垃圾回收機制使用的是分代的思想。
依據對象的存活周期進行分類,短命對象歸為新生代,長命對象歸為老年代。 根據不同代的特點,選取合適的收集算法。 少量對象存活(新生代,朝生夕死的特性),適合復制算法, 大量對象存活(老年代,生命周期很長,甚至和應用程序存放時間一樣),適合標記清理或者標記壓縮算法。
以上一定注意:Java沒有采用引用計數算法!
 
經過上述總結,想到 所有的算法,需要能夠識別一個垃圾對象,那么怎么才能識別呢?
因此需要給出一個可觸及性的定義
  • 可觸及的–從GC ROOT這個根節點對象,沿着引用的鏈條,可以觸及到這個對象,該對象就叫可觸及的,也就是之前說的可達性算法的思想。
  • 可復活的–一旦所有引用被釋放,就是可復活狀態,因為在finalize()中可能復活該對象(finalize方法只會調用一次)。
  • 不可觸及的–在finalize()后,可能會進入不可觸及狀態,不可觸及的對象不可能復活,就可以回收了。
   引出一個方法的理解:finalize方法
 

  GC准備釋放內存的時候,會先調用finalize()。而調用了這個方法不代表對象一定會被回收。因為GC和finalize() 都是靠不住的,只要JVM還沒有快到耗盡內存的地步,它是不會浪費時間進行垃圾回收的。

  finalize()在什么時候被調用?
  有三種情況

  • 所有對象被Garbage Collection時自動調用,比如運行System.gc()的時候。
  • 程序退出時為每個對象調用一次finalize方法。
  • 顯式的調用finalize方法。

  finalize 是Object的 protected 方法,子類可以覆蓋該方法以實現資源清理工作,GC在回收對象之前調用該方法。finalize與C++中的析構函數不是對應的。C++中的析構函數調用的時機是確定的(對象離開作用域或delete掉),但Java中的finalize的調用具有不確定性。

  不建議用finalize方法完成“非內存資源”的清理工作,因為Java語言規范並不保證finalize方法會被及時地執行、而且根本不會保證它們會被執行,而且 finalize 方法可能會帶來性能問題。因為JVM通常在單獨的低優先級線程中完成finalize的執行,finalize方法中,可將待回收對象賦值給GC Roots可達的對象引用,從而達到對象再生的目的。finalize方法至多由GC執行一次(用戶當然可以手動調用對象的finalize方法,但並不影響GC對finalize的行為)

  但建議用於:

  • 清理本地對象(通過JNI創建的對象);
  • 作為確保某些非內存資源(如Socket、文件等)釋放的一個補充:在finalize方法中顯式調用其他資源釋放方法。
 
 
 
  說到這里,不得不提下Java的四種引用類型:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
前面說了,GC是分代的,GC的的回收條件取決於識別該對象是不是垃圾。而識別垃圾對象又取決於指向該對象的引用類型。Java中有四種引用類型,強,軟,弱,虛。
如果一個對象只有弱引用指向它,GC會立即回收該對象,這是一種急切回收方式。相對的,如果有軟引用指向這些對象,則只有在JVM需要內存時才回收這些對象。弱引用和軟引用的特殊行為使得它們在某些情況下非常有用。
例如:軟引用可以很好的用來實現緩存,當JVM需要內存時,垃圾回收器就會回收這些只有被軟引用指向的對象。而弱引用非常適合存儲元數據,例如:存儲ClassLoader引用。如果沒有類被加載,那么也沒有指向ClassLoader的引用。一旦上一次的強引用被去除,只有弱引用的ClassLoader就會被回收。
  • 強引用:類似我們常見的,比如 A a = new A();a就叫強引用。任何被強引用指向的對象都不能GC,這些對象都是在程序中需要的。
  • 軟引用:使用java.lang.ref.SoftReference類來表示,軟引用可以很好的用來實現緩存,當JVM需要內存時,垃圾回收器就會回收這些只有被軟引用指向的對象。如下:
Counter prime = new Counter(); 
SoftReference soft = new SoftReference(prime) ; //soft reference
prime = null; 
View Code

  強引用置空之后,代碼的第二行為對象Counter創建了一個軟引用,該引用同樣不能阻止垃圾回收器回收對象,但是可以延遲回收,軟引用更適用於緩存機制,而弱引用更適用於存貯元數據。

  • 弱引用:使用java.lang.ref.WeakReference 類來表示,弱引用非常適合存儲元數據,例如:存儲ClassLoader引用。如果沒有類被加載,那么也沒有指向ClassLoader的引用。一旦上一次的強引用被去除,只有弱引用的ClassLoader就會被回收。也就是說如果一個對象只有弱引用指向它,GC會立即回收該對象,這是一種急切回收方式。如:
Counter counter = new Counter(); // strong reference 
WeakReference<Counter> weakCounter = newWeakReference<Counter>(counter); //weak reference
counter = null; 
View Code

  只要給強引用對象counter賦null,該對象就可以被垃圾回收器回收。因為該對象不再含有其他強引用,即使指向該對象的弱引用weakCounter也無法阻止垃圾回收器對該對象的回收。相反的,如果該對象含有軟引用,Counter對象不會立即被回收,除非JVM需要內存。

  另一個使用弱引用的例子是WeakHashMap,它是除HashMap和TreeMap之外,Map接口的另一種實現。WeakHashMap有一個特點:map中的鍵值(keys)都被封裝成弱引用,也就是說一旦強引用被刪除,WeakHashMap內部的弱引用就無法阻止該對象被垃圾回收器回收。

  • 虛引用:沒什么實際用處,就是一個標志,當GC的時候好知道。擁有虛引用的對象可以在任何時候GC。
  除了了解弱引用、軟引用、虛引用和WeakHashMap,還需要了解ReferenceQueue。在創建任何弱引用、軟引用和虛引用的過程中,可以通過如下代碼提供引用隊列ReferenceQueue:
 
ReferenceQueue refQueue = new ReferenceQueue();
DigitalCounter digit = new DigitalCounter();
PhantomReference<DigitalCounter> phantom = new PhantomReference<DigitalCounter>(digit, refQueue);
View Code

  引用實例被添加在引用隊列中,可以在任何時候通過查詢引用隊列回收對象。

  

  現在我對一個對象的生命周期進行描述:

  新建Java對象A首先處於可達的,未執行finalize方法的狀態, 隨着程序的運行,一些引用關系會消失,或者變遷,當對A使用可達性算法判斷,對象A變成了 GC Roots 不可達時,A從可達狀態變遷到不可達狀態,但是JVM不會就就這樣把它清理了,而是在第一次GC的時候,對它首先進行一個標記(標記清除算法),之后最少還要再進行一次篩選,而對其篩選的的條件就是看該對象是否覆蓋了Object的finalize方法,或者看這個對象是否執行過一次finalize方法。如果沒有執行,也沒有覆蓋,就滿足篩選條件,JVM將其放入F-Queue隊列,由JVM的一個低優先級的線程執行該隊列中對象的finalize方法。此時執行finalize方法優先級是很低的,且不會保證等待finalize方法執行完畢才進行第二次回收(怕發生無限等待的情景,JVM崩潰),之后不久GC對隊列里的對象進行二輪回收,去判斷該對象是否可達,若不可達,才進行回收,否則,對象“復活”( 執行finalize的過程中,應用程序是可以讓對象再次被引用,復活的)。而在可達性判斷的時候,還要兼顧四種引用類型,根據不同的引用類型特點去判斷是否是回收的對象。看例子:
 
package wys.demo1;

public class Demo1 {
    public static Demo1 obj;
    
    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        
        System.out.println("CanReliveObj finalize called");
        
        obj = this;// 把obj復活了!!!
    }
    
    @Override
    public String toString(){
        return "I am CanReliveObj";
    }
    
    public static void main(String[] args) throws InterruptedException{
        obj = new Demo1();// 強引用
        obj = null;   //不會被立即回收,是可復活的對象
        
        System.gc();// 主動建議JVM做一次GC,GC之前會調用finalize方法,而我在里面把obj復活了!!!
        Thread.sleep(1000);

        if(obj == null){
            System.out.println("obj 是 null");
        }else{
            System.out.println("obj 可用");
        }
        
        System.out.println("第二次gc");
        obj = null;    //不可復活
        System.gc();
        Thread.sleep(1000);
        
        if(obj == null){
            System.out.println("obj 是 null");
        }else{
            System.out.println("obj 可用");
        }
    }
}
View Code

  結果:

CanReliveObj finalize called
obj 可用
第二次gc
obj 是 null

  說明JVM不管程序員手動調用finalize,JVM它就是執行一次finalize方法。執行finalize方法完畢后,GC會再次進行二輪回收,去判斷該對象是否可達,若不可達,才進行回收。

  

  建議:避免使用finalize方法!

  太復雜了,還是讓系統照管比較好。可以定義其它的方法來釋放非內存資源。建議使用try-catch-finally來替代它執行清理操作。

  如果手動調用了finalize,很容易出錯。且它執行的優先級低,何時被調用,不確定——也就是何時發生GC不確定,因為只有當內存告急時,GC才工作,即使GC工作,finalize方法也不一定得到執行,這是由於程序中的其他線程的優先級遠遠高於執行finalize()的線程優先級。 因此當finalize還沒有被執行時,系統的其他資源,比如文件句柄、數據庫連接池等已經消耗殆盡,造成系統崩潰。且垃圾回收和finalize方法的執行本身就是對系統資源的消耗,有可能造成程序的暫時停止,因此在程序中盡量避免使用finalize方法。

  上面提到了GC或者執行finalize可能造成程序暫停,這引出一個概念: Stop-The-World現象。
  這是Java中一種全局暫停的現象,全局停頓,所有Java代碼停止,類似JVM掛起的狀態……但是native代碼可以執行,但不能和JVM交互。這多半由於GC引起,其他的引起原因比如:
  • Dump線程
  • JVM的死鎖檢查
  • 堆的Dump。
  這三者出現概率很低,多半是程序員手動引起的,而GC是JVM自動引起的。
  
   GC時為什么會有全局停頓?
  類比在聚會時打掃房間,聚會時很亂,又有新的垃圾產生,房間永遠打掃不干凈,只有讓大家停止活動了,才能將房間在某一個狀態下打掃干凈。回程序中就是只有程序暫停了,才能全面,完整,正確的清理一次垃圾對象,否則前腳清理了,后腳還有新的,永遠清理不完,對判斷垃圾對象也是一個判斷上干擾的問題,也永遠干凈不了。
 
   Stop-The-World現象危害
  長時間服務停止,沒有響應,一般新生代的GC停頓時間很短,零點幾秒。而老年代比較時間長,幾秒甚至幾十分鍾……一般堆內存越大,GC時間越長,也就是 Stop-The-World越久。所以,JVM的內存不是越大越好,要根據實際情況設置。
  遇到HA系統,可能引起主備切換,嚴重危害生產環境。比如一個系統,一個主機服務器,一個備機服務器,不會同時啟動,我們會只使用一個,比如主機暫時因為GC沒有響應,如果時間太長,我們會使用備機,一旦主機恢復了,主機也啟動了,此時備機主機都啟動了,很可能導致服務器數據不一致……
  
  前面羅嗦了一堆,那么這些算法是如何在JVM中配合使用的呢?那么就引出新的問題需要解決: JVM的垃圾回收器。
  回憶下堆的結構:還是以Java 7為例子:
  Java堆整體分兩代,新生代和老年代,顧名思義,前者存放新生對象,大部分都是朝生夕死!進行GC的次數不多,后者存放的是時間比較久的對象,也就是多次GC還沒死的對象。對象創建的時候,大部分都是放入新生代的eden區,除非是很大的對象,可能會直接存放到老年代,還有之前說的棧上分配(逃逸分析)。
  如果eden對象在GC時幸存,就會進入幸存區,也就是s0,s1,或者叫from和to,或者叫survivor(兩個),大小一樣。完全對稱,功能也一樣。前面說了GC有復制算法,那么就是使用在這里,GC在新生代時,eden區的存活對象被復制到未使用的幸存區,假設是to,而正在使用的是from區的年輕的對象也會一起被復制到了to區,如果to區滿了,這些對象也和大對象,老年對象一樣直接進入了老年代保存(擔保空間)。此時,eden區剩余的對象和from區剩余的對象就是垃圾對象,能直接GC,to區存放的是新生代的此次GC活下來的對象。避免了產生內存碎片。

  先不說了,先看看JVM的垃圾回收器吧,先看一種最古老的收集器——串行收集器

  最古老,最穩定,效率高,但是串行的最大問題就是停頓時間很長!因為串行收集器只使用一個線程去回收,可能會產生較長的停頓現象。我們可以使用參數-XX:+UseSerialGC,設置新生代、老年代使用串行回收,此時新生代使用復制算法,老年代使用標記-壓縮算法(標記-壓縮算法首先需要從根節點開始,對所有可達對象做一次標記。但之后,它並不簡單的清理未標記的對象,而是將所有的存活對象壓縮到內存的一端。之后,清理邊界外所有的空間。有效解決內存碎片問題)。

  因為串行收集器只使用一個線程去回收,可能會產生較長的停頓現象。

 

  還有一種收集器叫並行收集器(兩種並行收集器)

  • 一種是ParNew並行收集器。使用JVM參數設置XX:+UseParNewGC,設置之后,那么新生代就是並行回收,而老年代依然是串行回收,也就是並行回收器不會影響老年代,它是Serial收集器在新生代的並行版本,新生代並行依然使用復制算法,但是是多線程,需要多核支持,我們可以使用JVM參數: XX:ParallelGCThreads 去限制線程的數量。如圖:

  注意:新生代的多線程回收不一定快!看在多核還是單核,和具體環境。、

  • 還有一種是Parallel收集器,它類似ParNew,但是更加關注JVM的吞吐量!同樣是在新生代復制算法,老年代使用標記壓縮算法,可以使用JVM參數XX:+UseParallelGC設置使用Parallel並行收集器+ 老年代串行,或者使用XX:+UseParallelOldGC,使用Parallel並行收集器+ 並行老年代。也就是說,Parallel收集器可以同時讓新生代和老年代都並行收集。如圖:

  關於並行收集器還有兩個參數設置:
  -XX:MaxGCPauseMills,代表最大的GC線程占用的停頓時間,單位是毫秒,GC盡力保證回收時間不超過設定值,不是100%的保證。
  -XX:GCTimeRatio,GC使用的cpu時間占總時間的百分比,理解為吞吐量,0-100的取值范圍,垃圾收集時間占總時間的比,默認99,即最大允許1%時間做GC。我們肯定希望停頓時間短,且占用總時間比例少,但是這兩個參數是矛盾的。因為停頓時間和吞吐量不可能同時調優。
  如果GC很頻繁,那么GC的最大停頓時間變短,但吞吐量變小,如果GC次數很少,最大的停頓時間就會變長,但吞吐量增大。

  

  最后看一個很重要的收集器-CMS(並發標記清除收集器Concurrent Mark Sweep)收集器

  顧名思義,它在老年代使用的是標記清除算法,而不是標記壓縮算法,也就是說CMS是老年代收集器(新生代使用ParNew),所謂並發標記清除就是CMS與用戶線程一起執行。標記-清除算法與標記-壓縮相比,並發階段會降低吞吐量,使用參數-XX:+UseConcMarkSweepGC打開。

   CMS運行過程比較復雜,着重實現標記的過程,可分為:

  • 初始標記,標記GC ROOT 根可以直接關聯到的對象(會產生全局停頓),但是初始標記速度快。
  • 並發標記(和用戶線程一起),主要的標記過程,標記了系統的全部的對象(不論垃圾不垃圾)。
  • 重新標記,由於並發標記時,用戶線程依然運行(可能產生新的對象),因此在正式清理前,再做一次修正,會產生全局停頓
  • 並發清除(和用戶線程一起),基於標記結果,直接清理對象。這也是為什么使用標記清除算法的原因,因為清理對象的時候用戶線程還能執行!標記壓縮算法的壓縮過程涉及到內存塊移動,這樣會有沖突。
  • 並發重置,為下一次GC做准備工作。

 

   CMS的特點
  盡可能降低了JVM的停頓時間,但是會影響系統整體吞吐量和性能,比如:
  1. 在用戶線程運行過程中,分一半CPU去做GC,系統性能在GC階段,反應速度就下降一半。
  2. 清理不徹底。因為在清理階段,用戶線程還在運行,會產生新的垃圾,無法清理。
  3. 因為和用戶線程基本上是一起運行的,故不能在空間快滿時再清理。

可以使用-XX:CMSInitiatingOccupancyFraction設置觸發CMS GC的閾值,設置空間內存占用到多少時,去觸發GC,如果不幸內存預留空間不夠,就會引起concurrent mode failure。

可以使用-XX:+ UseCMSCompactAtFullCollection, Full GC后,進行一次整理,而整理過程是獨占的,會引起停頓時間變長。

可以使用-XX:+CMSFullGCsBeforeCompaction,設置進行幾次Full GC后,進行一次碎片整理。
還可以使用-XX:ParallelCMSThreads,設定CMS的線程數量,一般設置為cpu數量,不用太大。
 
   為減輕GC壓力,我們需要注意些什么?

   從三個方面考慮:

  • 軟件如何設計架構
  • 代碼如何寫
  • 堆空間如何分配

 

歡迎關注

dashuai的博客是終身學習踐行者,大廠程序員,且專注於工作經驗、學習筆記的分享和日常吐槽,包括但不限於互聯網行業,附帶分享一些PDF電子書,資料,幫忙內推,歡迎拍磚!

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM