照片由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