揭露 FileSystem 引起的線上 JVM 內(nèi)存溢出問題
內(nèi)存泄漏定義(memory leak):一個不再被程序使用的對象或變量還在內(nèi)存中占有存儲空間,JVM不能正?;厥崭膶ο蠡蛘咦兞?。一次內(nèi)存泄漏似乎不會有大的影響,但內(nèi)存泄漏堆積后的后果就是內(nèi)存溢出。
內(nèi)存溢出(out of memory):是指在程序運行過程中,由于分配的內(nèi)存空間不足或使用不當(dāng)?shù)仍?,?dǎo)致程序無法繼續(xù)執(zhí)行的一種錯誤,此時就會報錯OOM,即所謂的內(nèi)存溢出。
一、背景
周末小葉正在王者峽谷亂殺,手機突然收到大量機器CPU告警,CPU使用率超過80%就會告警,同時也收到該服務(wù)的Full GC告警。該服務(wù)是小葉項目組非常重要的服務(wù),小葉趕緊放下手中的王者榮耀打開電腦查看問題。
圖1.1 CPU告警 Full GC告警
二、問題發(fā)現(xiàn)
2.1 監(jiān)控查看
因為服務(wù)CPU和Full GC告警了,打開服務(wù)監(jiān)控查看CPU監(jiān)控和Full GC監(jiān)控,可以看到兩個監(jiān)控在同一時間點都有一個異常凸起,可以看到在CPU告警的時候,Full GC特別頻繁,猜測可能是Full GC導(dǎo)致的CPU使用率上升告警。
圖2.1 CPU使用率
圖2.2 Full GC次數(shù)
2.2 內(nèi)存泄漏
從Full Gc頻繁可以知道服務(wù)的內(nèi)存回收肯定存在問題,故查看服務(wù)的堆內(nèi)存、老年代內(nèi)存、年輕代內(nèi)存的監(jiān)控,從老年代的常駐內(nèi)存圖可以看到,老年代的常駐內(nèi)存越來越多,老年代對象無法回收,最后常駐內(nèi)存全部被占滿,可以看出明顯的內(nèi)存泄漏。
圖2.3 老年代內(nèi)存
圖2.4 JVM內(nèi)存
2.3 內(nèi)存溢出
從線上的錯誤日志也可以明確知道服務(wù)最后是OOM了,所以問題的根本原因是內(nèi)存泄漏導(dǎo)致內(nèi)存溢出OOM,最后導(dǎo)致服務(wù)不可用。
圖2.5 OOM日志
三、問題排查
3.1 堆內(nèi)存分析
在明確問題原因為內(nèi)存泄漏之后,我們第一時間就是dump服務(wù)內(nèi)存快照,將dump文件導(dǎo)入至MAT(Eclipse Memory Analyzer)進行分析。Leak Suspects 進入疑似泄露點視圖。
圖3.1 內(nèi)存對象分析
圖3.2 對象鏈路圖
打開的dump文件如圖3.1所示,2.3G的堆內(nèi)存 其中 org.apache.hadoop.conf.Configuration對象占了1.8G,占了整個堆內(nèi)存的78.63%。
展開該對象的關(guān)聯(lián)對象和路徑,可以看到主要占用的對象為HashMap,該HashMap由FileSystem.Cache對象持有,再上層就是FileSystem??梢圆孪雰?nèi)存泄漏大概率跟FileSystem有關(guān)。
3.2 源碼分析
找到內(nèi)存泄漏的對象,那么接下來一步就是找到內(nèi)存泄漏的代碼。
在圖3.3我們的代碼里面可以發(fā)現(xiàn)這么一段代碼,在每次與hdfs交互時,都會與hdfs建立一次連接,并創(chuàng)建一個FileSystem對象。但在使用完FileSystem對象之后并未調(diào)用close()方法釋放連接。
但是此處的Configuration實例和FileSystem實例都是局部變量,在該方法執(zhí)行完成之后,這兩個對象都應(yīng)該是可以被JVM回收的,怎么會導(dǎo)致內(nèi)存泄漏呢?
圖3.3
(1)猜想一:FileSystem是不是有常量對象?
接下里我們就查看FileSystem類的源碼,FileSystem的init和get方法如下:
圖3.4
從圖3.4最后一行代碼可以看到,F(xiàn)ileSystem類存在一個CACHE,通過disableCacheName控制是否從該緩存拿對象。該參數(shù)默認值為false。也就是默認情況下會通過CACHE對象返回FileSystem。
圖3.5
從圖3.5可以看到CACHE為FileSystem類的靜態(tài)對象,也就是說,該CACHE對象會一直存在不會被回收,確實存在常量對象CACHE,猜想一得到驗證。
那接下來看一下CACHE.get方法:
從這段代碼中可以看出:
- 在Cache類內(nèi)部維護了一個Map,該Map用于緩存已經(jīng)連接好的FileSystem對象,Map的Key為Cache.Key對象。每次都會通過Cache.Key獲取FileSystem,如果未獲取到,才會繼續(xù)創(chuàng)建的流程。
- 在Cache類內(nèi)部維護了一個Set(toAutoClose),該Set用于存放需自動關(guān)閉的連接。在客戶端關(guān)閉時會自動關(guān)閉該集合中的連接。
- 每次創(chuàng)建的FileSystem都會以Cache.Key為key,F(xiàn)ileSystem為Value存儲在Cache類中的Map中。那至于在緩存時候是否對于相同hdfs URI是否會存在多次緩存,就需要查看一下Cache.Key的hashCode方法了。
Cache.Key的hashCode方法如下:
schema和authority變量為String類型,如果在相同的URI情況下,其hashCode是一致。而unique該參數(shù)的值每次都是0。那么Cache.Key的hashCode就由ugi.hashCode()決定。
由以上代碼分析可以梳理得到:
- 業(yè)務(wù)代碼與hdfs交互過程中,每次交互都會新建一個FileSystem連接,結(jié)束時并未關(guān)閉FileSystem連接。
- FileSystem內(nèi)置了一個static的Cache,該Cache內(nèi)部有一個Map,用于緩存已經(jīng)創(chuàng)建連接的FileSystem。
- 參數(shù)fs.hdfs.impl.disable.cache,用于控制FileSystem是否需要緩存,默認情況下是false,即緩存。
- Cache中的Map,Key為Cache.Key類,該類通過schem,authority,ugi,unique 4個參數(shù)來確定一個Key,如上Cache.Key的hashCode方法。
(2)猜想二:FileSystem同樣hdfs URI是不是多次緩存?
FileSystem.Cache.Key構(gòu)造函數(shù)如下所示:ugi由UserGroupInformation的getCurrentUser()決定。
繼續(xù)看UserGroupInformation的getCurrentUser()方法,如下:
其中比較關(guān)鍵的就是是否能通過AccessControlContext獲取到Subject對象。在本例中通過get(final URI uri, final Configuration conf,final String user)獲取時候,在debug調(diào)試時,發(fā)現(xiàn)此處每次都能獲取到一個新的Subject對象。也就是說相同的hdfs路徑每次都會緩存一個FileSystem對象。
猜想二得到驗證:同一個hdfs URI會進行多次緩存,導(dǎo)致緩存快速膨脹,并且緩存沒有設(shè)置過期時間和淘汰策略,最終導(dǎo)致內(nèi)存溢出。
(3)FileSystem為什么會重復(fù)緩存?
那為什么會每次都獲取到一個新的Subject對象呢,我們接著往下看一下獲取AccessControlContext的代碼,如下:
其中比較關(guān)鍵的是getStackAccessControlContext方法,該方法調(diào)用了Native方法,如下:
該方法會返回當(dāng)前堆棧的保護域權(quán)限的AccessControlContext對象。
我們通過圖3.6 get(final URI uri, final Configuration conf,final String user) 方法可以看到,如下:
- 先通過UserGroupInformation.getBestUGI方法獲取了一個UserGroupInformation對象。
- 然后在通過UserGroupInformation的doAs方法去調(diào)用了get(URI uri, Configuration conf)方法
- 圖3.7 UserGroupInformation.getBestUGI方法的實現(xiàn),此處關(guān)注一下傳入的兩個參數(shù)ticketCachePath,user。ticketCachePath是獲取配置hadoop.security.kerberos.ticket.cache.path的值,在本例中該參數(shù)未配置,因此ticketCachePath為空。user參數(shù)是本例中傳入的用戶名。
- ticketCachePath為空,user不為空,因此最終會執(zhí)行圖3.7的createRemoteUser方法
圖3.6
圖3.7
圖3.8
從圖3.8標紅的代碼可以看到在createRemoteUser方法中,創(chuàng)建了一個新的Subject對象,并通過該對象創(chuàng)建了UserGroupInformation對象。至此,UserGroupInformation.getBestUGI方法執(zhí)行完成。
接下來看一下UserGroupInformation.doAs方法(FileSystem.get(final URI uri, final Configuration conf, final String user)執(zhí)行的最后一個方法),如下:
然后在調(diào)用Subject.doAs方法,如下:
最后在調(diào)用AccessController.doPrivileged方法,如下:
該方法為Native方法,該方法會使用指定的AccessControlContext來執(zhí)行
PrivilegedExceptionAction,也就是調(diào)用該實現(xiàn)的run方法。即FileSystem.get(uri, conf)方法。
至此,就能夠解釋在本例中,通過get(final URI uri, final Configuration conf,final String user) 方法創(chuàng)建FileSystem時,每次存入FileSystem的Cache中的Cache.key的hashCode都不一致的情況了。
小結(jié)一下:
- 在通過get(final URI uri, final Configuration conf,final String user)方法創(chuàng)建FileSystem時,由于每次都會創(chuàng)建新的UserGroupInformation和Subject對象。
- 在Cache.Key對象計算hashCode時,影響計算結(jié)果的是調(diào)用了UserGroupInformation.hashCode方法。
- UserGroupInformation.hashCode方法,計算為:System.identityHashCode(subject)。即如果Subject是同一個對象則返回相同的hashCode,由于在本例中每次都不一樣,因此計算的hashCode不一致。
- 綜上,就導(dǎo)致每次計算Cache.key的hashCode不一致,便會重復(fù)寫入FileSystem的Cache。
(4)FileSystem的正確用法
從上述分析,既然FileSystem.Cache都沒有起到應(yīng)起的作用,那為什么要設(shè)計這個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)方法最后是調(diào)用FileSystem get(URI uri, Configuration conf)方法的,區(qū)別在于FileSystem get(URI uri, Configuration conf)方法于缺少也就是缺少每次新建Subject的的操作。
圖3.9
沒有新建Subject的的操作,那么圖3.9 中Subject為null,會走最后的getLoginUser方法獲取loginUser。而loginUser是靜態(tài)變量,所以一旦該loginUser對象初始化成功,那么后續(xù)會一直使用該對象。UserGroupInformation.hashCode方法將會返回一樣的hashCode值。也就是能成功的使用到緩存在FileSystem的Cache。
圖3.10
四、解決方案
經(jīng)過前面的介紹,如果要解決FileSystem 存在的內(nèi)存泄露問題,我們有以下兩種方式:
(1)使用public static FileSystem get(URI uri, Configuration conf):
- 該方法是能夠使用到FileSystem的Cache的,也就是說對于同一個hdfs URI是只會有一個FileSystem連接對象的。
- 通過System.setProperty("HADOOP_USER_NAME", "hive")方式設(shè)置訪問用戶。
- 默認情況下fs.automatic.close=true,即所有的連接都會通過ShutdownHook關(guān)閉。
(2)使用public static FileSystem get(final URI uri, final Configuration conf, final String user):
- 該方法如上分析,會導(dǎo)致FileSystem的Cache失效,且每次都會添加至Cache的Map中,導(dǎo)致不能被回收。
- 在使用時,一種方案是:保證對于同一個hdfs URI只會存在一個FileSystem連接對象。
- 另一種方案是:在每次使用完FileSystem之后,調(diào)用close方法,該方法會將Cache中的FileSystem刪除。
基于我們已有的歷史代碼最小改動的前提下,我們選擇了第二種修改方式。在我們每次使用完FileSystem之后都關(guān)閉FileSystem對象。
五、優(yōu)化結(jié)果
對代碼進行修復(fù)發(fā)布上線之后,如下圖一所示,可以看到修復(fù)之后老年代的內(nèi)存可以正?;厥樟?,至此問題終于全部解決。
六、總結(jié)
內(nèi)存溢出是 Java 開發(fā)中最常見的問題之一,其原因通常是由于內(nèi)存泄漏導(dǎo)致內(nèi)存無法正?;厥找鸬?。在我們這篇文章中,詳細介紹一次完整的線上內(nèi)存溢出的處理過程。
總結(jié)一下我們在碰到內(nèi)存溢出時候的常用解決思路:
(1)生成堆內(nèi)存文件:
在服務(wù)啟動命令添加
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/usr/local/base
讓服務(wù)在發(fā)生oom時自動dump內(nèi)存文件,或者使用 jamp 命令dump內(nèi)存文件。
(2)堆內(nèi)存分析:使用內(nèi)存分析工具幫助我們更深入地分析內(nèi)存溢出問題,并找到導(dǎo)致內(nèi)存溢出的原因。以下是幾個常用的內(nèi)存分析工具:
- Eclipse Memory Analyzer:一款開源的 Java 內(nèi)存分析工具,可以幫助我們快速定位內(nèi)存泄漏問題。
- VisualVM Memory Analyzer:一個基于圖形化界面的工具,可以幫助我們分析java應(yīng)用程序的內(nèi)存使用情況。
(3)根據(jù)堆內(nèi)存分析定位到具體的內(nèi)存泄漏代碼。
(4)修改內(nèi)存泄漏代碼,重新發(fā)布驗證。
內(nèi)存泄漏是內(nèi)存溢出的常見原因,但不是唯一原因。常見導(dǎo)致內(nèi)存溢出問題的原因還是有:超大對象、堆內(nèi)存分配太小、死循環(huán)調(diào)用等等都會導(dǎo)致內(nèi)存溢出問題。
在遇到內(nèi)存溢出問題時,我們需要多方面思考,從不同角度分析問題。通過我們上述提到的方法和工具以及各種監(jiān)控幫助我們快速定位和解決問題,提高我們系統(tǒng)的穩(wěn)定性和可用性。