JVM性能監測及調優(4)

如何優化JVM內存分配?

JVM 調優是一個系統而又複雜的過程,但我們知道,在大多數情況下,我們基本不用去調整 JVM 內存分配,因爲一些初始化的參數已經可以保證應用服務正常穩定地工作了。

但所有的調優都是有目標性的,JVM 內存分配調優也一樣。沒有性能問題的時候,我們自然不會隨意改變 JVM 內存分配的參數。那有了問題呢?有了什麼樣的性能問題我們需要對其進行調優呢?又該如何調優呢?

JVM 內存分配性能問題

談到 JVM 內存表現出的性能問題時,你可能會想到一些線上的 JVM 內存溢出事故。但這方面的事故往往是應用程序創建對象導致的內存回收對象難,一般屬於代碼編程問題。

但其實很多時候,在應用服務的特定場景下,JVM 內存分配不合理帶來的性能表現並不會像內存溢出問題這麼突出。可以說如果你沒有深入到各項性能指標中去,是很難發現其中隱藏的性能損耗。

JVM 內存分配不合理最直接的表現就是頻繁的 GC,這會導致上下文切換等性能問題,從而降低系統的吞吐量、增加系統的響應時間。因此,如果你在線上環境或性能測試時,發現頻繁的 GC,且是正常的對象創建和回收,這個時候就需要考慮調整 JVM 內存分配了,從而減少 GC 所帶來的性能開銷。

對象在堆中的生存週期
瞭解了性能問題,那需要做的勢必就是調優了。但先別急,在瞭解 JVM 內存分配的調優過程之前,我們先來看看一個新創建的對象在堆內存中的生存週期,爲後面的學習打下基礎。

我們知道,在 JVM 內存模型的堆中,堆被劃分爲新生代和老年代,新生代又被進一步劃分爲 Eden 區和 Survivor 區,最後 Survivor 由 From Survivor 和 To Survivor 組成。

當我們新建一個對象時,對象會被優先分配到新生代的 Eden 區中,這時虛擬機會給對象定義一個對象年齡計數器(通過參數 -XX:MaxTenuringThreshold 設置)。

同時,也有另外一種情況,當 Eden 空間不足時,虛擬機將會執行一個新生代的垃圾回收(Minor GC)。這時 JVM 會把存活的對象轉移到 Survivor 中,並給對象的年齡 +1。對象在 Survivor 中同樣也會經歷 MinorGC,每經過一次 MinorGC,對象的年齡將會 +1。

當然了,內存空間也是有設置閾值的,可以通過參數 -XX:PetenureSizeThreshold 設置直接被分配到老年代的最大對象,這時如果分配的對象超過了設置的閥值,對象就會直接被分配到老年代,這樣做的好處就是可以減少新生代的垃圾回收。

查看 JVM 堆內存分配
我們知道了一個對象從創建至回收到堆中的過程,接下來我們再來了解下 JVM 堆內存是如何分配的。在默認不配置 JVM 堆內存大小的情況下,JVM 根據默認值來配置當前內存大小。我們可以通過以下命令來查看堆內存配置的默認值:

java -XX:+PrintFlagsFinal -version | grep HeapSize
jmap -heap 17284

通過命令,我們可以獲得在這臺機器上啓動的 JVM 默認最大堆內存爲 1953MB,初始化大小爲 124MB。

在 JDK1.7 中,默認情況下年輕代和老年代的比例是 1:2,我們可以通過–XX:NewRatio 重置該配置項。年輕代中的 Eden 和 To Survivor、From Survivor 的比例是 8:1:1,我們可以通過 -XX:SurvivorRatio 重置該配置項。

在 JDK1.7 中如果開啓了 -XX:+UseAdaptiveSizePolicy 配置項,JVM 將會動態調整 Java 堆中各個區域的大小以及進入老年代的年齡,–XX:NewRatio 和 -XX:SurvivorRatio 將會失效,而 JDK1.8 是默認開啓 -XX:+UseAdaptiveSizePolicy 配置項的。

還有,在 JDK1.8 中,不要隨便關閉 UseAdaptiveSizePolicy 配置項,除非你已經對初始化堆內存 / 最大堆內存、年輕代 / 老年代以及 Eden 區 /Survivor 區有非常明確的規劃了。否則 JVM 將會分配最小堆內存,年輕代和老年代按照默認比例 1:2 進行分配,年輕代中的 Eden 和 Survivor 則按照默認比例 8:2 進行分配。這個內存分配未必是應用服務的最佳配置,因此可能會給應用服務帶來嚴重的性能問題。

JVM 內存分配的調優過程

我們先使用 JVM 的默認配置,觀察應用服務的運行情況,下面我將結合一個實際案例來講述。現模擬一個搶購接口,假設需要滿足一個 5W 的併發請求,且每次請求會產生 20KB 對象,我們可以通過千級併發創建一個 1MB 對象的接口來模擬萬級併發請求產生大量對象的場景,具體代碼如下:

@RequestMapping(value = “/test1”)
public String test1(HttpServletRequest request) {
List<Byte[]> temp = new ArrayList<Byte[]>();

Byte[] b = new Byte[1024*1024];
temp.add(b);

return "success";

}

AB 壓測
分別對應用服務進行壓力測試,以下是請求接口的吞吐量和響應時間在不同併發用戶數下的變化情況:

可以看到,當併發數量到了一定值時,吞吐量就上不去了,響應時間也迅速增加。那麼,在 JVM 內部運行又是怎樣的呢?

分析 GC 日誌

此時我們可以通過 GC 日誌查看具體的回收日誌。我們可以通過設置 VM 配置參數,將運行期間的 GC 日誌 dump 下來,具體配置參數如下:

-XX:+PrintGCTimeStamps -XX:+PrintGCDetails -Xloggc:/log/heapTest.log

以下是各個配置項的說明:
-XX:PrintGCTimeStamps:打印 GC 具體時間;
-XX:PrintGCDetails :打印出 GC 詳細日誌;
-Xloggc: path:GC 日誌生成路徑。

收集到 GC 日誌後,我們就可以使用GCViewer 工具打開它,進而查看到具體的 GC 日誌如下:

主頁面顯示 FullGC 發生了 13 次,右下角顯示年輕代和老年代的內存使用率幾乎達到了 100%。而 FullGC 會導致 stop-the-world 的發生,從而嚴重影響到應用服務的性能。此時,我們需要調整堆內存的大小來減少 FullGC 的發生。

參考指標

我們可以將某些指標的預期值作爲參考指標,上面的 GC 頻率就是其中之一,那麼還有哪些指標可以爲我們提供一些具體的調優方向呢?

GC 頻率:高頻的 FullGC 會給系統帶來非常大的性能消耗,雖然 MinorGC 相對 FullGC 來說好了許多,但過多的 MinorGC 仍會給系統帶來壓力。

內存:這裏的內存指的是堆內存大小,堆內存又分爲年輕代內存和老年代內存。首先我們要分析堆內存大小是否合適,其實是分析年輕代和老年代的比例是否合適。如果內存不足或分配不均勻,會增加 FullGC,嚴重的將導致 CPU 持續爆滿,影響系統性能。

吞吐量:頻繁的 FullGC 將會引起線程的上下文切換,增加系統的性能開銷,從而影響每次處理的線程請求,最終導致系統的吞吐量下降。

延時:JVM 的 GC 持續時間也會影響到每次請求的響應時間。

具體調優方法

調整堆內存空間減少 FullGC:通過日誌分析,堆內存基本被用完了,而且存在大量 FullGC,這意味着我們的堆內存嚴重不足,這個時候我們需要調大堆內存空間。

java -jar -Xms4g -Xmx4g heapTest-0.0.1-SNAPSHOT.jar

以下是各個配置項的說明:
-Xms:堆初始大小;
-Xmx:堆最大值。

調大堆內存之後,我們再來測試下性能情況,發現吞吐量提高了 40% 左右,響應時間也降低了將近 50%。

再查看 GC 日誌,發現 FullGC 頻率降低了,老年代的使用率只有 16% 了。

調整年輕代減少 MinorGC:通過調整堆內存大小,我們已經提升了整體的吞吐量,降低了響應時間。那還有優化空間嗎?我們還可以將年輕代設置得大一些,從而減少一些 MinorGC

java -jar -Xms4g -Xmx4g -Xmn3g heapTest-0.0.1-SNAPSHOT.jar

再進行 AB 壓測,發現吞吐量上去了。

再查看 GC 日誌,發現 MinorGC 也明顯降低了,GC 花費的總時間也減少了。

設置 Eden、Survivor 區比例:在 JVM 中,如果開啓 AdaptiveSizePolicy,則每次 GC 後都會重新計算 Eden、From Survivor 和 To Survivor 區的大小,計算依據是 GC 過程中統計的 GC 時間、吞吐量、內存佔用量。這個時候 SurvivorRatio 默認設置的比例會失效。

在 JDK1.8 中,默認是開啓 AdaptiveSizePolicy 的,我們可以通過 -XX:-UseAdaptiveSizePolicy 關閉該項配置,或顯示運行 -XX:SurvivorRatio=8 將 Eden、Survivor 的比例設置爲 8:2。大部分新對象都是在 Eden 區創建的,我們可以固定 Eden 區的佔用比例,來調優 JVM 的內存分配性能。

再進行 AB 性能測試,我們可以看到吞吐量提升了,響應時間降低了。

總結
JVM 內存調優通常和 GC 調優是互補的,基於以上調優,我們可以繼續對年輕代和堆內存的垃圾回收算法進行調優。這裏可以結合上一講的內容,一起完成 JVM 調優。

雖然分享了一些 JVM 內存分配調優的常用方法,但我還是建議你在進行性能壓測後如果沒有發現突出的性能瓶頸,就繼續使用 JVM 默認參數,起碼在大部分的場景下,默認配置已經可以滿足我們的需求了。但滿足不了也不要慌張,結合今天所學的內容去實踐一下,相信你會有新的收穫。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章