Jvm實戰

JVM 參數簡介

在開始實踐之前我們有必要先簡單瞭解一下 JVM 參數配置,因爲本文之後的實驗中提到的 JVM 中的棧,堆大小,使用的垃圾收集器等都需要通過 JVM 參數來設置

先來看下如何運行一個 Java 程序

public class Test {
    public static  void main(String[] args) {
        System.out.println("test");
    }
}
  1. 首先我們通過 javac Test.java 將其轉成字節碼

     

  2. 其次我們往往會輸入 java Test 這樣的命令來啓動 JVM 進程來執行此程序,其實我們在啓動 JVM 進程的時候,可以指定相應的 JVM 的參數,如下藍色部分

指定這些 JVM 參數我們就可以指定啓動 JVM 進程以哪種模式(server 或 client),運行時分配的堆大小,棧大小,用什麼垃圾收集器等等,JVM 參數主要分以下三類

1、 標準參數(-),所有的 JVM 實現都必須實現這些參數的功能,而且向後兼容;例如 -verbose:gc(輸出每次GC的相關情況)

2、 非標準參數(-X),默認 JVM 實現這些參數的功能,但是並不保證所有 JVM 實現都滿足,且不保證向後兼容,棧,堆大小的設置都是通過這個參數來配置的,用得最多的如下

參數示例 表示意義
-Xms512m JVM 啓動時設置的初始堆大小爲 512M
-Xmx512m JVM 可分配的最大堆大小爲 512M
-Xmn200m 設置的年輕代大小爲 200M
-Xss128k 設置每個線程的棧大小爲 128k

3、非Stable參數(-XX),此類參數各個 jvm 實現會有所不同,將來可能會隨時取消,需要慎重使用, -XX:-option 代表關閉 option 參數,-XX:+option 代表要關閉 option 參數,例如要啓用串行 GC,對應的 JVM 參數即爲 -XX:+UseSerialGC。非 Stable 參數主要有三大類

  • 行爲參數(Behavioral Options):用於改變 JVM 的一些基礎行爲,如啓用串行/並行 GC

參數示例 表示意義
-XX:-DisableExplicitGC 禁止調用System.gc();但jvm的gc仍然有效
-XX:-UseConcMarkSweepGC 對老生代採用併發標記交換算法進行GC
-XX:-UseParallelGC 啓用並行GC
-XX:-UseParallelOldGC 對Full GC啓用並行,當-XX:-UseParallelGC啓用時該項自動啓用
-XX:-UseSerialGC 啓用串行GC

 

  • 性能調優(Performance Tuning):用於 jvm 的性能調優,如設置新老生代內存容量比例

     

參數示例 表示意義
-XX:MaxHeapFreeRatio=70 GC後java堆中空閒量佔的最大比例
-XX:NewRatio=2 新生代內存容量與老生代內存容量的比例
-XX:NewSize=2.125m 新生代對象生成時佔用內存的默認值
-XX:ReservedCodeCacheSize=32m 保留代碼佔用的內存容量
-XX:ThreadStackSize=512 設置線程棧大小,若爲0則使用系統默認值

 

  • 調試參數(Debugging Options):一般用於打開跟蹤、打印、輸出等 JVM 參數,用於顯示 JVM 更加詳細的信息

     

參數示例 表示意義
-XX:HeapDumpPath=./java_pid.hprof 指定導出堆信息時的路徑或文件名
-XX:-HeapDumpOnOutOfMemoryError 當首次遭遇OOM時導出此時堆中相關信息
-XX:-PrintGC 每次GC時打印相關信息
-XX:-PrintGC Details 每次GC時打印詳細信息

畫外音:以上只是列出了比較常用的 JVM 參數,更多的 JVM 參數介紹請查看文末的參考資料

明白了 JVM 參數是幹啥用的,接下來我們進入實戰演練,下文中所有程序運行時對應的 JVM 參數都以 VM Args 的形式寫在開頭的註釋裏,讀者如果在執行程序時記得要把這些 JVM 參數給帶上哦

發生 OOM 的主要幾種場景及相應解決方案

有些人可能會覺得奇怪, GC 不是會自動幫我們清理垃圾以騰出使用空間嗎,怎麼還會發生 OOM, 我們先來看下有哪些場景會發生 OOM

1、Java 虛擬機規範中描述在棧上主要會發生以下兩種異常

  • StackOverflowError 異常


    這種情況主要是因爲單個線程請求棧深度大於虛擬機所允許的最大深度(如常用的遞歸調用層級過深等),再比如單個線程定義了大量的本地變量,導致方法幀中本地變量表長度過大等也會導致 StackOverflowError 異常,

     

    一句話:在單線程下,當棧楨太大或虛擬機容量太小導致內存無法分配時,都會發生 StackOverflowError 異常。

     

  • 虛擬機在擴展棧時無法申請到足夠的內存空間,會拋出 OOM 異常
    看如下例子:

     

/**
 * VM Args:-Xss160k
 */
public class Test {
    private void dontStop() {
        while(true) {
        }
    }

    public void stackLeakByThread() {
        while (true) {
            Thread thread = new Thread(new Runnable() {
                @Override
                public void run() {
                    dontStop();
                }
            });
            thread.start();
        }
    }

    public static  void main(String[] args) {
        Test oom = new Test();
        oom.stackLeakByThread();
    }
}

運行以上代碼會拋出「java.lang.OutOfMemoryError: unable to create new native thread」的異常

原因不難理解,操作系統給每個進程分配的內存是有限制的,比如 32 位的 Windows 限制爲 2G,虛擬機提供了參數來控制 Java 堆和方法的這兩部內存的最大值,剩餘的內存爲 「2G - Xmx(最大堆容量)= 線程數 * 每個線程分配的虛擬機棧(-Xss)+本地方法棧 」(程序計數器消耗內存很少,可忽略),每個線程都會被分配對應的虛擬機棧大小,所以總可創建的線程數肯定是固定的

像以上代碼這樣不斷地創建線程當然會造成最終無法分配,不過這也給我們提供了一個新思路,如果是因爲建立過多的線程導致的內存溢出,而我們又想多創建線程,可以通過減少最大堆(-Xms)和減少虛擬機棧大小(-Xss)來實現。

2、堆溢出 (java.lang.OutOfMemoryError:Java heap space

主要原因有兩點

  • 1.大對象的分配,最有可能的是大數組分配

示例如下:

/**
* VM Args:-Xmx12m
 */
class OOM {
    static final int SIZE=2*1024*1024;
    public static void main(String[] a) {
        int[] i = new int[SIZE];
    }
}

我們指定了堆大小爲 12M,執行 「java -Xmx12m OOM」命令就發生了 OOM 異常,如果指定 13M 則以上程序就能正常執行,所以對於由於大對象分配導致的堆溢出這種 OOM,我們一般採用增大堆內存的方式來解決

畫外音:有人可能會說分配的數組大小不是隻有 2 * 1024 * 1024 * 4(一個 int 元素佔 4 個字節)= 8M, 怎麼分配 12 M 還不夠,因爲 JVM 進程除了分配數組大小,還有指向類(數組中元素對應的類)信息的指針、鎖信息等,實際需要的堆空間是可能超過 12M 的, 12M 也只是嘗試出來的值,不同的機器可能不一樣

  • 2.內存泄漏
    我們知道在 Java 中,開發者創建和銷燬對象是不需要自己開闢空間的,JVM 會自動幫我們完成,在應用程序整個生命週期中,JVM 會定時檢查哪些對象可用,哪些不再使用,如果對象不再使用的話理論上這塊內存會被回收再利用(即GC),如果無法回收就會發生內存泄漏

/**
* VM Args:-Xmx4m
 */
public class KeylessEntry {
    static class Key {
        Integer id; 
        Key(Integer id) {
            this.id = id;
        }  
        @Override
        public int hashCode() {
            return id.hashCode();
        }
    }

    public static void main(String[] args) {
        Map m = new HashMap();
        while(true) {
            for(int i=0;i<10000;i++) {
                if(!m.containsKey(new Key(i))) {
                    m.put(new Key(i), "Number:" + i);
                }
            }
        }
    }
}

執行以上代碼就會發生內存泄漏

第一次循環,map 裏存有 10000 個 key value,但之後的每次循環都會新增 10000 個元素,因爲 Key 這個 class 漏寫了 equals 方法,導致對於每一個新創建的 new Key(i) 對象,即使 i 相同也會被認定爲屬於兩個不同的對象,這樣 m.containsKey(new Key(i)) 結果均爲 false,結果就是 HashMap 中的元素將一直增加

解決方式也很簡單,爲 Key 添加 equals 方法即可,如下

@Override
public boolean equals(Object o) {
   boolean response = false;
   if (o instanceof Key) {
      response = (((Key)o).id).equals(this.id);
   }
   return response;
}

對於這種內存泄漏導致的 OOM, 單純地增大堆大小是無法解決根本問題的,只不過是延緩了 OOM 的發生,最根本的解決方式還是要通過 heap dump analyzer 等方式來找出內存泄漏的代碼來修復解決,後文會給出一個例子來分析

3、java.lang.OutOfMemoryError:GC overhead limit exceeded

Sun 官方對此的定義:超過98%的時間用來做 GC 並且回收了不到 2% 的堆內存時會拋出此異常

導致的後果就是由於經過幾個 GC 後只回收了不到 2% 的內存,堆很快又會被填滿,然後又頻繁發生 GC,導致 CPU 負載很快就達到 100%

另外我們知道 GC 會引起 「Stop The World 」的問題,阻塞工作線程,所以會導致嚴重的性能問題,產生這種 OOM 的原因與「java.lang.OutOfMemoryError:Java heap space」類似,主要是由於分配大內存數組或內存泄漏導致的, 解決方案如下:

  • 檢查項目中是否有大量的死循環或有使用大內存的代碼,優化代碼。

  • dump 內存(後文會講述如何 dump 出內存),檢查是否存在內存泄露,如果沒有,可考慮通過 -Xmx 參數設置加大內存。

4、java.lang.OutOfMemoryError:Permgen space

在 Java 8 以前有永久代(其實是用永久代實現了方法區的功能)的概念,存放了被虛擬機加載的類,常量,靜態變量,JIT 編譯後的代碼等信息,所以如果錯誤地頻繁地使用 String.intern() 方法或運行期間生成了大量的代理類都有可能導致永久代溢出,解決方案如下

  • 是否永久代設置的過小,如果可以,適應調大一點

  • 檢查代碼是否有大量的反射操作

  • dump 之後通過 mat 檢查是否存在大量由於反射生成的代碼類

5、java.lang.OutOfMemoryError:Requested array size exceeds VM limit

該錯誤由 JVM 中的 native code 拋出。JVM 在爲數組分配內存之前,會執行基於所在平臺的檢查:分配的數據結構是否在此平臺中是可尋址的,平臺一般允許分配的數據大小在 1 到 21 億之間,如果超過了這個數就會拋出這種異常

碰到這種異常一般我們只要檢查代碼中是否有創建超大數組的地方即可。

6、java.lang.OutOfMemoryError: Out of swap space

Java 應用啓動的時候分被分配一定的內存空間(通過 -Xmx 及其他參數來指定), 如果 JVM 要求的總內存空間大小大於可用的本機內存,則操作系統會將內存中的部分數據交換到硬盤上

 

如果此時 swap 分區大小不足或者其他進程耗盡了本機的內存,則會發生 OOM, 可以通過增大 swap 空間大小來解決,但如果在交換空間進行 GC 造成的 「Stop The World」增加大個數量級,所以增大 swap 空間一定要慎重,所以一般是通過增大本機內存或優化程序減少內存佔用來解決。

7、Out of memory:Kill process or sacrifice child

爲了理解這個異常,我們需要知識一些操作系統的知識,我們知道,在操作系統中執行的程序,都是以進程的方式運行的,而進程是由內核調度的

在內核的調度任務中,有一個「Out of memory killer」的調度器,它會在系統可用內存不足時被激活,然後選擇一個進程把它幹掉,哪個進程會被幹掉呢,簡單地說會優先幹掉佔用內存大的應用型進程

 

如圖示,進程 4 佔用內存最大,最有可能被幹掉

解決這種 OOM 最直接簡單的方法就是升級內存,或者調整 OOM Killer 的優先級,減少應用的不必要的內存使用等等

看了以上的各種 OOM 產生的情況,可以看出:GC 和是否發生 OOM 沒有必然的因果關係! 

GC 主要發生在堆上,而 從以上列出的幾種發生 OOM 的場景可以看出,空間不足無法再創建線程,或者存在死循環一直在分配對象導致 GC 無法回收對象或者一次分配大內存數組(超過堆的大小)等都可能導致 OOM, 所以 OOM 與 GC 並沒有必然的因果關係

OOM 問題排查的一些常用工具

接下來我們來看下如何排查造成 OOM 的原因,內存泄漏是最常見的造成 OOM 的一種原因,所以接下來我們以來看看怎麼使用工具來排查這種問題,使用到的工具主要有兩大類

1、使用 mat(Eclipse Memory Analyzer) 來分析 dump(堆轉儲快照) 文件

主要步驟如下

  • 運行 Java 時添加 「-XX:+HeapDumpOnOutOfMemoryError」 參數來導出內存溢出時的堆信息,生成 hrof 文件, 添加 「-XX:HeapDumpPath」可以指定 hrof 文件的生成路徑,如果不指定則 hrof 文件生成在與字節碼文件相同的目錄下

     

  • 使用 MAT(Eclipse Memory Analyzer)來分析 hrof 文件,查出內存泄漏的原因

接下來我們就來看看如何用以上的工具查看如下內存泄漏案例

/**
* VM Args:-Xmx10m
 */
import java.util.ArrayList;
import java.util.List;
public class Main {
    public static void main(String[] args) {
        List list = new ArrayList();
        while (true) {
            list.add("OutOfMemoryError soon");
        }
    }
}

爲了讓以上程序快速產生 OOM, 我把堆大小設置成了 10M, 這樣執行 「java -Xmx10m -XX:+HeapDumpOnOutOfMemoryError Main」後很快就發生了 OOM,此時我們就拿到了 hrof 文件,下載 MAT 工具,打開 hrof,進行分析,打開之後選擇 「Leak Suspects Report」進行分析,可以看到發生 OOM 的線程的堆棧信息,明確定位到是哪一行造成的

 

如圖示,可以看到 Main.java 文件的第 12 行導致了這次的 OOM

2、使用 jvisualvm 來分析

用第一種方式必須等 OOM 後才能 dump 出 hprof 文件,但如果我們想在運行中觀察堆的使用情況以便查出可能的內存泄漏代碼就無能爲力了,這時我們可以藉助 jvisualvm 這款工具

 jvisualvm 的功能強大,除了可以實時監控堆內存的使用情況,還可以跟蹤垃圾回收,運行中 dump 中堆內存使用情況、cpu分析,線程分析等,是查找分析問題的利器

更騷的是它不光能分析本地的 Java 程序,還可以分析線上的 Java 程序運行情況, 本身這款工具也是隨 JDK 發佈的,是官方力推的一款運行監視,故障處理的神器。

我們來看看如何用 jvisualvm 來分析上文所述的存在內存泄漏的如下代碼

import java.util.Map;
import java.util.HashMap;

public class KeylessEntry {
    static class Key {
        Integer id; 
        Key(Integer id) {
            this.id = id;
        }  
        @Override
        public int hashCode() {
            return id.hashCode();
        }
    }

    public static void main(String[] args) {
        Map m = new HashMap();
        while(true) {
            for(int i=0;i<10000;i++) {
                if(!m.containsKey(new Key(i))) {
                    m.put(new Key(i), "Number:" + i);
                }
            }
        }
    }
}

打開 jvisualvm (終端輸入 jvisualvm 執行即可),打開後,將堆大小設置爲 500M,執行命令 java Xms500m -Xmx500m KeylessEntry,此時可以觀察到左邊出現了對應的應用 KeylessEntry,雙擊點擊 open

打開之後可以看到展示了 CPU,堆內存使用,加載類及線程的情況

注意看堆(Heap)的使用情況,一直在上漲

此時我們再點擊 「Heap Dump」

過一會兒即可看到內存中對象的使用情況

可以看到相關的 TreeNode 有 291w 個,遠超正常情況下的 10000 個!說明 HashMap 一直在增長,自此我們可以定位出問題代碼所在!

3、使用 jps + jmap 來獲取 dump 文件

jps 可以列出正在運行的虛擬機進程,並顯示執行虛擬機主類及這些進程的本地虛擬機唯一 ID,如圖示

拿到進程的 pid 後,我們就可以用 jmap 來 dump 出堆轉儲文件了,執行命令如下


 

jmap -dump:format=b,file=heapdump.phrof pid

拿到 dump 文件後我們就可以用 MAT 工具來分析了。

但這個命令在生產上一定要慎用!因爲JVM 會將整個 heap 的信息 dump 寫入到一個文件,heap 比較大的話會導致這個過程比較耗時,並且執行過程中爲了保證 dump 的信息是可靠的,會暫停應用!

GC 日誌格式怎麼看

接下來我們看看 GC 日誌怎麼看,日誌可以有效地幫助我們定位問題,所以搞清楚 GC 日誌的格式非常重要,來看下如下例子


 

/**
 *  VM Args:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:+PrintGCTimeStamps  -XX:+UseSerialGC -XX:SurvivorRatio=8
 */
public class TestGC {
    private static final int _1MB = 1024 * 1024;
    public static void main(String[] args) {
        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 日誌信息

10.080: 2[GC 3(Allocation Failure) 0.080: 4[DefNew: 56815K->280K(9216K),6 0.0043690 secs] 76815K->6424K(19456K), 80.0044111 secs]9 [Times: user=0.00 sys=0.01, real=0.01 secs]

以上是發生 Minor GC 的 GC 是日誌,如果發生 Full GC 呢,格式如下

10.088: 2[Full GC 3(Allocation Failure) 0.088: 4[Tenured: 50K->210K(10240K), 60.0009420 secs] 74603K->210K(19456K), [Metaspace: 2630K->2630K(1056768K)], 80.0009700 secs]9[Times: user=0.01 sys=0.00, real=0.02 secs]

兩者格式其實差不多,一起來看看,主要以本例觸發的 Minor GC 來講解, 以上日誌中標的每一個數字與以下序號一一對應

  1. 開頭的 0.080,0.088 代表了 GC 發生的時間,這個數字的含義是從 Java 虛擬機啓動以來經過的秒數

     

  2. [GC 或者 [Full GC 說明了這次垃圾收集的停頓類型,注意不是用來區分新生代 GC 還是老年化 GC 的,如果有 Full,說明這次 GC 是發生了 Stop The World 的,如果是調用 System.gc() 所觸發的收集,這裏會顯示 [Full GC(System)

     

  3. 之後的 Allocation Failure 代表了觸發 GC 的原因,在這個程序中我們設置了新生代的大小爲 10M(-Xmn10M),Eden:S0:S1 = 8:1:1(-XX:SurvivorRatio=8),也就是說 Eden 區佔了 8M, 當分配 allocation4 時,由於將要分配的總大小爲 10M,超過了 Eden 區,所以此時會發生 GC

     

  4. 接下來的 [DefNew[Tenured[Metaspace 表示 GC 發生的區域,這裏顯示的區域名與使用的 GC 收集器是密切相關的

     

    在此例中由於新生代我們使用了 Serial 收集器,此收集器新生代名爲「Default New Generation」,所以顯示的是 [DefNew,如果是 ParNew 收集器,新生代名稱就會變爲 [ParNew`,意爲 「Parallel New Generation」,如果採用 「Parallel Scavenge」收集器,則配套的新生代名稱爲「PSYoungGen」,老年代與新生代一樣,名稱也是由收集器決定的

     

  5. 再往後 6815K->280K(9216K) 表示 「GC 前該內存區域已使用容量 -> GC 後該內存區域已使用容量(該內存區域總容量)」

     

  6. 0.0043690 secs 表示該塊內存區域 GC 所佔用的時間,單位是秒

     

  7. 6815K->6424K(19456K) 表示「GC 前 Java 堆已使用容量 -> GC 後 Java 堆已使用容量(java 堆總容量)」。

     

  8. 0.0044111 secs 表示整個 GC 執行時間,注意和 6 中 0.0043690 secs 的區別,後者專指相關區域所花的 GC 時間,而前者指的 GC 的整體堆內存變化所花時間(新生代與老生代的的內存整理),所以前者是肯定大於後者的!

     

  9. 最後一個 [Times: user=0.01 sys=0.00, real=0.02 secs] 這裏的 user, sys 和 real 與Linux 的 time 命令所輸出的時間一致,分別代表用戶態消耗的 CPU 時間,內核態消耗的 CPU 時間,和操作從開始到結束所經過的牆鍾時間,牆鍾時間包括各種非運算的等待耗時,例如等待磁盤 I/O,等待線程阻塞,而 CPU 時間不包括這些耗時,但當系統有多 CPU 或者多核的話,多線程操作會疊加這些 CPU 時間,所以 user 或 sys 時間是可能超過 real 時間的。

知道了 GC 日誌怎麼看,我們就可以根據 GC 日誌有效定位問題了,如我們發現 Full GC 發生時間過長,則結合我們上文應用中打印的 OOM 日誌可能可以快速定位到問題

jstat 與可視化 APM 工具構建

jstat 是用於監視虛擬機各種運行狀態信息的命令行工具,可以顯示本地或者遠程虛擬機進程中的類加載,內存,垃圾收集,JIT 編譯等運行數據,jstat 支持定時查詢相應的指標,如下

jstat -gc 2764 250 22

定時針對 2764 進程輸出堆的垃圾收集情況的統計,可以顯示 gc 的信息,查看gc的次數及時間,利用這些指標,把它們可視化,對分析問題會有很大的幫助,如圖示

下圖就是我司根據 jstat 做的一部分 gc 的可視化報表,能快速定位發生問題的問題點,如果大家需要做 APM 可視化工具,建議配合使用 jstat 來完成。

再談 JVM 參數設置

經過前面對 JVM 參數的介紹及相關例子的實驗,相信大家對 JVM 的參數有了比較深刻的理解,接下來我們再談談如何設置 JVM 參數

1、首先 Oracle 官方推薦堆的初始化大小與堆可設置的最大值一般是相等的,即 Xms = Xmx,因爲起始堆內存太小(Xms),會導致啓動初期頻繁 GC,起始堆內存較大(Xmx)有助於減少 GC 次數

2、調試的時候設置一些打印參數,如-XX:+PrintClassHistogram -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:+PrintHeapAtGC -Xloggc:log/gc.log,這樣可以從gc.log裏看出一些端倪出來

3、系統停頓時間過長可能是 GC 的問題也可能是程序的問題,多用 jmap 和 jstack 查看,或者killall -3 Java,然後查看 Java 控制檯日誌,能看出很多問題

4、 採用併發回收時,年輕代小一點,年老代要大,因爲年老大用的是併發回收,即使時間長點也不會影響其他程序繼續運行,網站不會停頓

5、仔細瞭解自己的應用,如果用了緩存,那麼年老代應該大一些,緩存的 HashMap 不應該無限制長,建議採用 LRU 算法的 Map 做緩存,LRUMap 的最大長度也要根據實際情況設定

要設置好各種 JVM 參數,還可以對 server 進行壓測, 預估自己的業務量,設定好一些 JVM 參數進行壓測看下這些設置好的 JVM 參數是否能滿足要求

總結

本文通過詳細介紹了 JVM 參數及 GC 日誌, OOM 發生的原因及相應的調試工具,相信讀者應該掌握了基本的 MAT,jvisualvm 這些工具排查問題的技巧,不過這些工具的介紹本文只是提到了一些皮毛,大家可以在再深入瞭解相應工具的一些進階技能,這能對自己排查問題等大有裨益!

文中的例子大家可以去試驗一下,修改一下參數看下會發生哪些神奇的現象,親自動手做一遍能對排查問題的思路更加清晰哦

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