《沙盤模擬系列》JVM如何調優

不學無數的程式設計師發表於2019-12-29

紙上得來終覺淺 絕知此事要躬行

我所在的公司基本上是沒有機會進行JVM引數調優的,但是如果有些東西自己不親身經歷一下,看再多的理論知識也只能算是紙上談兵,真正碰到問題的時候還是不知道該怎麼分析。所以就自己製造一些問題然後看其現象,利用所學的知識事前推測,看現象是不是和自己推測的一樣。這樣不僅對自己所學的知識又是一次鞏固,而且也能鍛鍊自己解決問題的能力(雖然問題是自己製造的)。

其實在寫這篇文章之前已經看過好好幾遍關於JVM調優那一塊的內容,無論是書還是部落格,但是大都看完了感覺自己懂了,但是真正自己模擬操作的時候又覺得什麼都不會,但是經過自己模擬一遍以後發現能夠將之前的知識都關聯起來,形成了一個面,感覺理解有深了一點。這裡強調一下希望大家看完以後,能夠自己在機器上模擬一遍,採用不同的引數然後自己猜想結果並驗證

工具準備

工欲善其事,必先利其器。在分析JVM之前我們需要先將工具準備一下,一個是視覺化的垃圾回收工具,另一個是壓測的工具。

GcViews安裝

  1. GcViews程式碼從Git上下載下來github地址
  2. 在專案的根目錄中執行命令mvn clean install
  3. 然後發現在根目錄中生成了target資料夾,在裡面可以找到gcviewer-1.37-SNAPSHOT.jar檔案

JMeter安裝

Apache JMeter是一個開源的壓力測試具,JMeter是基於Java開發的,JMeter不僅僅用於Web壓力測試,還用開源用於基於訪問式軟體做壓力測試,可對靜態檔案、資料庫、FTP、SSH等做壓力測試

  • 下載JMeter,下載地址
  • 將其解壓下來,我的地址是/Users/hupengfei/apache-jmeter-5.1.1
  • 開啟終端進入到其bin目錄下面
  • 執行命令sh jmeter

然後裡面如何配置引數的話我這裡就不細說了,大家可以看這篇文章JMeter Http 壓力測試【圖解】

理論介紹

對於JVM調優來說,主要是對JVM垃圾收集的優化,一般來說是因為有問題了才需要優化,所以對於JVM的GC來說如果你觀察到你的應用服務程式的CPU使用率比較高,並且在GC日誌中發現GC次數比較頻繁、GC停頓時間長,這就表明你需要對GC進行優化了。

在對GC調優的過程中,我們不進行必要知道一些GC的原理,更重要要熟練使用各種可監控和分析的工具,具備GC調優的實戰能力。而目前來說使用率最高的兩款垃圾收集器有兩個一個是CMS一個是G1。從Java9開始,採用G1作為預設的垃圾收集器,而G1的目標也是逐步要取代CMS。所以下面我簡單介紹一下這兩款收集器的區別。

可以使用命令java -XX:+PrintCommandLineFlags -version在命令列檢視輸出預設的一些引數。此處可檢視各個版本預設的垃圾收集器

  • Java 7: Parallel GC
  • Java 8: Parallel GC
  • Java 9: G1 GC
  • Java 10: G1 GC

CMS收集器

CMS收集器將Java堆分為年輕代年老代(在Java8中就已經去掉了永久代,轉為了元空間,而元空間是直接儲存在記憶體中的,並不在JVM中)。這主要是因為有研究表明,超過百分之90的物件在第一次GC時就會被回收掉,但是少數物件會存活較長的時間。

CMS中還將年輕代分為兩部分,一部分是倖存者空間(Survivor)伊甸園空間(Eden)。新的物件始終在Eden空間上建立,一旦一個物件在一次垃圾收集後還倖存的話,就會被移動到倖存者空間。當一個物件在多次垃圾收集後還存活,它會被移動到年老代。這樣做的目的是在年輕代和年老代採用不同的垃圾收集演算法,已達到較高的收集效率。比如由於年輕代的物件存活時間較短,一次垃圾回收遺留的物件較少,所以採用複製-整理演算法。但是在老年代中,物件存活時間較長,有可能一次垃圾回收回收的物件較少,遺留的物件較多,所以採用標記-整理演算法

G1收集器

與CMS相比,G1有兩大特點

  • G1可以併發完成大部分的GC工作,這期間不會“Stop-The-World”
  • G1使用非連續的空間,這使得G1能夠有效的處理非常大的堆,G1可以同時收集年輕代和老年代。G1並沒有將Java堆分成三個空間(Eden、Survior和Old),而是將堆分成了許多非常小的區域。這些區域的大小是固定的(預設情況下每個區域大小為2MB)。每個區域都分配一個空間。

圖中的U表示未分配的區域,G1將堆拆分成小的區域,一個最大的好處就是能夠做區域性區域的垃圾回收,而不是每次要回收整個區域比如年輕代和年老代,這樣回收的停頓時間會比較短。收集過程大概如下

  • 將所有存活的物件從收集的區域複製到未分配的區域。比如收集的區域是Eden空間,把Eden中的存活物件複製到未分配的區域,這個未分配的區域就成了Survior空間,理想情況下,如果一個區域全部是垃圾(意味一個存活的物件都沒有),則可以直接將該區域宣告為“未分配”。
  • 為了優化收集時間,G1總是優先選擇垃圾最多的區域,從而最大限度減少後續分配和釋放堆空間所需的工作量。這也是G1收集器名字的由來——Garbage-First

實戰演練

我使用的版本是Java8,使用的Java垃圾回收器是CMS的

下面我通過實際的例子來實戰一下Java程式中由於青年代設定過小,導致頻繁的GC,我們將通過GC日誌分析工具來觀察GC活動並定位問題。

首先我們建立一個SpringBoot的程式,作為我們的調優物件。程式碼如下:

 1@RestController
2@Slf4j
3public class GcTestController {
4
5    private List<Greeting> objListCache = new ArrayList<>();
6
7    @RequestMapping("/greeting")
8    public Greeting greeting() {
9        Greeting greeting = new Greeting();
10        if (objListCache.size() >= 100000) {
11            log.info("clean the List!!!!!!!!!!");
12            objListCache.clear();
13        } else {
14            objListCache.add(greeting);
15        }
16        return greeting;
17    }
18}
19
20@Data
21class Greeting {
22    private String message1;
23    private String message2;
24    private String message3;
25    private String message4;
26    private String message5;
27    private String message6;
28    private String message7;
29    private String message8;
30    private String message9;
31    private String message10;
32    private String message11;
33    private String message12;
34    private String message13;
35    private String message14;
36    private String message15;
37    private String message16;
38    private String message17;
39    private String message18;
40    private String message19;
41    private String message20;
42}
複製程式碼

上面程式碼建立一個物件池,當物件池中的物件達到100000的時候才會清空一次,用來模擬老年代的物件。這裡大家可以利用我上一篇文章幾百萬資料放入記憶體不會把系統撐爆嗎?大概計算一下10W個物件放在記憶體中大概佔用多少記憶體。這裡我就直接說了10萬個Greeting物件大概佔用10M的空間。

所以下面我在Idea中設定啟動引數設定,引數如下

1-Xmx52m -Xmn9m -Xss256k -XX:+PrintGC -XX:+UseConcMarkSweepGC -Xloggc:/Users/hupengfei/Downloads/gclog/gc.log
複製程式碼

我給程式設定的初始堆大小是52MB,設定的年輕代的大小為9MB,年輕代中預設Eden區和Survior區比例是4:1,所以大概年輕代中Eden區大小為7.2MB,目的是為了讓大家看到在Eden區沒有回收的物件會進入到老年代,在Eden區滿了的話那麼就會發生Young GC。

然後我們使用JMeter壓測工具向程式傳送測試請求,注意這裡我設定的訪問時間是10分鐘,然後一個執行緒不間斷進行訪問。

十分鐘過後我們可以使用GCViewer工具開啟GC日誌,我們看到如下的這張圖

  • 藍色的線條:表示已經使用堆的大小,我們看到它的週期是上下震盪的,這是因為我們的物件池要擴充套件到10萬才會被清空。
  • 底部綠色線條:表示發生GC活動,我們可以看到堆的使用率上升以後,會觸發頻繁的GC
  • 中間黑色的線條:表示Full GC,我們可以看到伴隨Full GC藍線下降了,這說明Full GC回收了老年代的物件

基於上面的圖所展現的,我們可以得到一個結論,就是設定的年輕代不夠,為什麼會得出這樣的結論呢?

  • GC活動頻繁:可以看到綠色的線條比較密集
  • Java堆的記憶體在發生Full GC後能夠被回收,說明不是記憶體洩露

通過GCView左邊的顯示,我們可以看到總GC發生了1622次其中Full GC發生一次。

接下來我們在總堆大小不變的情況下,我們僅僅調整一下年輕代的大小,將其調整為16MB,然後我們再來看一下圖

我們可以看到雖然還有一次的Full GC 但是年輕代的GC並沒有那麼頻繁了。並且累計GC暫停的時間只有1.48秒

如果我們還想繼續優化呢?就是繼續擴大堆記憶體的總大小,接下來我們將堆設定為200MB,年輕代設定為80MB,我們再來看一下效果。

可以看到同樣時間內,已經沒有了Full GC,並且年輕代的GC發生更少了

調優策略

針對於CMS收集器來說,我們要設定合理的年輕代和年老代的大小,你可能會問有沒有一個固定的公式呢?其實我這裡並沒有,調優的過程是一個迭代的過程,可以採用JVM的預設值,然後進行壓測分析GC日誌。觀察在不同情況下GC的回收情況。

如果我們看到頻繁發生Minor GC,而頻繁GC效率又不高,說明我們的物件並沒有那麼快被回收,這時候我們可以適當調大年輕代大小,然後觀察。

如果我們看到年老代的記憶體使用率處在高位,導致頻繁的發生Full GC。這種一般分為兩種情況

  • 如果每次Full GC年老代記憶體佔用率沒有下來,有可能是記憶體洩漏,需要排查程式碼
  • 如果Full GC後記憶體佔用率下來了,說明不是記憶體洩漏,可以考慮調大老年代

程式碼地址

已經將測試程式碼放到了GitHub上github.com/modouxiansh…上,並且將我多次試驗的GC日誌也給放進去了,大家不想自己試驗的可以將GC日誌給下載下來自己看一下圖

筆者文筆功力尚淺,如有不妥,請慷慨指出,必定感激不盡

總結

紙上的知識,或者說是書上或者網上的知識,終究還是作者自己的經驗總結。必然有作者的思路。但是未必就與實際相結合,更重要的是一句話所要傳達的準確資訊不是每個人看過那種文字描述就能得到的。如果恰恰有這方面的經歷就會產生共鳴。

我認為,人讀書就是為了學習,而學習也恰恰是為了自身的成長。所以學習的中心在於人而不是書本。學習的本質就是在於要將自己所學的知識與自身相結合。如果不與自身相結合,自己不能對書本的知識產生共鳴,就很難深刻理解書中的道理,自然也很難記住這種道理。

書中的知識,大多是作者自身的理解和感悟,所以很難將這種讓作者共鳴的場景重現在讀者的腦海中,讓作者也產生共鳴,因此“紙上得來終覺淺”。只有解構書中的知識並與自身聯絡,“絕知此事要躬行”,那麼我們在學習知識的同時也是在理解我們自身,理解我們所在的世界,獲得心靈的共鳴,獲得知識的鞏固。

所以我也會在我文章中再三的強調,如果大家想要對這方面知識更加深刻的話,那麼一定要自己在機器上自己跑一遍,自己觀察一下,自己修改幾個引數,驗證一下情況。有可能我碰到的坑你碰不到,你碰到的坑我沒碰到。那麼你碰到這個坑自己解決了就是對於自己能力的提升。

參考

相關文章