JVM調優實踐

JVM調優是一個非常依賴實踐的工作,本文就是在某些場景下對JVM調優方法的整理。

CPU佔用高

CPU佔用高是我們在線上會遇到的場景。出現這種情況,我們首先需要定位消耗CPU資源的代碼。

我們以下面的代碼爲例,介紹怎麼定位問題:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class InfiniteLoop {
public static void main(String[] args) {
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
long i = 0;
while (true) {
i++;
}
}
});
thread.start();
}
}

這段代碼就是一個簡單的死循環。

執行程序後,執行top命令:

top-cpu

通過top命令,我們發現PID爲10995的Java進程佔用CPU高達99.9%

下一步如何定位到具體線程?

執行以下命令顯示線程列表:

1
ps -mp pid -o THREAD,tid,time

ps-thread

找到了佔用CPU最高的線程11005,佔用CPU時間爲02:23

然後通過以下命令將找到的線程ID轉換爲16進制格式:

printf "%x\n" tid

printf_%x

最後通過以下命令打印線程的堆棧信息:

jstack pid | grep tid -A 30

jstack_pid

通過線程堆棧信息,我們可以定位到是InfiniteLoop中的run方法。

Full GC頻繁

在線上環境,頻繁的執行Full GC會導致程序經常發生停頓,從而導致接口的響應時間變長,這時就需要對JVM的狀態進行監控,確定Full GC發生的原因。

首先我們在啓動程序的時候可以加上GC日誌相關的參數,主要有以下幾個:

  • -XX:+PrintGC:輸出GC日誌
  • -XX:+PrintGCDetails:輸出GC的詳細日誌
  • -XX:+PrintGCTimeStamps:輸出GC的時間戳(以基準時間的形式)
  • -XX:+PrintGCDateStamps:輸出GC的時間戳(以日期的形式,如2018-08-29T19:22:48.741-0800
  • -XX:+PrintHeapAtGC:在進行GC的前後打印出堆的信息
  • -Xloggc:gc.log:日誌文件的輸出路徑

現在通過程序來模擬Full GC頻繁發生的情形:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class Object1 {
int size = (10 * 1024 * 1024) / 4;
int[] nums = new int[size];
public Object1() {
for (int i = 0; i < size; i++) {
nums[i] = i;
}
}

}

class Object2 {
int size = (1 * 1024 * 1024) / 4;
int[] nums = new int[size];
public Object2() {
for (int i = 0; i < size; i++) {
nums[i] = i;
}
}
}

public class HeapOOM {
public static void main(String[] args) throws InterruptedException {
Object1 object1 = new Object1();
while (true) {
Object2 object2 = new Object2();
Thread.sleep(100);
}
}
}

我們知道Java堆被劃分爲新生代老年代。默認比例爲1:2(可以通過-XX:NewRatio設定)。

新生代又分爲EdenFrom SurvivorTo Survivor。這樣劃分的目的是爲了使JVM能夠更好地管理堆內存中的對象,包括內存的分派以及回收。默認比例爲Eden:From:To = 8:1:1(可以通過參數-XX:SurvivorRatio來設定,-XX:SurvivorRatio=8表示Eden與一個Survivor空間比例爲8:1

一般新建的對象會分配到Eden區。這些對象經過第一次Minor GC後,如果仍然存活,將會被移到Survivor區。在Survivor每熬過一輪Minor GC年齡就增加1

當年齡達到一定程度是(年齡閾值,默認爲15,可以通過-XX:MaxTenuringThreshold來設置),就會被移動到老年代。

fromto之間會經常互換角色,from變成toto變成from。每次GC時,把Eden存活的對象和From Survivor中存活且沒超過年齡閾值的對象複製到To Survivor中,From Survivor清空,變成To Survivor

GC分爲兩種:

  • Minor GC是發生在新生代中的垃圾收集動作,所採用的是複製算法,所採用的是複製算法,因爲Minor GC比較頻繁,因此一般回收速度較快。
  • Full GC是發生在老年代的垃圾收集動作,所採用的是標記-清除算法,速度比Minor GC慢10倍以上

大對象直接進入老年代。比如很長的字符串以及數組。通過設置-XX:PretenureSizeThreshold,令大於這個值的對象直接在老年代分配。這樣做是爲了避免在Eden和兩個Survivor之間發生大量的內存複製。

什麼時候發生Minor GC?什麼時候發生Full GC

  • 當新生代Eden區沒有足夠的空間進行分配時,虛擬機將發起一次Minor GC
  • 老年代空間不足時發起一次Full GC

我們以下面的命令來執行程序:

1
java -Xms30m -Xmx30m -Xmn2m -XX:SurvivorRatio=8 -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=dump/dump.hprof dump.HeapOOM

以下是對上面JVM參數的說明:

  • -Xms:堆初始大小
  • -Xmx:堆最大值
  • -Xmn:新生代大小(老年代大小=堆大小-新生代大小)
  • -XX:+HeapDumpOnOutOfMemoryError:發生內存溢出時生成heapdump文件
  • -XX:HeapDumpPath:指定heapdump文件

我們之所以將新生代的大小設爲2m,是因爲這樣新建的Object2對象就無法在新生代上分配,從而直接進入老年代,當老年代空間佔滿後就會觸發Full GC。

程序執行之後,我們從GC日誌中看到頻繁發生Full GC,於是我們開始定位Full GC發生的原因。

gc_log

以下面的兩段GC日誌,來看一下GC日誌的含義:

1
2
3
1.840: [GC (Allocation Failure) [PSYoungGen: 573K->432K(1536K)] 28221K->28088K(30208K), 0.0014619 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
1.842: [GC (Allocation Failure) [PSYoungGen: 432K->400K(1536K)] 28088K->28056K(30208K), 0.0005985 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
1.843: [Full GC (Allocation Failure) [PSYoungGen: 400K->0K(1536K)] [ParOldGen: 27656K->10558K(28672K)] 28056K->10558K(30208K), [Metaspace: 2657K->2657K(1056768K)], 0.0038527 secs] [Times: user=0.01 sys=0.00, real=0.01 secs]

最前面的數字1.840:1.842:1.843:代表了GC發生的時間,這個數字的含義是從Java虛擬機啓動以來經過的秒數

GC日誌開頭的[GC[Full GC說明了這次垃圾收集的停頓類型,而不是用來區分新生代GC還是老年代GC的。如果有”Full GC”,說明這次GC是發生了Stop-The-World的。

接下來的[PSYoungGen[ParOldGen[Metaspace表示GC發生的區域。這裏顯示的區域名稱與使用的GC收集器是密切相關的,例如上面的PSYoungGen表示採用Parallel Scavenge收集器,ParOldGen表示採用Parallel Old收集器。如果使用Serial收集器顯示[DefNew,如果使用ParNew收集器顯示[ParNew

後面方括號內部的400K->0K(1536K)含義是”GC前該內存區域已經使用容量->GC後該內存區域已使用容量(該內存區域總容量)”。而在方括號之外的28056K->10558K(30208K)表示”GC前Java堆已使用容量->GC後Java堆已使用容量(Java堆總容量)”。

再往後的0.0038527 secs表示該內存區域GC所佔用的時間,單位是秒。有的收集器會給出更具體的時間數據,如[Times: user=0.01 sys=0.00, real=0.01 secs],這裏面的usersysreal與Linux的time命令所輸出的時間含義一致,分別代表用戶態消耗的CPU時間、內核態消耗的CPU時間和操作從開始到結束所經過的牆鍾時間(Wall Clock Time)。CPU時間與牆鍾時間的區別是,牆鍾時間包括各種非運算的等待耗時,例如等待磁盤IO、等待線程阻塞,而CPU時間不包括這些耗時,但當系統有多CPU或者多核的話,多線程操作會疊加這些CPU時間,所以讀者看到user或sys時間超過real時間是完全正常的。

下面開始定位問題。

首先執行jps命令定位程序的進程號。

然後執行jstat命令監視Java堆的狀況.

1
jstat -gc 11172 1000

其中11172是進程號,1000表示每隔1000毫秒打印一次日誌

jstat_g

  • S0CS1C(Survivor0、Survivor1):兩個Survivor區的大小
  • S0US1U(Survivor0、Survivor1):兩個Survivor區的使用大小
  • EC(Eden):Eden區的大小
  • EU(Eden):Eden區的使用大小
  • OC(Old):老年代大小
  • OU(Old):老年代使用大小
  • MC:元數據區大小
  • MU:元數據區使用大小
  • CCSC:壓縮類空間大小
  • CCSU:壓縮類空間使用大小
  • YGC(Young GC):年輕代垃圾回收次數
  • YGCT(Young GC Time):年輕代垃圾回收總耗時(秒)
  • FGC(Full GC):老年代垃圾回收次數
  • FGCT(Full GC Time):老年代垃圾回收總耗時(秒)
  • GCT(GC Time):所有GC總耗時(秒)

可以看到,程序在不斷髮生Full GC。

執行jmap把當前的堆dump下來:

1
jmap -dump:live,format=b,file=dump.hprof 11172

其中11172是進程ID

然後將dump.hprof文件使用VisualVM來打開

dump_hprof

我們可以看到,int[]對象佔用的空間最大,其中int[]#1的GC Root指向了dump.Object1對象,無法被回收,這樣一個大對象佔用了老年代空間,因此導致了頻繁發生Full GC。

解決這個問題有兩種思路:

  • 一般情況下原因都是代碼問題,導致某個大對象沒有及時釋放,在多次GC之後進入老年代空間。我們要做的首先是定位到佔用大量空間的對象,優化其中的代碼,及時釋放大對象,騰空老年代空間
  • 增加新生代的大小,讓對象都在新生代分配與釋放,從而不進入老年代空間。這樣就會大大減少Full GC的發生
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章