天天看點

揭露 FileSystem 引起的線上 JVM 記憶體溢出問題

作者:閃念基因

本文主要介紹了由FileSystem類引起的一次線上記憶體洩漏導緻記憶體溢出的問題分析解決全過程。

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

記憶體溢出(out of memory):是指在程式運作過程中,由于配置設定的記憶體空間不足或使用不當等原因,導緻程式無法繼續執行的一種錯誤,此時就會報錯OOM,即所謂的記憶體溢出。

一、背景

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

揭露 FileSystem 引起的線上 JVM 記憶體溢出問題
揭露 FileSystem 引起的線上 JVM 記憶體溢出問題

圖1.1 CPU告警 Full GC告警

二、問題發現

2.1 監控檢視

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

揭露 FileSystem 引起的線上 JVM 記憶體溢出問題

圖2.1 CPU使用率

揭露 FileSystem 引起的線上 JVM 記憶體溢出問題

圖2.2 Full GC次數

2.2 記憶體洩漏

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

揭露 FileSystem 引起的線上 JVM 記憶體溢出問題

圖2.3 老年代記憶體

揭露 FileSystem 引起的線上 JVM 記憶體溢出問題

圖2.4 JVM記憶體

2.3 記憶體溢出

從線上的錯誤日志也可以明确知道服務最後是OOM了,是以問題的根本原因是記憶體洩漏導緻記憶體溢出OOM,最後導緻服務不可用。

揭露 FileSystem 引起的線上 JVM 記憶體溢出問題

圖2.5 OOM日志

三、問題排查

3.1 堆記憶體分析

在明确問題原因為記憶體洩漏之後,我們第一時間就是dump服務記憶體快照,将dump檔案導入至MAT(Eclipse Memory Analyzer)進行分析。Leak Suspects 進入疑似洩露點視圖。

揭露 FileSystem 引起的線上 JVM 記憶體溢出問題

圖3.1 記憶體對象分析

揭露 FileSystem 引起的線上 JVM 記憶體溢出問題

圖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回收的,怎麼會導緻記憶體洩漏呢?

揭露 FileSystem 引起的線上 JVM 記憶體溢出問題

圖3.3

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

接下裡我們就檢視FileSystem類的源碼,FileSystem的init和get方法如下:

揭露 FileSystem 引起的線上 JVM 記憶體溢出問題
揭露 FileSystem 引起的線上 JVM 記憶體溢出問題
揭露 FileSystem 引起的線上 JVM 記憶體溢出問題

圖3.4

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

揭露 FileSystem 引起的線上 JVM 記憶體溢出問題

圖3.5

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

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

揭露 FileSystem 引起的線上 JVM 記憶體溢出問題

從這段代碼中可以看出:

  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方法如下:

揭露 FileSystem 引起的線上 JVM 記憶體溢出問題

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()決定。

揭露 FileSystem 引起的線上 JVM 記憶體溢出問題

繼續看UserGroupInformation的

getCurrentUser()方法,如下:

揭露 FileSystem 引起的線上 JVM 記憶體溢出問題

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

猜想二得到驗證:同一個hdfs URI會進行多次緩存,導緻緩存快速膨脹,并且緩存沒有設定過期時間和淘汰政策,最終導緻記憶體溢出。

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

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

揭露 FileSystem 引起的線上 JVM 記憶體溢出問題

其中比較關鍵的是

getStackAccessControlContext方法,該方法調用了Native方法,如下:

揭露 FileSystem 引起的線上 JVM 記憶體溢出問題

該方法會傳回目前堆棧的保護域權限的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方法
揭露 FileSystem 引起的線上 JVM 記憶體溢出問題

圖3.6

揭露 FileSystem 引起的線上 JVM 記憶體溢出問題

圖3.7

揭露 FileSystem 引起的線上 JVM 記憶體溢出問題

圖3.8

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

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

揭露 FileSystem 引起的線上 JVM 記憶體溢出問題

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

揭露 FileSystem 引起的線上 JVM 記憶體溢出問題

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

揭露 FileSystem 引起的線上 JVM 記憶體溢出問題

該方法為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時,由于每次都會建立新的UserGroupInformation和Subject對象。
  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 引起的線上 JVM 記憶體溢出問題

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

揭露 FileSystem 引起的線上 JVM 記憶體溢出問題

圖3.9

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

揭露 FileSystem 引起的線上 JVM 記憶體溢出問題
揭露 FileSystem 引起的線上 JVM 記憶體溢出問題

圖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 引起的線上 JVM 記憶體溢出問題
揭露 FileSystem 引起的線上 JVM 記憶體溢出問題
揭露 FileSystem 引起的線上 JVM 記憶體溢出問題

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

五、優化結果

對代碼進行修複釋出上線之後,如下圖一所示,可以看到修複之後老年代的記憶體可以正常回收了,至此問題終于全部解決。

揭露 FileSystem 引起的線上 JVM 記憶體溢出問題
揭露 FileSystem 引起的線上 JVM 記憶體溢出問題

六、總結

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

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

(1)生成堆記憶體檔案:

在服務啟動指令添加

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

讓服務在發生oom時自動dump記憶體檔案,或者使用 jamp 指令dump記憶體檔案。

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

  • Eclipse Memory Analyzer:一款開源的 Java 記憶體分析工具,可以幫助我們快速定位記憶體洩漏問題。
  • VisualVM Memory Analyzer:一個基于圖形化界面的工具,可以幫助我們分析java應用程式的記憶體使用情況。

(3)根據堆記憶體分析定位到具體的記憶體洩漏代碼。

(4)修改記憶體洩漏代碼,重新釋出驗證。

記憶體洩漏是記憶體溢出的常見原因,但不是唯一原因。常見導緻記憶體溢出問題的原因還是有:超大對象、堆記憶體配置設定太小、死循環調用等等都會導緻記憶體溢出問題。

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

作者:Ye Jidong

來源-微信公衆号:vivo網際網路技術

出處:https://mp.weixin.qq.com/s/_OtCE-BBQiLRAS14ZtDDJw

繼續閱讀