Full GC 的時間和次數是管理 java 的應用服務不得不考慮的問題,高吞吐量和低停頓是追求高質量服務重要目標,從而會有根據業務的特點衍生出各種垃圾回收器。在實戰中如何根據如何使用 ParNew ,CMS 等回收器和配置各種引數,要在理論結合實踐中不斷優化。
一、問題的發現
看到線上的服務機器一些節點時不時地有 TCP 報警 ,所以我們斷定是 TCP 的連線出現了問題。
讓我們來回顧一下 TCP 的三次握手和四次揮手,借網上的一個圖:
當第一次握手,建立半連線狀態:
client 通過 connect 向 server 發出 SYN 包時,client 會維護一個 socket 佇列,如果 socket 等待佇列滿了,而 client 也會由此返回 connection time out,只要是 client 沒有收到 第二次握手 SYN+ACK,3s 之後,client 會再次傳送,如果依然沒有收到,9s 之後會繼續傳送。
此時 server 會維護一個 SYN 佇列,半連線 syn 佇列的長度為 max(64, /proc/sys/net/ipv4/tcp_max_syn_backlog) ,在機器的 tcp_max_syn_backlog 值在 /proc/sys/net/ipv4/tcp_max_syn_backlog 下配置,當 server 收到 client 的 SYN 包後,會進行第二次握手傳送 SYN+ACK 的包加以確認,client 的 TCP 協議棧會喚醒 socket 等待佇列,發出 connect 呼叫。
當第三次握手時,當 server 接收到 ACK 報之後, 會進入一個新的叫 accept 的佇列,該佇列的長度為 min(backlog, somaxconn),預設情況下,somaxconn 的值為 128,表示最多有 129 的 ESTAB 的連線等待 accept(),而 backlog 的值則應該是由 int listen(int sockfd, int backlog) 中的第二個引數指定,listen 裡面的 backlog 可以有我們的應用程式去定義的。
那麼我們的 RPC 服務的 thrift 協議封裝定義在 TCP 這層的 backlog 的大小是 50。
同樣通過,ss -l 可以知道 Send-Q 表示的則是最大的 listen backlog 數值,這就就是上面提到的 min(backlog, somaxconn) 的值。
當 accept 佇列滿了之後,即使 client 繼續向 server 傳送 ACK 的包,也會不被相應,此時,server 通過 /proc/sys/net/ipv4/tcp_abort_on_overflow 來決定如何返回,0 表示直接丟丟棄該 ACK,1 表示傳送 RST 通知 client;相應的,client 則會分別返回 read timeout 或者 connection reset by peer。
總的來說:可以看到,整個 TCP 連線中我們的 Server 端 有如下的兩個 queue:
1. 一個是 半連線佇列:(syn queue) queue(max(tcp_max_syn_backlog, 64)),用來儲存 SYN_SENT 以及 SYN_RECV 的資訊。
2. 另外一個是完全連線佇列: accept queue(min(somaxconn, backlog)),儲存 ESTAB 的狀態,那麼建立連線之後,我們的應用服務的執行緒就可以 accept() 處理業務需求了。
那麼回到報警的問題,通過監控觀察發現,每次報警都會和一次 full gc 的時間點吻合,而且 full time 達幾秒,到先線上去看一下我們服務的執行緒數,沒有到高峰期,但業務執行緒就會達到 200+,在高併發的情況下,可以想像所有的服務執行緒暫停導致對上面的 accept 佇列堆積的影響。
可知,服務由於 full gc 暫停卡頓引起的 tcp 連線 ,看看 GC 日誌同樣驗證問題。
發現這時服務都沒有監控了。可能卡住了,監控也不上報了。
可以看出問題的原因是由於 promotion failed,concurrent mode failure 或 promotion failed 會導致一次 full gc,而 gc time 回達數秒。下面兩種情況都會轉向 Full GC,網站停頓時間較長
垃圾回收時 promotion failed 是個比較嚴重問題,一般可能是兩種原因產生。
第一個原因是救助空間不夠,救助空間裡的物件還不應該被移動到年老代,但年輕代又有很多物件需要放入 Survivor 救助空間;
第二個原因是年老代沒有足夠的空間接納來自年輕代的物件;這兩種情況都會轉向 Full GC,網站停頓時間較長。
線上機器 JVM 引數配置:
1 2 3 |
JVM_GC="-XX:+PrintGC -XX:+PrintGCTimeStamps -XX:+PrintGCDateStamps -XX:+PrintGCDetails -XX:+PrintHeapAtGC -XX:+PrintTenuringDistribution -XX:+UseParNewGC -XX:+UseConcMarkSweepGC -XX:ParallelCMSThreads=4 -XX:CMSInitiatingOccupancyFraction=80 -XX:+CMSParallelRemarkEnabled -XX:+ExplicitGCInvokesConcurrent -XX:+CMSPermGenSweepingEnabled -XX:+CMSClassUnloadingEnabled" JVM_SIZE="-Xms4500m -Xmx4500m" JVM_HEAP="-XX:PermSize=128m -XX:MaxPermSize=256m -XX:SurvivorRatio=8 -XX:NewRatio=3" |
根據上面介紹了 promontion faild 產生的原因是 EDEN 空間不足的情況下將 EDEN 與 From survivor 中的存活物件存入 To survivor 區時, To survivor 區的空間不足,再次晉升到 old gen 區,而 old gen 區記憶體也不夠的情況下產生了 promontion faild 從而導致 full gc. 那可以推斷出:eden+from survivor < old gen 區剩餘記憶體時,不會出現 promontion faild 的情況,即:
(Xmx-Xmn)*(1-CMSInitiatingOccupancyFraction/100)>=(Xmn-Xmn/(SurvivorRatior+2)) 進而推斷出
CMSInitiatingOccupancyFraction <=((Xmx-Xmn)-(Xmn-Xmn/(SurvivorRatior+2)))/(Xmx-Xmn)*100(只有這個公式滿足時才有可能不會 promontion faild)
按線上配置計算,((Xmx-Xmn)-(Xmn-Xmn/(SurvivorRatior+2)))/(Xmx-Xmn)*100=73%,所以有時後 young 區大物件到 old 區,因為線上– XX:CMSInitiatingOccupancyFraction =80, 而這時 old 區恰好到大於 73%,空間不足,會發生 promontion faild。
二、優化的第一步
實驗策略:CMSInitiatingOccupancyFraction 調整為 70,這樣單純的修改 XX:CMSInitiatingOccupancyFraction 閥值,目的是嘗試更早的對 old 區開始收集,已避免上面提到的情況:在回收完成之前,堆沒有足夠空間分配。
釋出到線上,觀察一段時間,發現日誌沒有了 promontion faild,但是 full gc times 和 count 並沒有明顯的變化。
三、優化的第二步
上面方法不太好,因為沒有用到救助空間,所以年老代容易滿,CMS 執行會比較頻繁。我改善了一下,還是用救助空間,但是把救助空間加大,這樣也不會有 promotion failed。為了解決暫停問題和 promotion failed 問題,最後設定 – XX:SurvivorRatio=1 ,並把 MaxTenuringThreshold 去掉,這樣即沒有暫停又不會有 promotoin failed,而且更重要的是,年老代和永久代上升非常慢(因為好多物件到不了年老代就被回收了),所以 CMS 執行頻率非常低,好近小時才執行一次,這樣,伺服器都不用重啟了。
1 2 3 |
JVM_GC="-XX:+PrintGC -XX:+PrintGCTimeStamps -XX:+PrintGCDateStamps -XX:+PrintGCDetails -XX:+PrintHeapAtGC -XX:+PrintTenuringDistribution -XX:+UseParNewGC -XX:+UseConcMarkSweepGC -XX:+UseCMSCompactAtFullCollection -XX:CMSFullGCsBeforeCompaction=1 -XX:ParallelCMSThreads=4 -XX:CMSInitiatingOccupancyFraction=56 -XX:+CMSParallelRemarkEnabled -XX:+ExplicitGCInvokesConcurrent -XX:+CMSPermGenSweepingEnabled -XX:+CMSClassUnloadingEnabled" JVM_SIZE="-Xms4500m -Xmx4500m" JVM_HEAP="-Xmn1500m -XX:PermSize=128m -XX:MaxPermSize=256m -XX:SurvivorRatio=1" |
引數解釋說明:
-XX:CMSInitiatingOccupancyFraction=60(嘗試更早的對old區開始收集,已避免上面提到的情況:在回收完成之前,堆沒有足夠空間分配)
-XX:+UseCMSCompactAtFullCollection -XX:CMSFullGCsBeforeCompaction=1(CMS垃圾回收會產生脆片,GC的時候整理一下)
-Xmn1500m(增加年輕態的記憶體空間)
-XX:SurvivorRatio=1(MaxTenuringThreshold去掉,這樣即沒有暫停又不會有promotoin failed,而且更重要的是,年老代和永久代上升非常慢)
四、優化的第三步
(1)調整快取過期時間
dump 記憶體發現,IdServer 物件比較大,這個是定時更新任務引起。一般的,gc 的行為和程式碼的結構及記憶體中的物件有很大的關係,程式碼中用了 guava 的本地快取物件達幾百 M,11 分鐘會更新一次,每當做一次資料的更新,會產生大物件到 old 區,會 promotion failed 導致 full gc,現在根據業務特點調整 cache 時間為 1 小時。
(2)調整 JVM 引數只是一方面,更應該精簡程式碼層面去優化,減少記憶體使用率
下一步要把,這個本地快取進行精簡, 清楚一些沒有必要的物件,節約記憶體,如果必須的可考慮使用序列化後本地快取只儲存序列化後的版本,在使用該物件的時候進行反序列化。
五、總結
CMS 的另一個缺點是它需要更大的堆空間。因為 CMS 標記階段應用程式的執行緒還是在執行的,那麼就會有堆空間繼續分配的情況,為了保證在 CMS 回收完堆之前還有空間分配給正在執行的應用程式,必須預留一部分空間。也就是說,CMS 不會在老年代滿的時候才開始收集。相反,它會嘗試更早的開始收集,已避免上面提到的情況:在回收完成之前,堆沒有足夠空間分配!預設當老年代使用 68% 的時候,CMS 就開始行動了。 X:CMSInitiatingOccupancyFraction =n 來設定這個閥值,總得來說,CMS 回收器減少了回收的停頓時間,但是降低了堆空間的利用率。