[Android] 抓到了!Memory Leak

Roy Huang
Pinkoi Engineering
Published in
12 min readJan 6, 2022
Photo by Daan Mooij on Unsplash

在 Android 的開發中,你也許聽過/遇到過,又或是「被問過」 memory leak,我們都知道 memory leak 很重要,但是,memory leak 到底是什麼?

隨著 Pinkoi Android App的使用人數逐漸攀升、使用者逛的商品越來越多,Memory leak 的問題也漸漸的浮上檯面(汗),所以就花了一些時間處理並且記錄一下。

什麼是 Memory Leak?

中文翻譯為「記憶體洩漏」,原因大多是出至於開發者在開發時因疏失或錯誤導致「程式已經執行結束了,卻不能釋放記憶體」,進而產生 OOM (Out Of Memory)。

Java 的 JVM 有所謂的「垃圾回收機制(Garbage collection)」,它會去追中記憶體的配置狀態,若發現此對象已經不會被使用到了,就會回收該對象所佔用的記憶體資源。

取至 這邊

JVM 會使用「可達性分析算法」來判斷是否需要回收該物件,也就是說,GC Root 會從靜態物件或是執行中的執行緒為起點,從這些起始點向下搜尋,查看這個對象參照到哪裡,看看這個對象是不是有在使用,若發現都沒在使用,就會回收它。

而 Memory Leak 就是某個物件持有某個物件不放,導致 GC 一直以為有在使用中,所以永遠也不會回收該物件。

Android 開發中常見的案例

在 Android 中有幾個很常見的 memory leak 例子,像是:

  • register 了一個 listener,但沒有在 destroy 時 unregister
  • 將 activity /fragment 的 context 傳入某物件中,但是 activity /fragment 銷毀時沒有解除他們之間的關係
  • inner class,在 activity / fragment 中使用 inner class 執行耗時操作,但在 activity / fragment destroy 時,inner class 還沒結束
  • Singleton 持有 activity / fragment context,由於 Singleton 是 static,生命週期比 activity / fragment context 還長,因而讓 context 無法釋放

如何解決抓出 memory leak

目前大致上有兩個工具可以幫助我們:

在我剛開始接觸 Android 時,當時最火紅的就是 LeakCanary,但隨著時代的演進,Android Studio 的進步,現在用 IDE 內建的 Profiler 就已經很好用拉!所以下面我們就著重在 Profiler 上面介紹。

Profiler

位置:

IDE 的上方跟下方都有入口可以看到

在 IDE 右上方有個小圖示可以進入
IDE 下方

點開來後你會看到 Profiler 開始記錄目前的 CPU、MEMORY、NETWORK、ENERGY 的個別使用狀況

而我們要抓 memory leak,所以就往 memory 那個區塊任意地方點一下

點擊之後會放大 memory 區塊的使用情況,其中左邊有個「Capture heap dump」,我們就是需要靠它來幫助我們 dump 出 memory 使用狀況,抓出洩漏的地方

先試著點擊看看「Capture heap dump」然後點選「Record」,稍等一下後會出現 memory 分析後的結果,其中特別要跟大家介紹的是下圖紅框中的幾個名詞所代表的意思

為了怕我解釋有誤,先貼上官方說明如下:

  • Allocations: Number of allocations in the heap.
  • Native Size: Total amount of native memory used by this object type (in bytes). This column is visible only for Android 7.0 and higher.
  • You will see memory here for some objects allocated in Java because Android uses native memory for some framework classes, such as Bitmap.
  • Shallow Size: Total amount of Java memory used by this object type (in bytes).
  • Retained Size: Total size of memory being retained due to all instances of this class (in bytes).

接著來看一下這些名詞所代表的意思,用圖片來看會更容易理解。

以下圖片 Heap 圖來至這個網站

Heap 有分為 Native 和 Java,每個 APP 都有一個被分配可使用的記憶體大小,當 APP 使用超過這個記憶體大小的時候,即會發生所謂的 OOM。

下圖表示某個段 Heap Dump 記錄的 APP 記憶體狀態。

注意紅色的節點,在這個圖中,紅色節點引用了 Native Heap。

這張圖的狀況不太常見,但在 Android 8.0 開始,可能會把 Bitmap 存放在 Native 內,減少 JVM 的存放壓力。

所以可以看到圖中的紅色節點存放在 Java Heap 且同時指向 Native Heap。

「Native Size」:物件使用 native memory 的大小,藍色節點

「Shallow Size」:物件本身消耗 memory 的大小,紅色節點

「Retained Size」 則是下圖橘色節點,代表的是紅色節點所參照到後面的狀態,Retained Size 可能會很大,因為後面的節點是可以重複訪問的。

我們可以看到,如果今天把紅色節點刪除後,這些橘色節點將沒有被參照到,所以將會被 GC 回收。

接著我們回到 Profiler,點擊隨便一個 class name,會跑出一個 instance list,並且右方多了一個「Depth」

這個 「Depth」所代表的意思是從「GC root 到物件 instance 要經過的深度」

以上圖來看的話,藍色節點的 Depth 為 2,紅色節點 Depth 為 7,若我們今天想要回收這兩個節點

以紅色來說,只要左方其中一個節點被破壞,紅色節點就無法被 GC Root 訪問,所以會被回收

以藍色來說,由於被兩個節點所指向,所以我們需要同時破壞藍色節點的左右兩個節點才能使藍色節點被回收

那今天如果 「Depth」為 1 呢?

代表這個節點直接被 GC Root 引用,所以永遠也不會被回收,以 Android 來說,如果在 activity 註冊了一個事件(如:LocationListener),但沒有在 activity 被銷毀時反註冊,就會造成 memory leak。

如何找出 Memory Leak ?

我們可以通過一些方法來測試看看是不有 memory leak 的問題:

  • 進入一個頁面然後出來,再進去一個頁面,然後出來,反覆地進出相同或不同的頁面,觀察記憶體的變化,是持續上升?還是會在離開畫面時降回去
  • 旋轉螢幕畫面多次,使 activity lifecycle 重新建立

現在我們來測試一個實際的例子

  1. 一開始的時候 call Stack 和 Heap 都是空的

2. 接下來我們創一個 activity,在裡面會需要執行一段耗時的操作 (DownloadTask)

這個階段是使用者打開 APP 後發生的行為

MainActivity invoke DownloadTask

此時 Stack 中 MainActivity 被 new 出來放在 Heap,同時 MainActivity 也 new 了 DownloadTask 並且 call run()

2. 接著為了要在下載完成時通知使用者,我把 MainActivity context 傳入 DownloadTask

DownloadTask 持有 MainActivity context

3. 但是使用者在一進入 APP(MainActivity)時就馬上退出了 APP,此時 DownloadTask 依然還在執行,memory 狀態變成:

發生 leak 了

此時我們用 Profiler 來檢查哪邊發生 leak,我們一樣先把 memory dump 出來後,會很明顯的發現 Profiler 幫我們標注出來哪邊 Leak 了

接著點擊 Leaks,我們發現 MainActivity 有 leak 的現象

點擊 Class 中的 MainActivity,再點選 MainActivity Instance,接著看到右方的 References,這邊可以幫助我們判斷是哪個物件被卡住了

從 References 中我們可以很快看到 holdActivity 還殘留在 DownloadTask 中沒有被清除

4. 發現 memory leak 原因在於 DownloadTask 持有 MainActivity context,且執行時間比 MainActivity 還久,所以我們在 onDestroy 時把 DownloadTask 的 context 清除,避免 leak

我們預期離開 APP 時,Heap 中應該都要是空的,回到初始狀態,所以我們用 Profiler 檢查看看

但是…

怎麼還是 Leak 了?

Photo by arash payam on Unsplash

在 onDestroy 做完 holdActiviy = null 之後,你可能會發現還是發生 Leak,不過別擔心,這是因為 GC 的機制導致的,GC 不會在物件沒有被使用時就馬上回收物件,而是會過一段時間才回收,他就像垃圾車一樣,一段時間或是有需要收垃圾了才來。

還好 Profiler 有個很棒的功能可以解決這件事,我們可以在 memory dump 的那個介面的上方找到一個垃圾桶,他的作用就是強制呼叫垃圾車(GC)來收垃圾!

點擊垃圾桶

點擊垃圾桶後也不要馬上 dump 哦,點擊之後,你會看到 memory 圖表的下方會出現一個垃圾桶,等他出現後在 Capture heap dump

查看垃圾桶

看到垃圾桶之後我們就可以再次 dump 看看是不是真的解決了 memory leak了

leak 消失了 👏

結語

Memory Leak 的問題可大可小,攸關使用者的裝置效能、使用 app 的使用期間長短,一點點的 memory leak 對效能好的手機可能沒感覺,但是使用舊一點的手機可能就時常會遇到閃退的狀況。也算是一個無聲的殺手,默默地就突然來的閃退。

完整範例也已經放上 GitHub~

參考文章:

若對文章有任何想法或是有需要調整的地方歡迎大家留言,若覺得不錯的話也歡迎給我一些掌聲 👏

--

--