我發現,目前不少外部資料對 G1 的介紹大多還停留在 JDK 7 或更早期的實現,很多結論已經存在較大偏差,甚至一些過去的 GC 選項已經不再推薦使用。所以,今天我會選取新版 JDK 中的默認 G1 GC 作為重點進行詳解,并且我會從調優實踐的角度,分析典型場景和調優思路。下面我們一起來更新下這方面的知識。
今天我要問你的問題是,談談你的 GC 調優思路?
## 典型回答
談到調優,這一定是針對特定場景、特定目的的事情, 對于 GC 調優來說,首先就需要清楚調優的目標是什么?從性能的角度看,通常關注三個方面,內存占用(footprint)、延時(latency)和吞吐量(throughput),大多數情況下調優會側重于其中一個或者兩個方面的目標,很少有情況可以兼顧三個不同的角度。當然,除了上面通常的三個方面,也可能需要考慮其他 GC 相關的場景,例如,OOM 也可能與不合理的 GC 相關參數有關;或者,應用啟動速度方面的需求,GC 也會是個考慮的方面。
基本的調優思路可以總結為:
* 理解應用需求和問題,確定調優目標。假設,我們開發了一個應用服務,但發現偶爾會出現性能抖動,出現較長的服務停頓。評估用戶可接受的響應時間和業務量,將目標簡化為,希望 GC 暫停盡量控制在 200ms 以內,并且保證一定標準的吞吐量。
* 掌握 JVM 和 GC 的狀態,定位具體的問題,確定真的有 GC 調優的必要。具體有很多方法,比如,通過 jstat 等工具查看 GC 等相關狀態,可以開啟 GC 日志,或者是利用操作系統提供的診斷工具等。例如,通過追蹤 GC 日志,就可以查找是不是 GC 在特定時間發生了長時間的暫停,進而導致了應用響應不及時。
* 這里需要思考,選擇的 GC 類型是否符合我們的應用特征,如果是,具體問題表現在哪里,是 Minor GC 過長,還是 Mixed GC 等出現異常停頓情況;如果不是,考慮切換到什么類型,如 CMS 和 G1 都是更側重于低延遲的 GC 選項。
* 通過分析確定具體調整的參數或者軟硬件配置。
* 驗證是否達到調優目標,如果達到目標,即可以考慮結束調優;否則,重復完成分析、調整、驗證這個過程。
## 考點分析
今天考察的 GC 調優問題是 JVM 調優的一個基礎方面,很多 JVM 調優需求,最終都會落實在 GC 調優上或者與其相關,我提供的是一個常見的思路。
真正快速定位和解決具體問題,還是需要對 JVM 和 GC 知識的掌握,以及實際調優經驗的總結,有的時候甚至是源自經驗積累的直覺判斷。面試官可能會繼續問項目中遇到的真實問題,如果你能清楚、簡要地介紹其上下文,然后將診斷思路和調優實踐過程表述出來,會是個很好的加分項。
專欄雖然無法提供具體的項目經驗,但是可以幫助你掌握常見的調優思路和手段,這不管是面試還是在實際工作中都是很有幫助的。另外,我會還會從下面不同角度進行補充:
* [上一講](http://time.geekbang.org/column/article/10513)中我已經談到,涉及具體的 GC 類型,JVM 的實際表現要更加復雜。目前,G1 已經成為新版 JDK 的默認選擇,所以值得你去深入理解。
* 因為 G1 GC 一直處在快速發展之中,我會側重它的演進變化,尤其是行為和配置相關的變化。并且,同樣是因為 JVM 的快速發展,即使是收集 GC 日志等方面也發生了較大改進,這也是為什么我在上一講留給你的思考題是有關日志相關選項,看完講解相信你會很驚訝。
* 從 GC 調優實踐的角度,理解通用問題的調優思路和手段。
## 知識擴展
首先,先來整體了解一下 G1 GC 的內部結構和主要機制。
從內存區域的角度,G1 同樣存在著年代的概念,但是與我前面介紹的內存結構很不一樣,其內部是類似棋盤狀的一個個 region 組成,請參考下面的示意圖。

region 的大小是一致的,數值是在 1M 到 32M 字節之間的一個 2 的冪值數,JVM 會盡量劃分 2048 個左右、同等大小的 region,這點可以從源碼[heapRegionBounds.hpp](http://hg.openjdk.java.net/jdk/jdk/file/fa2f93f99dbc/src/hotspot/share/gc/g1/heapRegionBounds.hpp)中看到。當然這個數字既可以手動調整,G1 也會根據堆大小自動進行調整。
在 G1 實現中,年代是個邏輯概念,具體體現在,一部分 region 是作為 Eden,一部分作為 Survivor,除了意料之中的 Old region,G1 會將超過 region 50% 大小的對象(在應用中,通常是 byte 或 char 數組)歸類為 Humongous 對象,并放置在相應的 region 中。邏輯上,Humongous region 算是老年代的一部分,因為復制這樣的大對象是很昂貴的操作,并不適合新生代 GC 的復制算法。
你可以思考下 region 設計有什么副作用?
例如,region 大小和大對象很難保證一致,這會導致空間的浪費。不知道你有沒有注意到,我的示意圖中有的區域是 Humongous 顏色,但沒有用名稱標記,這是為了表示,特別大的對象是可能占用超過一個 region 的。并且,region 太小不合適,會令你在分配大對象時更難找到連續空間,這是一個長久存在的情況,請參考[OpenJDK 社區的討論](http://mail.openjdk.java.net/pipermail/hotspot-gc-use/2017-November/002726.html)。這本質也可以看作是 JVM 的 bug,盡管解決辦法也非常簡單,直接設置較大的 region 大小,參數如下:
~~~
-XX:G1HeapRegionSize=<N, 例如 16>M
~~~
從 GC 算法的角度,G1 選擇的是復合算法,可以簡化理解為:
* 在新生代,G1 采用的仍然是并行的復制算法,所以同樣會發生 Stop-The-World 的暫停。
* 在老年代,大部分情況下都是并發標記,而整理(Compact)則是和新生代 GC 時捎帶進行,并且不是整體性的整理,而是增量進行的。
我在[上一講](http://time.geekbang.org/column/article/10513)曾經介紹過,習慣上人們喜歡把新生代 GC(Young GC)叫作 Minor GC,老年代 GC 叫作 Major GC,區別于整體性的 Full GC。但是現代 GC 中,這種概念已經不再準確,對于 G1 來說:
* Minor GC 仍然存在,雖然具體過程會有區別,會涉及 Remembered Set 等相關處理。
* 老年代回收,則是依靠 Mixed GC。并發標記結束后,JVM 就有足夠的信息進行垃圾收集,Mixed GC 不僅同時會清理 Eden、Survivor 區域,而且還會清理部分 Old 區域。可以通過設置下面的參數,指定觸發閾值,并且設定最多被包含在一次 Mixed GC 中的 region 比例。
~~~
–XX:G1MixedGCLiveThresholdPercent
–XX:G1OldCSetRegionThresholdPercent
~~~
從 G1 內部運行的角度,下面的示意圖描述了 G1 正常運行時的狀態流轉變化,當然,在發生逃逸失敗等情況下,就會觸發 Full GC。

G1 相關概念非常多,有一個重點就是 Remembered Set,用于記錄和維護 region 之間對象的引用關系。為什么需要這么做呢?試想,新生代 GC 是復制算法,也就是說,類似對象從 Eden 或者 Survivor 到 to 區域的“移動”,其實是“復制”,本質上是一個新的對象。在這個過程中,需要必須保證老年代到新生代的跨區引用仍然有效。下面的示意圖說明了相關設計。

G1 的很多開銷都是源自 Remembered Set,例如,它通常約占用 Heap 大小的 20% 或更高,這可是非常可觀的比例。并且,我們進行對象復制的時候,因為需要掃描和更改 Card Table 的信息,這個速度影響了復制的速度,進而影響暫停時間。
描述 G1 內部的資料很多,我就不重復了,如果你想了解更多內部結構和算法等,我建議參考一些具體的[介紹](https://www.infoq.com/articles/G1-One-Garbage-Collector-To-Rule-Them-All),書籍方面我推薦 Charlie Hunt 等撰寫的《Java Performance Companion》。
接下來,我介紹下大家可能還不了解的 G1 行為變化,它們在一定程度上解決了專欄其他講中提到的部分困擾,如類型卸載不及時的問題。
* 上面提到了 Humongous 對象的分配和回收,這是很多內存問題的來源,Humongous region 作為老年代的一部分,通常認為它會在并發標記結束后才進行回收,但是在新版 G1 中,Humongous 對象回收采取了更加激進的策略。
我們知道 G1 記錄了老年代 region 間對象引用,Humongous 對象數量有限,所以能夠快速的知道是否有老年代對象引用它。如果沒有,能夠阻止它被回收的唯一可能,就是新生代是否有對象引用了它,但這個信息是可以在 Young GC 時就知道的,所以完全可以在 Young GC 中就進行 Humongous 對象的回收,不用像其他老年代對象那樣,等待并發標記結束。
* 我在[專欄第 5 講](http://time.geekbang.org/column/article/7349),提到了在 8u20 以后字符串排重的特性,在垃圾收集過程中,G1 會把新創建的字符串對象放入隊列中,然后在 Young GC 之后,并發地(不會 STW)將內部數據(char 數組,JDK 9 以后是 byte 數組)一致的字符串進行排重,也就是將其引用同一個數組。你可以使用下面參數激活:
~~~
-XX:+UseStringDeduplication
~~~
注意,這種排重雖然可以節省不少內存空間,但這種并發操作會占用一些 CPU 資源,也會導致 Young GC 稍微變慢。
* 類型卸載是個長期困擾一些 Java 應用的問題,在[專欄第 25 講](http://time.geekbang.org/column/article/10192)中,我介紹了一個類只有當加載它的自定義類加載器被回收后,才能被卸載。元數據區替換了永久代之后有所改善,但還是可能出現問題。
G1 的類型卸載有什么改進嗎?很多資料中都談到,G1 只有在發生 Full GC 時才進行類型卸載,但這顯然不是我們想要的。你可以加上下面的參數查看類型卸載:
~~~
-XX:+TraceClassUnloading
~~~
幸好現代的 G1 已經不是如此了,8u40 以后,G1 增加并默認開啟下面的選項:
~~~
-XX:+ClassUnloadingWithConcurrentMark
~~~
也就是說,在并發標記階段結束后,JVM 即進行類型卸載。
* 我們知道老年代對象回收,基本要等待并發標記結束。這意味著,如果并發標記結束不及時,導致堆已滿,但老年代空間還沒完成回收,就會觸發 Full GC,所以觸發并發標記的時機很重要。早期的 G1 調優中,通常會設置下面參數,但是很難給出一個普適的數值,往往要根據實際運行結果調整
~~~
-XX:InitiatingHeapOccupancyPercent
~~~
在 JDK 9 之后的 G1 實現中,這種調整需求會少很多,因為 JVM 只會將該參數作為初始值,會在運行時進行采樣,獲取統計數據,然后據此動態調整并發標記啟動時機。對應的 JVM 參數如下,默認已經開啟:
~~~
-XX:+G1UseAdaptiveIHOP
~~~
* 在現有的資料中,大多指出 G1 的 Full GC 是最差勁的單線程串行 GC。其實,如果采用的是最新的 JDK,你會發現 Full GC 也是并行進行的了,在通用場景中的表現還優于 Parallel GC 的 Full GC 實現。
當然,還有很多其他的改變,比如更快的 Card Table 掃描等,這里不再展開介紹,因為它們并不帶來行為的變化,基本不影響調優選擇。
前面介紹了 G1 的內部機制,并且穿插了部分調優建議,下面從整體上給出一些調優的建議。
首先,**建議盡量升級到較新的 JDK 版本**,從上面介紹的改進就可以看到,很多人們常常討論的問題,其實升級 JDK 就可以解決了。
第二,掌握 GC 調優信息收集途徑。掌握盡量全面、詳細、準確的信息,是各種調優的基礎,不僅僅是 GC 調優。我們來看看打開 GC 日志,這似乎是很簡單的事情,可是你確定真的掌握了嗎?
除了常用的兩個選項,
~~~
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
~~~
還有一些非常有用的日志選項,很多特定問題的診斷都是要依賴這些選項:
~~~
-XX:+PrintAdaptiveSizePolicy // 打印 G1 Ergonomics 相關信息
~~~
我們知道 GC 內部一些行為是適應性的觸發的,利用 PrintAdaptiveSizePolicy,我們就可以知道為什么 JVM 做出了一些可能我們不希望發生的動作。例如,G1 調優的一個基本建議就是避免進行大量的 Humongous 對象分配,如果 Ergonomics 信息說明發生了這一點,那么就可以考慮要么增大堆的大小,要么直接將 region 大小提高。
如果是懷疑出現引用清理不及時的情況,則可以打開下面選項,掌握到底是哪里出現了堆積。
~~~
-XX:+PrintReferenceGC
~~~
另外,建議開啟選項下面的選項進行并行引用處理。
~~~
-XX:+ParallelRefProcEnabled
~~~
需要注意的一點是,JDK 9 中 JVM 和 GC 日志機構進行了重構,其實我前面提到的**PrintGCDetails 已經被標記為廢棄**,而**PrintGCDateStamps 已經被移除**,指定它會導致 JVM 無法啟動。可以使用下面的命令查詢新的配置參數。
~~~
java -Xlog:help
~~~
最后,來看一些通用實踐,理解了我前面介紹的內部結構和機制,很多結論就一目了然了,例如:
* 如果發現 Young GC 非常耗時,這很可能就是因為新生代太大了,我們可以考慮減小新生代的最小比例。
~~~
-XX:G1NewSizePercent
~~~
降低其最大值同樣對降低 Young GC 延遲有幫助。
~~~
-XX:G1MaxNewSizePercent
~~~
如果我們直接為 G1 設置較小的延遲目標值,也會起到減小新生代的效果,雖然會影響吞吐量。
* 如果是 Mixed GC 延遲較長,我們應該怎么做呢?
還記得前面說的,部分 Old region 會被包含進 Mixed GC,減少一次處理的 region 個數,就是個直接的選擇之一。
我在上面已經介紹了 G1OldCSetRegionThresholdPercent 控制其最大值,還可以利用下面參數提高 Mixed GC 的個數,當前默認值是 8,Mixed GC 數量增多,意味著每次被包含的 region 減少。
~~~
-XX:G1MixedGCCountTarget
~~~
今天的內容算是拋磚引玉,更多內容你可以參考[G1 調優指南](https://docs.oracle.com/javase/9/gctuning/garbage-first-garbage-collector-tuning.htm#JSGCT-GUID-4914A8D4-DE41-4250-B68E-816B58D4E278)等,遠不是幾句話可以囊括的。需要注意的是,也要避免過度調優,G1 對大堆非常友好,其運行機制也需要浪費一定的空間,有時候稍微多給堆一些空間,比進行苛刻的調優更加實用。
今天我梳理了基本的 GC 調優思路,并對 G1 內部結構以及最新的行為變化進行了詳解。總的來說,G1 的調優相對簡單、直觀,因為可以直接設定暫停時間等目標,并且其內部引入了各種智能的自適應機制,希望這一切的努力,能夠讓你在日常應用開發時更加高效。
## 一課一練
關于今天我們討論的題目你做到心中有數了嗎?今天的思考題是,定位 Full GC 發生的原因,有哪些方式?
- 前言
- 開篇詞
- 開篇詞 -以面試題為切入點,有效提升你的Java內功
- 模塊一 Java基礎
- 第1講 談談你對Java平臺的理解?
- 第2講 Exception和Error有什么區別?
- 第3講 談談final、finally、 finalize有什么不同?
- 第4講 強引用、軟引用、弱引用、幻象引用有什么區別?
- 第5講 String、StringBuffer、StringBuilder有什么區別?
- 第6講 動態代理是基于什么原理?
- 第7講 int和Integer有什么區別?
- 第8講 對比Vector、ArrayList、LinkedList有何區別?
- 第9講 對比Hashtable、HashMap、TreeMap有什么不同?
- 第10講 如何保證集合是線程安全的? ConcurrentHashMap如何實現高效地線程安全?
- 第11講 Java提供了哪些IO方式? NIO如何實現多路復用?
- 第12講 Java有幾種文件拷貝方式?哪一種最高效?
- 第13講 談談接口和抽象類有什么區別?
- 第14講 談談你知道的設計模式?
- 模塊二 Java進階
- 第15講 synchronized和ReentrantLock有什么區別呢?
- 第16講 synchronized底層如何實現?什么是鎖的升級、降級?
- 第17講 一個線程兩次調用start()方法會出現什么情況?
- 第18講 什么情況下Java程序會產生死鎖?如何定位、修復?
- 第19講 Java并發包提供了哪些并發工具類?
- 第20講 并發包中的ConcurrentLinkedQueue和LinkedBlockingQueue有什么區別?
- 第21講 Java并發類庫提供的線程池有哪幾種? 分別有什么特點?
- 第22講 AtomicInteger底層實現原理是什么?如何在自己的產品代碼中應用CAS操作?
- 第23講 請介紹類加載過程,什么是雙親委派模型?
- 第24講 有哪些方法可以在運行時動態生成一個Java類?
- 第25講 談談JVM內存區域的劃分,哪些區域可能發生OutOfMemoryError?
- 第26講 如何監控和診斷JVM堆內和堆外內存使用?
- 第27講 Java常見的垃圾收集器有哪些?
- 第28講 談談你的GC調優思路?
- 第29講 Java內存模型中的happen-before是什么?
- 第30講 Java程序運行在Docker等容器環境有哪些新問題?
- 模塊三 Java安全基礎
- 第31講 你了解Java應用開發中的注入攻擊嗎?
- 第32講 如何寫出安全的Java代碼?
- 模塊四 Java性能基礎
- 第33講 后臺服務出現明顯“變慢”,談談你的診斷思路?
- 第34講 有人說“Lambda能讓Java程序慢30倍”,你怎么看?
- 第35講 JVM優化Java代碼時都做了什么?
- 模塊五 Java應用開發擴展
- 第36講 談談MySQL支持的事務隔離級別,以及悲觀鎖和樂觀鎖的原理和應用場景?
- 第37講 談談Spring Bean的生命周期和作用域?
- 第38講 對比Java標準NIO類庫,你知道Netty是如何實現更高性能的嗎?
- 第39講 談談常用的分布式ID的設計方案?Snowflake是否受冬令時切換影響?
- 周末福利
- 周末福利 談談我對Java學習和面試的看法
- 周末福利 一份Java工程師必讀書單
- 結束語
- 結束語 技術沒有終點
- 結課測試 Java核心技術的這些知識,你真的掌握了嗎?