本課時主要講解如何在大流量高并發場景下進行估算和調優。
我們知道,垃圾回收器一般使用默認參數,就可以比較好的運行。但如果用錯了某些參數,那么后果可能會比較嚴重,我不只一次看到有同學想要驗證某個剛剛學到的優化參數,結果引起了線上 GC 的嚴重問題。
所以你的應用程序如果目前已經滿足了需求,那就不要再隨便動這些參數了。另外,優化代碼獲得的性能提升,遠遠大于參數調整所獲得的性能提升,你不要純粹為了調參數而走了彎路。
那么,GC 優化有沒有可遵循的一些規則呢?這些“需求”又是指的什么?我們可以將目標歸結為三點:
1. 系統容量(Capacity)
2. 延遲(Latency)
3. 吞吐量(Throughput)
#### 考量指標

#### 系統容量
系統容量其實非常好理解。比如,領導要求你每個月的運維費用不能超過 x 萬,那就決定了你的機器最多是 2C4G 的。
舉個比較極端的例子。假如你的內存是無限大的,那么無論是存活對象,還是垃圾對象,都不需要額外的計算和回收,你只需要往里放就可以了。這樣,就沒有什么吞吐量和延遲的概念了。
但這畢竟是我們的一廂情愿。越是資源限制比較嚴格的系統,對它的優化就會越明顯。通常在一個資源相對寬松的環境下優化的參數,平移到另外一個限制資源的環境下,并不是最優解。
#### 吞吐量-延遲
接下來我們看一下吞吐量和延遲方面的概念。
假如你開了一個面包店,你的首要目標是賣出更多的面包,因為賺錢來說是最要緊的。
為了讓客人更快買到面包,你引進了很多先進的設備,使得制作面包的間隔減少到 30 分鐘,一批面包可以有 100 個。
工人師傅是拿工資的,并不想和你一樣加班。按照一天 8 小時工作制,每天就可以制作 8x2x100=1600 個面包。
但是你很不滿意,因為每天的客人都很多,需求大約是 2000 個面包。
你只好再引進更加先進的設備,這種設備可以一次做出 200 個面包,一天可以做 2000~3000 個面包,但是每運行一段時間就需要冷卻一會兒。
原來每個客人最多等 30 分鐘就可以拿到面包,現在有的客人需要等待 40 分鐘。客人通常受不了這么長的等待時間,第二天就不來了。
考慮到我們的營業目標,就可以抽象出兩個概念。
* 吞吐量,也就是每天制作的面包數量。
* 延遲,也就是等待的時間,涉及影響顧客的滿意度。

? ? ? ?? ? ?
吞吐量大不代表響應能力高,吞吐量一般這么描述:在一個時間段內完成了多少個事務操作;在一個小時之內完成了多少批量操作。
響應能力是以最大的延遲時間來判斷的,比如:一個桌面按鈕對一個觸發事件響應有多快;需要多長時間返回一個網頁;查詢一行 SQL 需要多長時間,等等。
這兩個目標,在有限的資源下,通常不能夠同時達到,我們需要做一些權衡。
#### 選擇垃圾回收器
接下來,再回顧一下前面介紹的垃圾回收器,簡單看一下它們的應用場景。
* 如果你的堆大小不是很大(比如 100MB),選擇串行收集器一般是效率最高的。參數:-XX:+UseSerialGC。
* 如果你的應用運行在單核的機器上,或者你的虛擬機核數只有 1C,選擇串行收集器依然是合適的,這時候啟用一些并行收集器沒有任何收益。參數:-XX:+UseSerialGC。
* 如果你的應用是“吞吐量”優先的,并且對較長時間的停頓沒有什么特別的要求。選擇并行收集器是比較好的。參數:-XX:+UseParallelGC。
* 如果你的應用對響應時間要求較高,想要較少的停頓。甚至 1 秒的停頓都會引起大量的請求失敗,那么選擇 G1、ZGC、CMS 都是合理的。雖然這些收集器的 GC 停頓通常都比較短,但它需要一些額外的資源去處理這些工作,通常吞吐量會低一些。參數:-XX:+UseConcMarkSweepGC、-XX:+UseG1GC、-XX:+UseZGC 等。
從上面這些出發點來看,我們平常的 Web 服務器,都是對響應性要求非常高的。選擇性其實就集中在 CMS、G1、ZGC 上。
而對于某些定時任務,使用并行收集器,是一個比較好的選擇。
#### 大流量應用特點
這是一類對延遲非常敏感的系統。吞吐量一般可以通過堆機器解決。
如果一項業務有價值,客戶很喜歡,那億級流量很容易就能達到了。假如某個接口一天有 10 億次請求,每秒的峰值大概也就 5~6 w/秒,雖然不算是很大,但也不算小。最直接的影響就是:可能你發個版,幾萬用戶的請求就抖一抖。
一般達到這種量級的系統,承接請求的都不是一臺服務器,接口都會要求快速響應,一般不會超過 100ms。
這種系統,一般都是社交、電商、游戲、支付場景等,要求的是短、平、快。長時間停頓會堆積海量的請求,所以在停頓發生的時候,表現會特別明顯。我們要考量這些系統,有很多指標。
* 每秒處理的事務數量(TPS);
* 平均響應時間(AVG);
* TP 值,比如 TP90 代表有 90% 的請求響應時間小于 x 毫秒。
可以看出來,它和 JVM 的某些指標很像。
尤其是 TP 值,最能代表系統中到底有多少長尾請求,這部分請求才是影響系統穩定性的元兇。大多數情況下,GC 增加,長尾請求的數量也會增加。
我們的目標,就是減少這些停頓。本課時假定使用的是 CMS 垃圾回收器。
#### 估算
在《編程珠璣》第七章里,將估算看作程序員的一項非常重要的技能。這是一種化繁為簡的能力,不要求極度精確,但對問題的分析有著巨大的幫助。
拿一個簡單的 Feed 業務來說。查詢用戶在社交網站上發送的帖子,還需要查詢第一頁的留言(大概是 15 條),它們共同組成了每次查詢后的實體。
```
class?Feed{
???private?User?user;
???private?List<Comment>?commentList;
???private?String?content;
}
```
這種類型的數據結構,一般返回體都比較大,大概會有幾 KB 到幾十 KB 不等。我們就可以對這些數據進行以大體估算。具體的數據來源可以看日志,也可以分析線上的請求。

這個接口每天有 10 億次請求,假如每次請求的大小有 20KB(很容易達到),那么一天的流量就有 18TB 之巨。假如高峰請求 6w/s,我們部署了 10 臺機器,那么每個 JVM 的流量就可以達到 120MB/s,這個速度算是比較快的了。
如果你實在不知道怎么去算這個數字,那就按照峰值的 2 倍進行準備,一般都是 OK 的。
#### 調優
問題是這樣的,我們的機器是 4C8GB 的,分配給了 JVM 1024*8GB/3*2= 5460MB 的空間。那么年輕代大小就有 5460MB/3=1820MB。進而可以推斷出,Eden 區的大小約 1456MB,那么大約只需要 12 秒,就會發生一次 Minor GC。不僅如此,每隔半個小時,會發生一次 Major GC。
不管是年輕代還是老年代,這個 GC 頻率都有點頻繁了。
提醒一下,你可以算一下我們的 Survivor 區大小,大約是 182MB 左右,如果稍微有點流量偏移,或者流量突增,再或者和其他接口共用了 JVM,那么這個 Survivor 區就已經裝不下 Minor GC 后的內容了。總有一部分超出的容量,需要老年代來補齊。這些垃圾信息就要保存更長時間,直到老年代空間不足。

我們發現,用戶請求完這些信息之后,很快它們就會變成垃圾。所以每次 MinorGC 之后,剩下的對象都很少。
也就是說,我們的流量雖然很多,但大多數都在年輕代就銷毀了。如果我們加大年輕代的大小,由于 GC 的時間受到活躍對象數的影響,回收時間并不會增加太多。
如果我們把一半空間給年輕代。也就是下面的配置:
```
-XX:+UseConcMarkSweepGC -Xmx5460M -Xms5460M -Xmn2730M
```
重新估算一下,發現 Minor GC 的間隔,由 12 秒提高到了 18 秒。
線上觀察:
```
[ParNew: 2292326K‐>243160K(2795520K), 0.1021743 secs]
3264966K‐>10880154K(1215800K), 0.1021417 secs]
[Times: user=0.52 sys=0.02, real=0.2 secs]
```
Minor GC 有所改善,但是并沒有顯著的提升。相比較而言,Major GC 的間隔卻增加到了 3 小時,是一個非常大的性能優化。這就是在容量限制下的初步調優方案。
此種場景,我們可以更加激進一些,調大年輕代(順便調大了幸存區),讓對象在年輕代停留的時間更長一些,有更多的 buffer 空間。這樣 Minor GC 間隔又可以提高到 23 秒。參數配置:
```
-XX:+UseConcMarkSweepGC -Xmx5460M -Xms5460M -Xmn3460M
```
一切看起來很美好,但還是有一個瑕疵。
問題如下:由于每秒的請求都非常大,如果應用重啟或者更新,流量瞬間打過來,JVM 還沒預熱完畢,這時候就會有大量的用戶請求超時、失敗。
為了解決這種問題,通常會逐步的把新發布的機器進行放量預熱。比如第一秒 100 請求,第二秒 200 請求,第三秒 5000 請求。大型的應用都會有這個預熱過程。

如圖所示,負載均衡器負責服務的放量,server4 將在 6 秒之后流量正常流通。但是奇怪的是,每次重啟大約 20 多秒以后,就會發生一次詭異的 Full GC。
注意是 Full GC,而不是老年代的 Major GC,也不是年輕代的 Minor GC。
事實上,經過觀察,此時年輕代和老年代的空間還有很大一部分,那 Full GC 是怎么產生的呢?
一般,Full GC 都是在老年代空間不足的時候執行。但不要忘了,我們還有一個區域叫作 Metaspace,它的容量是沒有上限的,但是每當它擴容時,就會發生 Full GC。
使用下面的命令可以看到它的默認值:
```
java -XX:+PrintFlagsFinal 2>&1 | grep Meta
```
默認值如下:
```
size_t MetaspaceSize = 21807104 ? ? ?{pd product} {default}
size_t MaxMetaspaceSize = 18446744073709547520 ? ? ?{product} {default}
```
可以看到 MetaspaceSize 的大小大約是 20MB。這個初始值太小了。
現在很多類庫,包括 Spring,都會大量生成一些動態類,20MB 很容易就超了,我們可以試著調大這個數值。
按照經驗,一般調整成 256MB 就足夠了。同時,為了避免無限制使用造成操作系統內存溢出,我們同時設置它的上限。配置參數如下:
```
-XX:+UseConcMarkSweepGC -Xmx5460M -Xms5460M -Xmn3460M -XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=256M
```
經觀察,啟動后停頓消失。
這種方式通常是行之有效的,但也可以通過擴容機器內存或者擴容機器數量的辦法,顯著地降低 GC 頻率。這些都是在估算容量后的優化手段。
我們把部分機器升級到 8C16GB 的機器,使用如下的參數:
```
-XX:+UseConcMarkSweepGC -Xmx10920M -Xms10920M -Xmn5460M -XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=256M
```
相比較其他實例,系統運行的特別棒,系統平均 1 分鐘左右發生一次 MinorGC,老年代觀察了一天才發生 GC,響應水平明顯提高。
這是一種非常簡單粗暴的手段,但是有效。我們看到,對 JVM 的優化,不僅僅是優化參數本身。我們的目的是解決問題,尋求多種有用手段。
#### 總結
其實,如果沒有明顯的內存泄漏問題和嚴重的性能問題,專門調優一些 JVM 參數是非常沒有必要的,優化空間也比較小。
所以,我們一般優化的思路有一個重要的順序:
1. 程序優化,效果通常非常大;
2. 擴容,如果金錢的成本比較小,不要和自己過不去;
3. 參數調優,在成本、吞吐量、延遲之間找一個平衡點。
本課時主要是在第三點的基礎上,一步一步地增加 GC 的間隔,達到更好的效果。
我們可以再加一些原則用以輔助完成優化。
1. 一個長時間的壓測是必要的,通常我們使用 JMeter 工具。
2. 如果線上有多個節點,可以把我們的優化在其中幾個節點上生效。等優化真正有效果之后再全面推進。
3. 優化過程和目標之間可能是循環的,結果和目標不匹配,要推翻重來。?

我們的業務場景是高并發的。對象誕生的快,死亡的也快,對年輕代的利用直接影響了整個堆的垃圾收集。
足夠大的年輕代,會增加系統的吞吐,但不會增加 GC 的負擔。
容量足夠的 Survivor 區,能夠讓對象盡可能的留在年輕代,減少對象的晉升,進而減少 Major GC。
我們還看到了一個元空間引起的 Full GC 的過程,這在高并發的場景下影響會格外突出,尤其是對于使用了大量動態類的應用來說。通過調大它的初始值,可以解決這個問題。
#### 課后問答、
* 1、老是, 針對nginx放量有沒有具體算法可以參考? 首次100請求, 第2秒200請求, 以此類推300/400...這是我們邏輯上的, 具體應該在那個地方實現怎么實現呢?
答案:對于nginx,可以使用lua腳本。最笨的辦法,手動改動nginx upstream的weight權重。對springclound來說,可以重寫Robbin的負載均衡策略。自研框架也是,主要是在負載均衡層面進行控制。關于預熱算法,可以參考Guava的RateLimiter,它是線性增長的算法。
* 2、問題是這樣的,我們的機器是 4C8GB 的,分配給了 JVM 1024*8GB/3*2= 5460MB 的空間。那么年輕代大小就有 5460MB/3=1820MB。”這里的JVM 的空間“1024*8GB/3*2 ” 除以3 是為什么? 再乘以 2 是什么意思??意思是說 JVM的總內存是 服務器物理內存的 三分之二??默認的不是 四分之一嗎?請指點,謝謝!
答案:這里不是默認的,是推薦比例。
* 3、老師,調優那部分5460m是通過參數指定的吧,還是根據系統內存默認配置的,另外每半小時majorgc這個時間是怎么估算的啊
答案: 是的,通過手動指定的。這里的MajorGC的頻率不是估算的,是實際觀測的。老年代的估算可以根據年輕代的提升速率算一下(參考GCLOG)。
- 前言
- 開篇詞
- 基礎原理
- 第01講:一探究竟:為什么需要 JVM?它處在什么位置?
- 第02講:大廠面試題:你不得不掌握的 JVM 內存管理
- 第03講:大廠面試題:從覆蓋 JDK 的類開始掌握類的加載機制
- 第04講:動手實踐:從棧幀看字節碼是如何在 JVM 中進行流轉的
- 垃圾回收
- 第05講:大廠面試題:得心應手應對 OOM 的疑難雜癥
- 第06講:深入剖析:垃圾回收你真的了解嗎?(上)
- 第06講:深入剖析:垃圾回收你真的了解嗎?(下)
- 第07講:大廠面試題:有了 G1 還需要其他垃圾回收器嗎?
- 第08講:案例實戰:億級流量高并發下如何進行估算和調優
- 實戰部分
- 第09講:案例實戰:面對突如其來的 GC 問題如何下手解決
- 第10講:動手實踐:自己模擬 JVM 內存溢出場景
- 第11講:動手實踐:遇到問題不要慌,輕松搞定內存泄漏
- 第12講:工具進階:如何利用 MAT 找到問題發生的根本原因
- 第13講:動手實踐:讓面試官刮目相看的堆外內存排查
- 第14講:預警與解決:深入淺出 GC 監控與調優
- 第15講:案例分析:一個高死亡率的報表系統的優化之路
- 第16講:案例分析:分庫分表后,我的應用崩潰了
- 進階部分
- 第17講:動手實踐:從字節碼看方法調用的底層實現
- 第18講:大廠面試題:不要搞混 JMM 與 JVM
- 第19講:動手實踐:從字節碼看并發編程的底層實現
- 第20講:動手實踐:不為人熟知的字節碼指令
- 第21講:深入剖析:如何使用 Java Agent 技術對字節碼進行修改
- 第22講:動手實踐:JIT 參數配置如何影響程序運行?
- 第23講:案例分析:大型項目如何進行性能瓶頸調優?
- 彩蛋
- 第24講:未來:JVM 的歷史與展望
- 第25講:福利:常見 JVM 面試題補充