一、前要
JVM調優是一個系統而又複雜的過程,由於Java虛擬機自動管理內存,在大多數情況下,我們基本上不用去調整JVM內存分配,因爲一些初始化參數已經可以保證應用服務正常穩定地工作。但是當有性能問題的時候該怎麼去調優,該去關注什麼呢?在去做這項工作前就必須去了解JVM是怎麼去管理內存的,GC是怎麼完成的。
二、標記算法
垃圾回收是對已經分配出去的但又不再使用的內存進行回收,以便能夠再次分配。JVM主要是對堆空間那些死亡對象所佔據的空間進行回收。那麼如何判別一個對象存亡呢?
1.引用計數法算法
該算法的做法是爲每個對象添加一個引用計數器,用來統計指向改對象的引用個數。一旦某個對象的引用計數器爲0,則說明該對象已經死亡,便可以被回收了。
具體實現:如果有一個引用被賦值爲某一個對象,那麼將該對象的引用計算器+1。如果一個指向某一對象的引用,被賦值爲其他值,那麼將該對象的引用計算器-1。也就是說,所有的引用更新操作,相應的就會發生對象引用計算器的增減。可以看出有兩個問題,第一是需要額外的空間來存儲計數器以及繁瑣的更新操作,第二就是無法處理循環引用對象。如下圖:a/b對象相互引用,同時沒有其他引用指向a或者b,那麼a、b實際上已經死亡了,但是由於計數器不爲0,導致不可回收,從而造成內存泄露。
2.可達性分析算法
其實Java 虛擬機的主流垃圾回收期採用的是可達性分析算法來判斷對象是否存活的。該算法的基本思路是將一系列GC Roots作爲初始的存活對象集合,然後從該集合出發,探索所有能夠被該集合引用的對象,並將其加入到該集合中,該過程稱爲標記(mark)。最終未被探索到的對象便是可以回收的。
其實該過程有兩次標記過程:如果對象在進行可達性分析後發現沒有與GC Roots相連接的引用鏈(Reference Chain),那它將會被第一次標記並進行一次篩選,篩選條件是此對象是否有必要執行finalize()方法。當對象沒有覆蓋finalize()方法,或者finalize()方法以及被虛擬機調用過,都視爲“沒有必要執行”。如果對象被判斷爲有必要執行finalize()方法,那麼就會放置在一個叫F-Queued的隊列中,同時虛擬機會自動建立低優先級的Finalizer線程去執行它,進行第二次標記。如果對象覆蓋finalize()方法且成功逃逸,那麼就會移除即將回收集合,否則被回收(具體可以翻閱《深入理解Java 虛擬機》,這裏不做過多解釋)
三、垃圾回收算法
1.清除(sweep)
把死亡對象所佔據的內存標記爲空閒內存,並記錄在一個空閒列表(free list)之中,當需要新建對象時,內存管理模塊便會從該空閒列表中尋找空閒內存,劃分給新建的對象
但是該算法有兩個不足之處:一是分配效率較低,只要是內存空間不連續,java 虛擬機需要逐個訪問空閒列表中的項來查找是否能夠放入新對象的空閒內存;另外一個是空間問題,清除之後會產生大量不連續的內存碎片,那麼就是導致無法找到足夠的連續內存分配空間,從而無法分配。
2、複製(copy)
該算法主要是爲了解決分配效率問題。把內存區域分爲兩等分,分別用兩個指針from和to來維護,並且只是用from指針指向內存區域來分配內存。當發生垃圾回收時,便把存活的對象複製到to指針指向的內存區域中,並且交換from指針和to指針的內容。該算法解決了內存碎片問題,但是直接導致了內存使用率降低。(該算法被採用來回收新生代:內存分爲Eden和 from survivor、to survivor,由於存在分配擔保(Handle Promotion)問題,所以老年代一般不能選用這種算法)
3、整理(compact)
即把存活的對象聚集到內存區域的起始位置,從而留下一段連續的內存空間。這種做法能夠解決內存碎片化的問題,但代價是性能開銷
四、Java 虛擬機的堆劃分
Java 虛擬機將堆劃分爲新生代和老年代,其中新生代被劃分爲Eden區以及兩塊大小相同的Survivor區。JDK 1.8默認 -XX:+UseAdaptiveSurvivorSizePolicy配置項,JVM會根據分配最小堆內存,年輕代和老年代按照默認比例1:2進行分配,年輕代中的Eden和Survivor則按照8:2進行分配
五、內存調優實戰
通過上述介紹,大致瞭解了內存分配情況以及GC相關算法,下面通過一個例子演示Java 堆內存設置過小導致頻繁GC,同時通過GCViewer工具分析GC日誌定位問題
該案例採用jdk 爲1.8,垃圾回收器採用ParNew+CMS收集器
1.新建一個Spring Boot應用,作爲調優對象,代碼如下
@RestController
@SpringBootApplication
public class App {
private Queue<QueueObejct> queueCache = new ConcurrentLinkedDeque<>();
/**
* 模擬秒殺接口每次請產生1M對象使用1千併發進行模擬年輕代, Minor GC
* 同時將對象放入隊列中模擬老年代,每2w次清空,FUll GC
* @return
*/
@RequestMapping("/")
public String index() {
//List<Byte[]> temp = new ArrayList<>();
//Byte[] b = new Byte[1024*1024];
//temp.add(b);
QueueObejct queueObejct = new QueueObejct("hello world!");
if(queueCache.size()>200000){
queueCache.clear();
}else {
queueCache.add(queueObejct);
}
return "success";
}
public static void main(String[] args) {
SpringApplication.run(App.class, args);
}
class QueueObejct{
private String msg;
public QueueObejct(String msg) {
this.msg = msg;
}
public String getMsg() {
return msg;
}
}
}
2、執行命令啓動應用
java -Xms32m -Xmx32m -Xss256k -XX:+PrintGCTimeStamps -XX:+PrintGCDetails -Xloggc:/Users/xx/logs/gc.log -XX:CMSInitiatingOccupancyFraction=80 -XX:+UseCMSInitiatingOccupancyOnly -XX:+UseConcMarkSweepGC -XX:+UseParNewGC -jar /Users/xx/IdeaProjects/xx/first-spring-boot-application/target/spring-boot-application-1.0.0-SNAPSHOT.jar
參數說明:
-Xms1024m -Xmx1024m :堆大小
-XX:PrintGCTimeStamps:打印 GC 具體時間;
-XX:PrintGCDetails :打印出 GC 詳細日誌;
-Xloggc: path:GC 日誌生成路徑。
-XX:+UseConcMarkSweepGC :老年代垃圾回收器爲CMS
-XX:+UseParNewGC :年輕代垃圾回收器爲ParNew
-XX:CMSInitiatingOccupancyFraction=80 : CMS垃圾收集器,當老年代達到80%時,觸發CMS垃圾回收
-XX:+UseCMSInitiatingOccupancyOnly 只是用設定的回收閾值(上面指定的70%),如果不指定,JVM僅在第一次使用設定值,後續則自動調整
查看JVM 啓動信息:
jinfo -flags 11184
查看堆分配信息:
jmap -heap 11184
GC日誌如下:
3.使用Jmeter進行併發壓測模擬
4、使用GCViewer進行日誌分析(只選擇3條線 堆使用線,GC,Full GC線)
1、20併發持續3min:
- 圖中藍線表示已使用堆內存大小,週期性上下符合我們對象池達2w清理
- 綠色先表示年輕代GC活動情況,從圖中可以看出當堆使用率上去了,會觸發頻繁的GC活動
- 圖中黑線表示Full GC,從圖中可以看出伴隨着Full GC ,藍色會下降,說明回收了老年代對象
2、分析結論:
- GC活動頻繁:年輕代GC和年老代GC都比較密集,而且gc 觸發類型基本上都爲Allocation Failure,說明內存空間不足
- 從藍色線的動態變化來看,對象在GC後是能夠被回收的,說明不是內存泄露
3、GC pause
GC 停頓時間爲34.46s
4、調大堆的大小
java -Xms1024m -Xmx1024m -Xss256k -XX:+PrintGCTimeStamps -XX:+PrintGCDetails -Xloggc:/Users/xx/logs/gc.log -XX:CMSInitiatingOccupancyFraction=80 -XX:+UseCMSInitiatingOccupancyOnly -XX:+UseConcMarkSweepGC -XX:+UseParNewGC -jar /Users/xxxx/first-spring-boot-application/target/spring-boot-application-1.0.0-SNAPSHOT.jar
新的GC 日誌分析圖如下:
同樣20併發持續3min,年輕代的GC 頻率很低,沒有發生Full GC,並且累計GC暫停時間只有0.73s
5、總結
如果我們看年輕代的內存使用率處在高位,導致頻繁的 Minor GC,而頻繁 GC 的效率又不高,說明對象沒那麼快能被回收,這時年輕代可以適當調大一點。
如果我們看年老代的內存使用率處在高位,導致頻繁的 Full GC,這樣分兩種情況:如果每次 Full GC 後年老代的內存佔用率沒有下來,可以懷疑是內存泄漏;如果 Full GC 後年老代的內存佔用率下來了,說明不是內存泄漏,我們要考慮調大年老代。
上述優化只是一個簡單指導案例實際生產環境優化會比這更復雜,調整的參數會更多,結合的指標也會很多,例如調整年輕代Eden和survivor比例,Young 和Tenured 比例以及垃圾回收器的設置、同時還需分析GC後回收情況是否有內存泄露,結合jmap -histo pid 查看內存實例情況等等