紙上得來終覺淺 絕知此事要躬行
我所在的公司基本上是沒有機會進行JVM參數(shù)調優(yōu)的,但是如果有些東西自己不親身經(jīng)歷一下,看再多的理論知識也只能算是紙上談兵,真正碰到問題的時候還是不知道該怎么分析。所以就自己制造一些問題然后看其現(xiàn)象,利用所學的知識事前推測,看現(xiàn)象是不是和自己推測的一樣。這樣不僅對自己所學的知識又是一次鞏固,而且也能鍛煉自己解決問題的能力(雖然問題是自己制造的)。
其實在寫這篇文章之前已經(jīng)看過好好幾遍關于JVM調優(yōu)那一塊的內容,無論是書還是博客,但是大都看完了感覺自己懂了,但是真正自己模擬操作的時候又覺得什么都不會,但是經(jīng)過自己模擬一遍以后發(fā)現(xiàn)能夠將之前的知識都關聯(lián)起來,形成了一個面,感覺理解有深了一點。這里強調一下希望大家看完以后,能夠自己在機器上模擬一遍,采用不同的參數(shù)然后自己猜想結果并驗證
工具準備
工欲善其事,必先利其器。在分析JVM之前我們需要先將工具準備一下,一個是可視化的垃圾回收工具,另一個是壓測的工具。
GcViews安裝
- 將
GcViews代碼從Git上下載下來github地址 - 在項目的根目錄中執(zhí)行命令
mvn clean install - 然后發(fā)現(xiàn)在根目錄中生成了
target文件夾,在里面可以找到gcviewer-1.37-SNAPSHOT.jar文件
JMeter安裝
Apache JMeter是一個開源的壓力測試具,JMeter是基于Java開發(fā)的,JMeter不僅僅用于Web壓力測試,還用開源用于基于訪問式軟件做壓力測試,可對靜態(tài)文件、數(shù)據(jù)庫、FTP、SSH等做壓力測試
- 下載JMeter,下載地址
- 將其解壓下來,我的地址是
/Users/hupengfei/apache-jmeter-5.1.1 - 打開終端進入到其
bin目錄下面 - 執(zhí)行命令
sh jmeter
然后里面如何配置參數(shù)的話我這里就不細說了,大家可以看這篇文章JMeter Http 壓力測試【圖解】
理論介紹
對于JVM調優(yōu)來說,主要是對JVM垃圾收集的優(yōu)化,一般來說是因為有問題了才需要優(yōu)化,所以對于JVM的GC來說如果你觀察到你的應用服務進程的CPU使用率比較高,并且在GC日志中發(fā)現(xiàn)GC次數(shù)比較頻繁、GC停頓時間長,這就表明你需要對GC進行優(yōu)化了。
在對GC調優(yōu)的過程中,我們不進行必要知道一些GC的原理,更重要要熟練使用各種可監(jiān)控和分析的工具,具備GC調優(yōu)的實戰(zhàn)能力。而目前來說使用率最高的兩款垃圾收集器有兩個一個是CMS一個是G1。從Java9開始,采用G1作為默認的垃圾收集器,而G1的目標也是逐步要取代CMS。所以下面我簡單介紹一下這兩款收集器的區(qū)別。
可以使用命令
java -XX:+PrintCommandLineFlags -version在命令行查看輸出默認的一些參數(shù)。此處可查看各個版本默認的垃圾收集器
- Java 7: Parallel GC
- Java 8: Parallel GC
- Java 9: G1 GC
- Java 10: G1 GC
CMS收集器
CMS收集器將Java堆分為年輕代和年老代(在Java8中就已經(jīng)去掉了永久代,轉為了元空間,而元空間是直接存儲在內存中的,并不在JVM中)。這主要是因為有研究表明,超過百分之90的對象在第一次GC時就會被回收掉,但是少數(shù)對象會存活較長的時間。
CMS中還將年輕代分為兩部分,一部分是幸存者空間(Survivor)和伊甸園空間(Eden)。新的對象始終在Eden空間上創(chuàng)建,一旦一個對象在一次垃圾收集后還幸存的話,就會被移動到幸存者空間。當一個對象在多次垃圾收集后還存活,它會被移動到年老代。這樣做的目的是在年輕代和年老代采用不同的垃圾收集算法,已達到較高的收集效率。比如由于年輕代的對象存活時間較短,一次垃圾回收遺留的對象較少,所以采用復制-整理算法。但是在老年代中,對象存活時間較長,有可能一次垃圾回收回收的對象較少,遺留的對象較多,所以采用標記-整理算法。

G1收集器
與CMS相比,G1有兩大特點
- G1可以并發(fā)完成大部分的GC工作,這期間不會“Stop-The-World”
- G1使用非連續(xù)的空間,這使得G1能夠有效的處理非常大的堆,G1可以同時收集年輕代和老年代。G1并沒有將Java堆分成三個空間(Eden、Survior和Old),而是將堆分成了許多非常小的區(qū)域。這些區(qū)域的大小是固定的(默認情況下每個區(qū)域大小為2MB)。每個區(qū)域都分配一個空間。

圖中的U表示未分配的區(qū)域,G1將堆拆分成小的區(qū)域,一個最大的好處就是能夠做局部區(qū)域的垃圾回收,而不是每次要回收整個區(qū)域比如年輕代和年老代,這樣回收的停頓時間會比較短。收集過程大概如下
- 將所有存活的對象從收集的區(qū)域復制到未分配的區(qū)域。比如收集的區(qū)域是Eden空間,把Eden中的存活對象復制到未分配的區(qū)域,這個未分配的區(qū)域就成了Survior空間,理想情況下,如果一個區(qū)域全部是垃圾(意味一個存活的對象都沒有),則可以直接將該區(qū)域聲明為“未分配”。
- 為了優(yōu)化收集時間,G1總是優(yōu)先選擇垃圾最多的區(qū)域,從而最大限度減少后續(xù)分配和釋放堆空間所需的工作量。這也是G1收集器名字的由來——Garbage-First
實戰(zhàn)演練
我使用的版本是Java8,使用的Java垃圾回收器是CMS的
下面我通過實際的例子來實戰(zhàn)一下Java程序中由于青年代設置過小,導致頻繁的GC,我們將通過GC日志分析工具來觀察GC活動并定位問題。
首先我們建立一個SpringBoot的程序,作為我們的調優(yōu)對象。代碼如下:
@RestController
@Slf4j
public class GcTestController {
private List<Greeting> objListCache = new ArrayList<>();
@RequestMapping("/greeting")
public Greeting greeting() {
Greeting greeting = new Greeting();
if (objListCache.size() >= 100000) {
log.info("clean the List!!!!!!!!!!");
objListCache.clear();
} else {
objListCache.add(greeting);
}
return greeting;
}
}
@Data
class Greeting {
private String message1;
private String message2;
private String message3;
private String message4;
private String message5;
private String message6;
private String message7;
private String message8;
private String message9;
private String message10;
private String message11;
private String message12;
private String message13;
private String message14;
private String message15;
private String message16;
private String message17;
private String message18;
private String message19;
private String message20;
}
上面代碼創(chuàng)建一個對象池,當對象池中的對象達到100000的時候才會清空一次,用來模擬老年代的對象。這里大家可以利用我上一篇文章幾百萬數(shù)據(jù)放入內存不會把系統(tǒng)撐爆嗎?大概計算一下10W個對象放在內存中大概占用多少內存。這里我就直接說了10萬個Greeting對象大概占用10M的空間。
所以下面我在Idea中設置啟動參數(shù)設置,參數(shù)如下
-Xmx52m -Xmn9m -Xss256k -XX:+PrintGC -XX:+UseConcMarkSweepGC -Xloggc:/Users/hupengfei/Downloads/gclog/gc.log
我給程序設置的初始堆大小是52MB,設置的年輕代的大小為9MB,年輕代中默認Eden區(qū)和Survior區(qū)比例是4:1,所以大概年輕代中Eden區(qū)大小為7.2MB,目的是為了讓大家看到在Eden區(qū)沒有回收的對象會進入到老年代,在Eden區(qū)滿了的話那么就會發(fā)生Young GC。
然后我們使用JMeter壓測工具向程序發(fā)送測試請求,注意這里我設置的訪問時間是10分鐘,然后一個線程不間斷進行訪問。
十分鐘過后我們可以使用GCViewer工具打開GC日志,我們看到如下的這張圖

- 藍色的線條:表示已經(jīng)使用堆的大小,我們看到它的周期是上下震蕩的,這是因為我們的對象池要擴展到10萬才會被清空。
- 底部綠色線條:表示發(fā)生GC活動,我們可以看到堆的使用率上升以后,會觸發(fā)頻繁的GC
- 中間黑色的線條:表示Full GC,我們可以看到伴隨Full GC藍線下降了,這說明Full GC回收了老年代的對象
基于上面的圖所展現(xiàn)的,我們可以得到一個結論,就是設置的年輕代不夠,為什么會得出這樣的結論呢?
- GC活動頻繁:可以看到綠色的線條比較密集
- Java堆的內存在發(fā)生Full GC后能夠被回收,說明不是內存泄露
通過GCView左邊的顯示,我們可以看到總GC發(fā)生了1622次其中Full GC發(fā)生一次。

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

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

如果我們還想繼續(xù)優(yōu)化呢?就是繼續(xù)擴大堆內存的總大小,接下來我們將堆設置為200MB,年輕代設置為80MB,我們再來看一下效果。

可以看到同樣時間內,已經(jīng)沒有了Full GC,并且年輕代的GC發(fā)生更少了

調優(yōu)策略
針對于CMS收集器來說,我們要設置合理的年輕代和年老代的大小,你可能會問有沒有一個固定的公式呢?其實我這里并沒有,調優(yōu)的過程是一個迭代的過程,可以采用JVM的默認值,然后進行壓測分析GC日志。觀察在不同情況下GC的回收情況。
如果我們看到頻繁發(fā)生Minor GC,而頻繁GC效率又不高,說明我們的對象并沒有那么快被回收,這時候我們可以適當調大年輕代大小,然后觀察。
如果我們看到年老代的內存使用率處在高位,導致頻繁的發(fā)生Full GC。這種一般分為兩種情況
- 如果每次Full GC年老代內存占用率沒有下來,有可能是內存泄漏,需要排查代碼
- 如果Full GC后內存占用率下來了,說明不是內存泄漏,可以考慮調大老年代
代碼地址
已經(jīng)將測試代碼放到了GitHub上https://github.com/modouxiansheng/Doraemon
上,并且將我多次試驗的GC日志也給放進去了,大家不想自己試驗的可以將GC日志給下載下來自己看一下圖

筆者文筆功力尚淺,如有不妥,請慷慨指出,必定感激不盡
總結
紙上的知識,或者說是書上或者網(wǎng)上的知識,終究還是作者自己的經(jīng)驗總結。必然有作者的思路。但是未必就與實際相結合,更重要的是一句話所要傳達的準確信息不是每個人看過那種文字描述就能得到的。如果恰恰有這方面的經(jīng)歷就會產(chǎn)生共鳴。
我認為,人讀書就是為了學習,而學習也恰恰是為了自身的成長。所以學習的中心在于人而不是書本。學習的本質就是在于要將自己所學的知識與自身相結合。如果不與自身相結合,自己不能對書本的知識產(chǎn)生共鳴,就很難深刻理解書中的道理,自然也很難記住這種道理。
書中的知識,大多是作者自身的理解和感悟,所以很難將這種讓作者共鳴的場景重現(xiàn)在讀者的腦海中,讓作者也產(chǎn)生共鳴,因此“紙上得來終覺淺”。只有解構書中的知識并與自身聯(lián)系,“絕知此事要躬行”,那么我們在學習知識的同時也是在理解我們自身,理解我們所在的世界,獲得心靈的共鳴,獲得知識的鞏固。
所以我也會在我文章中再三的強調,如果大家想要對這方面知識更加深刻的話,那么一定要自己在機器上自己跑一遍,自己觀察一下,自己修改幾個參數(shù),驗證一下情況。有可能我碰到的坑你碰不到,你碰到的坑我沒碰到。那么你碰到這個坑自己解決了就是對于自己能力的提升。