高吞吐、低延迟 Java 应用的 GC 优化实践
2019獨(dú)角獸企業(yè)重金招聘Python工程師標(biāo)準(zhǔn)>>>
背景
高性能應(yīng)用構(gòu)成了現(xiàn)代網(wǎng)絡(luò)的支柱。LinkedIn 內(nèi)部有許多高吞吐量服務(wù)來(lái)滿足每秒成千上萬(wàn)的用戶請(qǐng)求。為了獲得最佳的用戶體驗(yàn),以低延遲響應(yīng)這些請(qǐng)求是非常重要的。
例如,我們的用戶經(jīng)常使用的產(chǎn)品是 Feed —— 它是一個(gè)不斷更新的專業(yè)活動(dòng)和內(nèi)容的列表。Feed 在 LinkedIn 的系統(tǒng)中隨處可見(jiàn),包括公司頁(yè)面、學(xué)校頁(yè)面以及最重要的主頁(yè)資訊信息。基礎(chǔ) Feed 數(shù)據(jù)平臺(tái)為我們的經(jīng)濟(jì)圖譜(會(huì)員、公司、群組等)中各種實(shí)體的更新建立索引,它必須高吞吐低延遲地實(shí)現(xiàn)相關(guān)的更新。如下圖,LinkedIn Feeds 信息展示:
為了將這些高吞吐量、低延遲類型的 Java 應(yīng)用程序用于生產(chǎn),開發(fā)人員必須確保在應(yīng)用程序開發(fā)周期的每個(gè)階段都保持一致的性能。確定最佳垃圾收集(Garbage Collection, GC)配置對(duì)于實(shí)現(xiàn)這些指標(biāo)至關(guān)重要。
這篇博文將通過(guò)一系列步驟來(lái)明確需求并優(yōu)化 GC,它的目標(biāo)讀者是對(duì)使用系統(tǒng)方法進(jìn)行 GC 優(yōu)化來(lái)實(shí)現(xiàn)應(yīng)用的高吞吐低延遲目標(biāo)感興趣的開發(fā)人員。在 LinkedIn 構(gòu)建下一代 Feed 數(shù)據(jù)平臺(tái)的過(guò)程中,我們總結(jié)了該方法。這些方法包括但不限于以下幾點(diǎn):并發(fā)標(biāo)記清除(Concurrent Mark Sweep,CMS(參考[2]) 和 G1(參考 [3]) 垃圾回收器的 CPU 和內(nèi)存開銷、避免長(zhǎng)期存活對(duì)象導(dǎo)致的持續(xù) GC、優(yōu)化 GC 線程任務(wù)分配提升性能,以及可預(yù)測(cè) GC 停頓時(shí)間所需的 OS 配置。
優(yōu)化 GC 的正確時(shí)機(jī)?
GC 的行為可能會(huì)因代碼優(yōu)化以及工作負(fù)載的變化而變化。因此,在一個(gè)已實(shí)施性能優(yōu)化的接近完成的代碼庫(kù)上進(jìn)行 GC 優(yōu)化非常重要。而且在端到端的基本原型上進(jìn)行初步分析也很有必要,該原型系統(tǒng)使用存根代碼并模擬了可代表生產(chǎn)環(huán)境的工作負(fù)載。這樣可以獲取該架構(gòu)延遲和吞吐量的真實(shí)邊界,進(jìn)而決定是否進(jìn)行縱向或橫向擴(kuò)展。
在下一代 Feed 數(shù)據(jù)平臺(tái)的原型開發(fā)階段,我們幾乎實(shí)現(xiàn)了所有端到端的功能,并且模擬了當(dāng)前生產(chǎn)基礎(chǔ)設(shè)施提供的查詢工作負(fù)載。這使我們?cè)诠ぷ髫?fù)載特性上有足夠的多樣性,可以在足夠長(zhǎng)的時(shí)間內(nèi)測(cè)量應(yīng)用程序性能和 GC 特征。
優(yōu)化 GC 的步驟
下面是一些針對(duì)高吞吐量、低延遲需求優(yōu)化 GC 的總體步驟。此外,還包括在 Feed 數(shù)據(jù)平臺(tái)原型實(shí)施的具體細(xì)節(jié)。盡管我們還對(duì) G1 垃圾收集器進(jìn)行了試驗(yàn),但我們發(fā)現(xiàn) ParNew/CMS 具有最佳的 GC 性能。
1. 理解 GC 基礎(chǔ)知識(shí)
由于 GC 優(yōu)化需要調(diào)整大量的參數(shù),因此理解 GC 工作機(jī)制非常重要。Oracle 的 Hotspot JVM 內(nèi)存管理白皮書(參考 [4])是開始學(xué)習(xí) Hotspot JVM GC 算法非常好的資料。而了解 G1 垃圾回收器的理論知識(shí),可以參閱(參考 [3])。
2. 仔細(xì)考量 GC 需求
為了降低對(duì)應(yīng)用程序性能的開銷,可以優(yōu)化 GC 的一些特征。像吞吐量和延遲一樣,這些 GC 特征應(yīng)該在長(zhǎng)時(shí)間運(yùn)行的測(cè)試中觀察到,以確保應(yīng)用程序能夠在經(jīng)歷多個(gè) GC 周期中處理流量的變化。
? ? ? ? ·? ? ?Stop-the-world 回收器回收垃圾時(shí)會(huì)暫停應(yīng)用線程。停頓的時(shí)長(zhǎng)和頻率不應(yīng)該對(duì)應(yīng)用遵守 SLA 產(chǎn)生不利的影響。
? ? ? ? ·? ? ?并發(fā) GC 算法與應(yīng)用線程競(jìng)爭(zhēng) CPU 周期。這個(gè)開銷不應(yīng)該影響應(yīng)用吞吐量。
? ? ? ? ·? ? ?非壓縮 GC 算法會(huì)引起堆碎片化,進(jìn)而導(dǎo)致的 Full GC 長(zhǎng)時(shí)間 Stop-the-world,因此,堆碎片應(yīng)保持在最小值。
? ? ? ? ·? ? ?垃圾回收工作需要占用內(nèi)存。某些 GC 算法具有比其他算法更高的內(nèi)存占用。如果應(yīng)用程序需要較大的堆空間,要確保 GC 的內(nèi)存開銷不能太大。
? ? ? ? ·? ? ?要清楚地了解 GC 日志和常用的 JVM 參數(shù),以便輕松地調(diào)整 GC 行為。因?yàn)?GC 運(yùn)行隨著代碼復(fù)雜性增加或工作負(fù)載特性的改變而發(fā)生變化
我們使用 Linux 操作系統(tǒng)、Hotspot Java7u51、32GB 堆內(nèi)存、6GB 新生代(Young Gen)和 -XX:CMSInitiatingOccupancyFraction 值為 70(Old GC 觸發(fā)時(shí)其空間占用率)開始實(shí)驗(yàn)。設(shè)置較大的堆內(nèi)存是用來(lái)維持長(zhǎng)期存活對(duì)象的對(duì)象緩存。一旦這個(gè)緩存生效,晉升到 Old Gen 的對(duì)象速度會(huì)顯著下降。
使用最初的 JVM 配置,每 3 秒發(fā)生一次 80ms 的 Young GC 停頓,超過(guò) 99.9% 的應(yīng)用請(qǐng)求延遲 100ms(999線)。這樣的 GC 效果可能適合于 SLA 對(duì)延遲要求不太嚴(yán)格應(yīng)用。然而,我們的目標(biāo)是盡可能減少應(yīng)用請(qǐng)求的 999 線。GC 優(yōu)化對(duì)于實(shí)現(xiàn)這一目標(biāo)至關(guān)重要。
3. 理解 GC 指標(biāo)
衡量應(yīng)用當(dāng)前情況始終是優(yōu)化的先決條件。了解 GC 日志的詳細(xì)細(xì)節(jié)(參考 [5])(使用以下選項(xiàng)):
可以對(duì)該應(yīng)用的 GC 特征有總體的把握。
在 LinkedIn 的內(nèi)部監(jiān)控 inGraphs 和報(bào)表系統(tǒng) Naarad,生成了各種有用的指標(biāo)可視化圖形,比如 GC 停頓時(shí)間百分比、一次停頓最大持續(xù)時(shí)間以及長(zhǎng)時(shí)間內(nèi) GC 頻率。除了 Naarad,有很多開源工具比如 gclogviewer 可以從 GC 日志創(chuàng)建可視化圖形。在此階段,可以確定 GC 頻率和暫停持續(xù)時(shí)間是否滿足應(yīng)用程序滿足延遲的要求。
4. 降低 GC 頻率
在分代 GC 算法中,降低 GC 頻率可以通過(guò):(1) 降低對(duì)象分配/晉升率;(2) 增加各代空間的大小。
在 Hotspot JVM 中,Young GC 停頓時(shí)間取決于一次垃圾回收后存活下來(lái)的對(duì)象的數(shù)量,而不是 Young Gen 自身的大小。增加 Young Gen 大小對(duì)于應(yīng)用性能的影響需要仔細(xì)評(píng)估:
? ? ? ? ·? ? ?如果更多的數(shù)據(jù)存活而且被復(fù)制到 Survivor 區(qū)域,或者每次 GC 更多的數(shù)據(jù)晉升到 Old Gen,增加 Young Gen 大小可能導(dǎo)致更長(zhǎng)的 Young GC 停頓。較長(zhǎng)的 GC 停頓可能會(huì)導(dǎo)致應(yīng)用程序延遲增加和(或)吞吐量降低。
? ? ? ? ·? ? ?另一方面,如果每次垃圾回收后存活對(duì)象數(shù)量不會(huì)大幅增加,停頓時(shí)間可能不會(huì)延長(zhǎng)。在這種情況下,降低 GC 頻率可能會(huì)使整個(gè)應(yīng)用總體延遲降低和(或)吞吐量增加。
對(duì)于大部分為短期存活對(duì)象的應(yīng)用,僅僅需要控制上述的參數(shù);對(duì)于長(zhǎng)期存活對(duì)象的應(yīng)用,就需要注意,被晉升的對(duì)象可能很長(zhǎng)時(shí)間都不能被 Old GC 周期回收。如果 Old GC 觸發(fā)閾值(Old Gen 占用率百分比)比較低,應(yīng)用將陷入持續(xù)的 GC 循環(huán)中。可以通過(guò)設(shè)置高的 GC 觸發(fā)閾值可避免這一問(wèn)題。
由于我們的應(yīng)用在堆中維持了長(zhǎng)期存活對(duì)象的較大緩存,將 Old GC 觸發(fā)閾值設(shè)置為
來(lái)增加觸發(fā) Old GC 的閾值。我們也試圖增加 Young Gen 大小來(lái)減少 Young GC 頻率,但是并沒(méi)有采用,因?yàn)檫@增加了應(yīng)用的 999 線。
5. 縮短 GC 停頓時(shí)間
減少 Young Gen 大小可以縮短 Young GC 停頓時(shí)間,因?yàn)檫@可能導(dǎo)致被復(fù)制到 Survivor 區(qū)域或者被晉升的數(shù)據(jù)更少。但是,正如前面提到的,我們要觀察減少 Young Gen 大小和由此導(dǎo)致的 GC 頻率增加對(duì)于整體應(yīng)用吞吐量和延遲的影響。Young GC 停頓時(shí)間也依賴于 tenuring threshold (晉升閾值)和 Old Gen 大小(如步驟 6 所示)。
在使用 CMS GC 時(shí),應(yīng)將因堆碎片或者由堆碎片導(dǎo)致的 Full GC 的停頓時(shí)間降低到最小。通過(guò)控制對(duì)象晉升比例和減小 -XX:CMSInitiatingOccupancyFraction 的值使 Old GC 在低閾值時(shí)觸發(fā)。所有選項(xiàng)的細(xì)節(jié)調(diào)整和他們相關(guān)的權(quán)衡,請(qǐng)參考 Web Services 的 Java 垃圾回收(參考 [5])和 Java 垃圾回收精粹(參考 [6])。
我們觀察到 Eden 區(qū)域的大部分 Young Gen 被回收,幾乎沒(méi)有 3-8 年齡對(duì)象在 Survivor 空間中死亡,所以我們將 tenuring threshold 從 8 降低到 2 (使用選項(xiàng):-XX:MaxTenuringThreshold=2 ),以降低 Young GC 消耗在數(shù)據(jù)復(fù)制上的時(shí)間。
我們還注意到 Young GC 暫停時(shí)間隨著 Old Gen 占用率上升而延長(zhǎng)。這意味著來(lái)自 Old Gen 的壓力使得對(duì)象晉升花費(fèi)更多的時(shí)間。為解決這個(gè)問(wèn)題,將總的堆內(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ù)分配
為了進(jìn)一步降低 Young GC 停頓時(shí)間,我們決定研究 GC 線程綁定任務(wù)的參數(shù)來(lái)進(jìn)行優(yōu)化。
-XX:ParGCCardsPerStrideChunk 參數(shù)控制 GC 工作線程的任務(wù)粒度,可以幫助不使用補(bǔ)丁而獲得最佳性能,這個(gè)補(bǔ)丁用來(lái)優(yōu)化 Young GC 中的 Card table(卡表)掃描時(shí)間(參考[7])。有趣的是,Young GC 時(shí)間隨著 Old Gen 的增加而延長(zhǎng)。將這個(gè)選項(xiàng)值設(shè)為 32678,Young GC 停頓時(shí)間降低到平均 50ms。此時(shí)應(yīng)用的 999 線在 60ms。
還有一些的參數(shù)可以將任務(wù)映射到 GC 線程,如果操作系統(tǒng)允許的話,-XX:+BindGCTaskThreadsToCPUs 參數(shù)可以綁定 GC 線程到個(gè)別的 CPU 核(見(jiàn)解釋?[1])。使用親緣性 -XX:+UseGCTaskAffinity 參數(shù)可以將任務(wù)分配給 GC 工作線程(見(jiàn)解釋?[2])。然而,我們的應(yīng)用并沒(méi)有從這些選項(xiàng)帶來(lái)任何好處。實(shí)際上,一些調(diào)查顯示這些選項(xiàng)在 Linux 系統(tǒng)不起作用。
7. 了解 GC 的 CPU 和內(nèi)存開銷
并發(fā) GC 通常會(huì)增加 CPU 使用率。雖然我們觀察到 CMS 的默認(rèn)設(shè)置運(yùn)行良好,但是 G1 收集器的并發(fā) GC 工作會(huì)導(dǎo)致 CPU 使用率的增加,顯著降低了應(yīng)用程序的吞吐量和延遲。與 CMS 相比,G1 還增加了內(nèi)存開銷。對(duì)于不受 CPU 限制的低吞吐量應(yīng)用程序,GC 導(dǎo)致的高 CPU 使用率可能不是一個(gè)緊迫的問(wèn)題。
下圖是 ParNew/CMS 和 G1 的 CPU 使用百分比:相對(duì)來(lái)說(shuō) CPU 使用率變化明顯的節(jié)點(diǎn)使用 G1 參數(shù) -XX:G1RSetUpdatingPauseTimePercent=20:
下圖是 ParNew/CMS 和 G1 每秒服務(wù)的請(qǐng)求數(shù):吞吐量較低的節(jié)點(diǎn)使用 G1 參數(shù) -XX:G1RSetUpdatingPauseTimePercent=20
8. 為 GC 優(yōu)化系統(tǒng)內(nèi)存和 I/O 管理
通常來(lái)說(shuō),GC 停頓有兩種特殊情況:(1) 低 user time,高 sys time 和高 real time(2) 低 user time,低 sys time 和高 real time。這意味著基礎(chǔ)的進(jìn)程/OS設(shè)置存在問(wèn)題。情況 (1) 可能意味著 JVM 頁(yè)面被 Linux 竊取;情況 (2) 可能意味著 GC 線程被 Linux 用于磁盤刷新,并卡在內(nèi)核中等待 I/O。在這些情況下,如何設(shè)置參數(shù)可以參考該 PPT(參考 [8])。
另外,為了避免在運(yùn)行時(shí)造成性能損失,我們可以使用 JVM 選項(xiàng) -XX:+AlwaysPreTouch 在應(yīng)用程序啟動(dòng)時(shí)先訪問(wèn)所有分配給它的內(nèi)存,讓操作系統(tǒng)把內(nèi)存真正的分配給 JVM。我們還可以將 vm.swappability 設(shè)置為0,這樣操作系統(tǒng)就不會(huì)交換頁(yè)面到 swap(除非絕對(duì)必要)。
可能你會(huì)使用 mlock 將 JVM 頁(yè)固定到內(nèi)存中,這樣操作系統(tǒng)就不會(huì)將它們交換出去。但是,如果系統(tǒng)用盡了所有的內(nèi)存和交換空間,操作系統(tǒng)將終止一個(gè)進(jìn)程來(lái)回收內(nèi)存。通常情況下,Linux 內(nèi)核會(huì)選擇具有高駐留內(nèi)存占用但運(yùn)行時(shí)間不長(zhǎng)的進(jìn)程(OOM 情況下殺死進(jìn)程的工作流(參考[9])進(jìn)行終止。在我們的例子中,這個(gè)進(jìn)程很有可能就是我們的應(yīng)用程序。優(yōu)雅的降級(jí)是服務(wù)優(yōu)秀的屬性之一,不過(guò)服務(wù)突然終止的可能性對(duì)于可操作性來(lái)說(shuō)并不好 —— 因此,我們不使用 mlock,只是通過(guò) vm.swapability 來(lái)盡可能避免交換內(nèi)存頁(yè)到 swap 的懲罰。
LinkedIn 動(dòng)態(tài)信息數(shù)據(jù)平臺(tái)的 GC 優(yōu)化
對(duì)于該 Feed 平臺(tái)原型系統(tǒng),我們使用 Hotspot JVM 的兩個(gè) GC 算法優(yōu)化垃圾回收:
? ? ? ? ·? ? ?Young GC 使用 ParNew,Old GC 使用 CMS。
? ? ? ? ·? ? ?Young Gen 和 Old Gen 使用 G1。G1 試圖解決堆大小為 6GB 或更大時(shí),暫停時(shí)間穩(wěn)定且可預(yù)測(cè)在 0.5 秒以下的問(wèn)題。在我們用 G1 實(shí)驗(yàn)過(guò)程中,盡管調(diào)整了各種參數(shù),但沒(méi)有得到像 ParNew/CMS 一樣的 GC 性能或停頓時(shí)間的可預(yù)測(cè)值。我們查詢了使用 G1 發(fā)生內(nèi)存泄漏相關(guān)的一個(gè) bug(見(jiàn)解釋[3]),但還不能確定根本原因。
使用 ParNew/CMS,應(yīng)用每三秒進(jìn)行一次 40-60ms 的 Young GC 和每小時(shí)一個(gè) CMS GC。JVM 參數(shù)如下:
使用這些參數(shù),對(duì)于成千上萬(wàn)讀請(qǐng)求的吞吐量,我們應(yīng)用程序的 999 線降低到 60ms。
解釋
[1] -XX:+BindGCTaskThreadsToCPUs 參數(shù)似乎在Linux 系統(tǒng)上不起作用,因?yàn)?hotspot/src/os/linux/vm/oslinux.cpp 的 distributeprocesses 方法在 JDK7 或 JDK8 中沒(méi)有實(shí)現(xiàn)。
[2] -XX:+UseGCTaskAffinity 參數(shù)在 JDK7 和 JDK8 的所有平臺(tái)似乎都不起作用,因?yàn)槿蝿?wù)的親緣性屬性永遠(yuǎn)被設(shè)置為 sentinelworker = (uint) -1。源碼見(jiàn) hotspot/src/share/vm/gcimplementation/parallelScavenge/{gcTaskManager.cpp,gcTaskThread.cpp, gcTaskManager.cpp}。
[3] G1 存在一些內(nèi)存泄露的 bug,可能 Java7u51 沒(méi)有修改。這個(gè) bug 僅在 Java 8 修正了。
歡迎工作一到五年的Java工程師朋友們加入我的個(gè)人粉絲群Java填坑之路:789337293
群內(nèi)提供免費(fèi)的Java架構(gòu)學(xué)習(xí)資料(里面有高可用、高并發(fā)、高性能及分布式、Jvm性能調(diào)優(yōu)、Spring源碼,MyBatis,Netty,Redis,Kafka,Mysql,Zookeeper,Tomcat,Docker,Dubbo,Nginx等多個(gè)知識(shí)點(diǎn)的架構(gòu)資料)合理利用自己每一分每一秒的時(shí)間來(lái)學(xué)習(xí)提升自己,不要再用"沒(méi)有時(shí)間“來(lái)掩飾自己思想上的懶惰!趁年輕,使勁拼,給未來(lái)的自己一個(gè)交代!
?
轉(zhuǎn)載于:https://my.oschina.net/u/3999718/blog/3040395
總結(jié)
以上是生活随笔為你收集整理的高吞吐、低延迟 Java 应用的 GC 优化实践的全部?jī)?nèi)容,希望文章能夠幫你解決所遇到的問(wèn)題。
- 上一篇: iOS标准库中常用数据结构和算法之二叉排
- 下一篇: Pandas数据结构简介