照片由Nathan Lange r 在Unsplash上拍攝
Android 開發人員的武器庫中有許多用于檢測記憶體洩漏的工具,例如Android Studio Memory Profiler、LeakCanary、Perfetto等。這些工具在分析本地應用建構時非常有用。但是,在生産環境中,該應用程式在不同環境下的各種裝置上運作,并且在本地分析建構時很難預測所有邊緣情況。
在 Lyft,我們很好奇我們的應用程式在使用者裝置上的生産行為。是以,我們決定将可觀察性引入各種運作時性能名額,看看它如何改善使用者體驗。
我們已經釋出了一篇關于CPU 使用率監控的博文,在這篇文章中,我們将重點關注移動應用程式的記憶體占用。雖然監控記憶體占用的總體概念适用于 Android 和 iOS 平台,但我們将重點關注前者的實作細節。
Lyft 在推出新功能時依靠 A/B 測試。當一個功能準備好投入生産時,它會被一個功能标志覆寫,并作為實驗的一部分啟動。此實驗是針對特定使用者組運作的,目的是将名額與應用程式的标準版本進行比較。
釋出大型複雜功能時,重要的是要確定它不會在記憶體使用方面帶來任何回歸。如果該功能包含本機 C/C++ 代碼,則這一點尤其重要,因為它更有可能引入記憶體洩漏。
是以,我們想檢驗 以下假設。對于每個功能實驗,我們測量所有有權通路它的使用者的記憶體占用量(通過在運作時向分析報告名額)。然後,我們将其與應用程式的标準版本進行比較。如果變體顯示較大的記憶體使用值,則表明存在回歸或記憶體洩漏。
記憶體占用名額
首先,我們需要确定 Android 上記憶體名額的可用性,收集這些名額并不像人們想象的那麼簡單。我們對應用程式程序的記憶體使用情況或換句話說應用程式的記憶體占用感興趣。
Android 提供了各種 API 來檢索應用程式的記憶體使用名額。然而,最困難的部分不是檢索名額,而是確定它們合适并提供有意義的資料。
由于 Android Studio 有一個内置的記憶體分析器,我們決定将其用作參考點。如果我們得到與記憶體分析器相同的值,則我們的資料是正确的。
Android Studio 記憶體分析器顯示的主要名額之一稱為 PSS。
PSS
按比例設定大小( PSS ) — 應用程式使用的私有和共享記憶體量,其中共享記憶體量與其共享的程序數成正比。
例如,如果 3 個程序共享 3MB,則每個程序在 PSS 中獲得 1MB。
AndroidDebug為這些資料公開了一個 API。
import android.os.Debug
import android.os.Debug.MemoryInfo
val memoryInfo = MemoryInfo()
Debug.getMemoryInfo(memoryInfo)
val summary: Map<String, String> = memoryInfo.getMemoryStats()
以程式設計方式擷取包含PSS的MemoryInfo
Debug.MemoryInfo包括 PSS 以及它包含的元件數量。檢視其儲存的資料摘要的最簡單方法是調用該getMemoryStats函數。它會生成具有名額的鍵值對,如下例所示。
code: 12128 kB
stack: 496 kB
graphics: 996 kB
java-heap: 8160 kB
native-heap: 4516 kB
private-other: 2720 kB
system: 4955 kB // Includes all shared memory
total-pss: 33971 kB // A sum of everything except 'total-swap'
total-swap: 17520 kB
MemoryInfo摘要示例
這些是 Android Studio 通常在 Memory Profiler 中顯示的數字。
為了在沒有任何附加資訊的情況下獲得 PSS 值,可以使用下面的調用。
import android.os.Debug
val pssKb: Long = Debug.getPss()
以程式設計方式擷取PSS
美國海軍
唯一集大小( USS ) — 應用程式使用的私有記憶體量,不包括共享記憶體。
它的價值可以從我們上面看到的 PSS 名額中得出。為了以程式設計方式擷取它,我們可以再次使用Debug.MemoryInfo.
import android.os.Debug.MemoryInfo
val memoryInfo: MemoryInfo = ...
val ussKb = with(memoryInfo) {
getTotalPrivateClean() + getTotalPrivateDirty()
}
以程式設計方式擷取USS
PSS和USS有什麼問題?
PSS 和 USS 是有用的,但至少Debug.getMemoryInfo可以Debug.getPss分别調用。200ms100ms
我們需要定期報告記憶體名額,是以為此使用耗時的 API 并不是最好的主意。
PSS 有一個更快的 Android API,但有一個問題。它的硬采樣率限制為 5 分鐘。這意味着如果更頻繁地調用,它會傳回上一次調用的緩存值。這不會很好地服務于我們的用例。
import android.os.Debug.MemoryInfo
val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
val pid = intArrayOf(android.os.Process.myPid())
// The sample rate for calling this API is limited to once per 5 minutes.
// If called more frequently, it returns the same data as pverious call.
val memoryInfo: MemoryInfo = activityManager.getProcessMemoryInfo(pid).first()
使用 API 以 5 分鐘的有限采樣率擷取PSS
但是,有一個類似的名額稱為 RSS。
訂閱服務
駐留集大小( RSS ) — 應用程式使用的私有和共享記憶體量,其中包括所有共享記憶體。
例如,如果三個程序共享 3MB,則每個程序在 RSS 中獲得 3MB。
這意味着 RSS 值是悲觀的,因為它們顯示的記憶體比應用程式實際使用的記憶體多。與 PSS 相比,此名額的檢索速度要快得多。此外,使用 RSS 值也很好,因為我們更感興趣的是比較 A/B 實驗的變體,是以我們可以在某種程度上犧牲精度作為權衡。
總的來說,如果比較任何給定時間點的集合大小名額,USS 将始終顯示最小值,而 RSS 是最大的值:RSS > PSS > USS 。
為了以程式設計方式擷取 RSS,我們需要引用一個系統檔案。該檔案位于,其中是應用程式程序的 ID。可用于以程式設計方式擷取。/proc/[pid]/statm[pid] android.os.Process.myPid()[pid]
讀取此檔案會産生如下内容:
3693120 27503 18904 1 0 319129 0
官方 Linux 文檔有助于闡明這些數字的含義。我們隻對第二個值感興趣。
- (2) resident— 常駐集大小,以頁數表示。
Linux 上的預設頁面大小為 4 kB。是以,為了計算 RSS 名額,我們使用下面的簡單公式。
rssKb = 居民 * 4
我們最終使用RSS作為識别應用程式記憶體占用的主要名額。
然而,這還不是全部。應用程式程序私有的記憶體包括許多元件,其中之一就是堆。
為了提高測量的精度,我們可以額外報告應用程式在 JVM 和本機堆中配置設定的記憶體量。在嘗試縮小使用 RSS 名額檢測到的回歸範圍時,這可能會派上用場。
JVM堆
第一個名額将幫助我們确定應用程式在 JVM 堆中配置設定的記憶體量。
val totalMemoryKb = Runtime.getRuntime().totalMemory() / 1024
val freeMemoryKb = Runtime.getRuntime().freeMemory() / 1024
val jvmHeapAllocatedKb = totalMemoryKb - freeMemoryKb
以程式設計方式擷取在JVM 堆中配置設定的記憶體量
本機堆
第二個名額顯示相同但在本機堆中。當應用程式包含自定義本機 C/C++ 庫時,它特别有用。
import android.os.Debug
val nativeHeapAllocatedKb = Debug.getNativeHeapAllocatedSize() / 1024
以程式設計方式擷取在本機堆中配置設定的記憶體量
檢測記憶體洩漏
現在我們已經确定了記憶體使用名額,讓我們看看如何使用它們來捕捉回歸。
首先,我們需要決定何時以及多久向分析報告記憶體名額。我們選擇了兩種情況:
- 每次 UI 螢幕關閉時報告帶有名額的快照。
- 如果使用者長時間停留在單個 UI 螢幕上,則定期報告包含名額的快照。通常,我們使用 1 分鐘的間隔,但這可以遠端配置。
現在,讓我們看看如何解釋資料。
A/B 實驗使我們能夠比較兩個應用程式變體之間的報告值:
- 治療——使用啟用了新功能的應用程式的使用者組。
- control——在禁用新功能的情況下,在正常狀态下使用應用程式的使用者組。
示例 1——沒有回歸
首先,作為基線,我們将看一下在記憶體占用方面沒有引入任何回歸的功能。
這是為 Lyft Android 應用程式為乘客添加一項功能的實驗示例。
示例 1——未引入任何回歸的實驗
正如我們在上面看到的,控制(綠線)和治療(橙線)變體是相同的。這意味着該功能沒有引入任何回歸,并且從記憶體使用的角度來看可以安全地部署到生産環境中。
示例 2——回歸
現在讓我們看一下引入回歸的特征。
這是為司機的 Lyft Android 應用程式添加功能的實驗示例。
示例 2 — 引入 記憶體使用回歸的實驗
這裡的新功能明顯增加了應用程式在每個百分位的記憶體占用。這是允許我們識别記憶體洩漏的回歸名額。
此圖基于 RSS 名額。為了縮小問題的根本原因,使用了 JVM 和本機堆配置設定的類似圖表。
示例 3——第 99 個百分位處的記憶體洩漏
最後一個例子是最重要的,因為它展示了這種記憶體監控方法的最大優勢。
在下圖中,兩個變體顯示幾乎相同的值,除了第 99 個百分位附近的明顯差異。
示例 3 —在第 99 個百分位引入記憶體洩漏的實驗
處理變體的記憶體占用量在第 99 個百分位數處顯着增加。
這導緻識别出在非常特殊的情況下為少數使用者發生的記憶體洩漏。但是,當它發生時,它會顯着增加記憶體使用量。
對于第二個示例,在本地分析時更有可能檢測到影響應用程式所有使用的記憶體洩漏。然而,對于第三個示例來說,它變得更加困難, 因為記憶體洩漏與一個容易在本地遺漏的邊緣情況有關。
學識
實施此類記憶體監控工具的挑戰之一是選擇顯示有效資料的正确名額。另一方面,重要的是收集這些名額不會耗費時間或資源。
另一項任務是在有效報告中可視化資料。每天比較控制和治療變體之間的平均值不會産生有意義的資料。更好的方法是從實驗一開始就使用百分位數分布,如上例所示。
總的來說,實驗運作的時間越長,資料越好。實驗啟動後需要幾天時間才能開始獲得有意義的資料。還取決于有多少使用者參與了實驗。
當記憶體洩漏出現在第 99 個百分位數附近時,此方法特别有用。這意味着記憶體洩漏是由于特定的邊緣情況而發生的,使用本地分析很難檢測到。
使用工具進行本地記憶體洩漏檢測是一項重要任務,可防止向使用者推廣回歸。在運作時報告性能名額可以揭示否則很難檢測到的問題。
Resources
- Memory allocation among processes — developers.android.com
- Understanding Android memory usage — Google I/O ‘18
- How do I discover memory usage of my application in Android? — stackoverflow.com
- proc(5) — Linux manual page
作者:Pavlo Stavytskyi
Google Developer Expert for Android | Sr. Staff Software Engineer at Turo
出處:https://morfly.medium.com/detecting-android-memory-leaks-in-production-29e9c97e2ba1