揭露 FileSystem 引起的線上 JVM 內存溢出問題

作者:來自 vivo 互聯網大數據團隊-Ye Jidong

本文主要介紹了由FileSystem類引起的一次線上內存泄漏導致內存溢出的問題分析解決全過程。

內存泄漏定義(memory leak):一個不再被程序使用的對象或變量還在內存中佔有存儲空間,JVM不能正常回收改對象或者變量。一次內存泄漏似乎不會有大的影響,但內存泄漏堆積後的後果就是內存溢出。

內存溢出(out of memory):是指在程序運行過程中,由於分配的內存空間不足或使用不當等原因,導致程序無法繼續執行的一種錯誤,此時就會報錯OOM,即所謂的內存溢出。 

一、背景

週末小葉正在王者峽谷亂殺,手機突然收到大量機器CPU告警,CPU使用率超過80%就會告警,同時也收到該服務的Full GC告警。該服務是小葉項目組非常重要的服務,小葉趕緊放下手中的王者榮耀打開電腦查看問題。

圖片

 

圖片

圖1.1 CPU告警   Full GC告警

二、問題發現

2.1 監控查看

因爲服務CPU和Full GC告警了,打開服務監控查看CPU監控和Full GC監控,可以看到兩個監控在同一時間點都有一個異常凸起,可以看到在CPU告警的時候,Full GC特別頻繁,猜測可能是Full GC導致的CPU使用率上升告警。

圖片

圖2.1 CPU使用率

圖片

圖2.2 Full GC次數  

2.2 內存泄漏

從Full Gc頻繁可以知道服務的內存回收肯定存在問題,故查看服務的堆內存、老年代內存、年輕代內存的監控,從老年代的常駐內存圖可以看到,老年代的常駐內存越來越多,老年代對象無法回收,最後常駐內存全部被佔滿,可以看出明顯的內存泄漏。

圖片

圖2.3 老年代內存

圖片

圖2.4 JVM內存

2.3 內存溢出

從線上的錯誤日誌也可以明確知道服務最後是OOM了,所以問題的根本原因是內存泄漏導致內存溢出OOM,最後導致服務不可用

圖片

圖2.5 OOM日誌    

三、問題排查

3.1 堆內存分析

在明確問題原因爲內存泄漏之後,我們第一時間就是dump服務內存快照,將dump文件導入至MAT(Eclipse Memory Analyzer)進行分析。Leak Suspects 進入疑似泄露點視圖。

圖片

圖3.1 內存對象分析

圖片

圖3.2 對象鏈路圖

打開的dump文件如圖3.1所示,2.3G的堆內存   其中 org.apache.hadoop.conf.Configuration對象佔了1.8G,佔了整個堆內存的78.63%

展開該對象的關聯對象和路徑,可以看到主要佔用的對象爲HashMap,該HashMap由FileSystem.Cache對象持有,再上層就是FileSystem。可以猜想內存泄漏大概率跟FileSystem有關。

3.2 源碼分析

找到內存泄漏的對象,那麼接下來一步就是找到內存泄漏的代碼。

在圖3.3我們的代碼裏面可以發現這麼一段代碼,在每次與hdfs交互時,都會與hdfs建立一次連接,並創建一個FileSystem對象。但在使用完FileSystem對象之後並未調用close()方法釋放連接。

但是此處的Configuration實例和FileSystem實例都是局部變量,在該方法執行完成之後,這兩個對象都應該是可以被JVM回收的,怎麼會導致內存泄漏呢?

圖片

圖3.3

(1)猜想一:FileSystem是不是有常量對象?

接下里我們就查看FileSystem類的源碼,FileSystem的init和get方法如下:

圖片

 

圖片

 

圖片

圖3.4

從圖3.4最後一行代碼可以看到,FileSystem類存在一個CACHE,通過disableCacheName控制是否從該緩存拿對象。該參數默認值爲false。也就是默認情況下會通過CACHE對象返回FileSystem。

圖片

圖3.5

從圖3.5可以看到CACHE爲FileSystem類的靜態對象,也就是說,該CACHE對象會一直存在不會被回收,確實存在常量對象CACHE,猜想一得到驗證。

那接下來看一下CACHE.get方法:

圖片

從這段代碼中可以看出:

  1. 在Cache類內部維護了一個Map,該Map用於緩存已經連接好的FileSystem對象,Map的Key爲Cache.Key對象。每次都會通過Cache.Key獲取FileSystem,如果未獲取到,纔會繼續創建的流程。

  2. 在Cache類內部維護了一個Set(toAutoClose),該Set用於存放需自動關閉的連接。在客戶端關閉時會自動關閉該集合中的連接。

  3. 每次創建的FileSystem都會以Cache.Key爲key,FileSystem爲Value存儲在Cache類中的Map中。那至於在緩存時候是否對於相同hdfs URI是否會存在多次緩存,就需要查看一下Cache.Key的hashCode方法了。

Cache.Key的hashCode方法如下:

圖片

schema和authority變量爲String類型,如果在相同的URI情況下,其hashCode是一致。而unique該參數的值每次都是0。那麼Cache.Key的hashCode就由ugi.hashCode()決定。

由以上代碼分析可以梳理得到:

  1. 業務代碼與hdfs交互過程中,每次交互都會新建一個FileSystem連接,結束時並未關閉FileSystem連接。

  2. FileSystem內置了一個static的Cache,該Cache內部有一個Map,用於緩存已經創建連接的FileSystem。

  3. 參數fs.hdfs.impl.disable.cache,用於控制FileSystem是否需要緩存,默認情況下是false,即緩存。

  4. Cache中的Map,Key爲Cache.Key類,該類通過schem,authority,ugi,unique 4個參數來確定一個Key,如上Cache.Key的hashCode方法。

(2)猜想二:FileSystem同樣hdfs URI是不是多次緩存?

FileSystem.Cache.Key構造函數如下所示:ugi由UserGroupInformation的getCurrentUser()決定。

圖片

繼續看UserGroupInformation的getCurrentUser()方法,如下:

圖片

其中比較關鍵的就是是否能通過AccessControlContext獲取到Subject對象。在本例中通過get(final URI uri, final Configuration conf,final String user)獲取時候,在debug調試時,發現此處每次都能獲取到一個新的Subject對象。也就是說相同的hdfs路徑每次都會緩存一個FileSystem對象

猜想二得到驗證:同一個hdfs URI會進行多次緩存,導致緩存快速膨脹,並且緩存沒有設置過期時間和淘汰策略,最終導致內存溢出。

(3)FileSystem爲什麼會重複緩存?

那爲什麼會每次都獲取到一個新的Subject對象呢,我們接着往下看一下獲取AccessControlContext的代碼,如下:

圖片

其中比較關鍵的是getStackAccessControlContext方法,該方法調用了Native方法,如下:

圖片

該方法會返回當前堆棧的保護域權限的AccessControlContext對象。

我們通過圖3.6 get(final URI uri, final Configuration conf,final String user) 方法可以看到,如下:

  • 先通過UserGroupInformation.getBestUGI方法獲取了一個UserGroupInformation對象。

  • 然後在通過UserGroupInformation的doAs方法去調用了get(URI uri, Configuration conf)方法

  • 圖3.7 UserGroupInformation.getBestUGI方法的實現,此處關注一下傳入的兩個參數ticketCachePath,user。ticketCachePath是獲取配置hadoop.security.kerberos.ticket.cache.path的值,在本例中該參數未配置,因此ticketCachePath爲空。user參數是本例中傳入的用戶名。

  • ticketCachePath爲空,user不爲空,因此最終會執行圖3.7的createRemoteUser方法

圖片

圖3.6

圖片

圖3.7

圖片

圖3.8

從圖3.8標紅的代碼可以看到在createRemoteUser方法中,創建了一個新的Subject對象,並通過該對象創建了UserGroupInformation對象。至此,UserGroupInformation.getBestUGI方法執行完成。

接下來看一下UserGroupInformation.doAs方法(FileSystem.get(final URI uri, final Configuration conf, final String user)執行的最後一個方法),如下:

圖片

然後在調用Subject.doAs方法,如下:

圖片

最後在調用AccessController.doPrivileged方法,如下:

圖片

該方法爲Native方法,該方法會使用指定的AccessControlContext來執行PrivilegedExceptionAction,也就是調用該實現的run方法。即FileSystem.get(uri, conf)方法。

至此,就能夠解釋在本例中,通過get(final URI uri, final Configuration conf,final String user) 方法創建FileSystem時,每次存入FileSystem的Cache中的Cache.key的hashCode都不一致的情況了。

小結一下:

  1. 在通過get(final URI uri, final Configuration conf,final String user)方法創建FileSystem時,由於每次都會創建新的UserGroupInformationSubject對象。

  2.  在Cache.Key對象計算hashCode時,影響計算結果的是調用了UserGroupInformation.hashCode方法。

  3. UserGroupInformation.hashCode方法,計算爲:System.identityHashCode(subject)。即如果Subject是同一個對象則返回相同的hashCode,由於在本例中每次都不一樣,因此計算的hashCode不一致。

  4. 綜上,就導致每次計算Cache.key的hashCode不一致,便會重複寫入FileSystem的Cache。

(4)FileSystem的正確用法

從上述分析,既然FileSystem.Cache都沒有起到應起的作用,那爲什麼要設計這個Cache呢。其實只是我們的用法沒用對而已。

在FileSystem中,有兩個重載的get方法:

public static FileSystem get(final URI uri, final Configuration conf, final String user)
public static FileSystem get(URI uri, Configuration conf)

 

圖片

我們可以看到 FileSystem get(final URI uri, final Configuration conf, final String user)方法最後是調用FileSystem get(URI uri, Configuration conf)方法的,區別在於FileSystem get(URI uri, Configuration conf)方法於缺少也就是缺少每次新建Subject的的操作。

圖片

圖3.9

沒有新建Subject的的操作,那麼圖3.9 中Subject爲null,會走最後的getLoginUser方法獲取loginUser。而loginUser是靜態變量,所以一旦該loginUser對象初始化成功,那麼後續會一直使用該對象。UserGroupInformation.hashCode方法將會返回一樣的hashCode值。也就是能成功的使用到緩存在FileSystem的Cache。

圖片

 

圖片

圖3.10

四、解決方案

經過前面的介紹,如果要解決FileSystem 存在的內存泄露問題,我們有以下兩種方式:

(1)使用public static FileSystem get(URI uri, Configuration conf):

  • 該方法是能夠使用到FileSystem的Cache的,也就是說對於同一個hdfs URI是隻會有一個FileSystem連接對象的。

  • 通過System.setProperty("HADOOP_USER_NAME", "hive")方式設置訪問用戶。

  • 默認情況下fs.automatic.close=true,即所有的連接都會通過ShutdownHook關閉。

(2)使用public static FileSystem get(final URI uri, final Configuration conf, final String user):

  • 該方法如上分析,會導致FileSystem的Cache失效,且每次都會添加至Cache的Map中,導致不能被回收。

  • 在使用時,一種方案是:保證對於同一個hdfs URI只會存在一個FileSystem連接對象。

  • 另一種方案是:在每次使用完FileSystem之後,調用close方法,該方法會將Cache中的FileSystem刪除。

圖片

 

圖片

 

圖片

基於我們已有的歷史代碼最小改動的前提下,我們選擇了第二種修改方式。在我們每次使用完FileSystem之後都關閉FileSystem對象。

五、優化結果

對代碼進行修復發佈上線之後,如下圖一所示,可以看到修復之後老年代的內存可以正常回收了,至此問題終於全部解決。

圖片

 

圖片

六、總結

內存溢出是 Java 開發中最常見的問題之一,其原因通常是由於內存泄漏導致內存無法正常回收引起的。在我們這篇文章中,詳細介紹一次完整的線上內存溢出的處理過程。

總結一下我們在碰到內存溢出時候的常用解決思路:

(1)生成堆內存文件

在服務啓動命令添加

 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/usr/local/base

讓服務在發生oom時自動dump內存文件,或者使用 jamp 命令dump內存文件。

(2)堆內存分析:使用內存分析工具幫助我們更深入地分析內存溢出問題,並找到導致內存溢出的原因。以下是幾個常用的內存分析工具:

  • Eclipse Memory Analyzer:一款開源的 Java 內存分析工具,可以幫助我們快速定位內存泄漏問題。

  • VisualVM Memory Analyzer:一個基於圖形化界面的工具,可以幫助我們分析java應用程序的內存使用情況。

(3)根據堆內存分析定位到具體的內存泄漏代碼。

(4)修改內存泄漏代碼,重新發布驗證。

內存泄漏是內存溢出的常見原因,但不是唯一原因。常見導致內存溢出問題的原因還是有:超大對象、堆內存分配太小、死循環調用等等都會導致內存溢出問題。

在遇到內存溢出問題時,我們需要多方面思考,從不同角度分析問題。通過我們上述提到的方法和工具以及各種監控幫助我們快速定位和解決問題,提高我們系統的穩定性和可用性。

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