1. Full GC (Ergonomics)
1.1 Java 進程一直進行 Full GC
例行檢查線上運行的 Java 服務,通過 jstat -gcutil < pid >
命令檢查 gc 情況的時候發現一個服務有點異常。可以看到以下打印的 gc 情況中,只有 FGC
的次數一直在變化,而YGC
維持不變,也就是說這個服務一直在進程 Full GC
,顯而易見是有問題的
S0 | S1 | E | O | M | CCS | YGC | YGCT | FGC | FGCT | GCT |
---|---|---|---|---|---|---|---|---|---|---|
0.00 | 0.00 | 100.00 | 99.97 | 90.48 | 88.34 | 17498 | 91.739 | 1452 |
570.310 | 662.049 |
0.00 | 0.00 | 13.47 | 99.97 | 90.48 | 88.34 | 17498 | 91.739 | 1453 |
571.179 | 662.918 |
0.00 | 0.00 | 39.33 | 99.97 | 90.48 | 88.34 | 17498 | 91.739 | 1454 |
571.879 | 663.118 |
1.2 Full GC 的原因
檢查 gc 日誌,發現有以下 log,可以看到發生 Full GC
的原因是 Ergonomics
,並且年老代 Full GC 前後佔用的內存幾乎不變。查找資料,發現當使用 Server 模式下的ParallelGC
收集器組合(Parallel Scavenge + Serial Old)
時,會在 Minor GC
前進行一次判斷,也就是 內存空間分配擔保機制:
- Eden 空間不足發生
Minor GC
之前,虛擬機先檢查老年代最大可用的連續空間是否大於新生代所有對象總空間,如果條件成立的話,那麼Minor GC
可以確認是安全的。如果不成立的話虛擬機查看HandlePromotionFailure
的值是否允許擔保失敗,如果HandlePromotionFailure
的值不允許冒險,那麼就要進行一次Full GC
。如果允許則檢查老年代最大可用的連續空間是否大於歷次晉升到老年代對象的平均大小,如果大於將繼續嘗試進行Minor GC
,小於的話就要進行Full GC
以保證Minor GC
的空間擔保成功
這個 Java 服務沒有通過啓動參數配置垃圾收集器,查看堆配置發現是使用的默認 Parallel GC
。顯然,合理的推測是空間分配擔保失敗導致了Full GC
[Full GC (Ergonomics) [PSYoungGen: 82944K->8916K(84992K)] [ParOldGen: 175101K->175101K(175104K)] 258045K->184018K(260096K), [Metaspace: 106250K->106166K(1153024K)], 0.4203767 secs] [Times: user=1.29 sys=0.00, real=0.42 secs]
1.3 檢查堆佔用
-
使用
jmap -heap 32006
命令查看堆內存堆使用情況,發現老年代的使用已經達到了99.97697796737938%
,只剩下了0.03M
,是可以和之前的推測相佐證的PS Old Generation capacity = 179306496 (171.0MB) used = 179265216 (170.96063232421875MB) free = 41280 (0.03936767578125MB) 99.97697796737938% used
-
使用命令
jmap -histo 32006 | head -n 30
查看堆內存中佔用內存最多的前 30 個類實例,發現可疑的類有以下 3 個。其中SpringValue
爲攜程開源框架 apollo 的內部類,LinkedListMultimap
是 apollo 引用的 google 開源的集合類,像這種開源的代碼如果有內存泄露的問題估計早就被曝出來修復掉了,所以唯一的疑點就是項目內部自己實現的MessageProcessor
這個類了- com.ctrip.framework.apollo.spring.property.SpringValue --53萬個對象,佔用 23M
- com.google.common.collect.LinkedListMultimap$Node --53萬個對象,佔用 21M
- com.service.task.client.MessageProcessor – 53萬個對象,佔用 17M
2. 代碼檢查
檢查代碼,發現 MessageProcessor
類通過註解 @Scope("prototype")
修飾,每次使用的時候都會新建一個對象,其內部還通過註解 @Value
引用了 apollo 配置。這個類的功能是從消息隊列中拉取消息,然後將其分發給處理函數,從而完成一次消息處理。這個類之所以被設計成多實例,可以參考Spring 多實例注入,沒錯,就是筆者自己寫的,因此原因也很清楚了
- 腳本每被調度一次,
MessageProcessor
就創建一個新實例用於從指定的消息隊列中拉消息。這樣時間一長,MessageProcessor
對象大量被創建,堆積在堆內存年輕代中,觸發Minor GC
。本來這些只使用一次的對象理應在多次 Minor GC 中慢慢被回收掉,但是 JVM 的動態年齡機制是如果在 Survivor 中相同年齡所有對象大小的總和大於 Survivor 空間的一半, 則年齡大於或等於該年齡的對象可以直接進入老年代,這樣大量的MessageProcessor
對象就跳過了年齡限制,直接進入老年代,導致老年代對象佔用的內存居高不下。這種情況下當Minor GC
觸發時,由於老年代剩餘內存空間不足,空間擔保必然失敗,就觸發了Full GC (Ergonomics)
3. 解決方式
- 使用多實例注入的思路是沒錯的,錯誤在於筆者的使用方式。之前的使用方式在腳本啓動的時候就會新建
MessageProcessor
對象,造成大量的重複對象被創建,不僅浪費了內存,還會在一定程度上影響性能。基於此解決的方法很簡單,只要通過@Bean(name = "xxxx")
註解爲每條隊列單獨配置好一個processor
消息處理者,再使用@Resource(name = "xxxx")
引用指定的MessageProcessor
對象,避免大量相同的MessageProcessor
對象被創建出來就可以了 - 使用多實例注入的實質是爲了解決
MessageProcessor
對象中某些屬性的線程隔離問題,故也可以使用單例MessageProcessor
對象,同時將需要隔離的屬性存入ThreadLocal
的解決方法。最後,最簡單粗暴的解決方式是,將需要隔離的屬性直接方法入參,這樣肯定不會有線程隔離問題了