[譯]高吞吐低延遲 Java 應(yīng)用的 GC 優(yōu)化

簡書滌生。
轉(zhuǎn)載請注明原創(chuàng)出處,謝謝!
如果讀完覺得有收獲的話,歡迎點贊加關(guān)注。

說明

本篇原文作者是 LinkedIn 的 Swapnil Ghike,這篇文章講述了 LinkedIn 的 Feed 產(chǎn)品的 GC 優(yōu)化過程,雖然文章寫作于 April 8, 2014,但其中的很多內(nèi)容和知識點非常有參考意義。因此,翻譯后獻給各位同學。
原文鏈接:Garbage Collection Optimization for High-Throughput and Low-Latency Java Applications。

背景

高性能應(yīng)用構(gòu)成了現(xiàn)代網(wǎng)絡(luò)的支柱。LinkedIn 內(nèi)部有許多高吞吐量服務(wù)來滿足每秒成千上萬的用戶請求。為了獲得最佳的用戶體驗,以低延遲響應(yīng)這些請求是非常重要的。

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


LinkedIn Feeds

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

這篇博文將通過一系列步驟來明確需求并優(yōu)化 GC,它的目標讀者是對使用系統(tǒng)方法進行 GC 優(yōu)化來實現(xiàn)應(yīng)用的高吞吐低延遲目標感興趣的開發(fā)人員。在 LinkedIn 構(gòu)建下一代 Feed 數(shù)據(jù)平臺的過程中,我們總結(jié)了該方法。這些方法包括但不限于以下幾點:并發(fā)標記清除(Concurrent Mark Sweep,CMS)G1 垃圾回收器的 CPU 和內(nèi)存開銷、避免長期存活對象導致的持續(xù) GC、優(yōu)化 GC 線程任務(wù)分配提升性能,以及可預(yù)測 GC 停頓時間所需的 OS 配置。

優(yōu)化 GC 的正確時機?

GC 的行為可能會因代碼優(yōu)化以及工作負載的變化而變化。因此,在一個已實施性能優(yōu)化的接近完成的代碼庫上進行 GC 優(yōu)化非常重要。而且在端到端的基本原型上進行初步分析也很有必要,該原型系統(tǒng)使用存根代碼并模擬了可代表生產(chǎn)環(huán)境的工作負載。這樣可以獲取該架構(gòu)延遲和吞吐量的真實邊界,進而決定是否進行縱向或橫向擴展。

在下一代 Feed 數(shù)據(jù)平臺的原型開發(fā)階段,我們幾乎實現(xiàn)了所有端到端的功能,并且模擬了當前生產(chǎn)基礎(chǔ)設(shè)施提供的查詢工作負載。這使我們在工作負載特性上有足夠的多樣性,可以在足夠長的時間內(nèi)測量應(yīng)用程序性能和 GC 特征。

優(yōu)化 GC 的步驟

下面是一些針對高吞吐量、低延遲需求優(yōu)化 GC 的總體步驟。此外,還包括在 Feed 數(shù)據(jù)平臺原型實施的具體細節(jié)。盡管我們還對 G1 垃圾收集器進行了試驗,但我們發(fā)現(xiàn) ParNew/CMS 具有最佳的 GC 性能。

1. 理解 GC 基礎(chǔ)知識

由于 GC 優(yōu)化需要調(diào)整大量的參數(shù),因此理解 GC 工作機制非常重要。Oracle 的 Hotspot JVM 內(nèi)存管理白皮書是開始學習 Hotspot JVM GC 算法非常好的資料。而了解 G1 垃圾回收器的理論知識,可以參閱該文章

2. 仔細考量 GC 需求

為了降低對應(yīng)用程序性能的開銷,可以優(yōu)化 GC 的一些特征。像吞吐量和延遲一樣,這些 GC 特征應(yīng)該在長時間運行的測試中觀察到,以確保應(yīng)用程序能夠在經(jīng)歷多個 GC 周期中處理流量的變化。

  • Stop-the-world 回收器回收垃圾時會暫停應(yīng)用線程。停頓的時長和頻率不應(yīng)該對應(yīng)用遵守 SLA 產(chǎn)生不利的影響。
  • 并發(fā) GC 算法與應(yīng)用線程競爭 CPU 周期。這個開銷不應(yīng)該影響應(yīng)用吞吐量。
  • 非壓縮 GC 算法會引起堆碎片化,進而導致的 Full GC 長時間 Stop-the-world,因此,堆碎片應(yīng)保持在最小值。
  • 垃圾回收工作需要占用內(nèi)存。某些 GC 算法具有比其他算法更高的內(nèi)存占用。如果應(yīng)用程序需要較大的堆空間,要確保 GC 的內(nèi)存開銷不能太大。
  • 要清楚地了解 GC 日志和常用的 JVM 參數(shù),以便輕松地調(diào)整 GC 行為。因為 GC 運行隨著代碼復雜性增加或工作負載特性的改變而發(fā)生變化

我們使用 Linux 操作系統(tǒng)、Hotspot Java7u51、32GB 堆內(nèi)存、6GB 新生代(Young Gen)和 -XX:CMSInitiatingOccupancyFraction 值為 70(Old GC 觸發(fā)時其空間占用率)開始實驗。設(shè)置較大的堆內(nèi)存是用來維持長期存活對象的對象緩存。一旦這個緩存生效,晉升到 Old Gen 的對象速度會顯著下降。

使用最初的 JVM 配置,每 3 秒發(fā)生一次 80ms 的 Young GC 停頓,超過 99.9% 的應(yīng)用請求延遲 100ms(999線)。這樣的 GC 效果可能適合于 SLA 對延遲要求不太嚴格應(yīng)用。然而,我們的目標是盡可能減少應(yīng)用請求的 999 線。GC 優(yōu)化對于實現(xiàn)這一目標至關(guān)重要。

3. 理解 GC 指標

衡量應(yīng)用當前情況始終是優(yōu)化的先決條件。了解 GC 日志的詳細細節(jié)(使用以下選項):

-XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:+PrintGCDateStamps 
-XX:+PrintTenuringDistribution -XX:+PrintGCApplicationStoppedTime

可以對該應(yīng)用的 GC 特征有總體的把握。

在 LinkedIn 的內(nèi)部監(jiān)控 inGraphs 和報表系統(tǒng) Naarad,生成了各種有用的指標可視化圖形,比如 GC 停頓時間百分比、一次停頓最大持續(xù)時間以及長時間內(nèi) GC 頻率。除了 Naarad,有很多開源工具比如 gclogviewer 可以從 GC 日志創(chuàng)建可視化圖形。
在此階段,可以確定 GC 頻率和暫停持續(xù)時間是否滿足應(yīng)用程序滿足延遲的要求。

4. 降低 GC 頻率

在分代 GC 算法中,降低 GC 頻率可以通過:(1)降低對象分配/晉升率;(2)增加各代空間的大小。

在 Hotspot JVM 中,Young GC 停頓時間取決于一次垃圾回收后存活下來的對象的數(shù)量,而不是 Young Gen 自身的大小。增加 Young Gen 大小對于應(yīng)用性能的影響需要仔細評估:

  • 如果更多的數(shù)據(jù)存活而且被復制到 Survivor 區(qū)域,或者每次 GC 更多的數(shù)據(jù)晉升到 Old Gen,增加 Young Gen 大小可能導致更長的 Young GC 停頓。較長的 GC 停頓可能會導致應(yīng)用程序延遲增加和(或)吞吐量降低。
  • 另一方面,如果每次垃圾回收后存活對象數(shù)量不會大幅增加,停頓時間可能不會延長。在這種情況下,降低 GC 頻率可能會使整個應(yīng)用總體延遲降低和(或)吞吐量增加。

對于大部分為短期存活對象的應(yīng)用,僅僅需要控制上述的參數(shù);對于長期存活對象的應(yīng)用,就需要注意,被晉升的對象可能很長時間都不能被 Old GC 周期回收。如果 Old GC 觸發(fā)閾值(Old Gen 占用率百分比)比較低,應(yīng)用將陷入持續(xù)的 GC 循環(huán)中。可以通過設(shè)置高的 GC 觸發(fā)閾值可避免這一問題。

由于我們的應(yīng)用在堆中維持了長期存活對象的較大緩存,將 Old GC 觸發(fā)閾值設(shè)置為

-XX:CMSInitiatingOccupancyFraction=92 -XX:+UseCMSInitiatingOccupancyOnly

來增加觸發(fā) Old GC 的閾值。我們也試圖增加 Young Gen 大小來減少 Young GC 頻率,但是并沒有采用,因為這增加了應(yīng)用的 999 線。

5. 縮短 GC 停頓時間

減少 Young Gen 大小可以縮短 Young GC 停頓時間,因為這可能導致被復制到 Survivor 區(qū)域或者被晉升的數(shù)據(jù)更少。但是,正如前面提到的,我們要觀察減少 Young Gen 大小和由此導致的 GC 頻率增加對于整體應(yīng)用吞吐量和延遲的影響。Young GC 停頓時間也依賴于 tenuring threshold (晉升閾值)和 Old Gen 大?。ㄈ绮襟E 6 所示)。

在使用 CMS GC 時,應(yīng)將因堆碎片或者由堆碎片導致的 Full GC 的停頓時間降低到最小。通過控制對象晉升比例和減小 -XX:CMSInitiatingOccupancyFraction 的值使 Old GC 在低閾值時觸發(fā)。所有選項的細節(jié)調(diào)整和他們相關(guān)的權(quán)衡,請參考 Web Services 的 Java 垃圾回收Java 垃圾回收精粹。

我們觀察到 Eden 區(qū)域的大部分 Young Gen 被回收,幾乎沒有 3-8 年齡對象在 Survivor 空間中死亡,所以我們將 tenuring threshold 從 8 降低到 2 (使用選項:-XX:MaxTenuringThreshold=2 ),以降低 Young GC 消耗在數(shù)據(jù)復制上的時間。

我們還注意到 Young GC 暫停時間隨著 Old Gen 占用率上升而延長。這意味著來自 Old Gen 的壓力使得對象晉升花費更多的時間。為解決這個問題,將總的堆內(nèi)存大小增加到 40GB,減小 -XX:CMSInitiatingOccupancyFraction 的值到 80,更快地開始 Old GC。盡管 -XX:CMSInitiatingOccupancyFraction 的值減小了,增大堆內(nèi)存可以避免頻繁的 Old GC。在此階段,我們的結(jié)果是 Young GC 暫停 70ms,應(yīng)用的 999 線在 80ms。

6. 優(yōu)化 GC 工作線程的任務(wù)分配

為了進一步降低 Young GC 停頓時間,我們決定研究 GC 線程綁定任務(wù)的參數(shù)來進行優(yōu)化。

-XX:ParGCCardsPerStrideChunk 參數(shù)控制 GC 工作線程的任務(wù)粒度,可以幫助不使用補丁而獲得最佳性能,這個補丁用來優(yōu)化 Young GC 中的 Card table(卡表掃描時間)。有趣的是,Young GC 時間隨著 Old Gen 的增加而延長。將這個選項值設(shè)為 32678,Young GC 停頓時間降低到平均 50ms。此時應(yīng)用的 999 線在 60ms。

還有一些的參數(shù)可以將任務(wù)映射到 GC 線程,如果操作系統(tǒng)允許的話,-XX:+BindGCTaskThreadsToCPUs 參數(shù)可以綁定 GC 線程到個別的 CPU 核。使用親緣性 -XX:+UseGCTaskAffinity 參數(shù)可以將任務(wù)分配給 GC 工作線程。然而,我們的應(yīng)用并沒有從這些選項帶來任何好處。實際上,一些調(diào)查顯示這些選項在 Linux 系統(tǒng)不起作用[1,2]。

7. 了解 GC 的 CPU 和內(nèi)存開銷

并發(fā) GC 通常會增加 CPU 使用率。雖然我們觀察到 CMS 的默認設(shè)置運行良好,但是 G1 收集器的并發(fā) GC 工作會導致 CPU 使用率的增加,顯著降低了應(yīng)用程序的吞吐量和延遲。與 CMS 相比,G1 還增加了內(nèi)存開銷。對于不受 CPU 限制的低吞吐量應(yīng)用程序,GC 導致的高 CPU 使用率可能不是一個緊迫的問題。

ParNew/CMS 和 G1 的 CPU 使用百分比:相對來說 CPU 使用率變化明顯的節(jié)點使用 G1 選項 -XX:G1RSetUpdatingPauseTimePercent=20
ParNew/CMS 和 G1 每秒服務(wù)的請求數(shù):吞吐量較低的節(jié)點使用 G1 選項 -XX:G1RSetUpdatingPauseTimePercent=20

8. 為 GC 優(yōu)化系統(tǒng)內(nèi)存和 I/O 管理

通常來說,GC 停頓有兩種特殊情況:
(1)低 user time,高 sys time 和高 real time
(2)低 user time,低 sys time 和高 real time。
這意味著基礎(chǔ)的進程/OS設(shè)置存在問題。
情況 (1) 可能意味著 JVM 頁面被 Linux 竊??;
情況 (2) 可能意味著 GC 線程被 Linux 用于磁盤刷新,并卡在內(nèi)核中等待 I/O。
在這些情況下,如何設(shè)置參數(shù)可以參考該PPT

另外,為了避免在運行時造成性能損失,我們可以使用 JVM 選項 -XX:+AlwaysPreTouch 在應(yīng)用程序啟動時先訪問所有分配給它的內(nèi)存,讓操作系統(tǒng)把內(nèi)存真正的分配給 JVM。我們還可以將 vm.swappability 設(shè)置為0,這樣操作系統(tǒng)就不會交換頁面到 swap(除非絕對必要)。

可能你會使用 mlock 將 JVM 頁固定到內(nèi)存中,這樣操作系統(tǒng)就不會將它們交換出去。但是,如果系統(tǒng)用盡了所有的內(nèi)存和交換空間,操作系統(tǒng)將終止一個進程來回收內(nèi)存。通常情況下,Linux 內(nèi)核會選擇具有高駐留內(nèi)存占用但運行時間不長的進程 (OOM 情況下殺死進程的工作流)。
在我們的例子中,這個進程很有可能就是我們的應(yīng)用程序。優(yōu)雅的降級是服務(wù)優(yōu)秀的屬性之一,不過服務(wù)突然終止的可能性對于可操作性來說并不好 —— 因此,我們不使用 mlock,只是通過 vm.swapability 來盡可能避免交換內(nèi)存頁到 swap 的懲罰。

LinkedIn Feed 數(shù)據(jù)平臺的 GC 優(yōu)化

對于該 Feed 平臺原型系統(tǒng),我們使用 Hotspot JVM 的兩個 GC 算法優(yōu)化垃圾回收:

  • Young GC 使用 ParNew,Old GC 使用 CMS。
  • Young Gen 和 Old Gen 使用 G1。G1 試圖解決堆大小為 6GB 或更大時,暫停時間穩(wěn)定且可預(yù)測在 0.5 秒以下的問題。在我們用 G1 實驗過程中,盡管調(diào)整了各種參數(shù),但沒有得到像 ParNew/CMS 一樣的 GC 性能或停頓時間的可預(yù)測值。我們查詢了使用 G1 發(fā)生內(nèi)存泄漏相關(guān)的一個 bug[3],但還不能確定根本原因。

使用 ParNew/CMS,應(yīng)用每三秒進行一次 40-60ms 的 Young GC 和每小時一個 CMS GC。JVM 參數(shù)如下:

// JVM sizing options
-server -Xms40g -Xmx40g -XX:MaxDirectMemorySize=4096m -XX:PermSize=256m -XX:MaxPermSize=256m   
// Young generation options
-XX:NewSize=6g -XX:MaxNewSize=6g -XX:+UseParNewGC -XX:MaxTenuringThreshold=2 -XX:SurvivorRatio=8 -XX:+UnlockDiagnosticVMOptions -XX:ParGCCardsPerStrideChunk=32768
// Old generation  options
-XX:+UseConcMarkSweepGC -XX:CMSParallelRemarkEnabled -XX:+ParallelRefProcEnabled -XX:+CMSClassUnloadingEnabled  -XX:CMSInitiatingOccupancyFraction=80 -XX:+UseCMSInitiatingOccupancyOnly   
// Other options
-XX:+AlwaysPreTouch -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:+PrintGCDateStamps -XX:+PrintTenuringDistribution -XX:+PrintGCApplicationStoppedTime -XX:-OmitStackTraceInFastThrow

使用這些參數(shù),對于成千上萬讀請求的吞吐量,我們應(yīng)用程序的 999 線降低到 60ms。

感謝

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

參考

[1] -XX:+BindGCTaskThreadsToCPUs 參數(shù)似乎在Linux 系統(tǒng)上不起作用,因為 hotspot/src/os/linux/vm/os_linux.cpp 的 distribute_processes 方法在 JDK7 或 JDK8 中沒有實現(xiàn)。
[2] -XX:+UseGCTaskAffinity 參數(shù)在 JDK7 和 JDK8 的所有平臺似乎都不起作用,因為任務(wù)的親緣性屬性永遠被設(shè)置為 sentinel_worker = (uint) -1。源碼見 hotspot/src/share/vm/gc_implementation/parallelScavenge/{gcTaskManager.cpp,gcTaskThread.cpp, gcTaskManager.cpp}。
[3] G1 存在一些內(nèi)存泄露的 bug,可能 Java7u51 沒有修改。這個 bug 僅在 Java 8 修正了。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容