摘要:當垃圾回收速度趕不上垃圾生成速度時,Shenandoah 首先會嘗試步調調整(pacing),即讓分配對象的線程稍作停頓,降低垃圾生成的速度。包括 Shenandoah 在內的大多數現代 GC 都可以輕鬆自如地處理大量的垃圾,但對於併發回收器來說,它們回收垃圾的速度需要比應用程序生成垃圾的速度更快。

如果你有關注與 JVM 開發相關的場景,你會發現,過去幾年是 Java 垃圾回收器的“復興”時期。先是 G1 成爲 Java 9 的默認垃圾回收器,繼而 Oracle 發佈了 ZGC(受 Azul 無停頓回收器 C4 的啓發),然後是 Red Hat 開發了 Shenandoah。從這些跡象可以看出:

  • 垃圾回收問題還遠沒有得到妥善的解決。

  • 人們越來越關注那些可以更快回收垃圾以及能夠處理更大堆內存的回收器。

在這篇文章中,我將分享我在 Grammarly 的一個真實項目中使用 Shenandoah 的經驗。寫這篇文章的目的並不是爲了對這項技術致敬,也絕對不是閒着蛋疼。我希望能夠讓讀者有足夠的理由去關注他們項目中使用的 GC,並解釋 Shenandoah 適合用在哪些場景中,以及如何在生產環境中用好它。

Shenandoah GC 是什麼東西?

Shenandoah GC 是最新的 JVM 垃圾回收器,由 Red Hat 的一個團隊負責開發。垃圾回收器的併發性是指在應用程序運行的同時進行垃圾回收,而這就是 Shenandoah 的目標——最小化垃圾回收對用戶代碼造成的停頓。Shenandoah 的另一個設計目標是可以處理大堆和小堆。

網上已經有很多與 Shenandoah GC 相關的資料,這裏就不再累述了。不過,下面還是列出了一些與 Shenandoah 和其他併發性 GC 相關的特點。

  • 經典 GC(也叫作 STW,Stop-The-World)會在沒有可用內存時暫停應用程序線程,回收垃圾,並壓縮存活的對象,然後讓應用程序繼續執行。這種停頓有可能長達幾十秒,而且會隨着堆的增大而延長。

  • 很多現代 GC(例如 G1)有分代的概念,它們根據對象在垃圾回收過程中存活下來的次數對這些對象進行分代,並針對每一個分代的對象使用不同的回收策略。

  • Shenandoah GC 也會造成 STW 停頓,但通常都很短暫,因爲它是在應用程序運行的同時執行大量的 GC。這種停頓不會隨着堆的增大而延長。

  • Shenandoah GC 沒有分代的概念,所以它需要在每次回收週期裏對存活對象進行標記(分代 GC 不需要這個操作)。不過反過來,Shenandoah 也避免了分代 GC 的一些額外的工作負載。

  • Shenandoah GC 的併發性是以降低應用程序的吞吐量爲代價。

Shenandoah 是 JDK 12 的一部分, AdoptOpenJDK 12 中就包含了 Shenandoah。不過,它也被移植到了 Java 8 和 Java 11 中,這個 頁面 列出了一些可用的二進制版本。

爲什麼要使用 Shenandoah GC

開發者社區對 GC 停頓存在一個很大的誤解,認爲 GC 停頓只會給那些對延遲敏感的應用程序(比如高頻交易應用程序)帶來重大影響。實際上,如果你的應用程序可以接受任意長度的 GC 停頓,那麼爲什麼不去選擇一個側重於吞吐量的 STW GC(比如 ParallelGC)呢?不過,如果你的應用程序是交互式的(比如一組 API 或一個網站),那麼 GC 停頓所造成的影響就會更加明顯了。GC 停頓會拖慢應用程序,在外界看來,它就像凍住了一樣。在 GC 停頓期間發給服務器的請求會更晚收到響應,根據停頓時間的不同(傳統的 GC 停頓有可能達到幾十秒),客戶端有可能會出現超時。如果客戶端進行重試,服務器端就會有更多待處理的請求,這個時候需要使用斷路器。長時間的 GC 停頓也可能造成服務的健康檢測失效,並導致服務被重啓。而在一個服務重啓期間,其他服務需要承擔更多的負載,它們所經歷的停頓會更長,這就像是一個惡性循環。

不可預測的 GC 停頓給系統帶來的影響遠遠超過了應用程序本身。客戶端出現回壓,請求隊列溢出,監控控制檯滿是各種超時異常,運維人員忙得團團轉。對於一個可以應對各種情況的系統來說,需要在 CPU 時間、隊列長度、可接受的響應時間方面具備緩衝能力。

Shenandoah 降低了應用程序的一部分吞吐量,但相比傳統 GC,它的代價要低一些。吞吐量的降低是可預測的,而且很容易做出應對計劃。例如,如果你發現應用程序的運行速度慢了 10%,那就增加 10% 的服務器。而 GC 停頓發生得非常迅速,你無法針對它們進行“自動伸縮”,你能做的是爲它們分配額外的資源,這些資源在大部分時間是閒置的,造成了金錢的浪費。

閒扯了這麼多,接下來讓我來介紹一下我在真實項目中使用 Shenandoah 的經驗。

初次相遇

先讓我介紹一下這個應用程序,它實際上是一個反向代理,會對請求做一些預處理和後處理操作。代理對進入的請求稍作修改,把它們發給多個上游服務器,在收到來自上游的響應後,對響應進行合併,然後返回給客戶端。這個看似簡單的項目實際上有點複雜,因爲請求和響應裏會帶有大量的 JSON,而且我們要求每秒處理 1 萬個請求,網絡帶寬達到了每秒 350MB。我們使用了 AWS c5.9xlarge 實例,設置了 57GB 內存。應用程序本身不需要消耗多大內存,但它需要有足夠的內存來暫存等待上游響應(最長響應時間爲 5 秒鐘)的請求。

剛開始我們使用的是 G1,新創建的服務在達到負載峯值之前可以正常運行,但在達到負載峯值後就變得非常脆弱。時而會出現幾秒鐘的 FullGC,並間接性地出現 100 毫秒到 200 毫秒的停頓。一個預期每秒可以處理 1 萬個請求的服務在耗費 70% 資源處理負載時伴隨着 5 秒鐘的停頓,這種情況你能想象嗎?很多請求被積壓起來,在停頓之後的數秒內,它就像抽了瘋一樣。停頓期間和停頓之後被掛起的請求造成了 QoS 的降級。

在一開始,調整 G1 選項似乎對我們有點幫助,但後來反而變得更加不穩定。最直接的辦法是調整年輕代和老年代比例,但這麼做讓應用程序出現奇怪的故障。我得承認自己並不是一個 GC 專家,我的方法可能有點稚嫩,但對於一個 Java 應用程序開發人員來說,你也別指望我對 GC 有多麼深入的瞭解。

經過一些無效的嘗試之後,我們切換到了可以使用 Shenandoah 的 OpenJDK 8 鏡像(shipilev/openjdk-shenandoah:8),並在命令行參數中加入 -XX:+UseShenandoahGC,然後就出現了下面的這種情況:

圖中顯示的是最大 GC 停頓的變化情況。Shenandoah 將“正常”的最大停頓從 50-150 毫秒減少到了 10-20 毫秒,而且圖中並沒有顯示使用 G1 時常會出現的數秒鐘的停頓。

突然間,服務的表現非常穩定。在解決了這些性能瓶頸之後,我們將每臺機器的吞吐量又提升了一些。我們將堆大小設置到了 57GB,即使堆大了很多,但延遲並沒有因此而增加。有了更大的堆緩衝區,我們就可以處理更大的流量高峯。總的 QoS 也得到了改進,並在更長的時間跨度內減少了延遲百分位。

在 Shenandoah 的日子裏

新垃圾回收器給我們帶來的好日子持續了一段時間。雖然只是切換了垃圾回收器,但它在應用程序運行時方面帶來的改進對我們來說是個巨大的勝利。不過,如果你對服務的性能和穩定性要求很苛刻,只是簡單地修改一兩個參數是不夠的。接下來,我將進一步介紹這個回收器以及如何更好地使用它。

jvm-hiccup-meter

jvm-hiccup-meter 是一個小型的工具庫,用來度量系統的停頓時間。它是 jHiccup 的極小化版本。jHiccup 用來累計程序運行整個過程的停頓時間,而 jvm-hiccup-meter 則通過回調持續地報告系統的停頓。

因爲 Shenandoah(或者其它 Java GC 也是)已經通過 MBean 和 GC 日誌告訴我們有關 GC 停頓的信息,所以這個庫似乎有點多餘。但是,在有些時候,它可以報告可能被 GC 漏掉的停頓,或者其他與 GC 無關的停頓(例如在進行堆轉儲時)。

這個所謂的庫只是一個簡單的 Java 類,如果你不想在項目中引入新的依賴,可以直接將這個類拷貝到項目中。

jvm-alloc-rate-meter

包括 Shenandoah 在內的大多數現代 GC 都可以輕鬆自如地處理大量的垃圾,但對於併發回收器來說,它們回收垃圾的速度需要比應用程序生成垃圾的速度更快。因此,如果能夠知道應用程序生成對象的速度就好了。

可惜的是,JVM 並沒有爲我們提供這種方式。我們可以從 GC 日誌中獲取一些信息,但並不能用來進行實時監控。不過,我們可以使用另一個叫作 jvm-alloc-rate-meter 的庫,用它來度量虛擬機分配內存的速率,並將這些數據發給監控系統。通過持續地觀察這些指標,我們就可以直觀地知道應用程序是不是分配了太多內存,這樣就可以檢測到可能會導致長 GC 停頓的峯值。

這個庫也只是一個 Java 類,也可以直接拷貝到項目中。

內存分配分析器

知道內存分配速率固然很有用,但如果我們想知道什麼時候該減少應用程序產生的垃圾,內存分析器似乎會更有用。它會告訴我們應用程序的哪些部分產生了最多的垃圾,然後我們就可以針對這些部分進行優化。

這類分析器有很多,我們最後選擇的是 async-profiler 。async-profiler 使用了非侵入式的方式,所以可能不會非常準確,但因爲開銷非常小,可以被用在生產環境中。另外,async-profiler 生成的圖表很容易看懂。

Shenandoah 的故障模式

即使 Shenandoah 很強大,擁有創新的設計,但它並不是一道魔法——它也只是一款運行在這個紛繁世界中的軟件而已。所以,在某些特定條件下,它無法達到所宣稱的停頓。因爲併發型的 GC 是與應用程序一起運行的,也就是說,在 GC 運行的同時應用程序會持續地分配新對象。如果應用程序產生垃圾的速度超過了 GC 的回收速度,我們就有麻煩了。Shenandoah 開發者團隊對這個回收器的故障模式也是直言不諱,並在文檔中詳細地描述了它們。

當垃圾回收速度趕不上垃圾生成速度時,Shenandoah 首先會嘗試步調調整(pacing),即讓分配對象的線程稍作停頓,降低垃圾生成的速度。這個與 STW 停頓有點像,但其實也不太像,因爲它其實隻影響個別線程,而不是整個應用程序。因爲步調調整不作爲 GC 停頓處理,所以監控工具很難看到它們,只能從 GC 日誌裏查找是否發生過步調調整。

如果這樣還不行,Shenandoah 會進入退化模式,也就是進行老式的 STW GC,不一樣的地方在於已經併發執行的 GC 工作不會重複執行。換句話說,如果 Shenandoah 能夠及時進行併發回收,即使進入退化模式,停頓也較短,因爲不需要在 STW 階段完成所有的工作。與步調調整不一樣的是,退化模式 GC 將被視爲正常的停頓,因此監控工具可以看得到。如果你發現 Shenandoah 進入退化模式,說明創建對象的速度太快了。

最後,如果 Shenandoah 在退化模式下無法釋放足夠的內存,仍然會發生 STW GC。Shenandoah 的 FullGC 是並行的,所以至少會比單線程的 STW GC 快,但停頓仍然會比較長。幸運的是,我們在我們的工作負載中還沒有碰到這樣的 FullGC。

Shenandoah 調優

使用 Shenandoah 的默認配置就可以應對大部分場景,所以大部分情況下你不需要去修改配置。不過,其中有一個最重要的參數 -Xmx,只要通過這個參數指定足夠大的堆內存,剩下的事情就交給 Shenandoah 了。不過,隨着你對它有了進一步的瞭解,可以對它做出適當的調整,讓它在各種特定的工作負載下運行得更好。

Shenandoah 的一個主要調節選項是 heuristic 類型,它會根據這個參數決定什麼觸發 GC。這個參數的默認值是 adaptive,有就是根據程序啓動前幾分鐘對象的分配速速來推斷 GC 的閾值。你也可以把它改成 static,並指定在剩餘多少可用內存時觸發 GC。如果你注重延遲而不是吞吐量,也可以把它設置爲 compact,這樣就不會發生步調調整或進入退化模式。我們最終選擇了 compact,CPU 使用率不會再像之前那麼高了。

一些發現

  • 大量的弱引用(軟引用、虛幻引用、finalizer)會增加 Shenandoah 的停頓時間,因爲這些引用需要在 STW 期間處理。即使應用程序中沒有直接使用弱引用,但一些依賴的庫或框架可能會用到。我們在項目中使用了內存泄漏檢測機制,而這個機制又使用了 finalizer,所以,當我們在生產環境中禁用了內存泄漏檢測機制,停頓時間就得到了大幅的改善。

  • 一般來說,Java 垃圾回收器與同步塊是不一樣的,因爲同步塊會導致監視器膨脹並增加根對象集合。我們之前犯了一個錯誤,爲了節省 50 字節的對象空間,使用同步類方法替代了 ReentrantLock。經過這個“優化”之後,Shenandoah 的停頓反而增加了,所以我們又回退到使用 ReentrantLock。

  • 這個發現讓我們感到很喫驚。不知道是什麼原因,在運行了 7 天之後,Shenandoah 的性能逐漸下降,停頓時間也逐漸變長。經過一番調試,我們發現了一個由反射調用( http://anshuiitk.blogspot.com/2010/11/excessive-full-garbage-collection.html)引起的類加載器泄漏。很顯然,JVM 會在運行時通過反射的方式生成類,而這些類不會被卸載。我們目前通過設置 -Dsun.reflect.inflationThreshold=2147483647 來臨時規避這個問題。

  • 確保 Shenandoah 擁有足夠的線程!當然,這個是由 Shenandoah 自行決定的,它會根據機器的 CPU 核數來決定使用多少個線程。不過,如果你剛好使用的是 Amazon ECS,並且運行在 JVM 9+ 上,而你又忘記爲容器設置 CPU 共享,那麼 Java 只會看到一個 CPU 核數!這個時候 Shenandoah 只使用一個線程來回收垃圾,那麼整個應用程序的運行速度可想而知了。

結論

我希望讀者們在讀完這篇文章後可以看看 Shenandoah 是不是可以解決你們的一些問題。如果可以,請分享你們的經驗,這樣就會有更多的人知道這個新垃圾回收器。

原文鏈接

Shenandoah GC in production: experience report

相關文章