JAVA系列之JVM記憶體調優

一、前提

JVM性能調優牽扯到各方面的取捨與平衡,往往是牽一髮而動全身,需要全盤考慮各方面的影響。在優化時候,切勿憑感覺或經驗主義進行調整,而是需要通過系統運行的客觀數據指標,不斷找到最優解。同時,在進行性能調優前,您需要理解並掌握以下的相關基礎理論知識:

1、JVM垃圾收集器和垃圾回收演算法
2、JVM性能監控常用工具和命令
3、JVM運行時數據區域
4、能夠讀懂gc日誌
5、記憶體分配與回收策略

二、JVM記憶體結構


從上圖可以看出,整個JVM記憶體是由棧記憶體、堆記憶體和永久代構成。

年輕代(New generation) = eden + s0 + s1
堆記憶體 = 年輕代 + 老年代(Old generation)
JDK1.8以前: JVM記憶體 = 棧記憶體 + 堆記憶體 + 永久代
JDK1.8以後: 由元空間取代了永久代,元空間並不在JVM中,而是使用本地記憶體。因此JVM記憶體 = 棧記憶體 + 堆記憶體

1、棧記憶體

棧記憶體歸屬於單個執行緒,也就是每創建一個執行緒都會分配一塊棧記憶體,而棧中存儲的東西只有本執行緒可見,屬於執行緒私有。
棧的生命周期與執行緒一致,一旦執行緒結束,棧記憶體也就被回收。
棧中存放的內容主要包括:8大基本類型 + 對象的引用 + 實例的方法

2、堆記憶體

堆記憶體是由年輕代和老年代構成,JDK1.8以後,永久代被元空間取代,使用直接記憶體,不佔用堆記憶體。堆記憶體是Jvm中空間最大的區域,所有執行緒共享堆,所有的數組以及記憶體對象的實例都在此區域分配。我們常說的垃圾回收就是作用於堆記憶體。

Eden區佔大容量,Survivor兩個區佔小容量,默認比例是8:1:1

3、永久代(元空間)

這個區域是常駐記憶體的。用來存放JDK自身攜帶的Class對象。Interface元數據,存儲的是Java運行時的一些環境。這個區域不存在垃圾回收!關閉虛擬機就會釋放這個區域的記憶體。
當發現系統中元空間佔用記憶體比較大時,排查方向是否載入了大量的第三方jar包,Tomcat部署了太多應用,大量動態生成的反射類等。

三、JVM常用參數

首先JVM記憶體限制於實際的最大物理記憶體,假設物理記憶體無限大的話,JVM記憶體的最大值跟作業系統有很大的關係。簡單的說就32位處理器雖然可控記憶體空間有4GB,但是具體的作業系統會給一個限制,這個限制一般是2GB-3GB(一般來說Windows系統下為1.5G-2G,Linux系統下為2G-3G),而64bit以上的處理器就不會有限制。

1、堆大小設置

java -server -Xmx4g -Xms4g -Xmn2g –Xss128k

-Xmx4g:設置JVM最大可用記憶體為4g。
-Xms4g:設置JVM最小可用記憶體為4g。一般配置為與-Xmx相同,避免每次垃圾回收完成後JVM重新分配記憶體。
-Xmn2g:設置年輕代大小為2G。整個堆大小=年輕代大小 + 年老代大小,所以增大年輕代後,將會減小年老代大小。
-Xss128k:設置每個執行緒的堆棧大小。JDK5.0以後每個執行緒默認大小為1M,以前每個執行緒大小為256K。根據應用的執行緒所需記憶體大小進行調整。在相同物理記憶體下,減小這個值能生成更多的執行緒。

java -server -Xmx4g -Xms4g -Xmn2g –Xss128k -XX:NewRatio=4 -XX:SurvivorRatio=4 -XX:MaxMetaspaceSize=16m -XX:MaxTenuringThreshold=0

-XX:NewRatio=4: 設置年輕代(包括Eden和兩個Survivor區)與年老代的比值(除去持久代)。設置為4,則年輕代與年老代所佔比值為1:4,年輕代占整個堆棧的1/5
-XX:SurvivorRatio=4: 設置年輕代中Eden區與Survivor區的大小比值。設置為4,則兩個Survivor區與一個Eden區的比值為2:4,一個Survivor區占整個年輕代的1/6
-XX:MaxMetaspaceSize=16m: 設置元空間最大可分配大小為16m。
-XX:MaxTenuringThreshold=0: 設置垃圾最大年齡。如果設置為0的話,則年輕代對象不經過Survivor區,直接進入年老代。對於年老代比較多的應用,可以提高效率。如果將此值設置為一個較大值,則年輕代對象會在Survivor區進行多次複製,這樣可以增加對象再年輕代的存活時間,增加在年輕代即被回收的概率。

2、垃圾回收器選擇

JVM給了三種選擇:串列收集器、並行收集器、並發收集器,但是串列收集器只適用於小數據量的情況,所以這裡的選擇主要針對並行收集器和並發收集器。默認情況下,JDK5.0以前都是使用串列收集器,如果想使用其他收集器需要在啟動時加入相應參數。JDK5.0以後,JVM會根據當前系統配置進行判斷。

2.1 吞吐量優先的並行收集器

java -server -Xmx4g -Xms4g -Xmn2g –Xss128k -XX:+UseParallelGC -XX:ParallelGCThreads=20 -XX:+UseParallelOldGC -XX:+UseAdaptiveSizePolicy

-XX:+UseParallelGC:選擇垃圾收集器為並行收集器。此配置僅對年輕代有效。即上述配置下,年輕代使用並發收集,而年老代仍舊使用串列收集。
-XX:ParallelGCThreads=20:配置並行收集器的執行緒數,即:同時多少個執行緒一起進行垃圾回收。此值最好配置與處理器數目相等。
-XX:+UseParallelOldGC:配置年老代垃圾收集方式為並行收集。JDK6.0支援對年老代並行收集。
-XX:+UseAdaptiveSizePolicy:設置此選項後,並行收集器會自動選擇年輕代區大小和相應的Survivor區比例,以達到目標系統規定的最低相應時間或者收集頻率等,此值建議使用並行收集器時,一直打開。

2.2 響應時間優先的並發收集器

java -server -Xmx4g -Xms4g -Xmn2g –Xss128k -XX:ParallelGCThreads=20 -XX:+UseConcMarkSweepGC -XX:+UseParNewGC -XX:CMSFullGCsBeforeCompaction=5 -XX:+UseCMSCompactAtFullCollection

-XX:+UseConcMarkSweepGC: 設置年老代為並發收集
-XX:+UseParNewGC: 設置年輕代為並行收集。可與CMS收集同時使用
-XX:CMSFullGCsBeforeCompaction:由於並發收集器不對記憶體空間進行壓縮、整理,所以運行一段時間以後會產生「碎片」,使得運行效率降低。此值設置運行多少次GC以後對記憶體空間進行壓縮、整理。
-XX:+UseCMSCompactAtFullCollection:打開對年老代的壓縮。可能會影響性能,但是可以消除碎片

3、其他輔助配置

GC日誌列印

-XX:+PrintGC:輸出形式:[GC 118250K->113543K(130112K), 0.0094143 secs] [Full GC 121376K->10414K(130112K), 0.0650971 secs]
-XX:+PrintGCDetails:輸出形式:[GC [DefNew: 8614K->781K(9088K), 0.0123035 secs] 118250K->113543K(130112K), 0.0124633 secs] [GC [DefNew: 8614K->8614K(9088K), 0.0000665 secs][Tenured: 112761K->10414K(121024K), 0.0433488 secs] 121376K->10414K(130112K), 0.0436268 secs]

OOM生成dump文件

-XX:+HeapDumpOnOutOfMemoryError 表示jvm發生oom異常時,自動生成dump文件
-XX:HeapDumpPath= 表示生成dump文件的存放目錄

四、記憶體溢出排查

一般來說記憶體溢出主要分為以下幾類:

堆溢出(java.lang.OutOfMemoryError: Java heap space)
棧深度不夠( java.lang.StackOverflowError)
棧執行緒數不夠(java.lang.OutOfMemoryError: unable to create new native thread)
元空間溢出(java.lang.OutOfMemoryError: Metaspace)

1、元空間溢出(java.lang.OutOfMemoryError: Metaspace)

Metaspace元空間主要是存儲類的元數據資訊,各種類描述資訊,比如類名、屬性、方法、訪問限制等,按照一定的結構存儲在Metaspace里。
一般來說,元空間大小是固定不變的。在出現溢出後,首先通過命令或監控工具(如下圖)查看元空間大小,再檢查是否-XX:MaxMetaspaceSize配置太小導致。

如果發現元空間大小是持續上漲的,則需要檢查程式碼是否存在大量的反射類載入、動態代理生成的類載入等導致。可以通過-XX:+TraceClassLoading -XX:+TraceClassUnloading記錄下類的載入和卸載情況,反推具體問題程式碼。

2、棧深度不夠(java.lang.StackOverflowError)

引發StackOverFlowError的常見原因有:

  • 無限循環遞歸調用
  • 同一時間執行大量方法,資源耗盡
  • 方法中聲明大量局部變數
  • 其它消耗棧資源的方法
  • xss配置太小導致
/**
 * VM Args: -Xss128k
 */
public class JavaStackSOF {
    
    private int stackLength = 1;
    
    public void stackLeak() {
        stackLength++;
        stackLeak();
    }
    
    public static void main(String[] args) {
        JavaStackSOF oom = new JavaStackSOF();
        try{
            oom.stackLeak();
        }catch(Throwable e) {
            System.out.println("stack length:" + oom.stackLength);
            throw e;
        }
    }
}
stack length:2101
Exception in thread "main" java.lang.StackOverflowError
    at com.sandy.jvm.chapter02.JavaStackSOF.stackLeak(JavaStackSOF.java:13)
    at com.sandy.jvm.chapter02.JavaStackSOF.stackLeak(JavaStackSOF.java:14)
    at com.sandy.jvm.chapter02.JavaStackSOF.stackLeak(JavaStackSOF.java:14)

3、棧執行緒數不夠(java.lang.OutOfMemoryError: unable to create new native thread)

這類錯誤目前在生成系統只遇到過一次,原因是:linux系統中非root用戶默認創建執行緒數最多是1024。解決辦法是修改文件:/etc/security/limits.d/90-nproc.conf
還有一種情況是-xss配置太大,那麼作業系統可創建的最大執行緒數太小導致,一般除非誤操作是不會出現此問題的。

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

堆溢出是常見也是最複雜的一種情況。導致堆溢出可能的情況有:

  • 堆記憶體配置太小
  • 超出預期的訪問量:訪問量飆升
  • 超出預期的數據量:系統中是否存在一次性提取大量數據到記憶體的程式碼
  • 記憶體泄漏

解決思路一般是:

一、堆dump文件獲取
1、通過參數配置自動獲取dump文件(推薦)
2、jmap -dump:format=b,file=filename.hprof pid
二、MAT工具分析
1、分析大對象、堆中存儲資訊、可能存在的記憶體泄漏地方,便於定位問題位置

五、JVM監控

常用的監控工具或命令有:jstack、jstat、jConsole、jvisualvm。監控指標主要是各記憶體區域大小是否合理、fullGC頻率及耗時、youngGC耗時、執行緒數等。

1、jstack

jstack主要用於列印執行緒堆棧資訊,幫助問題的定位。一般配合top -Hp PID使用。

通過top命令發現某個java服務佔用1234%的CPU,如圖:

通過top -Hp PID命令可以看到佔用CPU比較高的執行緒,如圖:

再次通過jstack PID>log.txt,輸出堆棧資訊即可進行排查定位。

2、jstat

jstat命令是分析JVM運行狀況的常用命令。

jstat -options
-class 用於查看類載入情況的統計
-compiler 用於查看HotSpot中即時編譯器編譯情況的統計
-gc 用於查看JVM中堆的垃圾收集情況的統計
-gccapacity 用於查看新生代、老生代及持久代的存儲容量情況
-gcmetacapacity 顯示metaspace的大小
-gcnew 用於查看新生代垃圾收集的情況
-gcnewcapacity 用於查看新生代存儲容量的情況
-gcold 用於查看老生代及持久代垃圾收集的情況
-gcoldcapacity 用於查看老生代的容量
-gcutil 顯示垃圾收集資訊
-gccause 顯示垃圾回收的相關資訊(通-gcutil),同時顯示最後一次僅當前正在發生的垃圾收集的原因
-printcompilation 輸出JIT編譯的方法資訊

jstat -gcutil為例:

[root@hadoop ~]# jstat -gcutil 3346 #顯示垃圾收集資訊
S0 S1 E O M CCS YGC YGCT FGC FGCT GCT
52.97 0.00 42.10 13.92 97.39 98.02 8 0.020 0 0.000 0.020 
  • S0:年輕代中第一個survivor(倖存區)已使用的占當前容量百分比
  • S1:年輕代中第二個survivor(倖存區)已使用的占當前容量百分比
  • E:年輕代中Eden(伊甸園)已使用的占當前容量百分比
  • O:old代已使用的占當前容量百分比
  • M:元數據區已使用的占當前容量百分比
  • CCS:壓縮類空間已使用的占當前容量百分比
  • YGC :從應用程式啟動到取樣時年輕代中gc次數
  • YGCT :從應用程式啟動到取樣時年輕代中gc所用時間(s)
  • FGC :從應用程式啟動到取樣時old代(全gc)gc次數
  • FGCT :從應用程式啟動到取樣時old代(全gc)gc所用時間(s)
  • GCT:從應用程式啟動到取樣時gc用的總時間(s)

3、jConsole

JConsole是基於JMX的可視化監視、管理工具。可以很方便的監視本地及遠程伺服器的java進程的記憶體使用情況。

3.1 被監控的程式運行時給虛擬機添加一些運行的參數

無需認證的遠程監控配置
-Dcom.sun.management.jmxremote.port=60001 //監控的埠號
-Dcom.sun.management.jmxremote.authenticate=false //關閉認證
-Dcom.sun.management.jmxremote.ssl=false
-Djava.rmi.server.hostname=192.168.1.2

3.2 客戶端連接被監控程式

找到 JDK 安裝路徑,打開bin文件夾,雙擊jconsole.exe,在已經打開的JConsole介面操作「連接->新建連接->選擇遠程進程->輸入遠程主機IP和埠號->點擊「連接

4、jvisualvm

jvisualvm與jConsole連接方式一致,連接後介面如下: