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

vivo互联网技术發表於2024-04-26

作者:來自 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)修改記憶體洩漏程式碼,重新發布驗證。

記憶體洩漏是記憶體溢位的常見原因,但不是唯一原因。常見導致記憶體溢位問題的原因還是有:超大物件、堆記憶體分配太小、死迴圈呼叫等等都會導致記憶體溢位問題。

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

相關文章