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

背景

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

例如,我們的用戶經常使用的產品是 Feed —— 它是一個不斷更新的專業活動和內容的列表。Feed 在 LinkedIn 的系統中隨處可見,包括公司頁面、學校頁面以及最重要的主頁資訊信息。基礎 Feed 數據平臺為我們的經濟圖譜(會員、公司、群組等)中各種實體的更新建立索引,它必須高吞吐低延遲地實現相關的更新。如下圖,LinkedIn Feeds 信息展示:

為了將這些高吞吐量、低延遲類型的 Java 應用程序用於生產,開發人員必須確保在應用程序開發週期的每個階段都保持一致的性能。確定最佳垃圾收集(Garbage Collection, GC)配置對於實現這些指標至關重要。


高吞吐、低延遲 Java 應用的 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])(使用以下選項):


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


可以對該應用的 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 觸發閾值設置為


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


來增加觸發 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:


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


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


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


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 參數如下:


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


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

解釋

[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 修正了。

關注我,私信回覆我“666"獲取免費的Java架構學習資料(裡面有高可用、高併發、高性能及分佈式、Jvm性能調優、Spring源碼,MyBatis,Netty,Redis,Kafka,Mysql,Zookeeper,Tomcat,Docker,Dubbo,Nginx等多個知識點的架構視頻學習資料以及電子書資料)合理利用自己每一分每一秒的時間來學習提升自己,不要再用"沒有時間“來掩飾自己思想上的懶惰!趁年輕,使勁拼,給未來的自己一個交代!


分享到:


相關文章: