Java Full GC (Ergonomics) 的排查

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 檢查堆佔用

  1. 使用 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
    
  2. 使用命令 jmap -histo 32006 | head -n 30 查看堆內存中佔用內存最多的前 30 個類實例,發現可疑的類有以下 3 個。其中 SpringValue 爲攜程開源框架 apollo 的內部類,LinkedListMultimap是 apollo 引用的 google 開源的集合類,像這種開源的代碼如果有內存泄露的問題估計早就被曝出來修復掉了,所以唯一的疑點就是項目內部自己實現的 MessageProcessor 這個類了

    1. com.ctrip.framework.apollo.spring.property.SpringValue --53萬個對象,佔用 23M
    2. com.google.common.collect.LinkedListMultimap$Node --53萬個對象,佔用 21M
    3. 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. 解決方式

  1. 使用多實例注入的思路是沒錯的,錯誤在於筆者的使用方式。之前的使用方式在腳本啓動的時候就會新建 MessageProcessor 對象,造成大量的重複對象被創建,不僅浪費了內存,還會在一定程度上影響性能。基於此解決的方法很簡單,只要通過@Bean(name = "xxxx")註解爲每條隊列單獨配置好一個 processor 消息處理者,再使用@Resource(name = "xxxx")引用指定的 MessageProcessor 對象,避免大量相同的MessageProcessor對象被創建出來就可以了
  2. 使用多實例注入的實質是爲了解決 MessageProcessor對象中某些屬性的線程隔離問題,故也可以使用單例MessageProcessor對象,同時將需要隔離的屬性存入 ThreadLocal 的解決方法。最後,最簡單粗暴的解決方式是,將需要隔離的屬性直接方法入參,這樣肯定不會有線程隔離問題了
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章