誰(shuí)動(dòng)了我的內(nèi)存,揭秘 OOM 崩潰下降 90% 的秘密
最近一直在做內(nèi)存和 ANR 相關(guān)的優(yōu)化,接下來(lái)我將會(huì)花幾篇文章梳理一下內(nèi)存相關(guān)的優(yōu)化,以及我是如何將 OOM 崩潰率下降 90% 。
今天這篇文章主要介紹內(nèi)存相關(guān)的知識(shí)點(diǎn),以及那些因素會(huì)導(dǎo)致 OOM 崩潰和相對(duì)應(yīng)的解決方案,所以通過(guò)這篇文章你將學(xué)習(xí)到以下內(nèi)容:
- 什么是虛擬內(nèi)存和物理內(nèi)存
- 32 位和 64 位設(shè)備可用虛擬內(nèi)存分別是多少
- 為什么虛擬內(nèi)存不足主要發(fā)生在 32 位的設(shè)備上
- 如何解決虛擬內(nèi)存不足的問(wèn)題
- App 啟動(dòng)完成之后,虛擬內(nèi)存的分布
- 如何解決 Java 堆內(nèi)存不足的問(wèn)題
- Java 堆上還有很多可用的內(nèi)存,為什么還會(huì)出現(xiàn) OOM
- 做性能優(yōu)化時(shí),需要關(guān)心那些指標(biāo)數(shù)據(jù)
不知道小伙伴們有沒(méi)有經(jīng)歷過(guò),相同的優(yōu)化方案,A 應(yīng)用上線之后,崩潰率下降很多,但是 B 應(yīng)用上線只有一點(diǎn)點(diǎn)收益,每個(gè)優(yōu)化方案,在不同的 App 上所得到的優(yōu)化效果未必一樣,因?yàn)槊總€(gè) App 在不同的國(guó)家和地區(qū)面對(duì)的用戶群體不一樣,因此機(jī)型也都不一樣,所以我們需要了解內(nèi)存相關(guān)的知識(shí)點(diǎn),結(jié)合線上和線下數(shù)據(jù),對(duì)自己的 App 進(jìn)行歸因,對(duì)癥下藥,才能取得較大的收益。
內(nèi)存是極其稀缺的資源,不合理的使用會(huì)導(dǎo)致可用內(nèi)存越來(lái)越少,可能會(huì)引發(fā)卡頓、ANR、OOM 崩潰、Native 崩潰等等,嚴(yán)重影響用戶的體驗(yàn)。所以當(dāng)我們?cè)谧鲂阅軆?yōu)化的時(shí)候,內(nèi)存優(yōu)化是非常重要的環(huán)節(jié)。
初期在做內(nèi)存優(yōu)化的時(shí)候,在我們的腦海里都會(huì)有一個(gè)潛意識(shí)「內(nèi)存占用越少越好」,在某些情況下是不對(duì)的。例如在高端機(jī)上我們可以多分配點(diǎn)內(nèi)存,可以提升用戶的體驗(yàn),但是在低端機(jī)上內(nèi)存本身就很小,所以我們應(yīng)盡量減少內(nèi)存的分配。例如針對(duì)損耗性能的動(dòng)畫、特效等等,在低端機(jī)上是不是可以關(guān)掉,或者關(guān)掉硬件加速、采用其他的方案代替,這樣不僅可以減少崩潰,還可以減少卡頓,提高用戶體驗(yàn)。
因?yàn)?Java 有自動(dòng)回收機(jī)制,所以在開(kāi)發(fā)過(guò)程中,很少有人會(huì)去關(guān)心內(nèi)存問(wèn)題,在腦海中都會(huì)有一個(gè)潛意識(shí) GC 會(huì)自動(dòng)回收,所以用完不會(huì)主動(dòng)釋放掉無(wú)用資源例如 Bitmap、動(dòng)畫、播放器等等,等待 GC 來(lái)回收,在實(shí)際項(xiàng)目中,依賴 GC 是不可靠的。首先 GC 自動(dòng)回收機(jī)制具有不確定性,GC 也分為了不同的類型,如果發(fā)生 Full GC 時(shí),會(huì)觸發(fā) stop the work 事件,會(huì)使 App 變得更加嚴(yán)重。
另外 GC 的回收機(jī)制根據(jù)可達(dá)性分析算法判斷一個(gè)對(duì)象是否可以被回收,如果存在內(nèi)存泄露,GC 是不會(huì)回收這些資源的,逐漸累積,當(dāng)達(dá)到堆的內(nèi)存上限時(shí),發(fā)生 OOM 崩潰了,所以你要保證自己不要寫出內(nèi)存泄露的代碼,以及團(tuán)隊(duì)其他人不要寫出內(nèi)存泄露的代碼,然而實(shí)際情況這是不可能的,所以依靠 GC 自動(dòng)回收機(jī)制這種想法是不可靠的。雖然 Java 有內(nèi)存回收機(jī)制,但是我們應(yīng)該在腦海中保留內(nèi)存管理的意識(shí),所以當(dāng)申請(qǐng)完內(nèi)存,退出或者不在使用時(shí),及時(shí)釋放掉內(nèi)存。真正做到 用時(shí)分配,及時(shí)釋放。
可用內(nèi)存越來(lái)越少時(shí),嚴(yán)重時(shí)會(huì)導(dǎo)致 OOM 崩潰,做過(guò) OOM 優(yōu)化的朋友應(yīng)該會(huì)發(fā)現(xiàn),線上捕獲的大部分 OOM 崩潰堆棧,都是壓死駱駝的最后一根稻草,并不是問(wèn)題的根本所在,所以我們需要對(duì) OOM 崩潰進(jìn)行歸因,找到占用內(nèi)存的大頭。降低整機(jī)已使用的內(nèi)存,從而降低 OOM 崩潰,因此我大概分為了以下幾個(gè)方面。
- 虛擬內(nèi)存和物理內(nèi)存
- 堆內(nèi)存
- 堆內(nèi)存泄露,指的是在程序運(yùn)行時(shí),給對(duì)象分配的內(nèi)存,當(dāng)程序退出或者退出界面時(shí),分配的內(nèi)存沒(méi)有釋放或者因?yàn)槠渌驘o(wú)法釋放
- 資源泄露,比如 FD、socket、線程等等,這些在每個(gè)手機(jī)上都是有數(shù)量的限制,如果使用了不釋放,就會(huì)因?yàn)橘Y源的耗盡而崩潰,我們?cè)诰€上就出現(xiàn)過(guò) FD 的泄露,導(dǎo)致崩潰率漲了 3 倍
- 分配的內(nèi)存到達(dá) Java 堆的上限
- 可用內(nèi)存很多,因?yàn)閮?nèi)存碎片化,沒(méi)有足夠的連續(xù)段的空間分配
- 對(duì)象的單次分配或者多次分配累計(jì)過(guò)大,例如在循環(huán)動(dòng)畫中一直創(chuàng)建 Bitmap
- Java 堆內(nèi)存溢出
- 內(nèi)存泄露
- FD 的數(shù)量超出當(dāng)前手機(jī)的閾值
- 線程的數(shù)量超出當(dāng)前手機(jī)的閾值
其中 FD 和線程崩潰占比很低,因此這不是我們前期優(yōu)化的重點(diǎn)。這篇文章我們重點(diǎn)介紹 虛擬內(nèi)存和物理內(nèi)存,下篇文章將會(huì)介紹堆內(nèi)存, 堆內(nèi)存是程序在運(yùn)行過(guò)程中為對(duì)象分配內(nèi)存的區(qū)域,它也屬于虛擬內(nèi)存的范圍。
虛擬內(nèi)存和物理內(nèi)存
介紹虛擬內(nèi)存之前,我們需要先介紹物理內(nèi)存,物理內(nèi)存就是實(shí)實(shí)在在的內(nèi)存(即內(nèi)存條),如果應(yīng)用直接對(duì)物理內(nèi)存操作,會(huì)存在很多問(wèn)題:
- 安全問(wèn)題,應(yīng)用之間的內(nèi)存空間沒(méi)有隔離,會(huì)導(dǎo)致應(yīng)用 A 可以修改應(yīng)用 B 的內(nèi)存數(shù)據(jù),這是非常不安全的
- 內(nèi)存空間利用率低,應(yīng)用對(duì)內(nèi)存的使用會(huì)出現(xiàn)內(nèi)存碎片化的問(wèn)題,即使還有很多內(nèi)存可以用,但是沒(méi)有足夠的連續(xù)段的內(nèi)存分配,而導(dǎo)致崩潰
- 效率低,多個(gè)應(yīng)用同時(shí)對(duì)物理內(nèi)存進(jìn)行讀取和寫入時(shí),使用效率會(huì)非常低
為了解決上面的問(wèn)題,我們需要為每個(gè)應(yīng)用分配 "中間內(nèi)存" 最終會(huì)映射到物理內(nèi)存上,這就是接下來(lái)要說(shuō)的虛擬內(nèi)存。
操作系統(tǒng)會(huì)為每個(gè)應(yīng)用分配一個(gè)獨(dú)立的虛擬內(nèi)存,實(shí)現(xiàn)應(yīng)用間的內(nèi)存隔離,避免了應(yīng)用 A 修改應(yīng)用 B 的內(nèi)存數(shù)據(jù)的問(wèn)題,虛擬內(nèi)存最終會(huì)映射到物理內(nèi)存上,當(dāng)應(yīng)用申請(qǐng)內(nèi)存時(shí),得到的是虛擬內(nèi)存,只有真正執(zhí)行寫操作時(shí),才會(huì)分配到物理內(nèi)存,好處是應(yīng)用可以使用連續(xù)的地址空間來(lái)訪問(wèn)不連續(xù)的物理內(nèi)存。
每個(gè)應(yīng)用程序可使用的虛擬內(nèi)存大小受 CPU 位寬及內(nèi)核的限制。我們常說(shuō)的 16 位 cpu,32 位 cpu,64 位 CPU,指的都是 CPU 的位寬,表示的是一次能夠處理的數(shù)據(jù)寬度,即 CPU 能處理的 2 進(jìn)制位數(shù),即分別是 16bit,32bit 和 64bit。而目前市面上常用的是 32 位和 64 的設(shè)備。
32 位和 64 位設(shè)備可用虛擬內(nèi)存分別是多少
32 位設(shè)備可以使用的虛擬內(nèi)存大小 3GB
32 位 CPU 架構(gòu)的設(shè)備可使用的地址空間大小為 2^32=4GB, 虛擬內(nèi)存空間分為 內(nèi)核空間 和 用戶空間,系統(tǒng)提供了三種虛擬地址空間分配的參數(shù),代表用戶空間可訪問(wèn)的虛擬地址空間大小。
- VMSPLIT_3G : 默認(rèn)值,表示用戶空間可使用 3GB 的低地址,剩下的 1GB 高地址分配給內(nèi)核
- VMSPLIT_2G : 表示用戶空間可使用 2GB 的低地址
- VMSPLIT_1G : 表示用戶空間可使用 1GB 的低地址
64 位應(yīng)用可以使用的虛擬內(nèi)存大小 512GB
64 位 CPU 架構(gòu)的設(shè)備雖然擁有 64 位的地址空間,但是不是全部都可以使用的,為了后期的擴(kuò)展,只能使用部分地址。
Android 默認(rèn)的虛擬地址的長(zhǎng)度配置為 CONFIG_ARM64_VA_BITS=39,即 Android 的 64 位應(yīng)用可使用的地址空間大小為 2^39=512GB。
當(dāng) 32 位應(yīng)用在 64 位的設(shè)備上運(yùn)行時(shí),可使用 4GB 虛擬地址空間,而 64 位應(yīng)用可使用 512GB 的空間。因此在 64 位機(jī)器上不存在虛擬空間不足的問(wèn)題。因此在 2019 年的時(shí)候 Google Play 要求除了提供 32 位的版本之外,還需要提供 64 位的版本。
在我們的 OOM 崩潰設(shè)備中,32 位的設(shè)備占比 50%+ 以上,虛擬內(nèi)存不足主要發(fā)生在 32 位的設(shè)備上。
為什么虛擬內(nèi)存不足主要發(fā)生在 32 位的設(shè)備上
在 32 位的設(shè)備上,受地址空間最大內(nèi)存 4 GB 限制,內(nèi)核空間占用 1G,剩下的 3G 是用戶空間,我們可以通過(guò)解析 /process/pid/smaps 文件,查看當(dāng)前虛擬內(nèi)存分配情況。https://android.googlesource.com/platform/frameworks/base/+/3025ef332c29e255388f74b2afefe05f64bce07c/core/jni/android_os_Debug.cpp
- 系統(tǒng)資源預(yù)分配,包含了 Zygote 進(jìn)程初始化時(shí),需要加載 Framework 層的代碼和資源。供 Fork 出來(lái)的子進(jìn)程可以直接使用。Framework 資源包含:Framework 層 Java 代碼、so、art 虛擬機(jī)、各種靜態(tài)資源字體、文件等等
- 系統(tǒng)預(yù)分配區(qū)域中其中 [anon:libwebview reservation] 區(qū)域占用 130MB 內(nèi)存
- App 自身資源,包括 App 中的代碼、資源、 App 直接或者間接開(kāi)啟線程消耗的??臻g、 App 申請(qǐng)的內(nèi)存、內(nèi)存文件映射等內(nèi)容。
- Java 堆用于分配 Java / Kotlin 創(chuàng)建的對(duì)象。由 GC 管理和回收,GC 回收時(shí)將 From Space 里的對(duì)象復(fù)制到 To Space,這兩片區(qū)域分別為 dalvik-main space 和 dalvik-main space 1, 這兩片區(qū)域的大小和我當(dāng)前測(cè)試機(jī) Java 堆大小一樣,都是 512 MB,如下圖所示
根據(jù) Android 源碼中的解釋,Java 堆的大小應(yīng)該是根據(jù) RAM Size 來(lái)設(shè)置的,這是一個(gè)經(jīng)驗(yàn)值,廠商是可以更改的,如果手機(jī) Root 之后,自己也可以改,無(wú)論 RAM 多大,到目前為止 Java 堆的上限默認(rèn)都是 512MB,Google 源碼的設(shè)置如下如下圖所示。https://android.googlesource.com/platform/frameworks/native/+/master/build
RAM (MB)-dalvik-heap.mk | heapsize (MB) |
phone-hdpi-dalvik-heap.mk | 32 |
512-dalvik-heap.mk | 128 |
1024-dalvik-heap.mk | 256 |
2048-dalvik-heap.mk | 512 |
4096-dalvik-heap.mk | 512 |
無(wú)論 RAM 多大,到目前為止堆的上限默認(rèn)都是 512MB |
- 內(nèi)存文件映射,mmap 是一種內(nèi)存映射文件的方法,我們的 APK、Dex、so 等等都是通過(guò) mmap 讀取的,會(huì)導(dǎo)致虛擬內(nèi)存增大,mmap 占用的內(nèi)存跟讀寫有關(guān)系
經(jīng)過(guò)分析內(nèi)核、系統(tǒng)資源、以及各 APP 的資源占用,最后留給我們使用的內(nèi)存并不是很多,所以我們要合理使用系統(tǒng)資源,真正做到 "用時(shí)分配,及時(shí)釋放"。
如何解決虛擬內(nèi)存不足的問(wèn)題
目前業(yè)界也有很多黑科技來(lái)釋放因系統(tǒng)占用的虛擬內(nèi)存不足的問(wèn)題,這些黑科技可以參考微信分享的文章 快速緩解 32 位 Android 環(huán)境下虛擬內(nèi)存地址空間不足的“黑科技”,大概有以下幾個(gè)方面的優(yōu)化。
Native 線程默認(rèn)的棧空間大小為 1M 左右,經(jīng)過(guò)測(cè)試大部分情況下線程內(nèi)執(zhí)行的邏輯并不需要這么大的空間,因此 Native 線程棧空間減半,可以減少 pthread_create OOM 崩潰
系統(tǒng)預(yù)分配區(qū)域中其中 [anon:libwebview reservation] 區(qū)域占用 130MB 內(nèi)存,可以嘗試釋放 WebView 預(yù)分配的內(nèi)存,減少一部分虛擬內(nèi)存
虛擬機(jī)堆空間減半,在上面提到過(guò)有兩片大小相同的區(qū)域分別 dalvik-main space 和 dalvik-main space 1,虛擬機(jī)堆空間減半其實(shí)就是減少其中一個(gè) main space 所占用的內(nèi)存
快手針對(duì)垃圾回收器 jemalloc 的優(yōu)化,釋放的是 anon:libc_malloc 所占用的虛擬內(nèi)存。
以下統(tǒng)計(jì)的是在 Android 7.0 App 首次啟動(dòng)完成 libc_malloc 占用的虛擬內(nèi)存 156MB
Vss Pss Rss name
159744 kB 81789 kB 82320 kB [anon:libc_malloc]
Android 11 之前使用的垃圾回收器是 jemalloc,Android 11 之后默認(rèn)使用的垃圾回收器是 scudo。
App 啟動(dòng)完成之后,虛擬內(nèi)存的分布
下圖是 App 在 Android 7.0 上啟動(dòng)完成之后所占用的虛擬內(nèi)存 (Vss),不同系統(tǒng)、不同的 App 虛擬內(nèi)存的分布都不一樣,,我們可以通過(guò)解析 /process/pid/smaps 文件,查看自己的 App 虛擬內(nèi)存分配情況。https://android.googlesource.com/platform/frameworks/base/+/3025ef332c29e255388f74b2afefe05f64bce07c/core/jni/android_os_Debug.cpp
正如上圖所示,主要分為三個(gè)部分:
- dalvik(即 Java 堆),程序在運(yùn)行過(guò)程中為對(duì)象分配內(nèi)存的區(qū)域
- 程序文件 dex 、 so 、 oat
- Native
針對(duì)上面的問(wèn)題,我們?cè)陧?xiàng)目中通過(guò)以下手段進(jìn)行優(yōu)化,重點(diǎn)優(yōu)化 dalvik 占用的內(nèi)存,因篇幅問(wèn)題,將會(huì)在后面的文章中,做詳細(xì)的分析:
- Android 3.0 ~ Android 7.0 上主要將 Bitmap 對(duì)象和像素?cái)?shù)據(jù)統(tǒng)一放到 Java 堆中,Java 堆上限 512MB,而 Native 占用虛擬內(nèi)存,32 的設(shè)備可使用 3GB,64 位的設(shè)備更大,因此我們可以嘗試將 Bitmap 分配到 Native 上,緩解 Java 堆的壓力,降低 OOM 崩潰。??N???
- 使用第三方圖片庫(kù)時(shí),需要針對(duì)高端機(jī)和低端機(jī)設(shè)置圖片庫(kù)不同的緩存大小,這樣我們?cè)诟叨藱C(jī)上保證體驗(yàn)的同時(shí),降低低端機(jī) OOM 崩潰率
- 收斂 Bitmap,避免重復(fù)創(chuàng)建 Bitmap,退出界面及時(shí)釋放掉資源(Bitmap、動(dòng)畫、播放器等等資源)
- 內(nèi)存回收兜底策略,當(dāng) Activity 或者 Fragment 泄露時(shí),與之相關(guān)聯(lián)的動(dòng)畫、Bitmap、 DrawingCache 、背景、監(jiān)聽(tīng)器等等都無(wú)法釋放,當(dāng)我們退出界面時(shí),遞歸遍歷所有的子 view,釋放相關(guān)的資源,降低內(nèi)存泄露時(shí)所占用的內(nèi)存
- 收斂線程,祖?zhèn)鞔a在項(xiàng)目中有很多地方使用了 new Thread 、 AsyncTask 、自己創(chuàng)建線程池等等操作,通過(guò)統(tǒng)一的線程池等手段減少 App 創(chuàng)建線程數(shù)量,降低系統(tǒng)的開(kāi)銷
- 針對(duì)低端機(jī)和高端機(jī)采用不同的策略,減少低端機(jī)內(nèi)存的占用
- 內(nèi)存泄露是永遠(yuǎn)也解決不完的,所以需要梳理一下 Top 系列泄露問(wèn)題,重點(diǎn)解決占用內(nèi)存最多的泄露,以及使用頻率最高的場(chǎng)景所產(chǎn)生的泄露
- 繁創(chuàng)建小對(duì)象,堆內(nèi)存累計(jì)過(guò)大,這些一般都是有明顯堆棧的,根據(jù)堆棧信息解決即可。例如在循環(huán)動(dòng)畫中一直創(chuàng)建 Bitmap
- 大對(duì)象,堆的單次分配內(nèi)存過(guò)大
- 刪減代碼,減少 dex 文件占用的內(nèi)存
- 減少 App 中 dex 數(shù)量,非必要功能,可以通過(guò)動(dòng)態(tài)下發(fā)
- 按需加載 so 文件,不要提前加載所有的 so 文件,需要使用時(shí)再去加載
Java 堆上還有很多可用的內(nèi)存,為什么還會(huì)出現(xiàn) OOM
很多小伙伴們都問(wèn)過(guò)我這么一個(gè)問(wèn)題,大概歸因了一下,主要有以下幾個(gè)原因:
- 內(nèi)存碎片化,沒(méi)有足夠的連續(xù)段的內(nèi)存分配
- 虛擬內(nèi)存不足
- 線程或者 FD 的數(shù)量超過(guò)當(dāng)前手機(jī)的閾值
文章的最后想提一點(diǎn),我們?cè)谧鲂阅軆?yōu)化的時(shí)候,不僅要關(guān)心性能指標(biāo)數(shù)據(jù),還需要關(guān)心對(duì)業(yè)務(wù)指標(biāo)數(shù)據(jù)的影響,比如對(duì)使用時(shí)長(zhǎng)、留存等等能提升多少。
為什么需要關(guān)心業(yè)務(wù)指標(biāo)數(shù)據(jù)?
性能指標(biāo)數(shù)據(jù),比如 OOM 崩潰率、Native 崩潰率、ANR 等等、可能只有客戶端的小伙伴才知道 OOM、Native、ANR 是什么意思,但是其他人(產(chǎn)品經(jīng)理、老板等等)他們是不知道的,也不會(huì)去關(guān)心這些,但是他們對(duì)使用時(shí)長(zhǎng)、留存等業(yè)務(wù)指標(biāo)數(shù)據(jù)更加的敏感,更能夠體現(xiàn)做這件事的價(jià)值,這只是闡述了我自己的觀點(diǎn),每個(gè)人站的角度不一樣,觀點(diǎn)也不一樣。
全文到這里就結(jié)束了,這篇文章只是梳理一下內(nèi)存相關(guān)的知識(shí)點(diǎn),以及有那些因素會(huì)導(dǎo)致 OOM 崩潰和相對(duì)應(yīng)的解決方案。