通俗易懂,android是如何管理記憶體的
封面來源://medium.com/android-news/android-performance-patterns-rescue-tips-8c1e4c7cb1f0
前言
很高興遇見你~
記憶體優化一直是 Android 開發中的一個非常重要的話題,他直接影響著我們 app 的性能表現。但這個話題涉及到的內容很廣且都偏向底層,讓很多開發者望而卻步。同時,記憶體優化更加偏向於「經驗知識」,需要在實際項目中去應用來學習。
因而本文並不想深入到底層去講記憶體優化的原理,而是著眼於宏觀,聊聊 android 是如何分配和管理記憶體、在記憶體不足的時候系統會如何處理以及會對用戶造成什麼樣的影響。
Android 應用基於 JVM 語言進行開發,雖然 google 根據移動設備特點開發了自家的虛擬機如 Dalvik、ART,但依舊是基於 JVM 模型,在堆區分配對象記憶體。因此 Java heap(java 堆)是android應用記憶體分配和回收的重點。其次,移動設備的 RAM 非常有限,如何為進程分配以及管理記憶體也是重中之重。
文章的主要內容是分析 Java heap、RAM 的記憶體管理,以及當記憶體不夠時 android 會如何處理。
那麼,我們開始吧。
Java Heap
Java Heap,也就是 JVM 中的堆區。簡單回顧一下 JVM 中運行時數據區域的劃分:
- 橙色區域的方法棧以及程式計數器屬於執行緒私有,主要存儲方法中的局部數據。
- 方法區主要存儲常量以及類資訊,執行緒共享。
- 堆區主要負責存儲創建的對象,幾乎一切對象的記憶體都在堆區中分配,同時也是執行緒共享。
我們在 android 程式中使用如 Object o = new Object() 程式碼創建的對象都會在堆區中分配一塊記憶體進行存儲,具體如何分配由虛擬機解決而不需要我們開發者干預。當一個對象不再使用時, JVM 中具有垃圾回收機制(GC),會自動釋放堆區中無用的對象,重新利用記憶體。當我們請求分配的記憶體已經超過堆區的記憶體大小,則會拋出 OOM 異常。
在 android 中,堆區是一個由 JVM 邏輯劃分的區域,他並不是真正的物理區域。堆區並不會直接全部映射和他等量大小的物理記憶體,而是到了需要使用時,才會去建立邏輯地址和物理地址的映射:
這樣可以給應用分配足夠的邏輯記憶體大小,同時也不必在啟動時一次性分配一大塊的物理記憶體。在相同大小的記憶體中,可以運行更多的程式。
當堆區進程 GC 之後,釋放出來多餘的空閑記憶體,會返還給系統,減少物理記憶體的佔用。但這個過程涉及到比較複雜的系統調用,若釋放的記憶體較為少量,可能得不償失,則無需返還給系統,在堆區中繼續使用即可。
在 GC 過程中,如果一個對象不再使用,但是其所佔用的記憶體無法被釋放,導致資源浪費,這種現象稱為記憶體泄漏。記憶體泄露會導致堆區中的對象越來越多,記憶體的壓力越來越大,甚至出現 OOM 。因此,記憶體泄露是我們必須要盡量避免的現象。
進程記憶體分配
堆區的記憶體分配,屬於進程內的記憶體分配,由進程自己管理。下面講一個應用,系統是如何為其分配記憶體的。
系統的運行記憶體,即為我們常說的 RAM ,是應用的運行空間。每個應用必須裝入記憶體中才可以被執行:
- 我們安裝的應用進程都位於硬碟中
- 當一個應用被執行時,需要裝入到 RAM 中才能被執行(zRAM 是為了壓縮數據節省空間而設計,後續會講到)
- CPU 與 RAM 交互,讀取指令、數據、寫入數據等
RAM 的大小為設備的硬體記憶體大小,是非常寶貴的資源。現代手機常見的運存是6G、8G或者12G,一些專為遊戲研發的手機甚至有18G,但同時價格也會跟上去。
Android 採用分頁存儲的方式把一個進程存儲到 RAM 中。分頁存儲,簡單來說就是把記憶體分割成很多個小塊,每個應用佔用不同的小塊,這些小塊也可以稱為頁:
前面講到,進程的堆區並不是一次性分配,當需要分配記憶體時,系統會為其分配空閑的頁;當這些頁被回收,那麼有可能被返還到系統中。
這裡的頁、塊概念涉及到作業系統的分頁存儲,這裡並不打算展開詳細講解,有興趣的讀者可以自行了解:分頁存儲-維基百科。本文中的「頁」與「塊」可以不嚴謹地理解為同個概念,為了幫助理解這裡不進行詳細地區分。
分配給進程的頁可以分為兩種類型:乾淨頁、臟頁:
- 乾淨頁:進程從硬碟中讀取數據或申請記憶體之後未進行修改。這種類型的頁面在記憶體不足的時候可以被回收,因為頁中存儲的數據可通過其他的途徑復原。
- 臟頁:進程對頁中的數據進行了修改或數據存儲。這類頁面不能被直接回收,否則會造成數據丟失,必須先進行數據存儲。
zRAM,是作為 RAM 中的一個分區,當記憶體不足時,可以把一些類型的頁壓縮之後存儲在zRAM中,當需要使用的時候再從zRAM中調出。通過壓縮來節省應用的空間佔用,同時不需要與硬碟進行調度,提高了速度。
這裡需要理解的一個點是:記憶體中的操作速度要遠遠比硬碟操作快。即使與zRAM的調入和調出需要壓縮和解壓,其速度也是比與硬碟交互快得多。
記憶體不足管理
前面我們一直強調,移動設備的記憶體容量是非常有限的,需要我們非常謹慎地去使用它。幸運的是,JVM 和 android 系統早就幫我們想到了這一點。
面對不同的記憶體壓力,android 會有不同的應對策略。從低到高依次是 GC、內核交換守護進程釋放記憶體、低記憶體終止守護進程殺死進程釋放記憶體;他們的代價也是逐步上升。下面我們依個來介紹一下。
GC 垃圾回收
GC 屬於 JVM 內部的記憶體管理機制,他管理的記憶體區域是堆區。當我們創建的對象越來多,堆區的壓力越來越大時,GC 機制就會啟動,開始回收堆區中的垃圾對象。
辨別一個對象是否是垃圾,虛擬機採用的是可達性分析法。即從一些確定活躍有用的對象出發,向下分析他的引用鏈;如果一個對象直接或者間接這些對象所引用,那麼他就不是垃圾,否則就是垃圾。這些確定活躍有用的對象稱為 GC Roots:
- 如上圖,其中綠色的對象被 GC Roots 直接或間接引用,則不會被回收;灰色的對象沒有被引用則被標記為垃圾
GC Roots對象的類型比較常見的是靜態變數以及棧中的引用。靜態變數比較好理解,他在整個進程的執行期間不會被回收,因此他肯定是有用的。棧,這裡指的是 JVM 運行數據區域中的方法棧,也就是局部變數引用,在方法執行期間肯定是活躍的。由於方法棧屬於執行緒私有,因此這裡等於活躍執行緒持有的對象不會被回收。
因此,如果一個對象對於我們的程式不再使用,則必須解除 GC Roots 對其的引用,否則會造成記憶體泄露。例如,不要把 activity 賦值給一個靜態變數,這樣會導致介面退出時activity無法被回收。
GC 也並不是直接對整個堆區進行回收,而是將堆區中的對象分成兩個部分:新生代、老年代。
剛創建的對象大都會被回收,而在多次回收中存活的對象則後續也很少被回收。新生代中存儲的對象主要是剛被創建不久的對象,而老年代則存儲著那些在多次 GC 中存活的對象。那麼我們可以針對這些不同特性的對象,執行不同的回收演算法來提高GC性能:
- 對於新創建的對象,我們需要更加頻繁地對他們進行GC來釋放記憶體,且每次只需要記錄需要留下來的對象即可,而不必要去標記其他大量需要被回收的對象,提高性能。
- 對於熬過很多次GC的對象,則可以以更低的頻率對他門進行GC,且每次只需要關注少量需要被回收的對象即可。
具體的垃圾回收演算法就不繼續展開了,了解到這裡就可以。感興趣的讀者可以點擊查看垃圾回收文章,或者閱讀相關書籍。
單次的垃圾回收速度是很快的,甚至我們都無法感知到。但當記憶體壓力越來越大,垃圾回收的速度跟不上記憶體分配的速度,此時就會出現記憶體分配等待 GC 的情況,也就是發生了卡頓。同時,我們無法控制 GC 的時機,JVM 有一套完整的演算法來決定什麼時候進行 GC。假如在我們滑動介面的時候觸發 GC ,那麼展示出來的就是出現了掉幀情況。因此,做好記憶體優化,對於 app 的性能表現非常重要。
內核交換守護進程
GC 是針對於 Java 程式內部進行的優化。對於移動設備來說,RAM 非常寶貴,如何在有限的 RAM 資源上進行分配記憶體,也是一個非常重要的話題。
我們的應用程式都運行在 RAM 中,當進程不斷申請記憶體分配,RAM 的剩餘記憶體達到一定的閾值時,會啟動內核交換守護進程來釋放記憶體以滿足資源的分配。
內核交換守護進程,是運行在系統內核的一個進程,他主要的工作時回收乾淨頁、壓縮頁等操作來釋放記憶體。前面講到,android 是基於分頁存儲的作業系統,每個進程都會被存儲到一些頁中。分頁的類型有兩種:乾淨頁、臟頁:
- 當內核交換守護進程啟動時,他會把乾淨頁回收以釋放記憶體。當進程再次訪問乾淨頁時,則需要去硬碟中再次讀取。
- 對於臟頁,內核交換守護進程會把他們壓縮後放入 zRAM 中。當進程訪問臟頁時,則需要從zRAM中解壓出來。
通過不斷回收和壓縮分頁的方式來釋放記憶體,以滿足新的記憶體請求。使用此方式釋放的記憶體也無法滿足新的記憶體請求時,android 會啟動低記憶體終止守護進程,來終止一些低優先順序的進程。
低記憶體終止守護進程
當 RAM 的被佔用記憶體達到一定的閾值,android 會根據進程的優先順序,終止部分進程來釋放記憶體。當低記憶體終止守護進程啟動時,說明系統的記憶體壓力已經非常大了,這在一些性能較差的設備中經常出現。
進程的優先順序從高到低排序如下,優先順序更高的進程會優先被終止:
圖片來源://developer.android.google.cn/topic/performance/memory-management
從上到下依次是:
- 後台應用:使用過的 app 會被快取在後台,下一次打開可以更加快速地進行切換。當記憶體不足時,此類應用會最快被殺死。
- 上一個應用:例如從微信跳轉到瀏覽器,此時微信就是上一個應用。
- 主螢幕應用:這是啟動器應用,也就是我們的桌面。如果這個進程被kill了,那麼返回桌面時會暫時黑屏。
- 服務:同步服務、上傳服務等等
- 可覺察的應用:例如正在播放的音樂軟體,他可以被我們感知到,但是不在前台。
- 前台應用:當前正在使用的應用,如果這個應用被kill了,需要向用戶報崩潰異常,此時的體驗是極差的。
- 持久性(服務):這些是設備的核心服務,例如電話和 WLAN。
- 系統:系統進程。這些進程被終止後,手機可能即將重新啟動,就像手機突然卡死重啟。
- 原生:系統使用的極低級別的進程,例如我們的內核交換守護進程。
當記憶體不足,會按照上面的規則,從上到下來終止進程,獲得記憶體資源。這也就是為什麼在 android 中我們的後台應用一直被殺死。為了避免我們的應用被優化,記憶體優化就顯得非常重要了。
最後再來回顧一下:
圖片來源://www.youtube.com/watch?v=w7K0jio8afM&t=488s&ab_channel=AndroidDevelopers
- 在0-1階段,系統的記憶體資源足夠,程式請求記憶體分配,系統會不斷地使用空閑頁來滿足應用的記憶體請求
- 在1-2階段,系統的可利用記憶體下降到一個閾值,程式繼續請求記憶體分配,內核交換守護進程啟動,開始釋放快取來滿足記憶體請求
- 在2-3階段,系統的被利用記憶體達到一個閾值,系統將啟動低記憶體終止守護進程來殺死進程釋放記憶體
最後
我們文章分析了 android 是如何對記憶體進行分配以及低記憶體時如何釋放記憶體來滿足記憶體請求。可以很明顯看到,當記憶體不足時,會嚴重影響我們 app 的體驗甚至整個用戶手機的體驗:
- 當記憶體不足會造成頻繁GC、回收乾淨頁、回寫快取,導致應用緩慢、卡頓
- 如果設備記憶體一直不夠,那麼會一直殺死進程影響用戶體驗,特別是這些進程是用戶非常在意的如遊戲、微信
- 記憶體佔用過高會讓app在後台被殺死、或者讓用戶的其他app被殺死、甚至整個系統無法運行而直接崩潰重啟,
- 不是所有的設備都有著高記憶體,有著設備只有很少的記憶體,在一些性能較差的設備上甚至會無法運行,這樣我們就失去了這些設備的市場
反觀現在中國的很多 app,有如扣扣、t寶、iqy,在我這個三年前的機器上運行會發生嚴重卡頓,偶爾還有ANR崩潰的出現;而當我去測試了youto、tele、Twit等 app ,發現基本不會發生卡頓,甚至在 youto 這樣有大量圖片影片載入的 app 介面切換也盡享絲滑。這兩種 app 的體驗是有著天壤之別的。
本文沒有講如何進行記憶體優化,是因為這一塊的內容設計到的太廣太深,無法在這篇文章中一併介紹。文章的目的只是為了幫助讀者了解android是如何管理記憶體以及記憶體不足可能造成的後果,對記憶體的重要性能有一個感性的認知。
如果文章對你有幫助,還希望留個贊鼓勵一下作者~
全文到此,原創不易,覺得有幫助可以點贊收藏評論轉發。
有任何想法歡迎評論區交流指正。
如需轉載請評論區或私信告知。另外歡迎光臨筆者的個人部落格:傳送門
