內存做爲計算機程序運行最重要的資源之一,須要運行過程當中作到合理的資源分配與回收,不合理的內存佔用輕則使得用戶應用程序運行卡頓、ANR、黑屏,重則致使用戶應用程序發生 OOM(out of memory)崩潰。抖音做爲一款用戶使用普遍的產品,須要在各類機器資源上保持優秀的流暢性和穩定性,內存優化是必需要重視的環節。
html本文從抖音 Java OOM 內存優化的治理實踐出發,嘗試給你們分享一下抖音團隊關於 Java 內存優化中的一些思考,包括工具建設、優化方法論。編程
抖音 Java OOM 背景
在未對抖音內存進行專項治理以前咱們梳理了一下總體內存指標的絕對值和相對崩潰,發現佔比都很高。另外,內存相關指標在去年春節活動時又再次激增達到歷史新高,因此總體來看內存問題至關嚴峻,必需要對其進行專項治理。抖音這邊經過前期歸因、工具建設以及投入一個雙月的內存專項治理將總體 Java OOM 優化了百分之 80。緩存
Java OOM Top 堆棧歸因
在對抖音的 Java 內存優化治理以前咱們先根據平臺上報的堆棧異常對當前的 OOM 進行歸因,主要分爲下面幾類:服務器
圖 1. OOM 分類
架構
其中 pthread_create
問題佔到了總比例大約在百分之 50,Java 堆內存超限爲百分之 40 多,剩下是少許的 fd 數量超限。其中 pthread_create
和 fd 數量不足均爲 native 內存限制致使的 Java 層崩潰,咱們對這部分的內存問題也作了針對性優化,主要包括:app
線程收斂、監控框架
線程棧泄漏自動修復異步
FD 泄漏監控jvm
虛擬內存監控、優化ide
抖音 64 位專項
治理以後 pthread_create
問題下降到了 0.02‰如下,這方面的治理實踐會在下一篇抖音 Native 內存治理實踐中詳細介紹,你們敬請期待。本文重點介紹 Java 堆內存治理。
堆內存治理思路
從 Java 堆內存超限的分類來看,主要有兩類問題:
1. 堆內存單次分配過大/屢次分配累計過大。
觸發這類問題的緣由有數據異常致使單次內存分配過大超限,也有一些是 StringBuilder 拼接累計大小過大致使等等。這類問題的解決思路比較簡單,問題就在當前的堆棧。
2. 堆內存累積分配觸頂。
這類問題的問題堆棧會比較分散,在任何內存分配的場景上都有可能會被觸發,那些高頻的內存分配節點發生的機率會更高,好比 Bitmap 分配內存。這類 OOM 的根本緣由是內存累積佔用過多,而當前的堆棧只是壓死駱駝的最後一根稻草,並非問題的根本所在。因此這類問題咱們須要分析總體的內存分配狀況,從中找到不合理的內存使用(好比內存泄露、大對象、過多小對象、大圖等)。
工具建設
工具思路
工欲善其事,必先利其器。從上面的內存治理思路看,工具須要主要解決的問題是分析總體的內存分配狀況,發現不合理的內存使用(好比內存泄露、大對象、過多小對象等)。
咱們從線下和線上兩個維度來建設工具:
線下
線下工具是最早考慮的,在研發和測試的時候可以提早發現內存泄漏問題。業界的主流工具也是這個思路,好比 Android Studio Memory Profiler、LeakCanary、Memory Analyzer (MAT)。
咱們基於 LeakCanary 核心庫在線下設計了一套自動分析上報內存泄露的工具,主要流程以下:
圖 2.線下自動分析流程
抖音在運行了一段線下的內存泄漏工具以後,發現了線下工具的各類弊端:
檢測出來的內存泄漏過多,而且也沒有比較好的優先級排序,研發消費不過來,歷史問題就一直堆積。另外也很難和業務研發溝通問題解決的收益,你們針對解決線下的內存泄漏問題的 ROI(投入產出比)比較難對齊。
線下場景能跑到的場景有限,很難把全部用戶場景窮盡。抖音用戶基數很大,咱們常常遇到一些線上的 OOM 激增問題,由於缺乏線上數據而無從查起。
Android 端的 HPORF 的獲取依賴原生的
Debug.dumpHporf
,dump 過程會掛起主線程致使明顯卡頓,線下使用體驗較差,常常會有研發反饋影響測試。LeakCanary 基於 Shark 分析引擎分析,分析速度較慢,一般在 5 分鐘以上才能分析完成,分析過程會影響進程內存佔用。
分析結果較爲單一,僅僅只能分析出 Fragment、Activity 內存泄露,像大對象、過多小對象問題致使的內存 OOM 沒法分析。
線上
正是因爲上述一些弊端,抖音最先的線下工具和治理流程並無起到什麼太大做用,咱們不得不從新審視一下,工具建設的重心從線下轉成了線上。線上工具的核心思路是:在發生 OOM 或者內存觸頂等觸發條件下,dump 內存的 HPROF 文件,對 HPROF 文件進行分析,分析出內存泄漏、大對象、小對象、圖片問題並按照泄露鏈路自動歸因,將大數據問題按照用戶發生次數、泄露大小、總大小等緯度排序,推動業務研發按照優先級順序來創建消費流程。爲此咱們研發了一套基於 HPORF 分析的線下、線上閉環的自動化分析工具 Liko(寓意 ko 內存 Leak 問題)。
Liko 介紹
Liko 總體架構
圖 3. Liko 架構圖
總體架構由客戶端、Server 端和核心分析引擎三部分構成。
客戶端
在客戶端完成 HPROF 數據採集和分析(針對端上分析模式),這裏線上和線下策略不一樣。
線上:主要在 OOM 和內存觸頂時經過用戶無感知 dump 來獲取 HPROF 文件,當 App 退出到後臺且內存充足的狀況進行分析,爲了儘可能減小對 App 運行時影響,主要經過裁剪 HPROF 回傳進行分析,爲減輕服務器壓力,對部分比例用戶採用端上分析做爲 Backup。
線下:dump 策略配置較爲激進,在 OOM、內存觸頂、內存激增、監測 Activity、Fragment 泄漏數量達到必定閾值多種場景下觸發 dump,並實時在端上分析上傳至後臺並在本地自動生成 html 報表,幫助研發提早發現可能存在的內存問題。
Server 端
Server 端根據線上回傳的大數據完成鏈路聚合、還原、分配,並根據用戶發生次數、泄露大小、總大小等緯度促進研發測消費,對於回傳分析模式則會另外進行 HPORF 分析。
分析引擎
基於 MAT 分析引擎完成內存泄露、大對象、小對象、圖片等自動歸因,同時支持在線下自動生成 Html 報表。
Liko 流程圖
圖 4. Liko 流程圖
總體流程分爲:
Hprof 收集
分析時機
分析策略
Hprof 收集
收集過程咱們設置了多種策略能夠自由組合,主要有 OOM、內存觸頂、內存激增、監測 Activity、Fragment 泄漏數量達到必定閾值時觸發,線下線上策略配置不一樣。
爲了解決 dump 掛起進程問題,咱們採用了子進程 dump+fileObsever 的方式完成 dump 採集和監聽。
在 fork 子進程以前先 Suspend
獲取主進程中的線程拷貝,經過 fork 系統調用建立子進程讓子進程擁有父進程的拷貝,而後 fork 出的子進程中調用 Hprof 的 DumpHeap
函數便可完成把耗時的 dump 操做在放在子進程。因爲 suspend
和 resume
是系統函數,咱們這裏經過自研的 native hook 工具對 libart.so
hook 獲取系統調用。因爲寫入是在子進程完成的,咱們經過 Android 提供的 fileObsever 文件寫入進行監控獲取 dump 完成時機。
圖 5.子進程 dump 流程圖
Hprof 分析時機
爲了達到分析過程對於用戶無感,咱們在線上、線下配置了不一樣的分析時機策略,線下在 dump 分析完成後根據內存狀態主動觸發分析,線上當用戶下次冷啓退出應用後臺且內存充足的狀況下觸發分析。
分析策略
分析策略咱們提供了兩種,一種在 Android 客戶端分析,一種回傳至 Server 端分析,均經過 MAT 分析引擎進行分析。
端上分析
分析引擎
端上分析引擎的性能很重要,這裏咱們主要對比了 LeakCanary 的分析引擎 Shark 和 Haha 庫的 MAT。
圖 6. Shark VS MAT
咱們在相同客戶端環境對 160M 的 HPROF 屢次分析對比發現 MAT 分析速度明顯優於 Shark,另外針對 MAT 分析後仍持有統治者樹佔用內存咱們也作了主動釋放,對比性能收益後採用基於 MAT 庫的分析引擎進行分析,對內存泄漏引用鏈路自動歸併、大對象小對象引用鏈自動分析、大圖線下自動還原線上過濾無用鏈路,分析結果以下:
內存泄漏
圖 7. 內存泄漏鏈路
對泄漏的 Activity 的引用鏈進行了聚合分析,方便一次性解決該 Activity 的泄漏鏈釋放內存。
大對象
圖 8. 大對象鏈路
大對象不止分析了引用鏈路,還遞歸分析了內部 top 持有對象(InRefrenrece
)的 RetainedSize。
小對象
圖 9. 小對象鏈路
小對象咱們對 top 的外部持有對象(OutRefrenrece
)進行聚合獲得佔有小對象最多的鏈路。
圖片
圖 10. 圖片鏈路
圖片咱們過濾了圖片庫等無效引用且對 Android 8.0 如下的大圖在線下進行了還原。
回傳分析
爲了最大限度的節省用戶流量且規避隱私風險,咱們經過自研 HPROF 裁剪工具 Tailor 在 dump 過程對 HPROF 進行了裁剪。
裁剪過程
圖 11. Tailor 裁剪流程
去除了無用信息
跳過 header
分 tag 裁剪
-
裁剪無用信息:char[]; byte[]; timestamp; stack trace serial number; class serial number;
壓縮數據信息
同時對數據進行 zlib 壓縮,在 server 端數據還原,總體裁剪效果:180M--->50M---->13M
優化實踐
內存泄漏
除了經過後臺根據 GCROOT+ 引用鏈自動分配研發跟進解決咱們常見的內存泄漏外,咱們還對系統致使一些內存泄漏進行了分析和修復。
系統異步 UI 泄漏
根據上傳聚合的引用鏈咱們發如今 Android 6.0 如下有一個 HandlerThread 做爲 GCROOT 持有大量 Activity 致使內存泄漏,根據引用發現這些泄漏的 Activity 都被一個 Runnable(這裏是 Runnable 是一個系統事件 SendViewStateChangedAccessibilityEvent
)持有,這些 Runnable 被添加到一個 RunQueuel 中,這個隊列自己被 TheadLocal 持有。
圖 12. HandlerThread 泄露鏈路
咱們從 SendViewStateChangedAccessibilityEvent
入手對源碼進行了分析發現它在 notifyViewAccessibilityStateChangedIfNeeded
中被拋出,系統的大量 view 都會在自身的一些 UI 方法(eg: setChecked)中觸發該函數。
SendViewStateChangedAccessibilityEvent
的 runOrPost
方法會走到咱們經常使用的 View 的 postDelay
方法中,這個方法在當 view 還未被 attched 到根 view 的時候會加入到一個 runQueue 中。
這個 runQueue 會在主線程下一次的 performTraversals()
中消費掉。
若是這個 runQueue 不在主線程那就沒有消費的機會。
根據上面的分析發現形成這種內存泄漏須要知足一些條件:
view 調用了
postDelay
方法 (這裏是notifyViewAccessisbilityStateChangeIfNeeded
觸發)
view 處於 detached 狀態
上述過程是在非主線程裏面操做的,ThreadLocal 非 UIThread,持有的 runQueue 不會走
performTraversals
消費掉。
圖 13. 反射清理流程
另外,Google 在 6.0 上也修復了 notifyViewAccessisbilityStateChangeIfNeeded
的判斷不嚴謹問題。
內存泄漏兜底
大量的內存泄漏,若是咱們都靠推動研發解決,常常會出現生產大於消費的狀況,針對這些未被消費的內存泄漏咱們在客戶端作了監控和止損,將 onDestory 的 Activity 添加到 WeakRerefrence 中,延遲 60s 監控是否回收,未回收則主動釋放泄漏的 Activity 持有的 ViewTree 的背景圖和 ImageView 圖片。
大對象
主要對三種類型的大對象進行優化
全局緩存:針對全局緩存咱們按需釋放和降級了不須要的緩存,儘可能使用弱引用代替強引用關係,好比針對頻繁泄漏的 EventBus 咱們將內部的訂閱者關係改成弱引用解決了大量的 EventBus 泄漏。
系統大對象:系統大對象如 PreloadDrawable、JarFile 咱們經過源碼分析肯定主動釋放並不干擾原有邏輯,在啓動完成或在內存觸頂時主動反射釋放。
動畫:用原生動畫代替了內存佔用較大的幀動畫,並對 Lottie 動畫泄漏作了手動釋放。
圖 14. 大對象優化點
小對象
小對象優化咱們集中在字段優化、業務優化、緩存優化三個緯度,不一樣的緯度有不一樣的優化策略。
圖 15. 小對象優化思路
通用類優化
在抖音的業務中,視頻是最核心且通用的 Model,抖音業務層的數據存儲分散在各個業務維護了各自視頻的 Model,Model 自己因爲聚合了各個業務須要的屬性不少致使單個實例內存佔用就不低,隨着用戶使用過程實例增加內存佔用愈來愈大。對 Model 自己咱們能夠從屬性優化和拆分這兩種思路來優化。
字段優化:針對一次性的屬性字段,在使用完以後及時清理掉緩存,好比在視頻 Model 內部存在一個 Json 對象,在反序列完成以後 Json 對象就沒有使用價值了,能夠及時清理。
類拆分:針對通用 Model 冗雜過多的業務屬性,嘗試對 Model 自己進行治理,將各個業務線須要用到的屬性進行梳理,將 Model 拆分紅多個業務 Model 和一個通用 Model,採用組合的方式讓各個業務線最小化依賴本身的業務 Model,減小大雜燴 Model 沒必要要的內存浪費。
業務優化
按需加載:抖音這邊 IM 會全局保存會話,App 啓動時會一次性 Load 全部會話,當用戶的會話過多時相應全局佔用的內存就會較大,爲了解決該問題,會話列表分兩次加載,首次只加載必定數量到內存,須要時再加載所有。
內存緩存限制或清理:首頁推薦列表的每一次 Loadmore 操做,都不會清理以前緩存起來的視頻對象,致使用戶長時間停留在推薦 Feed 時,緩存起來的視頻對象過多會致使內存方面的壓力。在經過實驗驗證不會對業務產生負面影響狀況下對首頁的緩存進行了必定數量的限制來減少內存壓力。
緩存優化
上面提到的視頻 Model,抖音最先使用 Manager 來管理通用的視頻實例。Manager 使用 HashMap 存儲了全部的視頻對象,最初的方案裏面沒有對內存大小進行限制且沒有清除邏輯,隨着使用時間的增長而不斷膨脹,最終出現 OOM 異常。爲了解決視頻 Model 無限膨脹的問題設計了一套緩存框架主要流程以下:
圖 16. 視頻緩存框架
使用 LRU 緩存機制來緩存視頻對象。在內存中緩存最近使用的 100 個視頻對象,當視頻對象從內存緩存中移除時,將其緩存至磁盤中。在獲取視頻對象時,首先從內存中獲取,若內存中沒有緩存該對象,則從磁盤緩存中獲取。在退出 App 時,清除 Manager 的磁盤緩存,避免磁盤空間佔用不斷增加。
圖片
關於圖片優化,咱們主要從圖片庫的管理和圖片自己優化兩個方面思考。同時對不合理的圖片使用也作了兜底和監控。
圖片庫
針對應用內圖片的使用情況對圖片庫設置了合理的緩存,同時在應用 or 系統內存吃緊的狀況下主動釋放圖片緩存。
圖片自身優化
咱們知道圖片內存大小公式 = 圖片分辨率 * 每一個像素點的大小。
圖片分辨率咱們經過設置合理的採樣來減小沒必要要的像素浪費。
//開啓採樣 ImagePipelineConfig config = ImagePipelineConfig.newBuilder(context) .setDownsampleEnabled(true) .build(); Fresco.initialize(context, config); //請求圖片時,傳入resize的大小,通常直接取View的寬高 ImageRequest request = ImageRequestBuilder.newBuilderWithSource(uri) .setResizeOptions(new ResizeOptions(50, 50)) .build();mSimpleDraweeView.setController( Fresco.newDraweeControllerBuilder() .setOldController(mSimpleDraweeView.getController()) .setImageRequest(request) .build());
而單個像素大小,咱們經過替換系統 drawable 默認色彩通道,將部分沒有透明通道的圖片格式由 ARGB_8888 替換爲 RGB565,在圖片質量上的損失幾乎肉眼不可見,而在內存上能夠直接節省一半。
圖片兜底
針對因 activity、fragment 泄漏致使的圖片泄漏,咱們在 onDetachedFromWindow
時機進行了監控和兜底,具體流程以下:
圖 17. 圖片兜底流程
圖片監控
關於對不合理的大圖 or 圖片使用咱們在字節碼層面進行了攔截和監控,在原生 Bitmap or 圖片庫建立時機記錄圖片信息,對不合理的大圖進行上報;另外在 ImageView 的設置過程當中針對 Bitmap 遠超過 view 自己超過大小的場景也進行了記錄和上報。
圖 18. 圖片字節碼監控方案
更多思考
是否是解決了 OOM 內存問題就告一段落了呢?做爲一隻追求極致的團隊,咱們除了解決靜態的內存佔用外也自研了 Kenzo(Memory Insight)工具嘗試解決動態內存分配形成的 GC 卡頓。
Kenzo 原理
Kenzo 採用 JVMTI 完成對內存監控工做,JVMTI(JVM Tool Interface)是 Java 虛擬機所提供的 native 編程接口。JVMTI 開發時,應用創建一個 Agent 使用 JVMTI,可使用 JVMTI 函數,設置回調函數,並從 Java 虛擬機中獲得當前的運行態信息,並做出本身的業務判斷。
圖 19. Agent 時序圖
Jvmti SetEventCallbacks
方法能夠設置目標虛擬機內部事件回調,能夠根據 jvmtiCapabilities
支持的能力和咱們關注的事件來定義須要 hook 的事件。
Kenzo 採用 Jvmti 完成以下事件回調:
類加載準備事件 -> 監控類加載
-
ClassPrepare:某個類的準備階段完成。
GC -> 監控 GC 事件與時間
-
GarbageCollectionStart:GC 啓動時。
GarbageCollectionFinish:GC 結束後。
對象事件 -> 監控內存分配
-
ObjectFree:GC 釋放一個對象時。
VMObjectAlloc:虛擬機分配一個對象的時候。
框架設計
Kenzo 總體分爲兩個部分:
生產端
採集內存數據
以 sdk 形式集成到宿主 App
消費端
處理生產端的數據
輸入 Kenzo 監控的內存數據
輸出可視化報表
圖 20. kenzo 框架
生產端主要以 Java 進行 API 調用,C++完成底層檢測邏輯,經過 JNI 完成底層邏輯控制。
消費端主要以 Python 完成數據的解析、視圖合成,以 HTML 完成頁面內容展現。
工做流
圖 21. kenzo 框架
可視化展現
圖 22. kenzo 聚合展現
啓動階段內存歸因
基於動態內存監控咱們對最爲核心的啓動場景的內存分配進行了歸因分析,優化了一些頭部的內存節點分配:
圖 23.啓動階段內存節點歸因
另外咱們也發現啓動階段存在大量的字符串拼接操做,雖然編譯器已經優化成了 StringBuider append
,可是深刻 StringBuider 源碼分析仍在存在大量的動態擴容動做(System.copy),爲了優化高頻場景觸發動態擴容的性能損耗,在 StringBuilder 在 append
的時候,不直接往 char[]
裏塞東西,而是先拿一個 String[]
把它們都存起來,到了最後才把全部 String 的 length 加起來,構造一個合理長度的 StringBuilder。經過使用編譯時字節碼替換的方式,替換全部 StringBuilder 的 append
方法使用自定義實現,優化後首次安裝首頁 Feed 滑動 1min 的 FPS 提高 1 幀/S,非首次安裝啓動,滑動 1min 的 FPS 提高 0.6 幀/S。
加入咱們
咱們是負責抖音客戶端基礎技術能力研發和前沿技術探索的客戶端團隊,咱們專一於性能、架構、穩定性、研發工具、編譯構建等方向的深耕,保障超大規模團隊的研發效率和工程質量,將 6 億人使用的抖音打形成極致用戶體驗的產品。
若是你對技術充滿熱情,歡迎加入抖音基礎技術團隊,讓咱們共建億級全球化 App。目前咱們在上海、北京、杭州、深圳均有招聘需求,內推能夠聯繫郵箱: tech@bytedance.com ;郵件標題: 姓名 - 工做年限 - 抖音 - 基礎技術 - Android / iOS 。
更多分享
歡迎關注「 字節跳動技術團隊 」
簡歷投遞聯繫郵箱「 tech@bytedance.com 」
點擊閱讀原文,快來加入咱們吧!