1. 程式人生 > >JVM GC 機制與效能優化

JVM GC 機制與效能優化

1 背景介紹

與C/C++相比,JAVA並不要求我們去人為編寫程式碼進行記憶體回收和垃圾清理。JAVA提供了垃圾回收器(garbage collector)來自動檢測物件的作用域),可自動把不再被使用的儲存空間釋放掉,也就是說,GC機制可以有效地防止記憶體洩露以及記憶體溢位。

JAVA 垃圾回收器的主要任務是

  • 分配記憶體
  • 確保被引用物件的記憶體不被錯誤地回收
  • 回收不再被引用的物件的記憶體空間

凡事都有兩面性。垃圾回收器在把程式設計師從釋放記憶體的複雜工作中解放出來的同時,為了實現垃圾回收,garbage collector必須跟蹤記憶體的使用情況,釋放沒用的物件,在完成記憶體的釋放之後還需要處理堆中的碎片, 這樣做必定會增加JVM的負擔。

為什麼要了解JAVA的GC機制? 綜上所述,除了作為一個程式設計師,精益求精是基本要求之外,深入瞭解GC機制讓我們的程式碼更有效率,尤其是在構建大型程式時,GC直接影響著記憶體優化和執行速度。

2 JAVA 記憶體區域

瞭解GC機制之前,需要首先搞清楚JAVA程式在執行的時候,記憶體究竟是如何劃分的。

JAVA執行時記憶體圖解

私有記憶體區的區域名稱和相應的特性如下表所示:

區域名稱 特性
程式計數器 指示當前程式執行到了哪一行,執行JAVA方法時紀錄正在執行的虛擬機器位元組碼指令地址;執行本地方法時,計數器值為undefined
虛擬機器棧 用於執行JAVA方法。棧幀儲存區域性變量表、運算元棧、動態連結、方法返回地址和一些額外的附加資訊。程式執行時棧幀入棧;執行完成後棧幀出棧
本地方法棧 用於執行本地方法,其它和虛擬機器棧類似

著重說一下虛擬機器棧中的區域性變量表,裡面存放了三個資訊:

  • 各種基本資料型別(boolean、byte、char、short、int、float、long、double)
  • 物件引用(reference)
  • returnAddress地址

這個returnAddress和程式計數器有什麼區別?前者是指示JVM的指令執行到哪一行,後者則是你的程式碼執行到哪一行。

私有記憶體區伴隨著執行緒的產生而產生,一旦執行緒中止,私有記憶體區也會自動消除,因此我們在本文中討論的記憶體回收主要是針對共享記憶體區。下面介紹一下共享記憶體區。

區域名稱 特性
JAVA堆 JAVA虛擬機器管理的記憶體中最大的一塊,所有執行緒共享,幾乎所有的物件例項和陣列都在這類分配記憶體。GC主要就是在JAVA堆中進行的
方法區 用於儲存已被虛擬機器載入的類資訊、常量、靜態變數、即時編譯器編譯後的程式碼等資料。但是已經被最新的 JVM 取消了。現在,被載入的類作為元資料載入到底層作業系統的本地記憶體區。

3 JAVA堆

既然GC主要發生在堆記憶體中,這部分我們會對堆記憶體進行比較詳細的描述。

堆記憶體是由存活和死亡的物件組成的。存活的物件是應用可以訪問的,不會被垃圾回收。死亡的物件是應用不可訪問尚且還沒有被垃圾收集器回收掉的物件。一直到垃圾收集器把這些物件回收掉之前,他們會一直佔據堆記憶體空間。堆是應用程式在執行期請求作業系統分配給自己的向高地址擴充套件的資料結構,是不連續的記憶體區域。用一句話總結堆的作用:程式執行時動態申請某個大小的記憶體空間
堆記憶體
新生代:剛剛新建的物件在Eden中,經歷一次Minor GC,Eden中的存活物件就會被移動到第一塊survivor space S0,Eden被清空;等Eden區再滿了,就再觸發一次Minor GC,Eden和S0中的存活物件又會被複制送入第二塊survivor space S1。S0和Eden被清空,然後下一輪S0與S1交換角色,如此迴圈往復。如果物件的複製次數達到16次,該物件就會被送到老年代中。

至於為什麼新生代要分出兩個survivor區,在我的另一篇部落格中有詳細介紹為什麼新生代記憶體需要有兩個Survivor區

老年代:如果某個物件經歷了幾次垃圾回收之後還存活,就會被存放到老年代中。老年代的空間一般比新生代大。

GC名稱 介紹
Minor GC 發生在新生代,頻率高,速度快(大部分物件活不過一次Minor GC)
Major GC 發生在老年代,速度慢
Full GC 清理整個堆空間

不過實際執行中,Major GC會伴隨至少一次 Minor GC,因此也不必過多糾結於到底是哪種GC(在有些資料中看到把full GC和Minor GC等價的說法)。

那麼,當我們建立一個物件後,它會被放在堆記憶體的哪個部分呢?
記憶體申請流程
如果Major GC之後還是老年代不足,那蒼天也救不了了。。。。JVM會丟擲記憶體不足的異常。

4 垃圾回收機制

JAVA 並沒有給我們提供明確的程式碼來標註一塊記憶體並將其回收。或許你會說,我們可以將相關物件設為 null 或者用 System.gc()。然而,後者將會嚴重影響程式碼的效能,因為一般每一次顯式的呼叫 system.gc() 都會停止所有響應,去檢查記憶體中是否有可回收的物件。這會對程式的正常執行造成極大的威脅。另外,呼叫該方法並不能保證 JVM 立即進行垃圾回收,僅僅是通知 JVM 要進行垃圾回收了,具體回收與否完全由 JVM 決定。這樣做是費力不討好。

垃圾回收器是利用有向圖來記錄和管理記憶體中的所有物件,通過該有向圖,就可以識別哪些物件“可達”,哪些物件“不可達”,“不可達”的物件就是可以被回收的。這裡舉一個很簡單的例子來說明這個原理:

public class Test{
  public static void main(String[] a){
     Integer n1=new Integer(9);
     Integer n2=new Integer(3);
     n2=n1;
     // other codes
  }
}

物件引用關係
如上圖所示,垃圾回收器在遍歷有向圖時,資源2所佔的記憶體不可達,垃圾回收器就會回收該塊記憶體空間。

4.1 垃圾回收演算法概述

追蹤回收演算法(tracing collector)
從根結點開始遍歷物件的應用圖。同時標記遍歷到的物件。遍歷完成後,沒有被標記的物件就是目前未被引用,可以被回收。

壓縮回收演算法(Compacting Collector)
把堆中活動的物件集中移動到堆的一端,就會在堆的另一端流出很大的空閒區域。這種處理簡化了消除碎片的工作,但可能帶來效能的損失。

複製回收演算法(Coping Collector)
把堆均分成兩個大小相同的區域,只使用其中的一個區域,直到該區域消耗完。此時垃圾回收器終端程式的執行,通過遍歷把所有活動的物件複製到另一個區域,複製過程中它們是緊挨著佈置的,這樣也可以達到消除記憶體碎片的目的。複製結束後程序會繼續執行,直到該區域被用完。
但是,這種方法有兩個缺陷:

  1. 對於指定大小的堆,需要兩倍大小的記憶體空間,
  2. 需要中斷正在執行的程式,降低了執行效率

按代回收演算法(Generational Collector)
為什麼要按代進行回收?這是因為不同物件生命週期不同,每次回收都要遍歷所有存活物件,對於整個堆記憶體進行回收無疑浪費了大量時間,對症下藥可以提高垃圾回收的效率。主要思路是:把堆分成若搞個子堆,每個子堆視為一代,演算法在執行的過程中優先收集“年幼”的物件,如果某個物件經過多次回收仍然“存活”,就移動到高一級的堆,減少對其掃描次數。

4.2 垃圾回收器

這裡寫圖片描述

回收器 概述 年輕代 老年代
序列回收器(serial collector) 客戶端模式的預設回收器,所謂的序列,指的就是單執行緒回收,回收時將會暫停所有應用執行緒的執行 參見本文第三部分 serial old回收器標記-清除-合併。標記所有存活物件,從頭遍歷堆,清除所有死亡物件,最後把存活物件移動到堆的前端,堆的後端就空了
並行回收器 伺服器模式的預設回收器,利用多個執行緒進行垃圾回收,充分利用CPU,回收期間暫停所有應用執行緒 Parallel Scavenge回收器,關注可控制的吞吐量(吞吐量=程式碼執行時間/(程式碼執行時間加垃圾回收時間)。吞吐量越大,垃圾回收時間越短,可以充分利用CPU。但是 parrellel old回收器,多執行緒,同樣採取“標記-清除-合併”。特點是“吞吐量優先”
CMS回收器 停頓時間最短,分為以下步驟:1初始標記;2併發標記;3重新標記;4併發清除。優點是停頓時間短,併發回收,缺點是無法處理浮動垃圾,而且會導致空間碎片產生 X 適用
G1回收器 新技術,將堆記憶體劃分為多個等大的區域,按照每個區域進行回收。工作過程是1初始標記;2併發標記;3最終標記;4篩選回收。特點是並行併發,分代收集,不會導致空間碎片,也可以由程式設計者自主確定停頓時間上限 適用 適用

附轉載的GC引數彙總以及一個使用例項,轉載來源是
JVM垃圾回收器工作原理及使用例項介紹
1. 與序列回收器相關的引數
-XX:+UseSerialGC:在新生代和老年代使用序列回收器。
-XX:+SuivivorRatio:設定 eden 區大小和 survivor 區大小的比例。
-XX:+PretenureSizeThreshold:設定大物件直接進入老年代的閾值。當物件的大小超過這個值時,將直接在老年代分配。
-XX:MaxTenuringThreshold:設定物件進入老年代的年齡的最大值。每一次 Minor GC 後,物件年齡就加 1。任何大於這個年齡的物件,一定會進入老年代。
2. 與並行 GC 相關的引數
-XX:+UseParNewGC: 在新生代使用並行收集器。
-XX:+UseParallelOldGC: 老年代使用並行回收收集器。
-XX:ParallelGCThreads:設定用於垃圾回收的執行緒數。通常情況下可以和 CPU 數量相等。但在 CPU 數量比較多的情況下,設定相對較小的數值也是合理的。
-XX:MaxGCPauseMills:設定最大垃圾收集停頓時間。它的值是一個大於 0 的整數。收集器在工作時,會調整 Java 堆大小或者其他一些引數,儘可能地把停頓時間控制在 MaxGCPauseMills 以內。
-XX:GCTimeRatio:設定吞吐量大小,它的值是一個 0-100 之間的整數。假設 GCTimeRatio 的值為 n,那麼系統將花費不超過 1/(1+n) 的時間用於垃圾收集。
-XX:+UseAdaptiveSizePolicy:開啟自適應 GC 策略。在這種模式下,新生代的大小,eden 和 survivor 的比例、晉升老年代的物件年齡等引數會被自動調整,以達到在堆大小、吞吐量和停頓時間之間的平衡點。
3. 與 CMS 回收器相關的引數
-XX:+UseConcMarkSweepGC: 新生代使用並行收集器,老年代使用 CMS+序列收集器。
-XX:+ParallelCMSThreads: 設定 CMS 的執行緒數量。
-XX:+CMSInitiatingOccupancyFraction:設定 CMS 收集器在老年代空間被使用多少後觸發,預設為 68%。
-XX:+UseFullGCsBeforeCompaction:設定進行多少次 CMS 垃圾回收後,進行一次記憶體壓縮。
-XX:+CMSClassUnloadingEnabled:允許對類元資料進行回收。
-XX:+CMSParallelRemarkEndable:啟用並行重標記。
-XX:CMSInitatingPermOccupancyFraction:當永久區佔用率達到這一百分比後,啟動 CMS 回收 (前提是-XX:+CMSClassUnloadingEnabled 激活了)。
-XX:UseCMSInitatingOccupancyOnly:表示只在到達閾值的時候,才進行 CMS 回收。
-XX:+CMSIncrementalMode:使用增量模式,比較適合單 CPU。
4. 與 G1 回收器相關的引數
-XX:+UseG1GC:使用 G1 回收器。
-XX:+UnlockExperimentalVMOptions:允許使用實驗性引數。
-XX:+MaxGCPauseMills:設定最大垃圾收集停頓時間。
-XX:+GCPauseIntervalMills:設定停頓間隔時間。
5. 其他引數
-XX:+DisableExplicitGC: 禁用顯示 GC。

常用引數如下

這裡寫圖片描述

調優例項

import java.util.HashMap;


public class GCTimeTest {
 static HashMap map = new HashMap();

 public static void main(String[] args){
 long begintime = System.currentTimeMillis();
 for(int i=0;i<10000;i++){
 if(map.size()*512/1024/1024>=400){
 map.clear();//保護記憶體不溢位
 System.out.println("clean map");
 }
 byte[] b1;
 for(int j=0;j<100;j++){
 b1 = new byte[512];
 map.put(System.nanoTime(), b1);//不斷消耗記憶體
 }
 }
 long endtime = System.currentTimeMillis();
 System.out.println(endtime-begintime);
 }
}

通過上面的程式碼執行 1 萬次迴圈,每次分配 512*100B 空間,採用不同的垃圾回收器,輸出程式執行所消耗的時間。
使用引數-Xmx512M -Xms512M -XX:+UseParNewGC 執行程式碼,輸出如下:
clean map 8565
cost time=1655
使用引數-Xmx512M -Xms512M -XX:+UseParallelOldGC –XX:ParallelGCThreads=8 執行程式碼,輸出如下:
clean map 8798
cost time=1998

5 JAVA效能優化

大多說針對記憶體的調優,都是針對於特定情況的。但是實際中,調優很難與JAVA執行動態特性的實際情況和工作負載保持一致。也就是說,幾乎不可能通過單純的調優來達到消除GC的目的。

真正影響JAVA程式效能的,就是碎片化。碎片是JAVA堆記憶體中的空閒空間,可能是TLAB剩餘空間,也可能是被釋放掉的具有較長生命週期的小物件佔用的空間。

下面是一些在實際寫程式的過程中應該注意的點,養成這些習慣可以在一定程度上減少記憶體的無謂消耗,進一步就可以減少因為記憶體不足導致GC不斷。類似的這種經驗可以多積累交流:

  1. 減少new物件。每次new物件之後,都要開闢新的記憶體空間。這些物件不被引用之後,還要回收掉。因此,如果最大限度地合理重用物件,或者使用基本資料型別替代物件,都有助於節省記憶體;
  2. 多使用區域性變數,減少使用靜態變數。區域性變數被建立在棧中,存取速度快。靜態變數則是在堆記憶體;
  3. 避免使用finalize,該方法會給GC增添很大的負擔;
  4. 如果是單執行緒,儘量使用非多執行緒安全的,因為執行緒安全來自於同步機制,同步機制會降低效能。例如,單執行緒程式,能使用HashMap,就不要用HashTable。同理,儘量減少使用synchronized
  5. 用移位符號替代乘除號。eg:a*8應該寫作a<<3
  6. 對於經常反覆使用的物件使用快取;
  7. 儘量使用基本型別而不是包裝型別,儘量使用一維陣列而不是二維陣列;
  8. 儘量使用final修飾符,final表示不可修改,訪問效率高
  9. 單執行緒情況下(或者是針對於區域性變數),字串儘量使用StringBuilder,比StringBuffer要快;
  10. String為什麼慢?因為String 是不可變的物件, 因此在每次對 String 型別進行改變的時候其實都等同於生成了一個新的 String 物件,然後將指標指向新的 String 物件。如果不能保證執行緒安全,儘量使用StringBuffer來連線字串。這裡需要注意的是,StringBuffer的預設快取容量是16個字元,如果超過16,apend方法呼叫私有的expandCapacity()方法,來保證足夠的快取容量。因此,如果可以預設StringBuffer的容量,避免append再去擴充套件容量。如果可以保證執行緒安全,就是用StringBuilder。示例下面兩個示例·:

示例一:

StringBuffer st = new StringBuffer(50);
st.append("let us cook");
st.append(" ");
st.append("a matcha cake for our dinner");
String s = st.toString();

示例二:

public String toString() {
    return new StringBuilder().append("[").append(name).append("]")
                .append("[").append(Message).append("]")
                .append("[").append(salary).append("]").toString();
    }