高吞吐低延遲 Java 應用的 GC 優化

說明

本篇原文做者是 LinkedIn 的 Swapnil Ghike,這篇文章講述了 LinkedIn 的 Feed 產品的 GC 優化過程,雖然文章寫做於 April 8, 2014,但其中的不少內容和知識點很是有學習和參考意義。所以,翻譯後獻給各位同窗。原文 Garbage Collection Optimization for High-Throughput and Low-Latency Java Applications,連接見參考 [1]。html

背景

高性能應用構成了現代網絡的支柱。LinkedIn 內部有許多高吞吐量服務來知足每秒成千上萬的用戶請求。爲了得到最佳的用戶體驗,以低延遲響應這些請求是很是重要的。java

例如,咱們的用戶常用的產品是 Feed —— 它是一個不斷更新的專業活動和內容的列表。Feed 在 LinkedIn 的系統中隨處可見,包括公司頁面、學校頁面以及最重要的主頁資訊信息。基礎 Feed 數據平臺爲咱們的經濟圖譜(會員、公司、羣組等)中各類實體的更新創建索引,它必須高吞吐低延遲地實現相關的更新。以下圖,LinkedIn Feeds 信息展現:爲了將這些高吞吐量、低延遲類型的 Java 應用程序用於生產,開發人員必須確保在應用程序開發週期的每一個階段都保持一致的性能。肯定最佳垃圾收集(Garbage Collection, GC)配置對於實現這些指標相當重要。linux

這篇博文將經過一系列步驟來明確需求並優化 GC,它的目標讀者是對使用系統方法進行 GC 優化來實現應用的高吞吐低延遲目標感興趣的開發人員。在 LinkedIn 構建下一代 Feed 數據平臺的過程當中,咱們總結了該方法。這些方法包括但不限於如下幾點:併發標記清除(Concurrent Mark Sweep,CMS(參考[2]) 和 G1(參考 [3]) 垃圾回收器的 CPU 和內存開銷、避免長期存活對象致使的持續 GC、優化 GC 線程任務分配提高性能,以及可預測 GC 停頓時間所需的 OS 配置。web

優化 GC 的正確時機?

GC 的行爲可能會因代碼優化以及工做負載的變化而變化。所以,在一個已實施性能優化的接近完成的代碼庫上進行 GC 優化很是重要。並且在端到端的基本原型上進行初步分析也頗有必要,該原型系統使用存根代碼並模擬了可表明生產環境的工做負載。這樣能夠獲取該架構延遲和吞吐量的真實邊界,進而決定是否進行縱向或橫向擴展。算法

在下一代 Feed 數據平臺的原型開發階段,咱們幾乎實現了全部端到端的功能,而且模擬了當前生產基礎設施提供的查詢工做負載。這使咱們在工做負載特性上有足夠的多樣性,能夠在足夠長的時間內測量應用程序性能和 GC 特徵。緩存

優化 GC 的步驟

下面是一些針對高吞吐量、低延遲需求優化 GC 的整體步驟。此外,還包括在 Feed 數據平臺原型實施的具體細節。儘管咱們還對 G1 垃圾收集器進行了試驗,但咱們發現 ParNew/CMS 具備最佳的 GC 性能。性能優化

1. 理解 GC 基礎知識

因爲 GC 優化須要調整大量的參數,所以理解 GC 工做機制很是重要。Oracle 的 Hotspot JVM 內存管理白皮書(參考 [4] )是開始學習 Hotspot JVM GC 算法很是好的資料。而瞭解 G1 垃圾回收器的理論知識,能夠參閱(參考 [3])。網絡

2. 仔細考量 GC 需求

爲了下降對應用程序性能的開銷,能夠優化 GC 的一些特徵。像吞吐量和延遲同樣,這些 GC 特徵應該在長時間運行的測試中觀察到,以確保應用程序可以在經歷多個 GC 週期中處理流量的變化。架構

  • Stop-the-world 回收器回收垃圾時會暫停應用線程。停頓的時長和頻率不該該對應用遵照 SLA 產生不利的影響。併發

  • 併發 GC 算法與應用線程競爭 CPU 週期。這個開銷不該該影響應用吞吐量。

  • 非壓縮 GC 算法會引發堆碎片化,進而致使的 Full GC 長時間 Stop-the-world,所以,堆碎片應保持在最小值。

  • 垃圾回收工做須要佔用內存。某些 GC 算法具備比其餘算法更高的內存佔用。若是應用程序須要較大的堆空間,要確保 GC 的內存開銷不能太大。

  • 要清楚地瞭解 GC 日誌和經常使用的 JVM 參數,以便輕鬆地調整 GC 行爲。由於 GC 運行隨着代碼複雜性增長或工做負載特性的改變而發生變化

咱們使用 Linux 操做系統、Hotspot Java7u5一、32GB 堆內存、6GB 新生代(Young Gen)和 -XX:CMSInitiatingOccupancyFraction 值爲 70(Old GC 觸發時其空間佔用率)開始實驗。設置較大的堆內存是用來維持長期存活對象的對象緩存。一旦這個緩存生效,晉升到 Old Gen 的對象速度會顯著降低。

使用最初的 JVM 配置,每 3 秒發生一次 80ms 的 Young GC 停頓,超過 99.9% 的應用請求延遲 100ms(999線)。這樣的 GC 效果可能適合於 SLA 對延遲要求不太嚴格應用。然而,咱們的目標是儘量減小應用請求的 999 線。GC 優化對於實現這一目標相當重要。

3. 理解 GC 指標

衡量應用當前狀況始終是優化的先決條件。瞭解 GC 日誌的詳細細節(參考 [5])(使用如下選項):

 
  1. -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:+PrintGCDateStamps

  2. -XX:+PrintTenuringDistribution -XX:+PrintGCApplicationStoppedTime

能夠對該應用的 GC 特徵有整體的把握。

在 LinkedIn 的內部監控 inGraphs 和報表系統 Naarad,生成了各類有用的指標可視化圖形,好比 GC 停頓時間百分比、一次停頓最大持續時間以及長時間內 GC 頻率。除了 Naarad,有不少開源工具好比 gclogviewer 能夠從 GC 日誌建立可視化圖形。在此階段,能夠肯定 GC 頻率和暫停持續時間是否知足應用程序知足延遲的要求。

4. 下降 GC 頻率

在分代 GC 算法中,下降 GC 頻率能夠經過:(1) 下降對象分配/晉升率;(2) 增長各代空間的大小。

在 Hotspot JVM 中,Young GC 停頓時間取決於一次垃圾回收後存活下來的對象的數量,而不是 Young Gen 自身的大小。增長 Young Gen 大小對於應用性能的影響須要仔細評估:

  • 若是更多的數據存活並且被複制到 Survivor 區域,或者每次 GC 更多的數據晉升到 Old Gen,增長 Young Gen 大小可能致使更長的 Young GC 停頓。較長的 GC 停頓可能會致使應用程序延遲增長和(或)吞吐量下降。

  • 另外一方面,若是每次垃圾回收後存活對象數量不會大幅增長,停頓時間可能不會延長。在這種狀況下,下降 GC 頻率可能會使整個應用整體延遲下降和(或)吞吐量增長。

對於大部分爲短時間存活對象的應用,僅僅須要控制上述的參數;對於長期存活對象的應用,就須要注意,被晉升的對象可能很長時間都不能被 Old GC 週期回收。若是 Old GC 觸發閾值(Old Gen 佔用率百分比)比較低,應用將陷入持續的 GC 循環中。能夠經過設置高的 GC 觸發閾值可避免這一問題。

因爲咱們的應用在堆中維持了長期存活對象的較大緩存,將 Old GC 觸發閾值設置爲

 
  1. -XX:CMSInitiatingOccupancyFraction=92 -XX:+UseCMSInitiatingOccupancyOnly

來增長觸發 Old GC 的閾值。咱們也試圖增長 Young Gen 大小來減小 Young GC 頻率,可是並無採用,由於這增長了應用的 999 線。

5. 縮短 GC 停頓時間

減小 Young Gen 大小能夠縮短 Young GC 停頓時間,由於這可能致使被複制到 Survivor 區域或者被晉升的數據更少。可是,正如前面提到的,咱們要觀察減小 Young Gen 大小和由此致使的 GC 頻率增長對於總體應用吞吐量和延遲的影響。Young GC 停頓時間也依賴於 tenuring threshold (晉升閾值)和 Old Gen 大小(如步驟 6 所示)。

在使用 CMS GC 時,應將因堆碎片或者由堆碎片致使的 Full GC 的停頓時間下降到最小。經過控制對象晉升比例和減少 -XX:CMSInitiatingOccupancyFraction 的值使 Old GC 在低閾值時觸發。全部選項的細節調整和他們相關的權衡,請參考 Web Services 的 Java 垃圾回收(參考 [5] )和 Java 垃圾回收精粹(參考 [6])。

咱們觀察到 Eden 區域的大部分 Young Gen 被回收,幾乎沒有 3-8 年齡對象在 Survivor 空間中死亡,因此咱們將 tenuring threshold 從 8 下降到 2 (使用選項:-XX:MaxTenuringThreshold=2 ),以下降 Young GC 消耗在數據複製上的時間。

咱們還注意到 Young GC 暫停時間隨着 Old Gen 佔用率上升而延長。這意味着來自 Old Gen 的壓力使得對象晉升花費更多的時間。爲解決這個問題,將總的堆內存大小增長到 40GB,減少 -XX:CMSInitiatingOccupancyFraction 的值到 80,更快地開始 Old GC。儘管 -XX:CMSInitiatingOccupancyFraction 的值減少了,增大堆內存能夠避免頻繁的 Old GC。在此階段,咱們的結果是 Young GC 暫停 70ms,應用的 999 線在 80ms。

6. 優化 GC 工做線程的任務分配

爲了進一步下降 Young GC 停頓時間,咱們決定研究 GC 線程綁定任務的參數來進行優化。

-XX:ParGCCardsPerStrideChunk 參數控制 GC 工做線程的任務粒度,能夠幫助不使用補丁而得到最佳性能,這個補丁用來優化 Young GC 中的 Card table(卡表)掃描時間(參考[7])。有趣的是,Young GC 時間隨着 Old Gen 的增長而延長。將這個選項值設爲 32678,Young GC 停頓時間下降到平均 50ms。此時應用的 999 線在 60ms。

還有一些的參數能夠將任務映射到 GC 線程,若是操做系統容許的話,-XX:+BindGCTaskThreadsToCPUs 參數能夠綁定 GC 線程到個別的 CPU 核(看法釋 [1])。使用親緣性 -XX:+UseGCTaskAffinity 參數能夠將任務分配給 GC 工做線程(看法釋 [2])。然而,咱們的應用並無從這些選項帶來任何好處。實際上,一些調查顯示這些選項在 Linux 系統不起做用。

7. 瞭解 GC 的 CPU 和內存開銷

併發 GC 一般會增長 CPU 使用率。雖然咱們觀察到 CMS 的默認設置運行良好,可是 G1 收集器的併發 GC 工做會致使 CPU 使用率的增長,顯著下降了應用程序的吞吐量和延遲。與 CMS 相比,G1 還增長了內存開銷。對於不受 CPU 限制的低吞吐量應用程序,GC 致使的高 CPU 使用率可能不是一個緊迫的問題。

下圖是 ParNew/CMS 和 G1 的 CPU 使用百分比:相對來講 CPU 使用率變化明顯的節點使用 G1 參數 -XX:G1RSetUpdatingPauseTimePercent=20:

下圖是 ParNew/CMS 和 G1 每秒服務的請求數:吞吐量較低的節點使用 G1 參數 -XX:G1RSetUpdatingPauseTimePercent=20

8. 爲 GC 優化系統內存和 I/O 管理

一般來講,GC 停頓有兩種特殊狀況:(1) 低 user time,高 sys time 和高 real time (2) 低 user time,低 sys time 和高 real time。這意味着基礎的進程/OS設置存在問題。狀況 (1) 可能意味着 JVM 頁面被 Linux 竊取;狀況 (2) 可能意味着 GC 線程被 Linux 用於磁盤刷新,並卡在內核中等待 I/O。在這些狀況下,如何設置參數能夠參考該 PPT(參考 [8])。

另外,爲了不在運行時形成性能損失,咱們可使用 JVM 選項 -XX:+AlwaysPreTouch 在應用程序啓動時先訪問全部分配給它的內存,讓操做系統把內存真正的分配給 JVM。咱們還能夠將 vm.swappability 設置爲0,這樣操做系統就不會交換頁面到 swap(除非絕對必要)。

可能你會使用 mlock 將 JVM 頁固定到內存中,這樣操做系統就不會將它們交換出去。可是,若是系統用盡了全部的內存和交換空間,操做系統將終止一個進程來回收內存。一般狀況下,Linux 內核會選擇具備高駐留內存佔用但運行時間不長的進程(OOM 狀況下殺死進程的工做流(參考[9])進行終止。在咱們的例子中,這個進程頗有可能就是咱們的應用程序。優雅的降級是服務優秀的屬性之一,不過服務忽然終止的可能性對於可操做性來講並很差 —— 所以,咱們不使用 mlock,只是經過 vm.swapability 來儘量避免交換內存頁到 swap 的懲罰。

LinkedIn 動態信息數據平臺的 GC 優化

對於該 Feed 平臺原型系統,咱們使用 Hotspot JVM 的兩個 GC 算法優化垃圾回收:

  • Young GC 使用 ParNew,Old GC 使用 CMS。

  • Young Gen 和 Old Gen 使用 G1。G1 試圖解決堆大小爲 6GB 或更大時,暫停時間穩定且可預測在 0.5 秒如下的問題。在咱們用 G1 實驗過程當中,儘管調整了各類參數,但沒有獲得像 ParNew/CMS 同樣的 GC 性能或停頓時間的可預測值。咱們查詢了使用 G1 發生內存泄漏相關的一個 bug(看法釋[3]),但還不能肯定根本緣由。

使用 ParNew/CMS,應用每三秒進行一次 40-60ms 的 Young GC 和每小時一個 CMS GC。JVM 參數以下:

 
  1. // JVM sizing options

  2. -server -Xms40g -Xmx40g -XX:MaxDirectMemorySize=4096m -XX:PermSize=256m -XX:MaxPermSize=256m

  3. // Young generation options

  4. -XX:NewSize=6g -XX:MaxNewSize=6g -XX:+UseParNewGC -XX:MaxTenuringThreshold=2 -XX:SurvivorRatio=8 -XX:+UnlockDiagnosticVMOptions -XX:ParGCCardsPerStrideChunk=32768

  5. // Old generation options

  6. -XX:+UseConcMarkSweepGC -XX:CMSParallelRemarkEnabled -XX:+ParallelRefProcEnabled -XX:+CMSClassUnloadingEnabled -XX:CMSInitiatingOccupancyFraction=80 -XX:+UseCMSInitiatingOccupancyOnly

  7. // Other options

  8. -XX:+AlwaysPreTouch -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:+PrintGCDateStamps -XX:+PrintTenuringDistribution -XX:+PrintGCApplicationStoppedTime -XX:-OmitStackTraceInFastThrow

使用這些參數,對於成千上萬讀請求的吞吐量,咱們應用程序的 999 線下降到 60ms。

感謝

參與了原型應用程序開發的同窗有:Ankit Gupta、Elizabeth Bennett、Raghu Hiremagalur、Roshan Sumbaly、Swapnil Ghike、Tom Chiang 和 Vivek Nelamangala。另外,感謝 Cuong Tran、David Hoa 和 Steven Ihde 在系統優化方面的幫助。

參考

[1] Garbage Collection Optimization for High-Throughput and Low-Latency Java Applications(https://engineering.linkedin.com/garbage-collection/garbage-collection-optimization-high-throughput-and-low-latency-java-applications)

[2] 併發標記清除(Concurrent Mark Sweep,CMS) https://www.oracle.com/technetwork/java/javase/memorymanagement-whitepaper-150215.pdf

[3] G1(https://www.oracle.com/webfolder/technetwork/tutorials/obe/java/G1GettingStarted/index.html)

[4] 內存管理白皮書(https://www.oracle.com/technetwork/java/javase/memorymanagement-whitepaper-150215.pdf)

[5]  Web Services 的 Java 垃圾回收(https://engineering.linkedin.com/26/tuning-java-garbage-collection-web-services)

[6] Java 垃圾回收精粹(http://mechanical-sympathy.blogspot.com/2013/07/java-garbage-collection-distilled.html)

[7] 卡表掃描時間(http://blog.ragozin.info/2012/03/secret-hotspot-option-improving-gc.html)

[8] Gc and-pagescan-attacks-by-linux(http://www.slideshare.net/cuonghuutran/gc-andpagescanattacksbylinux)

[9] OOM 狀況下殺死進程的工做流 (https://www.kernel.org/doc/gorman/html/understand/understand016.html)

解釋

[1] -XX:+BindGCTaskThreadsToCPUs 參數彷佛在Linux 系統上不起做用,由於 hotspot/src/os/linux/vm/oslinux.cpp 的 distributeprocesses 方法在 JDK7 或 JDK8 中沒有實現。

[2] -XX:+UseGCTaskAffinity 參數在 JDK7 和 JDK8 的全部平臺彷佛都不起做用,由於任務的親緣性屬性永遠被設置爲 sentinelworker = (uint) -1。源碼見 hotspot/src/share/vm/gcimplementation/parallelScavenge/{gcTaskManager.cpp,gcTaskThread.cpp, gcTaskManager.cpp}。

[3] G1 存在一些內存泄露的 bug,可能 Java7u51 沒有修改。這個 bug 僅在 Java 8 修正了。

相關文章
相關標籤/搜索