Inflation 引起的 MetaSpace Full GC 問題排查

1 背景

本文將用一個螞蟻集團線上實際案例,分享我們是如何排查由於 inflation 引起的 MetaSpace FGC 問題。

螞蟻集團的智能監控平臺深度利用了 Spark 的能力進行多維度數據聚合,Spark 由於其高效、易用、分佈式的能力在大數據處理中十分受歡迎。

關於智能監控的計算能力相關介紹,可以參考《螞蟻金服在 Service Mesh 監控落地經驗總結》 。

2 案例背景

在某次線上問題中出現不間斷性的任務彪高與積壓,數據產出延遲,非常不符合預期。查看 SparkUI 的 Event Timeline 發現下邊的現象:

大家應該都知道 Spark job 工作節點分爲 driver 和 executor,driver 更多的是任務管理和分發,executor 負責任務的執行。在整個 Spark job 生命週期開始時這兩種角色被新建出來,存活到 Spark job 生命週期結束。而上圖中的情況一般爲 executor 因爲各種異常情況失去心跳而被主動替換。查看對應 executor 的日誌發現有2分鐘沒有打印,懷疑爲 FGC 卡死了。最終在另一個現場找到了 gc 日誌:

2020-06-29T13:59:44.454+0800: 55336.665: [Full GC (Metadata GC Threshold) 2020-06-29T13:59:44.454+0800: 55336.665: [CMS[YG occupancy: 2295820 K (5242880 K)]2020-06-29T13:59:45.105+0800: 55337.316: [weak refs processing, 0.0004879 secs]2020-06-29T13:59:45.105+0800: 55337.316: [class unloading, 0.1113617 secs]2020-06-29T13:59:45.217+0800: 55337.428: [scrub symbol table, 0.0316596 secs]2020-06-29T13:59:45.248+0800: 55337.459: [scrub string table, 0.0018447 secs]: 5326206K->1129836K(8388608K), 85.6151442 secs] 7622026K->3425656K(13631488K), [Metaspace: 370361K->105307K(1314816K)], 85.8536592 secs] [Times: user=88.94 sys=0.07, real=85.85 secs]

觀察到因爲 Metadata 的原因,導致 FGC,整個應用凍結80秒。衆所周知 Metadata 主要存儲一些類等相關的元信息,其應該是相對恆定的,那麼究竟是什麼原因導致了 MetaSpace 的變化,讓我們一探究竟。

3 排查過程

MetaSpace 的目前參數爲 -XX:MetaspaceSize=400m -XX:MaxMetaspaceSize=512m 這個已經非常多了,查看監控,發現 MetaSpace 的曲線如下圖的鋸齒狀,這個說明不斷的有類對象生成和卸載,在極端情況會到 400m 以上,所以觸發 FGC 是符合常理的。但是整個應用的生命週期中,理論上不應該有大量的類在不斷的生成和卸載。

先看下代碼,是否有類動態生成,發現有2個地方比較可疑:

  1. QL 表達式,這個地方會有動態的類生成;
  2. 關鍵路徑上泛型的使用;

但是經過排查和驗證,發現這些都不是關鍵的點,因爲雖然是泛型但類的數量是固定的,並且 QL 表達式有 cache。

最終定位到一個 Spark 算子,發現一個現象:每次執行 reduce 這個操作時都會有大量的類對象生成。

那麼可以大膽的猜測:是由於 reduce 時發生 shuffle,由數據的序列化和反序列化引起。

添加啓動參數,-XX:+TraceClassLoading -XX:+TraceClassUnloading ,在類加載和卸載的情況下可以看到明細信息,同時對問題現場做內存 dump,發現有大量的 DelegatingClassLoader,並動態的在內存中生成了 sun.reflect.GeneratedSerializationConstructorAccessor 類。

那麼,很明顯引起 MetaSpace 抖動的原因就是 DelegatingClassLoader 生成了很多 ConstructorAccessor 對應的類對象,這個類是動態生成的,保存在內存中,無法找到原型。

爲了查看內存中這個類的具體信息,找到原型,這裏用到了一個非常強大的工具:arthas,arthas 是 Alibaba 開源的 Java 診斷工具,推薦每一位研發人員學習,具體教程見 :

https://alibaba.github.io/arthas/quick-start.html

arthas 可以很方便的觀察運行中的 JVM 的各種狀態,找一個現場用 classloader 命令觀察,發現有好幾千 DelegatingClassLoader:

隨便挑一個 DelegatingClassLoader 下的類反序列化看下,整個類沒什麼特別的,就是 new 一個對象出來,但是有個細節:引入了 com.alipay 這個包下的類,這個地方應該能提供什麼有用的信息。

我們嘗試把所有 GeneratedSerializationConstructorAccessor 的類 dump 下來做下統計,OpenJDK 可以做 ClassDump,找了下社區發現個小工具:

https://github.com/hengyunabc/dumpclass

java -jar dumpclass.jar -p 1234 -o /home/hadoop/dump/classDump sun.reflect.GeneratedSerializationConstruc*

可以看到導出了大概 9000 個 GeneratedSerializationConstructorAccessor 相關的類:

用 javap 反編譯後做下統計:

find ./ -name "GeneratedSerializationConstructorAccessor*" | xargs javap -verbose | grep "com.alipay.*" -o |  sort | uniq -c

發現有的類只生成 3 次,有的上千次,那麼他們區別是什麼?對比下發現差別在是否有默認的構造函數。

4 根因分析

根因是由於在反序列化時觸發了 JVM 的“inflation”操作,關於這個術語,下邊這個解釋非常通俗易懂:

“When using Java reflection, the JVM has two methods of accessing the information on the class being reflected. It can use a JNI accessor, or a Java bytecode accessor. If it uses a Java bytecode accessor, then it needs to have its own Java class and classloader (sun/reflect/GeneratedMethodAccessor class and sun/reflect/DelegatingClassLoader). Theses classes and classloaders use native memory. The accessor bytecode can also get JIT compiled, which will increase the native memory use even more. If Java reflection is used frequently, this can add up to a significant amount of native memory use. The JVM will use the JNI accessor first, then after some number of accesses on the same class, will change to use the Java bytecode accessor.This is called inflation, when the JVM changes from the JNI accessor to the bytecode accessor. Fortunately, we can control this with a Java property. The sun.reflect.inflationThreshold property tells the JVM what number of times to use the JNI accessor. If it is set to 0, then the JNI accessors are always used. Since the bytecode accessors use more native memory than the JNI ones, if we are seeing a lot of Java reflection, we will want to use the JNI accessors. To do this, we just need to set the inflationThreshold property to zero.”

由於 spark 使用了 kryo 序列化,翻譯了相關代碼和文檔:

InstantiatorStrategy
Kryo provides DefaultInstantiatorStrategy which creates objects using ReflectASM to call a zero argument constructor. If that is not possible, it uses reflection to call a zero argument constructor. If that also fails, then it either throws an exception or tries a fallback InstantiatorStrategy. Reflection uses setAccessible, so a private zero argument constructor can be a good way to allow Kryo to create instances of a class without affecting the public API.
DefaultInstantiatorStrategy is the recommended way of creating objects with Kryo. It runs constructors just like would be done with Java code. Alternative, extralinguistic mechanisms can also be used to create objects. The Objenesis StdInstantiatorStrategy uses JVM specific APIs to create an instance of a class without calling any constructor at all. Using this is dangerous because most classes expect their constructors to be called. Creating the object by bypassing its constructors may leave the object in an uninitialized or invalid state. Classes must be designed to be created in this way.
Kryo can be configured to try DefaultInstantiatorStrategy first, then fallback to StdInstantiatorStrategy if necessary.
kryo.setInstantiatorStrategy(new DefaultInstantiatorStrategy(new StdInstantiatorStrategy()));
Another option is SerializingInstantiatorStrategy, which uses Java’s built-in serialization mechanism to create an instance. Using this, the class must implement java.io.Serializable and the first zero argument constructor in a super class is invoked. This also bypasses constructors and so is dangerous for the same reasons as StdInstantiatorStrategy.
kryo.setInstantiatorStrategy(new DefaultInstantiatorStrategy(new SerializingInstantiatorStrategy()));

結論很清晰:

如果 Java 對象有默認的構造函數,DefaultInstantiatorStrategy 調用 Class.getConstructor().newInstance() 構建出來。在這個過程中 JDK 會將構造出來的 constructor accessor 緩存起來,避免反覆生成。

否則 StdInstantiatorStrategy 調用 Java 的特殊 API(如下圖的 newConstructorForSerialization)直接生成對象而不通過構造函數。

相關的代碼在:

org.objenesis.instantiator.sun.SunReflectionFactoryHelper#getNewConstructorForSerializationMethod

這個過程中沒有 cache 的過程,導致不斷的生成 constructor accessor,最後發生 inflation 生成了非常多的Metadata。

5 總結

inflation 是一個比較冷門的知識,但是每一個研發應該都會在有意無意見遇到它。那麼在使用反射的能力時、甚至是第三方庫在大量使用反射來實現某些功能時,都需要我們去注意和思考。

同時,問題的排查是需要按邏輯去思考和漸進尋找根因的,腦袋一團亂麻只會走不少彎路,引以爲戒。最後本文問題通過添加私有構造函數後解決,MetaSpace 監控空鋸齒狀消失:

作者介紹

凌嶼,高級開發工程師,一直從事智能監控相關研發工作,在海量數據清洗、大數據集處理、分佈式系統建設等有深入研究。

本文轉載自公衆號螞蟻智能運維(ID:gh_a6b742597569)。

原文鏈接

https://mp.weixin.qq.com/s/uwLXDfFmW1aFVeKNY9N79Q

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章