原文:Eliminating Large JVM GC Pauses Caused by Background IO Traffic
在生產環境中,我們屢次看到在JVM(Java虛擬機)中運行的應用程序偶爾會遭遇比較嚴重的暫停,我們稱之為STW(Stop-The-World),這個現象是因為JVM的GC日志記錄進程被后臺IO阻塞(比如系統頁緩存的回寫)鎖定所引起的。在STW暫停期間,JVM暫停了所有的應用線程,應用程序停止響應用戶的請求,因此對于一些等待敏感的用戶操作,這種暫停會造成不可接受的延遲。
我們的調查顯示,暫停是由于JVM GC(垃圾回收)在GC日志記錄過程中的write()系統調用誘導引起的。這類寫日志的操作,即便是采用了異步寫模式(比如緩沖IO或者非阻塞IO),仍然會被操作系統的機制包括頁緩存寫會鎖定相當長的時間。
我們討論了多種方案來緩解該問題。對于延遲敏感的Java應用,我們建議將Java日志文件已到一個獨立的或高性能的磁盤上(如SSD,tmpfs等)。
生產問題
當JVM管理的堆空間進行了垃圾回收,JVM可能會停止,從而導致了應用的STW暫停。鑒于啟動Java實例時配置的選項不同,不同類型的GC和JVM活動會被記錄到GC日志文件中。
盡管一些GC導致的STW暫停比如掃描、標志、整理堆對象是我們眾所周知的,但是我們發現依然有很多大型的STW暫停是有后臺IO阻塞引起。在我們的生產環境中,我們看到了在關鍵任務的Java應用中很多無法解釋的大型STW暫停(>5s)。這樣的暫停不能被應用層面的邏輯和JVM GC活動所解釋。如下面所示,展現了一個大型的超過4s的STW暫停和一些GC信息。垃圾收集器是G1。G1具有一個8GB的堆空間和并行的新生代垃圾收集,一般來講整個垃圾回收過程僅需小于1s即可完成,簡單的GC選項使開銷較小。然而,應用線程竟然暫停了超過4s。GC(如回收堆空間)的工作量不足以解釋4.17s這樣巨大的暫停時間。
``` 2015-12-20T16:09:04.088-0800: 95.743: [GC pause (G1 Evacuation Pause) (young) (initial-mark) 8258M->6294M(10G), 0.1343256 secs] 2015-12-20T16:09:08.257-0800: 99.912: Total time for which application threads were stopped: 4.1692476 seconds
``` 使用G1收集器產生的4,17s的GC STW暫停
另一個例子,下面的GC日志快照展示了一個11.45s的STW暫停。垃圾收集器是CMS(Concurrent Mode Sweep)。"用戶"/"系統"時間可以忽略不計,然而"real"GC時間大于11s。最后一行明確了11.45s的應用暫停時間。
2016-01-14T22:08:28.028+0000: 312052.604: [GC (Allocation Failure) 312064.042: [ParNew Desired survivor size 1998848 bytes, new threshold 15 (max 15) - age 1: 1678056 bytes, 1678056 total : 508096K->3782K(508096K), 0.0142796 secs] 1336653K->835675K(4190400K), 11.4521443 secs] [Times: user=0.18 sys=0.01, real=11.45 secs] 2016-01-14T22:08:39.481+0000: 312064.058: Total time for which application threads were stopped: 11.4566012 seconds
由CMS收集器引起的11.45sGC STW暫停
由于應用是十分延遲敏感的,我們花費了相當大的努力來研究這個問題。最終,我們重現了問題,找到了關鍵誘因,然后提出了幾項解決方案來解決它。
在實驗室環境中重現問題
首先,我們先從在實驗室環境中重現這個無法解釋的大型JVM暫停開始。出于可控性和重復性的考慮,我們設計了一個簡單的工作負載來移除生產應用程序的復雜性。
我們在兩個場景中運行這個工作負載:具備和不具備后臺IO活動。不具備后臺IO的場景用作“基線”,另一個引入后臺IO的場景用于重現問題。
Java工作負載
我們所使用的Java工作負載用向隊列中持續分配10KB的對象。當對象數量達到100,000時,一半的對象會被移除隊列。所以堆中對象的最大值時100,000個對象,約占用原始大小10GB。進程會持續固定的一段時間(如5min)。
Java源碼和生成后臺IO的腳本,都開源在這里
https://github.com/zhenyun/JavaGCworkload。我們考慮的主要性能指標是大JVM GC暫停的次數。
后臺IO
后臺IO是由腳本引入,該腳本負責持續的復制大文件。后臺工作負載大約產生150MB/s的寫負載,足以使單個硬盤跑滿。為了看到產生的IO負載有多嚴重,我們使用"sar -d -p 2"來收集一下統計數據:await(設備發出的IO請求送達的平均時間(單位,毫秒)),tps(每秒傳輸到物理設備上的數量總和)和wrsec-per-s(寫入到設備的扇區數)。平均數值是:await=421ms,tps=305,wrsec-per-s=302K。
系統配置
場景1(無后臺IO負載)
作為基線,本次運行無后臺IO負載。所有JVM GC暫停的時間序列數據所示如下圖所示。沒有發現超過250ms的單一暫停。
場景一中的所有JVM GC暫停(無后臺IO負載)
場景2(有后臺IO負載)
當后臺IO運行時,同樣的Java工作負載在5分鐘的運行期間,能夠看到3.6s的STW暫停,和三個超過0.5s的暫停。
場景二中的所有JVM GC暫停(有后臺IO負載)
調查
為了弄明白是什么系統調用引起的STW暫停,我們使用strace工具來取得由JVM實例產生的系統調用快照。
我們首先驗證了JVM將GC信息記錄到文件使用了異步IO。此外,我們追溯自啟動JVM所發出的所有系統調用。GC日志文件采用了異步模式打開,并且沒有觀察到由fsync()調用。
16:25:35.411993 open("gc.log", O_WRONLY|O_CREAT|O_TRUNC, 0666) = 3 <0.000073> 捕獲的JVM打開GC日志文件產生的open()系統調用
然而,快照顯示由JVM調用的異步write()系統調用有不同尋常的較長的執行時間。檢查系統調用和JVM暫停的時間戳,我們發現他們具備強烈的關聯關系。在下面的兩張圖表,我們展示了2分鐘內的延遲情況。
時間序列相關(JVM STW暫停)。
時間序列相關(write()系統調用延遲)
接著,我們放大焦點,關注發生在13:32:35時最大1.59s暫停。下面展示了相關的GC日志和strace輸出:
GC日志和strace輸出
讓我們來嘗試理解究竟發生了什么:
1、35.04時(第2行),新生代GC啟動,花費0.12s完成
2、新生代GC在35.17完成,JVM進行系統調用write(),嘗試將新生代GC統計輸出到GC日志文件(第4行)
3、write()調用被鎖定1.47s,最終在36.64(第5行)完成,花費1.47s。
4、當write()調用在36.64返回JVM,JVM紀錄了1.59s的STW暫停(0.12+1.47)(第3行)
換句話說,實際的STW暫停由兩部分組成:(1)GC時間(本例中新生代GC所花費時間);(2)GC日記錄日志所花費的時間(本例中的write()時間)。
這些數據表明,GC日志記錄過程算在了JVM STW暫停中,日志記錄的時間被當作STW暫停的一部分。特別的,整個應用的暫停主要包含兩部分:因為JVM GC活動引起的暫停和因為在JVM GC日志記錄時由操作系統阻塞write()系統調用所產生的暫停。下面的圖表展示了兩者的關系。
日志記錄期間JVM和操作系統的相互作用
如果GC日志記錄被操作系統阻塞,阻塞的時間也被計算為STW暫停的一部分。然而新問題是,為何緩沖寫會被阻塞?翻閱大量的資源包括內核源碼,我們意識到緩沖寫可能卡在內核代碼。包括多個原因,如:(1)穩定頁寫操作;(2)日志提交。
穩定頁寫操作:JVM向GC日志文件中的寫操作首先修改了相關的文件緩存頁內容。即使緩存頁稍后會通過操作系統的寫回機制持久化到磁盤文件中,對內存中頁的修改仍然會造成由“穩定頁寫操作”引起的頁面爭用。在“穩定頁寫操作”時,如果頁面正在被操作系統寫回,對這個頁的write()操作必須等待寫回完成。頁面鎖定來確保數據的一致性,避免部分新的一頁保留到磁盤。
日志提交:對于日志文件系統,適當的日志在文件寫入期間生成。寫入新內容到GC日志文件導致新的塊被分配,文件系統需要先將日志數據提交到文件。在日志提交期間,如果操作系統具有其他IO活動,提交操作可能需要稍作等待。如果后臺IO活動比較繁重,則等待時間會明顯加長。值得注意的是,EXT4文件系統具備一項“延遲分配”的特性,來推遲某些特定日志數據的操作系統寫回時間,可以有效緩解這個問題。此外要注意的是,將EXT4的數據模式從默認的“有序”更改為“寫回”并不會真正處理這個問題,因為日志需要在寫擴展調用返回前就被持久化。
后臺IO活動
從特定的JVM垃圾收集的角度來說,在典型的生產環境中,后臺IO活動是不可避免的。有幾類產生IO活動的源頭:(1)系統活動;(2)管理軟件;(3)其他協同應用;(4)同一JVM實例產生的IO。首先,操作系統包含許多機制(比如“/proc”文件系統)將數據寫入底層磁盤。第二,系統級的軟件比如CFEngine也會產生磁盤IO。第三,如果節點需要和其他應用協同共享磁盤,則其他應用會競爭IO。第四,特定的JVM實例也可能產生除GC日志之外的磁盤IO。
解決方案
盡管在當前的HotSpot JVM實現(也存在于其他虛擬機)中,GC日志記錄過程會被后臺IO活動阻塞,我們仍然有很多解決方案來幫助在寫入到GC日志文件時緩解這個問題。
首先,增強JVM可以完全的解決這個問題。尤其,當GC日志記錄活動和引起STW暫停的JVM GC進程分裂,這個問題就會消失不見。舉例來說,JVM可以將GC日志記錄功能,放入一個不同的線程來單獨處理日志文件寫操作,因此,不會造成STW暫停。但是獨立線程來記錄日志會存在JVM崩潰時丟失GC日志信息的風險。暴露給用戶一個JVM標志位允許用戶指定偏好應當時不錯的選擇。
由于后臺IO引起的STW暫停時間的長短依賴于究竟IO負載有多繁重,那么我們可以采用各種方式來減少后臺IO的強烈程度。舉例來說,在同一個節點上取消已分配的其他IO密集型應用程序,減少其它類型的日志記錄,改進日志循環等。
對于延遲敏感的應用來講,比如服務用戶進行交互操作,稍微大的STW暫停(比如大于0.25s)都是不可容忍的。因此,需要實施特別的方案。確保沒有由系統誘導產生的較大STW暫停的最底線就是使GC日志記錄動作避免被操作系統IO活動阻塞。
一種解決方案是將GC日志放入tmpfs(比如,-Xloggc:/tmpfs/gc.log)。因為tmpfs沒有磁盤備份,寫入到tmpfs不會產生磁盤活動,因此也不會被磁盤IO阻塞。但是這種方式存在兩個問題:(1)GC日志文件會在系統宕機時丟失;(2)tmpfs消耗物理內存。一種緩解方案就是隔段時間將日志文件持久化備份,可以減少丟失的數量。
另一種方案就是將GC日志文件放置在SSD(Solid-State Drives,固態硬盤)上,固態硬盤具備更好的IO性能。根據IO負載,SSD可用作GC日志記錄專屬的驅動器,或者與其他IO負載應用共享。然而,SSD的價格需要考慮在成本中。
相較使用SSD,一個更具成本效益的優勝方案是將GC日志文件放入一個專用的硬盤上面。由于該硬盤僅提供GC日志記錄,所以產生的磁盤IO看起來能達到降低暫停,提高JVM性能的目標。事實上,上面我們展示的場景1中,就用了這種配置方式,因此采用這種方式可以保證在GC日志記錄驅動器上沒有其它IO活動存在。
評估將GC日志放置在SSD和tmpfs上
我們采用了專有文件系統的方式,將GC日志放置在SSD和tmpfs驅動器上。我們運行了和場景2中同樣的Java負載和后臺IO負載。
對于SSD和tmpfs,我們觀察到了相似的結果,下面的圖表展示了將GC日志文件放置在SSD磁盤上的結果。我們發現JVM暫停性能比場景1中略差,但是所有的暫停均小于0.25s。結果表明,后臺IO負載并沒有影響應用的性能。
將GC日志記錄到SSD上所有的JVM STW暫停
結論
延遲敏感的Java應用程序需要較小的JVM GC暫停。然而,當磁盤IO繁重時,JVM明顯會被阻塞一段時間。
我們研究了這個問題,并且得出以下結論:
1、JVM GC需要write()系統調用來記錄GC活動;
2、由于后臺磁盤IO,像write()這樣的調用會被阻塞;
3、GC日志記錄過程是JVM暫停的一部分,因此write()調用的時間會計算在JVM STW暫停時間中。
我們提出了一系列的解決方案來緩解這個問題。尤其的,調查結果可以用于提高 JVM 實現,以避免此問題。對于延遲敏感的應用程序,一個即刻生效的解決方案即是將GC日志文件放置在單獨的硬盤或者高性能的磁盤驅動器,如SSD上面,來避免IO競爭。