1. 程式人生 > >高吞吐低延遲 Java 應用的 GC 優化

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

說明

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

背景

高效能應用構成了現代網路的支柱。LinkedIn 內部有許多高吞吐量服務來滿足每秒成千上萬的使用者請求。為了獲得最佳的使用者體驗,以低延遲響應這些請求是非常重要的。

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

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

優化 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 Java7u51、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 僅