位元組跳動應用效能監控幫助客戶Java OOM崩潰率下降80%

語言: CN / TW / HK
 
本文將會從Java記憶體基礎開始,詳細介紹“基於Hprof記憶體快照的線上Java OOM 歸因方案”的底層原理與技術細節,歡迎接入MARS-APMPlus 應用效能監控使用。
作者:位元組跳動終端技術——王濤

一、前言

如何定位和解決Android App因為記憶體不足(Java OOM)引發的線上問題一直是業界的難題。崩潰場景能抓取到的常規資訊中並不包括記憶體分配詳情——不瞭解記憶體被誰持有,自然也無法追查記憶體不足的根源。
針對這個問題,Client Infra和頭條抖音等業務方合作,通過一系列技術調研,自研了一套 基於Hprof記憶體快照的線上Java OOM 歸因方案,在內部廣泛應用並取得了極佳的效果。曾幫助Helo在一個雙月內 優化了80%的Java OOM問題,次日存留增長了2+%
火山引擎 MARS-APMPlus 應用效能監控平臺對外提供該解決方案後,美篇作為早期接入客戶,也同樣取得了雙月週期 減少80% Java OOM的好成績,深受客戶好評。
接下來本文將會從Java記憶體基礎開始,詳細介紹方案的底層原理與技術細節。希望大家能通過方案瞭解MARS-APMPlus 應用效能監控平臺,加入我們的 「 MARS-APMPlus 應用效能監控企業助力行動 」幫助團隊打造極致的使用者體驗

二、Java 記憶體基礎

2.1 Java 記憶體優化的重要性

記憶體是計算機的稀缺資源,作業系統本身也通過虛擬記憶體等方式來充分的使用記憶體資源。
如果Java 堆記憶體佔用過多,JVM頻繁GC會引起App的卡頓,影響App的易用性 。
更嚴重的Java 堆記憶體使用超過虛擬機器限制會導致OOM崩潰,影響App的可用性 。
從App的易用性和可用性來說,Java記憶體的優化還是十分重要的,特別是使用者使用應用的崩潰問題,應該得到有效解決。

2.2 為什麼會Java OOM崩潰

Java OOM,全稱是 Java Out Of Memory,字面意思是說Java 虛擬機器的記憶體用完。Java有一個相關的異常類 java.lang.OutOfMemoryError,官方有如下說明:
Thrown when the Java Virtual Machine cannot allocate an object because it is out of memory, and no more memory could be made available by the garbage collector. 
就是說,當Java 虛擬機器沒有更多的記憶體可以為物件分配空間,垃圾回收器也沒有更多的空間可以回收時,就會丟擲這個Error。
這裡面有幾個關鍵點,理解這幾個關鍵點,我們就會理解為什麼會發生Java OOM崩潰
  • Java虛擬機器都有哪些記憶體區域
  • 垃圾回收器是如何工作回收記憶體的
  • 每個物件佔據多大的記憶體空間
  • Java 虛擬機器當前的記憶體空間狀態以及OOM是如何發生的
下面會以簡潔的方式介紹這幾個關鍵的知識點。

2.1.1 Java虛擬機器的記憶體區域

Java 虛擬機器在執行 Java 程式的過程中會把它管理的記憶體劃分成若干個不同的資料區域,如下圖所示:
下面是每個區域的一個概要說明:
名稱
說明
是否執行緒間共享
PC Register
稱為程式計數器, 看作是當前執行緒所執行的位元組碼的行號指示器
JVM Stack
也稱為虛擬機器棧,記錄每個棧幀(Frame)中的區域性變數、方法返回地址等
Native Method Stack
本地 (原生) 方法棧,是呼叫作業系統原生本地方法時,所需要的記憶體區域

Heap

堆記憶體區,也是 GC 垃圾回收的主要場所,用於存放類的例項物件
Method Area
方法區,主要存放類結構、類成員定義,static 靜態成員等
Runtime Constant Pool
執行時常量池,比如:字串等
其中我們需要重點關注的是執行緒間共享的 Heep堆記憶體區域。這部分割槽域是GC垃圾回收的主要場所,用於存放類的例項物件。我們最常見的Java OOM都是因為堆記憶體使用超出虛擬機器最大可用記憶體閾值導致的崩潰。垃圾回收機制也是針對堆記憶體部分。

2.1.2 垃圾回收器是如何工作回收記憶體的

Java 虛擬機器有自動記憶體管理機制,通過垃圾回收器來管理記憶體,一旦確定程式不再使用某塊記憶體,它就會將該記憶體回收。
垃圾回收器當前主要通過可達性分析演算法判斷一個物件是否可以被回收:通過一系列稱為GC Roots的物件作為起點,從這些節點開始向下搜尋,搜尋所走過的路徑稱為引用鏈,當一個物件到GC Roots沒有任何引用鏈相連(即物件到GC Roots不可達),則證明此物件已死、可回收。下圖灰色部分即為可回收的記憶體物件。
GC Roots是可以從堆外部訪問的物件,例如Java執行緒當前活躍的棧幀裡指向GC堆裡的物件的引用,就是當前正在被呼叫方法的引用型別的引數和區域性變數等。
垃圾回收有不同的收集演算法,和不同型別的垃圾收集器,這裡只是概述背景不再詳細說明。是否可回收的核心是判斷一個物件是否到GC Roots不可達,不可達則物件會被回收釋放記憶體空間。
這裡我們知道了一個物件在什麼情況下被回收的。如果在記憶體裡沒有被回收,那就是因為有GC Root對它持有引用。在記憶體充足並有足夠大的連續空間時,虛擬機器會建立物件正常分配記憶體。

2.1.3 物件佔據多大的記憶體空間

上面我們知道了一個物件是如何被回收的,那麼記憶體中的物件到底佔據多大的記憶體呢。這裡會先介紹一個概念 Dominator Tree支配樹, Dominator Tree有以下幾個定義:
  • 物件X Dominator(支配)物件Y,當且僅當在物件樹中所有到達Y的路徑都必須經過X
  • 物件Y的直接Dominator,是指在物件引用關係中距離Y最近的Dominator
  • Dominator tree利用物件引用關係構建出來
物件引用關係和 Dominator tree的對應關係如下:
如上圖,因為A和B都引用到C,所以A釋放時,C記憶體不會被釋放。所以C這塊記憶體不會被計算到A或者B的Retained Size中,因此,物件樹在轉換成 Dominator tree時,會A、B、C三個是平級的。
將物件引用關係轉換成 Dominator Tree能幫助我們快速的發現佔用記憶體最大的塊,也能幫我們分析物件之間的依賴關係。
根據支配關係,物件大小有兩個定義Retained Size和Shallow Size:
  • Shallow Size:物件本身佔用記憶體的大小。也就是物件頭加成員變數(不是成員變數的值)的總和,如一個引用佔用32或64bit,一個integer佔4bytes,Long佔8bytes等。常規物件(非陣列)的Shallow Size 由其成員變數的數量和型別決定,陣列的 Shallow Size 由陣列元素的型別(物件型別、基本型別)和陣列長度決定。例如E的Shallow Size,只是自身大小和他引用的G沒有關係。
  • Retained Size:物件被垃圾回收器回收後能被GC從記憶體中移除的所有物件記憶體大小之和。相對於Shallow Size,Retained Size可以更精確的反映一個物件實際佔用的大小(若該物件釋放,Retained Size都可以被釋放)。例如E到C的引用鏈斷開後,會釋放E、G這2個物件。這2個物件的所佔記憶體之和就是E的Retained Size。
這裡我們就知道了如果要優化記憶體或者解決洩露,優先關注 Retained Size 較大的物件,因為Retained Size大的物件所能釋放的記憶體空間更大。

2.1.4 Java OOM的發生

學習了記憶體區域,垃圾回收機制,以及物件所佔用的記憶體空間大小,那麼Java OOM 到底是如何發生的呢。下面我們來看一個Java OOM異常時候的資訊:
java.lang.OutOfMemoryError: Failed to allocate a 65552 byte allocation with 23992 free bytes and 23KB until OOM, max allowed footprint 536870912, growth limit 536870912
OutOfMemoryError丟擲的地方在系統原始碼檔案/runtime/gc/heap.cc
//方法

void Heap::ThrowOutOfMemoryError(Thread* self, size_t byte_count, AllocatorType allocator_type) 

//異常資訊

  oss << "Failed to allocate a " << byte_count << " byte allocation with " << total_bytes_free

      << " free bytes and " << PrettySize(GetFreeMemoryUntilOOME()) << " until OOM,"

      << " target footprint " << target_footprint_.load(std::memory_order_relaxed)

      << ", growth limit "

      << growth_limit_;
看上面異常日誌,Java 虛擬機器堆記憶體只剩下23992位元組,無法分配65552位元組的空間,丟擲 OutOfMemoryError異常。
Android可以通過如下介面獲取到當前虛擬機器的記憶體狀態。
  • Runtime.getRuntime().maxMemory() : 當前虛擬機器例項的記憶體使用上限
  • Runtime.getRuntime().totalMemory() : 當前已經申請的記憶體,包括已經使用的和還沒有使用的
  • Runtime.getRuntime().freeMemory() : totalMemory中已經申請但是尚未使用的部分
  • used=totalMemory() - freeMemory(): 已經申請並且正在使用的部分
  • totalFree=maxMemory()-used: Java虛擬機器還可以使用的部分
下圖表述了記憶體指標之間的關係:
如果可用的記憶體無法提供分配物件所需的空間,則會產生 OutOfMemoryError異常。
本文主要講解最常遇到的Java 堆記憶體用盡導致的OOM問題解決方案。由於執行緒資料超限,虛擬記憶體用盡導致的OOM並不在當前的解決方案內。

2.3 Java記憶體相關工具

針對Java 堆記憶體問題,當前業界已經提供了一些分析Java記憶體的工具,內部也做了一些接入和測試
工具名稱
介紹
優點
缺點
MAT
The Eclipse Memory Analyzer is a fast and feature-rich that helps you find memory leaks and reduce memory consumption.
分析功能強大
線下分析,需要自己採集Hprof檔案
 
LeakCanary
 
LeakCanary is a memory leak detection library for Android.
可以接入App自動分析
線下分析,主要分析記憶體洩露
 
Android Studio Memory Profiler
可幫助使用者識別可能會導致應用卡頓、凍結甚至崩潰的記憶體洩露和記憶體抖動
可以動態記憶體監控,也可以靜態記憶體分析
線下分析,需要App debug模式
經過測試這些工具很難滿足產品解決Java OOM的需求,主要存在以下問題:
  • 都是線下工具,線下復現Java OOM問題困難
  • 自動化程度低,只能手動操作分析記憶體問題
  • 都是單點工具,只能分析單個hprof檔案,沒法聚合找到核心問題

三、Java OOM歸因方案

由於業界已有工具無法滿足解決線上Java OOM問題的需要,內部調研開發了一套基於Hprof記憶體檔案的線上Java OOM歸因方案,解決已有工具的痛點,可以高效解決線上Java OOM問題。工具擁有以下特點:
  • 高度還原場景:可以拿到Java OOM時候的場景記憶體資料
  • 自動化分析:可以自動化進行記憶體資料分析
  • 聚合找到核心問題:可以根據問題特徵聚合發現核心問題
  • 隱私安全:因為是線上監控,所以需要滿足使用者隱私安全的要求
因為方案是根據Hprof記憶體檔案進行設計,在進行詳細方案講解之前,先介紹一下Hprof記憶體檔案。

3.1 Hprof基礎知識

3.1.1 Hprof介紹

Hprof最初是由J2SE支援的一種二進位制堆轉儲格式,Hprof檔案儲存了當前java堆上所有的記憶體使用資訊(包括但不限於Class類資訊、物件資訊、引用關係等等),能夠完整的反映虛擬機器當前的記憶體狀態。

3.1.2 Hprof結構

Head:
Record:
Hprof檔案由Fixed Head和一系列的Record組成,Record包含字串資訊、類資訊、棧資訊、GC Root資訊、物件資訊。每個Record都是由1個位元組的Tag、4個位元組的Time、4個位元組的Length和Body組成,Tag表示該Record的型別,Body部分為該Record的內容,長度為Length。

3.1.3 Hprof檔案使用

Android Studio Memory Profiler、 LeakCanary、MAT 等工具分析記憶體資訊和引用鏈都是依賴Hprof檔案。
Android可以dump獲取到Hprof記憶體檔案,我們當前的方案也是基於獲取到的Hprof檔案來分析記憶體問題進行歸因。

3.2 方案概要

方案架構圖
上圖列出了客戶端、後端、和前端的工作內容:
  1. SDK:負責Hprof檔案的採集、裁剪、壓縮及上報等
  2. 服務端:Hprof檔案儲存、還原、自動分析、結果Retrace、issue聚合,自動分配
  3. 前端:問題展示包括記憶體洩露、大物件、類大物件
方案流程圖
這個圖比較清晰的介紹了方案的整個流程,業務方只需要接入SDK,就可以在平臺檢視核心記憶體問題,其他都是無感知的。

3.3 方案原理

下面會講解方案核心流程的原理

3.2.1 記憶體檔案端上dump

OOM 時候dump:
SDK 預設是在Java OOM 時dump記憶體快照。端SDK會註冊主程序的 UncaughtExceptionHandler,同時判斷是 Java OOM異常 ,然後會進行記憶體快照的 dump 操作。
Android中可以通過Debug.dumpHprofData()獲取到一個Hprof檔案,也支援使用Tailor通過xHook在 native 層 hook dump 同時裁剪的方式。
  • OOM之後還要再進行 dump 操作確實會容易dump失敗。
  • OOM時候App崩潰不可用,dump操作會在崩潰時候導致卡頓。
記憶體觸頂子程序dump:
通過fork系統呼叫建立子程序,這樣子程序就有父程序的拷貝,我們把耗時的dump操作在子程序做就可以了。這樣就提高了dump的成功率,也對App使用者互動無感知。當前也支援在平臺配置記憶體觸頂子程序dump的模式。記憶體觸頂是指當前記憶體使用佔最大記憶體的比例,預設是80%,支援配置。
當前預設依然使用Java OOM時候dump,因為這時更能還原記憶體嚴重不當使用的真實場景。

3.2.2 記憶體檔案的裁剪和還原

裁剪的原因:
  • 規避隱私風險:Hprof儲存了執行Dump時刻Java堆上所有的記憶體資訊,包括存在記憶體中的賬戶資訊等,這些敏感資訊必須裁剪掉。
  • 減小檔案大小:因為堆記憶體不足而OOM的時候獲到的Hprof檔案,約等於裝置單程序最大可用記憶體,一般檔案比較大有幾百M,大檔案上傳浪費使用者流量、頻寬以及導致上報成功率降低。
裁剪還原原理:
分析解決Java OOM問題,我們主要關心物件的大小,以及它的引用鏈。對於Hprof裡面的更多資訊,例如圖片畫素資料,具體的字串內容等我們並不關注,而且屬於隱私資料,這部分資料是我們可以裁剪的。
  1. 根據Hprof檔案的格式進行分析,分析我們不需要關注的資料塊
  2. 將檔案對映進記憶體,根據檔案格式找到想要裁剪的資料塊
  3. 再次寫入檔案的時候不要寫第2步找到的資料塊
  4. 這樣產生的Hprof檔案就是裁剪後的
實際裁剪掉的資料主要包括 String 的陣列以及 Bitmap 對應的 mBuffer 陣列(畫素資訊),這兩部分涉及敏感資訊且佔據空間較大。其他更多裁剪內容不再詳細說明。
上報到伺服器的裁剪後Hprof檔案,根據我們已知的裁剪方式,對裁剪的內容進行空字元填充還原。還原後的Hprof檔案格式和裁剪之前相同。並不影響MAT等工具進行記憶體分析。
 
裁剪效果:
隱私安全:裁剪後的字串和圖片畫素等資料已經為空
Hprof檔案大小變化明顯: 頭條裁剪前後資料平均值對比 355M-> 44M

3.2.3 記憶體檔案的自動化解析

當服務端接收到上報的記憶體快照之後會進行自動的分析,直接定位記憶體核心問題,分析之後的結果主要包含三部分:
  • 記憶體洩露
  • 大物件
  • 小物件
分析 Hprof 檔案需要首先將 Hprof 檔案按照格式進行解析, 並根據解析後資料構建引用關係圖
我們參考業界已經存在的Hprof解析實現,包括MAT,LeakCanary等,實現了 一套Hprof記憶體快照自動解析庫
下面講解這三部分內容是如何定義的,如何解析的,解析了哪些資料用來歸因,平臺效果如何。

3.2.3.1 記憶體洩露

記憶體洩露是在計算機中,由於疏忽或錯誤造成程式未能釋放已經不再使用的記憶體,是需要修復的問題。
例如Activity生命週期已經結束,執行了onDestroy(),但是依然存在到GC Root的引用鏈,導致Activity無法被GC回收。這個Activity 就可以認定為記憶體洩露。
根據 Retained Size大小我們可以判斷Activity的 洩露問題嚴重程度,越大越應該被優先解決。
根據 GC引用鏈我們可以判斷這個 Activity洩露的原因,被誰持有導致的洩露,確認如何解決。
如何判定洩露:
通過分析 Activity 的原始碼發現Activity呼叫 onDestroy 之後一個變數的值會發生變化,通過這個變數我們可以判斷 Activity 是不是走了 onDestroy,如果走了那說明這個 Activity 物件存在屬於洩露,沒有走則說明屬於正常使用。
private boolean mDestroyed;

final void performDestroy() {

    mDestroyed = true;

    xxx

}
通過Hprof解析庫找出Activity的例項,並對其mDestroyed屬性進行判斷是否為true。這樣就找到了洩露的Activity。
引用鏈和Retained大小:
找到了洩露物件之後我們需要知道它究竟是被誰引用導致不能釋放,上文已經介紹 Java 的垃圾回收機制通過可達性分析演算法判斷物件是否存活,一個物件能不能被回收就看 GC Root 到它之間有沒有強引用鏈。洩露的物件和 GCRoot 之間必然是存在強引用鏈。
根據Hprof中的例項資訊解析成描述引用關係的圖結構後,使用經典的圖搜尋演算法即可找到洩漏的物件到 GCRoot 的強引用鏈了,同時計算出物件的Retained Size大小。
洩露展示效果:
洩露類和導致它洩露的 引用鏈非常直觀展現了出來,可以通過 斷掉引用鏈來解決洩露
所有發現的洩漏問題都應該被解決修復,上面的case是因為靜態變數持有了Activity導致,這裡的mContext可以通過替換為Application來解決。

 

3.2.3.2 大物件

大物件:顧名思義就是比較大的物件,前面背景知識裡說的 RetainSize 較大的物件,也就是釋放掉之後總共可以回收的較大的物件。
大物件標準:目前判斷的依據是 RetainSize 大於 1 M的物件會被當做大物件,然後去找引用鏈。
if (object != null && object.getRetainedHeapSize() > MINIMAL){

    // 算作大物件

 }
同時我們會計算大物件持有了誰導致他比較大,也是通過大物件持有的變數來計算判斷。
大物件展示效果:
根據引用鏈判斷這個大物件被誰持有引用,是否屬於洩露可以修復。如果屬於正常使用,判斷大物件持有物件有誰,是否快取過大可以清除部分快取。下圖可以看到記憶體快取過大,Retained Size達到210M可以清除部分快取優化。
大物件往往是導致Java OOM的核心問題,關注高頻出現的Retained Size超大的大物件,優化後對Java OOM有非常好的優化效果。

 

3.2.3.3 類大物件

一個物件雖然比較小,但是它特別多,物件加在一起比較大也是需要我們重點關注的。例如一個物件只佔10kb,但是如果記憶體裡有2000個物件例項,總的記憶體佔用也是特別大的。
當前類大物件的預設定義:物件例項數量超過10,Retained Size超過20M的類。
我們會解析出這部分類大物件,然後計算出他們的引用鏈。
類大物件展示效果:
上面的case是說類ArticleCell的物件有364個,總Retained Size 是51.29M。其中280個被MainActivity所持有。所以如果要優化ArticleCell的記憶體佔用,可以優化MainActivity裡面的引用。

3.2.4 聚合和Retrace

3.2.4.1 聚合

通過聚合我們可以找到同類問題,並把高頻問題體現出來優先解決,達到四兩撥千斤的效果。
洩露是通過洩露類和引用它的業務程式碼作為聚合特徵來進行聚合。
大物件是通過大物件類和引用它的業務程式碼作為聚合特徵來進行聚合。
類大物件是根據類名來進行聚合。
洩露的聚合效果如下,可以根據排序直接定位到高頻洩露的Acitvity。

 

3.2.4.2 Retrace

類名和引用鏈Retrace:
Hprof檔案是混淆後的資料,對於解析出來的類名和引用鏈可以和崩潰一樣通過符號表進行自動解析還原。
Hprof檔案Retrace:
為了更好的分析單點問題,平臺也提供了單點自動化分析資料展示和單點原始Hprof檔案下載的能力。
 
對於下載下來的Hprof原始檔案也是混淆後的,客戶端分析非常不友好,是否可以把Hprof檔案也進行反混淆還原呢。
當然可以,當前開發了一個Hprof檔案Retrace工具,可以解析Hprof檔案,讀取類、變數和方法等資料,根據符號表還原成Retrace後的Hprof檔案,線下分析更方便。
通過平臺下載下來的Hprof就是經過填充還原Hprof結構,並且自動Retrace後的Hprof檔案。
解析前:
解析後:

3.2.5 自動分配

對於分析出來的問題,只分析出來還不足夠,並沒有實現閉環,我們需要通知到相應的同學去解決才可以,否則需要有同學來手動分配線上問題,比較浪費精力。
因此需要有自動分配的能力,內部通過解析聚合後issue的洩露Class,去程式碼倉庫或者根據配置找這個 Class 的Owner,傳送 Lark 通知給這位同學。
當前在火山引擎的MARS-APMPlus 應用效能監控無法獲取使用者的倉庫解析Class Owner,暫時無法自動分配。

3.2.6 總結

以上就是這套 基於Hprof記憶體快照的線上 Java OOM 歸因方案的原理介紹,這套方案實現了 高度場景還原自動化記憶體分析自動聚合Retrace、並實現了 隱私安全。
接入後優先分析解決聚合後的TOP問題,包括頻繁的洩露和頻繁出現的大物件,對Java OOM指標會有非常明顯的優化效果。
 

四、優化效果

4.1 內部效果

當前該解決方案已在位元組內部廣泛應用。包括頭條抖音在內的數十個App業務方在接入後,Java OOM均有明顯優化。以Helo為例,一個雙月內 優化了80+%的Java OOM問題,次日存留增長了2+%,效果顯著。

4.2 外部效果

當前這套方案已經在 火山引擎 MARS-APM Plus應用效能監控上線,美篇作為早期客戶,在一個雙月的優化後 Java OOM 降低了80%使用者卡頓率也下降了80%,優化效果非常明顯。

五、接入使用

MARS-APM Plus應用效能監控企業助力行動MARS-APM Plus 當前開始了企業助力行動,可以免費試用,歡迎註冊試用產品,發現並解決Java OOM問題。MARS-APM Plus除了支援對App進行監控,也支援對SDK進行穩定性監控和自定義事件打點。
進群:掃碼進群,會有同學對接如何開通 MARS-APM Plus應用效能監控服務。
MARS-APM Plus 應用效能監控為企業提供 針對應用的品質、效能以及自定義 埋點 APM  應用效能監控服務,幫助團隊打造極致的使用者體驗。基於海量資料的聚合分析,平臺可幫助客戶發現多類異常問題,並及時報警,做分配處理,同時平臺提供了豐富的歸因能力,包括且不限於異常分析、多維分析、自定義上報、單點日誌查詢等,結合靈活的報表能力可瞭解各類指標的趨勢變化。APM Plus 已服務了抖音、今日頭條、TikTok 等多個超大規模使用者量級移動 App。
 
 
當前講解的Java OOM解決方案,只是MARS-APM Plus 應用效能監控的一個功能模組,還有更多的能力會在後續進一步講解,也歡迎同學搶先接入試用。
 

🔥  火山引擎 APMPlus 應用效能監控是火山引擎應用開發套件 MARS 下的效能監控產品。我們通過先進的資料採集與監控技術,為企業提供全鏈路的應用效能監控服務,助力企業提升異常問題排查與解決的效率。目前產品正在免費公測階段,👉  戳這裡瞭解更多產品資訊。歡迎大家進行試用!