封面來源:https://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 會根據程式的優先順序,終止部分程式來釋放記憶體。當低記憶體終止守護程式啟動時,說明系統的記憶體壓力已經非常大了,這在一些效能較差的裝置中經常出現。
程式的優先順序從高到低排序如下,優先順序更高的程式會優先被終止:
圖片來源:https://developer.android.google.cn/topic/performance/memory-management
從上到下依次是:
- 後臺應用:使用過的 app 會被快取在後臺,下一次開啟可以更加快速地進行切換。當記憶體不足時,此類應用會最快被殺死。
- 上一個應用:例如從微信跳轉到瀏覽器,此時微信就是上一個應用。
- 主螢幕應用:這是啟動器應用,也就是我們的桌面。如果這個程式被kill了,那麼返回桌面時會暫時黑屏。
- 服務:同步服務、上傳服務等等
- 可覺察的應用:例如正在播放的音樂軟體,他可以被我們感知到,但是不在前臺。
- 前臺應用:當前正在使用的應用,如果這個應用被kill了,需要向使用者報崩潰異常,此時的體驗是極差的。
- 永續性(服務):這些是裝置的核心服務,例如電話和 WLAN。
- 系統:系統程式。這些程式被終止後,手機可能即將重新啟動,就像手機突然卡死重啟。
- 原生:系統使用的極低階別的程式,例如我們的核心交換守護程式。
當記憶體不足,會按照上面的規則,從上到下來終止程式,獲得記憶體資源。這也就是為什麼在 android 中我們的後臺應用一直被殺死。為了避免我們的應用被優化,記憶體優化就顯得非常重要了。
最後再來回顧一下:
圖片來源:https://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是如何管理記憶體以及記憶體不足可能造成的後果,對記憶體的重要效能有一個感性的認知。
如果文章對你有幫助,還希望留個贊鼓勵一下作者~
全文到此,原創不易,覺得有幫助可以點贊收藏評論轉發。
有任何想法歡迎評論區交流指正。
如需轉載請評論區或私信告知。另外歡迎光臨筆者的個人部落格:傳送門