在面試的時候經常穩的JVM調優問題
- 線上環境,如果內存飆升了,應該怎么排查呢?
- 線上環境,如果CPU飆升了,應該怎么排查呢?
內存飆升首先要考慮是不是類有很多,並且沒有被釋放;使用jmap可以檢查出哪個類很多
CPU飆升,可以使用Jstact 來找出CPU飆升的原因
下面就來研究Jmap,Jstact的用法。
目標:
- Jmap、Jstack、Jinfo詳解
- JvisualVm調優工具實戰
- JVM內存或CPU飆高如何定位
- JState命令預估JVM運行情況
- 系統頻繁Full GC導致系統卡頓實戰調優
- 內存泄漏到底是怎么回事?
一、前言
因為我的是mac電腦,所以運行程序都是在mac上,有時一些工具在mac上不是很好用。如果有不好用的情況,可以參考文章:
1. mac安裝多版本jdk
2. 徹底解決Jmap在mac版本無法使用的問題
以上是我在mac上運行Jmap時遇到的問題,如果你也遇到了,可以查看。
二、Jmap使用
1. Jmap -histo 進程號
這個命令是用來查看系統內存使用情況的,實例個數,以及占用內存。
命令:
jmap -histo 3241
運行結果:
num #instances #bytes class name
----------------------------------------------
1: 1101980 372161752 [B
2: 551394 186807240 [Ljava.lang.Object;
3: 1235341 181685128 [C
4: 76692 170306096 [I
5: 459168 14693376 java.util.concurrent.locks.AbstractQueuedSynchronizer$Node
6: 543699 13048776 java.lang.String
7: 497636 11943264 java.util.ArrayList
8: 124271 10935848 java.lang.reflect.Method
9: 348582 7057632 [Ljava.lang.Class;
10: 186244 5959808 java.util.concurrent.ConcurrentHashMap$Node
這里顯示的是,byte類型的數組,有多少個實例,占用多大內存。
- num:序號
- instances:實例數量
- bytes:占用空間大小
- class name:類名稱,[C is a char[],[S is a short[],[I is a int[],[B is a byte[],[[I is a int[][]
2. Jmap -heap 進程號
注意:Jmap命令在mac不太好用,具體參考前言部分。
windows或者linux上運行的命令是
Jmap -heap 進程號
mac上運行的命令是:(jdk8不能正常運行,jdk9以上可以)
jhsdb jmap --heap --pid 2139
執行結果
Attaching to process ID 2139, please wait...
Debugger attached successfully.
Server compiler detected.
JVM version is 11.0.2+9
using thread-local object allocation.
Garbage-First (G1) GC with 8 thread(s)
Heap Configuration:
MinHeapFreeRatio = 40
MaxHeapFreeRatio = 70
MaxHeapSize = 4294967296 (4096.0MB)
NewSize = 1363144 (1.2999954223632812MB)
MaxNewSize = 2576351232 (2457.0MB)
OldSize = 5452592 (5.1999969482421875MB)
NewRatio = 2
SurvivorRatio = 8
MetaspaceSize = 21807104 (20.796875MB)
CompressedClassSpaceSize = 1073741824 (1024.0MB)
MaxMetaspaceSize = 17592186044415 MB
G1HeapRegionSize = 1048576 (1.0MB)
Heap Usage:
G1 Heap:
regions = 4096
capacity = 4294967296 (4096.0MB)
used = 21654560 (20.651397705078125MB)
free = 4273312736 (4075.348602294922MB)
0.5041845142841339% used
G1 Young Generation:
Eden Space:
regions = 15
capacity = 52428800 (50.0MB)
used = 15728640 (15.0MB)
free = 36700160 (35.0MB)
30.0% used
Survivor Space:
regions = 5
capacity = 5242880 (5.0MB)
used = 5242880 (5.0MB)
free = 0 (0.0MB)
100.0% used
G1 Old Generation:
regions = 1
capacity = 210763776 (201.0MB)
used = 0 (0.0MB)
free = 210763776 (201.0MB)
0.0% used
通過上述結果分析,我們查詢的內容如下:
- 進程號:2139
- JDK版本號:11
- 使用的垃圾收集器:G1(jdk11默認的)
- G1垃圾收集器線程數:8
- 還可以知道堆空間大小,已用大小,元數據空間大小等等。
- 新生代,老年代region的大小。容量,已用,空閑等。
3. Jmap -dump 導出堆信息
這個命令是導出堆信息,當我們線上有內存溢出的情況的時候,可以使用Jmap -dump導出堆內存信息。然后再導入可視化工具用jvisualvm進行分析。
導出命令
jmap -dump:file=a.dump 進程號
我們還可以設置內存溢出自動導出dump文件(內存很大的時候,可能會導不出來)
1. -XX:+HeapDumpOnOutOfMemoryError
2. -XX:HeapDumpPath=./ (路徑)
下面有案例說明如何使用。
三、jvisualvm命令工具的使用
1. 基礎用法
上面我們有導出dump堆信息到文件中,可以使用jvisualvm工具導入dump堆信息,進行分析。
打開jvisualvm工具命令:
jvisualvm
打開工具界面如下:


點擊文件->裝入,可以導入文件,查看系統的運行情況了。
2.案例分析 - 堆空間溢出問題定位
下面通過工具來分析內存溢出的原因。
第一步:自定義一段可能會內存溢出的代碼,如下:
import com.aaa.jvm.User;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
@SpringBootApplicationpublic class JVMApplication {
public static void main(String[] args) {
List<Object> list = new ArrayList<>();
int i = 0;
int j = 0;
while (true) {
list.add(new User(i++, UUID.randomUUID().toString()));
new User(j--, UUID.randomUUID().toString());
}
}
}
第二步:配置參數
為了方便看到效果,所以我們會設置兩組參數。
第一組:設置堆空間大小,將堆空間設置的小一些,可以更快查看內存溢出的效果
‐Xms10M ‐Xmx10M ‐XX:+PrintGCDetails
設置的堆內存空間是10M,並且打印GC
第二組:設置內存溢出自動導出dump文件(內存很大的時候,可能會導不出來)
1. -XX:+HeapDumpOnOutOfMemoryError
2. -XX:HeapDumpPath=./ (路徑)
將這兩組參數添加到項目啟動配置中。
運行的過程中打印堆空間信息到文件中:
jmap -dump:file=a.dump,format=b 12152
后面我們可以使用工具導入堆文件進行分析(下面有說到)。
我們還可以設置內存溢出自動導出dump文件(內存很大的時候,可能會導不出來)
完整參數配置如下:
-Xms10M -Xmx10M -XX:+PrintGCDetails -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/Users/zhangsan/Downloads
-XX:+HeapDumpOnOutOfMemoryError 表示的是內存溢出的時候輸出文件
-XX:HeapDumpPath=/Users/zhangsan/Downloads 表示的是內存溢出的時候輸出文件的路徑
這里需要注意的是堆目錄要寫絕對路徑,不能寫相對路徑。
第三步:啟動項目,等待內存溢出
我們看到,運行沒有多長時間就內存溢出了。
查看導出到文件的目錄:
第四步:導入堆內存文件到jvisualvm工具
文件->裝入->選擇剛剛導出的文件
第五步:分析
我們主要看【類】這個模塊。
通過上圖我們可以明確看出,有三個類實例數特別多,分別是:byte[],java.lang.String,com.lxl.jvm.User。前兩個我們不容易看出是哪里的問題,但是第三個類com.lxl.jvm.User我們就看出來了,問題出在哪里。接下來就重點排查調用了這個類的地方,有沒有出現內存沒有釋放的情況。
這個程序很簡單,那么byte[]和java.lang.String到底是什么呢?我們的User對象結構中字段類型是String。
public class User {
private int id;
private String name;
}
既然有很多User,自然String也少不了。
那么byte[]是怎么回事呢?其實String類中有byte[]成員變量。所以也會有很多byte[]對象。
四、Jstack使用
Jstack可以用來查看堆棧使用情況,還可以查看進程死鎖情況。
4.1 [Jstack 進程號] 進程死鎖分析
1.執行命令:
Jstack 進程號
2. 死鎖案例分析:
package com.lxl.jvm;
public class DeadLockTest {
private static Object lock1 = new Object();
private static Object lock2 = new Object();
public static void main(String[] args) {
new Thread(() -> {
synchronized (lock1) {
try {
System.out.println("thread1 begin");
Thread.sleep(5000);
} catch (InterruptedException e) {
}
synchronized (lock2) {
System.out.println("thread1 end");
}
}
}).start();
new Thread(() -> {
synchronized (lock2) {
try {
System.out.println("thread2 begin");
Thread.sleep(5000);
} catch (InterruptedException e) {
}
synchronized (lock1) {
System.out.println("thread2 end");
}
}
}).start();
}
}
下面來分析一下這段代碼:
- 定義了兩個成員變量lock1,lock2
- main方法中定義了兩個線程。
- 線程1內部使用的是同步執行--上鎖,鎖是lock1。休眠5秒鍾之后,他要獲取第二把鎖,執行第二段代碼。
- 線程2和線程1類似,鎖相反。
- 問題:一開始,像個線程並行執行,線程一獲取lock1,線程2獲取lock2.然后線程1繼續執行,當休眠5s后獲取開啟第二個同步執行,鎖是lock2,但這時候很可能線程2還沒有執行完,所以還沒有釋放lock2,於是等待。線程2剛開始獲取了lock2鎖,休眠五秒后要去獲取lock1鎖,這時lock1鎖還沒釋放,於是等待。兩個線程就處於相互等待中,造成死鎖。
運行程序,通過Jstack命令來看看是否能檢測到當前有死鎖。
從這里面個異常可以看出,
- prio:當前線程的優先級
- cpu:cpu耗時
- os_prio:操作系統級別的優先級
- tid:線程id
- nid:系統內核的id
- state:當前的狀態,BLOCKED,表示阻塞。通常正常的狀態是Running我們看到Thread-0和Thread-1線程的狀態都是BLOCKED.
通過上面的信息,我們判斷出兩個線程的狀態都是BLOCKED,可能有點問題,然后繼續往下看。
我們從最后的一段可以看到這句話:Found one Java-level deadlock; 意思是找到一個死鎖。死鎖的線程號是Thread-0,Thread-1。
Thread-0:正在等待0x000000070e706ef8對象的鎖,這個對象現在被Thread-1持有。
Thread-1:正在等待0x000000070e705c98對象的鎖,這個對象現在正在被Thread-0持有。
最下面展示的是死鎖的堆棧信息。死鎖可能發生在DeadLockTest的第17行和第31行。通過這個提示,我們就可以找出死鎖在哪里了。
3. 使用jvisualvm查看死鎖
在程序代碼啟動的過程中,打開jvisualvm工具。
找到當前運行的類,查看線程,就會看到最頭上的一排紅字:檢測到死鎖。然后點擊“線程Dump”按鈕,查看相信的線程死鎖的信息。
這里可以找到線程私鎖的詳細信息,具體內容和上面使用Jstack命令查詢的結果一樣,這里實用工具更加方便。
4.2 Jstack找出占用cpu最高的線程堆棧信息。
我們使用案例來說明如何查詢cpu線程飆高的問題。
代碼:
package com.lxl.jvm;
public class Math {
public static int initData = 666;
public static User user = new User();
public User user1;
public int compute() {
int a = 1;
int b = 2;
int c = (a + b) * 10;
return c;
}
public static void main(String[] args) {
Math math = new Math();
while(true){
math.compute();
}
}
}
這是一段死循環代碼,會占滿cpu。下面就運行這段代碼,來看看如何排查cpu飆高的問題。
第一步:運行代碼,使用top命令查看cpu占用情況
top
我們看到cpu嚴重飆高,一般cpu達到80%就會報警了
第二步:使用top -p
命令查看飆高進程
使用【top -p 進程號】 查看進程id的cpu占用情況
第三步:按H,獲取每個線程的內存情況
需要注意的是,這里的H是大寫的H。
我們可以看出線程0和線程1線程號飆高。
第四步:找到內存和cpu占用最高的線程tid
通過上圖我們看到占用cpu資源最高的線程有兩個,線程號分別是4013442,4013457。我們一第一個為例說明,如何查詢這個線程是哪個線程,以及這個線程的什么地方出現問題,導致cpu飆高。
第五步:將線程tid轉化為十六進制
67187778是線程號為4013442的十六進制數。具體轉換可以網上查詢工具。
第六步:執行[ jstack 4013440|grep -A 10 67187778] 查詢飆高線程的堆棧信息
接下來查詢飆高線程的堆棧信息
jstack 4013440|grep -A 10 67190882
- 4013440:表示的是進程號
- 67187778: 表示的是線程號對應的十六進制數
通過這個方式可以查詢到這個線程對應的堆棧信息
從這里我們可以看出有問題的線程id是0x4cd0, 哪一句代碼有問題呢,Math類的22行。
第七步:查看對應的堆棧信息找出可能存在問題的代碼
上述方法定位問題已經很精確了,接下來就是區代碼里排查為什么會有問題了。
五、Jinfo
Jinfo命令主要用來查看jvm參數
1. 查看當前運行的jvm參數
jinfo -flags 線程id
執行結果:

從結果可以看出,我們使用的是CMS+Parallel垃圾收集器
2. 查看java系統參數
jinfo -sysprops 進程id
執行結果:
Java System Properties:
#Thu Nov 11 17:28:19 CST 2021
java.runtime.name=OpenJDK Runtime Environment
java.protocol.handler.pkgs=org.springframework.boot.loader
sun.boot.library.path=/data/java/jdk8/jre/lib/amd64
java.vm.version=25.40-b25
java.vm.vendor=Oracle Corporation
java.vendor.url=http\://java.oracle.com/
path.separator=\:
java.vm.name=OpenJDK 64-Bit Server VM
file.encoding.pkg=sun.io
user.country=CN
sun.java.launcher=SUN_STANDARD
sun.os.patch.level=unknown
java.vm.specification.name=Java Virtual Machine Specification
user.dir=/data/temp
java.runtime.version=1.8.0_41-b04
java.awt.graphicsenv=sun.awt.X11GraphicsEnvironment
java.endorsed.dirs=/data/java/jdk8/jre/lib/endorsed
os.arch=amd64
java.io.tmpdir=/tmp
line.separator=\n
java.vm.specification.vendor=Oracle Corporation
os.name=Linux
sun.jnu.encoding=UTF-8
java.library.path=/usr/java/packages/lib/amd64\:/usr/lib64\:/lib64\:/lib\:/usr/lib
java.specification.name=Java Platform API Specification
java.class.version=52.0
sun.management.compiler=HotSpot 64-Bit Tiered Compilers
os.version=5.10.23-5.al8.x86_64
user.home=/root
user.timezone=Asia/Shanghai
java.awt.printerjob=sun.print.PSPrinterJob
file.encoding=UTF-8
java.specification.version=1.8
user.name=root
java.class.path=chapter1-jvm-0.0.1-SNAPSHOT.jar
java.vm.specification.version=1.8
sun.java.command=chapter1-jvm-0.0.1-SNAPSHOT.jar
java.home=/data/java/jdk8/jre
sun.arch.data.model=64
user.language=zh
java.specification.vendor=Oracle Corporation
awt.toolkit=sun.awt.X11.XToolkit
java.vm.info=mixed mode
java.version=1.8.0_41
java.ext.dirs=/data/java/jdk8/jre/lib/ext\:/usr/java/packages/lib/ext
sun.boot.class.path=/data/java/jdk8/jre/lib/resources.jar\:/data/java/jdk8/jre/lib/rt.jar\:/data/java/jdk8/jre/lib/sunrsasign.jar\:/data/java/jdk8/jre/lib/jsse.jar\:/data/java/jdk8/jre/lib/jce.jar\:/data/java/jdk8/jre/lib/charsets.jar\:/data/java/jdk8/jre/lib/jfr.jar\:/data/java/jdk8/jre/classes
java.vendor=Oracle Corporation
file.separator=/
java.vendor.url.bug=http\://bugreport.sun.com/bugreport/
sun.io.unicode.encoding=UnicodeLittle
sun.cpu.endian=little
sun.cpu.isalist=
六、Jstat使用
Jstat命令是jvm調優非常重要,且非常有效的命令。我們來看看她的用法:
1. 垃圾回收統計 jstat -gc
jstat -gc 進程id
這個命令非常常用,在線上有問題的時候,可以通過這個命令來分析問題。
下面我們來測試一下,啟動一個項目,然后在終端駛入jstat -gc 進程id,得到如下結果:

上面的參數分別是什么意思呢?先識別參數的含義,然后根據參數進行分析
- S0C: 第一個Survivor區的容量
- S1C: 第二個Survivor區的容量
- S0U: 第一個Survivor區已經使用的容量
- S1U:第二個Survivor區已經使用的容量
- EC: 新生代Eden區的容量
- EU: 新生代Eden區已經使用的容量
- OC: 老年代容量
- OU:老年代已經使用的容量
- MC: 方法區大小(元空間)
- MU: 方法區已經使用的大小
- CCSC:壓縮指針占用空間
- CCSU:壓縮指針已經使用的空間
- YGC: YoungGC已經發生的次數
- YGCT: 這一次YoungGC耗時
- FGC: Full GC發生的次數
- FGCT: Full GC耗時
- GCT: 總的GC耗時,等於YGCT+FGCT
連續觀察GC變化的命令
jstat -gc 進程ID 間隔時間 打印次數
舉個例子:我要打印10次gc信息,每次間隔1秒
jstat -gc 進程ID 1000 10

這樣就連續打印了10次gc的變化,每次隔一秒。
這個命令是對整體垃圾回收情況的統計,下面將會差分處理。
2.堆內存統計
這個命令是打印堆內存的使用情況。
jstat -gccapacity 進程ID
- NGCMN:新生代最小容量
- NGCMX:新生代最大容量
- NGC:當前新生代容量
- S0C:第一個Survivor區大小
- S1C:第二個Survivor區大小
- EC:Eden區的大小
- OGCMN:老年代最小容量
- OGCMX:老年代最大容量
- OGC:當前老年代大小
- OC: 當前老年代大小
- MCMN: 最小元數據容量
- MCMX:最大元數據容量
- MC:當前元數據空間大小
- CCSMN:最小壓縮類空間大小
- CCSMX:最大壓縮類空間大小
- CCSC:當前壓縮類空間大小
- YGC:年輕代gc次數
- FGC:老年代GC次數
3.新生代垃圾回收統計
命令:
jstat -gcnew 進程ID [ 間隔時間 打印次數]
這個指的是當前某一次GC的內存情況
- S0C:第一個Survivor的大小
- S1C:第二個Survivor的大小
- S0U:第一個Survivor已使用大小
- S1U:第二個Survivor已使用大小
- TT: 對象在新生代存活的次數
- MTT: 對象在新生代存活的最大次數
- DSS: 期望的Survivor大小
- EC:Eden區的大小
- EU:Eden區的使用大小
- YGC:年輕代垃圾回收次數
- YGCT:年輕代垃圾回收消耗時間
4. 新生代內存統計
jstat -gcnewcapacity 進程ID

參數含義:
- NGCMN:新生代最小容量
- NGCMX:新生代最大容量
- NGC:當前新生代容量
- S0CMX:Survivor 1區最大大小
- S0C:當前Survivor 1區大小
- S1CMX:Survivor 2區最大大小
- S1C:當前Survivor 2區大小
- ECMX:最大Eden區大小
- EC:當前Eden區大小
- YGC:年輕代垃圾回收次數
- FGC:老年代回收次數
5. 老年代垃圾回收統計
命令:
jstat -gcold 進程ID

參數含義:
- MC:方法區大小
- MU:方法區已使用大小
- CCSC:壓縮指針類空間大小
- CCSU:壓縮類空間已使用大小
- OC:老年代大小
- OU:老年代已使用大小
- YGC:年輕代垃圾回收次數
- FGC:老年代垃圾回收次數
- FGCT:老年代垃圾回收消耗時間
- GCT:垃圾回收消耗總時間,新生代+老年代
6. 老年代內存統計
命令:
jstat -gcoldcapacity 進程ID

參數含義:
- OGCMN:老年代最小容量
- OGCMX:老年代最大容量
- OGC:當前老年代大小
- OC:老年代大小
- YGC:年輕代垃圾回收次數
- FGC:老年代垃圾回收次數
- FGCT:老年代垃圾回收消耗時間
- GCT:垃圾回收消耗總時間
7. 元數據空間統計
命令
jstat -gcmetacapacity 進程ID

- MCMN:最小元數據容量
- MCMX:最大元數據容量
- MC:當前元數據空間大小
- CCSMN:最小指針壓縮類空間大小
- CCSMX:最大指針壓縮類空間大小
- CCSC:當前指針壓縮類空間大小
- YGC:年輕代垃圾回收次數
- FGC:老年代垃圾回收次數
- FGCT:老年代垃圾回收消耗時間
- GCT:垃圾回收消耗總時間
8.整體運行情況
命令:
jstat -gcutil 進程ID

- S0:Survivor 1區當前使用比例
- S1:Survivor 2區當前使用比例
- E:Eden區使用比例
- O:老年代使用比例
- M:元數據區使用比例
- CCS:指針壓縮使用比例
- YGC:年輕代垃圾回收次數
- YGCT:年輕代垃圾回收消耗時間
- FGC:老年代垃圾回收次數
- FGCT:老年代垃圾回收消耗時間
- GCT:垃圾回收消耗總時間
七、案例分析
1. JVM運行情況預估
現在有一個線上的異常情況。具體的詳情如下;
- 機器配置:2核4G
- JVM內存大小:2G
- 系統運行時間:7天
- 期間發生的Full GC次數和耗時:500多次,200多秒
- 期間發生的Young GC的次數和耗時:1萬多次,500多秒。
如何能夠知道系統運行期間發生了多少次young gc和多少次full gc,並且他們的耗時是多少呢?使用如下命令:
jstat -gcutil 進程ID
然后就可以看到程序運行的結果了;
這幾個參數的具體含義是什么呢?
- S0:Survivor 1區當前使用比例
- S1:Survivor 2區當前使用比例
- E:Eden區使用比例
- O:老年代使用比例
- M:元數據區使用比例
- CCS:指針壓縮使用比例
- YGC:年輕代垃圾回收次數
- YGCT:年輕代垃圾回收消耗時間
- FGC:老年代垃圾回收次數
- FGCT:老年代垃圾回收消耗時間
- GCT:垃圾回收消耗總時間
2. JVM優化的思路
JVM優化的目標其實主要是Full GC。只要不發生Full GC,基本就不會出現OOM。所以如何優化Full GC就是我們的目標。往前推,老年代的對象是怎么來的呢?從新生代來的,那么我們就要避免朝生夕死的新生代對象進入到老年代。
1) 分析GC數據:
期間發生的Full GC次數和耗時:500多次,200多秒。那么平均7 * 24 * 3600秒/500 = 20分鍾發生一次Full GC, 每次full GC耗時:200秒/500=400毫秒;
期間發生的Young GC次數和耗時:1萬多次,500多秒,那么平均7 * 24 * 3600秒/10000 = 60秒也就是1分鍾發生一次young GC,每次young GC耗時:500/10000=50毫秒;
其實,從full GC和young GC的時間來看,還好,不太長。主要是發生的頻次,full gc發生的頻次太高了,20分鍾一次,通常我們的full gc怎么也要好幾個小時觸發一次,甚至1天才觸發一次。而young gc觸發頻次也過於頻繁,1分鍾觸發一次。
2)梳理內存模型
根據上述信息,我們可以畫一個內存模型出來。
先來看看原系統的JVM參數配置信息
‐Xms1536M ‐Xmx1536M ‐Xmn512M ‐Xss256K ‐XX:SurvivorRatio=6 ‐XX:MetaspaceSize=256M ‐XX:MaxMetaspaceSize=256M 2 ‐XX:+UseParNewGC ‐XX:+UseConcMarkSweepGC ‐XX:CMSInitiatingOccupancyFraction=75 ‐XX:+UseCMSInitiatingOccupancyOnly
- 堆空間:1.5G
- 新生代:512M
- 線程大小:256k
- ‐XX:SurvivorRatio新生代中Eden區域和Survivor區域:6。也就是Eden:S0: S1 = 6:1:1
- 元數據空間:256M
- 采用的垃圾收集器:CMS
- -XX:CMSInitiatingOccupancyFraction=75:CMS在對內存占用率達到75%的時候開始GC
- -XX:+UseCMSInitiatingOccupancyOnly: 只是用設定的回收閾值(上面指定的70%), 如果不指定, JVM僅在第一次使用設定值, 后續則自動調整.
根據參數我們梳理如下內存模型。堆內存空間都分配好了,那么上面說了每過60s觸發一次Young GC,那么就是說,平均每秒會產生384/60=6.4M的垃圾。而老年代,每過20分鍾就會觸發一次GC,而老年代可用的內存空是0.75G,也就是750多M。
現在的問題,為什么每過20分鍾,就會有750M的對象挪到老年代呢?解決了這個問題,我們就可以阻止對象挪到老年代
結合對象挪動到老年的規則分析這個模型可能會有哪些問題:
- 大對象
- 頑固的對象
- 動態年齡判斷機制
- 老年代空間擔保機制
-
首先分析:我們的系統里會不會有大對象。其實代碼使我們自己寫的,我們知道里面沒有特別大的對象。在年輕代放不下了,直接進入老年代,所以這種情況排除
-
第二個頑固的對象:我們這里通常都是朝生夕死的對象,頑固的對象就是系統的那些對象,如果是系統對象,也不應該是每過20分鍾都會產生700M老頑固對象啊。
-
第三個動態年齡判斷機制:一批對象的總大小大於這塊Survivor區域內存大小的50%(-XX:TargetSurvivorRatio可以指定),那么此時大於等於這批對象年齡最大值的對象,就可以直接進入老年代了。這個是有可能的,可能在垃圾收集的時候,Survivor區放不下了,那么就會直接放入到老年代。
既然這種情況有可能,那我們就來分析一下:
線程每秒中產生6M多的垃圾,如果並發量比較大的時候, 處理速度比較慢,可能1s處理不完,假設處理完數據要四五秒,就按5s來算,那一秒就可能產生30M的垃圾,這時候觸發Dden區垃圾回收的時候,這30M的垃圾要進入到S1區,而S1區很可能本身就有一部分對象了,再加上這30M就大於S1區的一半了,直接進入老年代。
這只是一種可能。
- 第四個觸發老年代空間擔保機制:其實觸發老年代空間擔保機制的概率很小,通常都是老年代空間很小的會后,會觸發。我們這里老年代比較大,所以基本不可能。
綜上所述,現在最有可能頻繁觸發GC的可能的原因是動態年齡判斷機制。我們之前在做優化的時候,遇到過。可以將Survivor區域放大一點,就可以了。
3. 案例模擬分析
我們用下面這個案例來模擬分析上述情況。分析找到問題。
第一步:啟動主程序
-
這時一個springboot的web程序,內容很簡單。創建項目的時候注意選擇web就可以了
-
然后里面定義了一個User對象。這個User對象比較特別,里面有個參數a分配0.1M的內存空間。
package com.jvm;
public class User {
private int id;
private String name;
byte[] a = new byte[1024*100];
......
}
- 接下來定義了一個接口類
package com.jvm;
import org.springframework.util.StopWatch;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.ArrayList;
@RestController
public class IndexController {
@RequestMapping("/user/process")
public String processUserData() throws InterruptedException {
ArrayList<User> users = queryUsers();
for (User user: users) {
//TODO 業務處理
System.out.println("user:" + user.toString());
}
return "end";
}
/**
* 模擬批量查詢用戶場景
* @return
*/
private ArrayList<User> queryUsers() {
ArrayList<User> users = new ArrayList<>();
for (int i = 0; i < 5000; i++) {
users.add(new User(i,"zhuge"));
}
return users;
}
}
接口類很簡單,每次調用接口,先創建5000個用戶,然后讓這5000個用戶區執行各自的業務邏輯。需要注意的是5000個用戶占用內存空間約500M。也就是說,每次調用這個接口,都會產生500M的對象。
- 然后定義一個測試類,測試類
@RunWith(SpringRunner.class)
@SpringBootTest(classes={Application.class})// 指定啟動類
public class ApplicationTests {
@Bean
public RestTemplate restTemplate() {
return new RestTemplate();
}
@Autowired
private RestTemplate restTemplate;
@Test
public void test() throws Exception {
for (int i = 0; i < 10000; i++) {
String result = restTemplate.getForObject("http://localhost:8080/user/process", String.class);
Thread.sleep(1000);
}
}
}
測試類很簡單,就是手動調用上面的接口。循環調用10000次。如果啟動10000次的話,
- 啟動主程序
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
@Bean
public RestTemplate restTemplate() {
return new RestTemplate();
}
}
第二步:配置jvm參數
我們要模擬線上的情況,所以參數也設置和線上一樣的情況。
-Xms1536M -Xmx1536M -Xmn512M -Xss256K -XX:SurvivorRatio=6 -XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=256M -XX:+UseParNewGC -XX:+UseConcMarkSweepGC -XX:CMSInitiatingOccupancyFraction=75 -XX:+UseCMSInitiatingOccupancyOnly
第三步:使用jstat gc命令觀察垃圾收集情況
程序啟動起來以后,可以使用jps命令查看進程,輸入命令查看gc觸發情況
jstat -gc 進程ID [間隔時間 觸發次數]
jstat -gc 8620 1000 10000
表示觀察8620這個進程,每隔1s打印一次gc情況,連續打印10000次
我們看到,程序啟動以后都是出發了4次young gc, 1次full gc。程序啟動觸發gc都是ok的。后面基本沒有什么垃圾產生了。
第四步:啟動test代碼,調用process接口
這一步沒啥說的,直接啟動程序就可以了
第五步:觀察終端gc的變化
為了保險期間,我們查看一下運行的參數是不是我們配置的參數
jinfo -flags 8620
程序啟動以后,我們發現頻發的觸發了gc,新生代gc觸發很頻繁,老年代也很頻繁。老年代gc觸發那么頻繁,那就是有問題了。根據上面的分析,最有可能的情況是動態年齡分配機制。可能產生的對象在survivor區放不下,直接進入老年代 。處理這個問題的方法是,擴大年輕代空間。
第六步:優化1:增加新生代內存空間,以及老年代觸發gc的比例
- 新生代空間擴大到1G
- 老年代空間縮小為0.5G
- 還要重新設置一個參數就是CMSInitiatingOccupancyFraction,原來是是75,這個參數是為了防止觸發老年代空間擔保機制。但這樣會有25%的空間基本是空閑的。當很少觸發full gc的時候,這個值可以縮小一些。
-Xms1536M -Xmx1536M -Xmn1024M -Xss256K -XX:SurvivorRatio=6 -XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=256M -XX:+UseParNewGC -XX:+UseConcMarkSweepGC -XX:CMSInitiatingOccupancyFraction=92 -XX:+UseCMSInitiatingOccupancyOnly
內存空間變化后:
再次啟動項目,看運行結果:

基本沒有gc觸發。說明我們的優化是有效的。然后在啟動測試程序:
這一次我們發現,觸發gc的次數相對來說少了,gc的速度相對於上一次小了一些,但是又有新的問題發生:老年代gc比年輕代還有頻繁。這是怎么回事呢?有什么情況會讓老年代觸發gc的頻率大於年輕代呢?
這可能會有幾種情況
- 元數據空間不夠,導致full gc,不斷擴大元數據空間
元數據空間比較好看,我們直接看輸出的參數
紅框圈出的就是元數據空間和元數據已用空間。我們來看實際使用情況
通過觀察,我們發現元數據大小基本上是不變的。所以,元數據空間不太會增加導致觸發full gc。
- 顯式的調用System.gc(),造成多余的full gc觸發。
這個情況一般在線上都會進制成都代碼觸發full gc。量通過XX:+DisableExplicitGC參數禁用,如果加上了這個JVM啟動參數,那么代碼中調用System.gc()沒有任何效果。
- 觸發了老年代空間擔保機制
結合之前學習的理論,我們知道,老年代空間擔保機制。有可能在觸發一次minor GC的時候觸發兩次Full GC。
來復習一下:
-
年輕代每次minor gc之前JVM都會計算下老年代剩余可用空間。如果這個可用空間小於年輕代里現有的所有對象大小之和(包括垃圾對象),就會看一個“-XX:-HandlePromotionFailure”(jdk1.8默認就設置了)的參數是否設置了,如果有這個參數,就會看看老年代的可用內存大小,是否大於之前每一次minor gc后進入老年代的對象的平均大小。
-
如果上一步結果是小於或者之前說的參數沒有設置,那么就會直接觸發一次Full GC,然后再觸發Minor GC, 如果回收完還是沒有足夠空間存放新的對象就會發生"OOM"
-
如果minor gc之后剩余存活的需要挪動到老年代的對象大小還是大於老年代可用空間,那么也會觸發full GC,Full GC完之后如果還是沒有空間放minor gc之后的存活對象,則也會發生“OOM”。
在梳理一下這塊邏輯,為什么叫擔保機制。在觸發Minor GC的時候,進行了一個條件判斷,預估老年代空間是否能夠放的下新生代的對象,如果能夠放得下,那么就直接觸發Minor GC, 如果放不下,那么先觸發Full GC。在觸發Full GC的時候設置了擔保參數會增加異步判斷,而不是直接觸發Full GC。判斷老年代剩余可用空間 是否小於 歷史每次Minor GC后進入老年代對象的平均值。這樣的判斷可以減少Full GC的次數。因為新生代在觸發Full GC以后是會回收一部分內存的,剩余部分再放入老年代,可能就能放下了。
通過回顧,我們看到老年代空間擔保機制中,當觸發一次Minor GC的時候,有可能會觸發兩次Full GC。這樣就導致Full GC的次數大於Minor GC。
由此可見,我們這次優化是失敗的, 還引入了新的問題。這里還有可能是大對象導致的,不一定是非常大的一個對象,也可能是多個對象在一個時刻產生的大對象。
第七步:優化2:查找大對象
我們在查找是否有大對象,或者某一個時間是否有大對象占用較大的內存空間,可以使用命令或者終端查看
jmap -histo 進程ID
前面都是系統對象,往下找我們看到一個自定義對象User,這個實例有10000個,占用內存空間240M
或者使用jvisualvm
點擊內存,就可以實時查看到系統進程的內存占用情況。點擊內存其實就是對 【jmap -histo 進程ID】命令的包裝
內存占用最多的是byte[]數組,占用了內存的95%。是什么情況讓byte數組占用這么多的內存呢?這個通常都是用戶自定義對象造成的。往下看,我們看到了User對象,user對象占用了12w字節數據,有5000個實例。
假如這個代碼不是我們寫的,是別人寫的,我們不熟悉。這時候可以通過以下方法定位問題
-
new User()對象不多。可以通過反查定位找出問題。
/** * 模擬批量查詢用戶場景 * @return */ private ArrayList<User> queryUsers() { ArrayList<User> users = new ArrayList<>(); for (int i = 0; i < 5000; i++) { users.add(new User(i,"zhuge")); } return users; }我們發現,在這里竟然創建了5000個對象。不過5000個對象應該也不多。通常一個對象也就幾k,我們進到User里看看
public class User { private int id; private String name; byte[] a = new byte[1024*100]; }意外發現,User里定義了一個byte數組,一個byte數組占用100k的空間。那問題就是這里了。
-
如果系統中new User()對象很多,應該怎么辦呢?
系統中有這么多對象,說明什么問題呢?new User()在反復執行,這樣的話,cpu占用率應該不低。如果這邊不好找,我們可以看看cpu占用情況。
這里的cpu其實就是對命令是對jstack命令的封裝【jstack 4013440|grep -A 10 67187778】
通過分析我們看出第一個take()方法占用cpu最高,達到98%,但是這個是什么東西,我們不太熟悉,看看第二個,第二個是queryUsers(),這個是我們自己的方法,可以看看這個方法的具體內容:
/**
* 模擬批量查詢用戶場景
* @return
*/
private ArrayList<User> queryUsers() {
ArrayList<User> users = new ArrayList<>();
for (int i = 0; i < 5000; i++) {
users.add(new User(i,"zhuge"));
}
return users;
}
public class User {
private int id;
private String name;
byte[] a = new byte[1024*100];
}
剛好就定位到這段代碼,我們發現他一下查詢了5000個對象,並且每個對象里定義了一個大對象。這樣我們就定位到了問題。
所以在查詢數據的時候,要注意是否有大對象,如果有大對象的話,需要預估一下內存消耗。剩下就是代碼優化的問題了。
我們這里降低查詢用戶數從一次5000到一次500,然后重啟代碼試一下:
private ArrayList<User> queryUsers() {
ArrayList<User> users = new ArrayList<>();
for (int i = 0; i < 500; i++) {
users.add(new User(i,"zhuge"));
}
return users;
}
來看看運行效果
觸發young gc的頻率降低了,而且基本不會觸發full gc了。說明這次優化是有效的。
