【Perfetto从入门到精通】3. Linux(Android)底层内存管理机制概述

Android 希望提供世界上第一个完整的开放手机平台解决方案。它将以 Linux 为基础,这一点和德州仪器一样,但它同时也将提供其他所有必要的组件;手机厂商只需要用这一个系统就可以推出自己的设备。Android 还将为应用开发者提供统一的编程模型,这样他们的应用程序就可以在所有的设备上运行。通过采用统一的平台来支持所有设备,Android 能简化手机厂商和开发者的工作。------《Android 传奇》

前言:Linux 内存管理的重要性

内存 作为应用性能指标中非常重要的一项,会对应用软件的稳定性、流畅性产生直接影响。如果应用内存使用不当,会导致频繁发生 GC,而 JVM 的 GC 具有 Stop The World 的特性,这将引起包括 UI 在内的全部线程卡顿。更有甚者,无法回收的内存会耗尽系统分配给应用的可用空间,最终发生 OOM。典型的问题场景有内存泄漏、内存抖动等。

可以说,应用的大部分性能问题都可以归结在 内存 上,要想保证应用具有良好的用户体验,首当其冲要解决的就是内存问题。即使应用当前不存在这方面的问题,但是随着功能的不断迭代,架构也必然逐渐腐化,如果没有全面完善的内存监控和预警机制,就难以做到在内存问题发生之前,防患于未然。

对于内存问题,我们不仅要知其然,还要知其所以然,Android 底层是基于 Linux 实现的,其内存机制也是脱胎于经典的 Linux 内存管理。

此外,在本文中,还将介绍几个很有用的 shell 命令,包含 dumpsys meminfocat /proc/[pid]/status 等等,用好这些命令,会是内存分析过程中的一大助力。

备注:本文所有的命令都省略了开头的 adb shell

dumpsys meminfo:查看进程当前的内存使用情况

在电脑控制台执行 adb shell dumpsys meminfo [packageName],可以查看该进程当前内存使用情况。

bash 复制代码
λ adb shell dumpsys meminfo com.google.samples.apps.nowinandroid.demo.debug
Applications Memory Usage (in Kilobytes):
Uptime: 655869283 Realtime: 912305084

** MEMINFO in pid 22144 [com.google.samples.apps.nowinandroid.demo.debug] **
                   Pss  Private  Private  SwapPss      Rss     Heap     Heap     Heap
                 Total    Dirty    Clean    Dirty    Total     Size    Alloc     Free
                ------   ------   ------   ------   ------   ------   ------   ------
  Native Heap    11347    11336        0     9038    12196    31844    20762     7264
  Dalvik Heap     9079     9060        0     1768     9740   108170     9866    98304
[下略]

Private Dirty 列中,记录了 Native 和 Dalvik(Java)内存使用量,而 dirty 的含义是什么?以及 clean, Rss, Pss, Swat 分别又代表了什么?

虚拟内存(VMA)与物理内存(Physical Memory)

这些都是 Linux 环境下内存管理的概念,站在内核的视角下,内存分配的最小粒度是 pages(分页),一个 page 大小是 4KB,这是 Linux 内核的基础页大小,是内存管理的最小单位,mmap()/malloc()/匿名页 最终都是按照 4KB 进行对齐。pages 在 "虚拟内存区(Virtual Memory Area,VMA)" 中进行管理和组织,它跟真实内存区是一一对应的,通过 VMA,可以将碎片化的真实内存映射为连续的虚拟内存。

应用进程并不通过 mmap() 函数直接声明内存占用,而是调用 malloc() ------native 代码,和 new() ------Java 代码来进行申请。

有两种类型的"虚拟内存区":

  • 基于文件的 VMA :底层使用文件实现,通过将文件描述符(fd)提供给 mmap() 函数,将在 VMA 上的读写操作映射为文件读写。在 Linux 系统中,动态链接器(ld)在运行新进程、动态加载库时,会使用基于文件的 VMA。Android 系统则在加载 dex 文件和资源时使用这一方式。
  • 匿名 VMA :非文件系统,只通过内存实现。调用 mmap(... MAP_ANONYMOUS ...) 会使用这种方式。动态申请的内存统统归于此类,例如 C 的 malloc()、C++ 的 new() 和 Java 的 new X() ------ 这也是我们要关注的重点。

应用从虚拟内存中申请内存,最终会映射到物理内存上,前者往往大于后者。我们统计内存占用时,通常只关注物理内存的大小,忽略虚拟内存。例如,系统分配给应用32MB 的虚拟内存,应用随后向其中写入 4KB 数据,实际上只占用了 4KB 的物理内存。在64位的系统上,虚拟内存地址很难耗尽,绝大多数场景下无需关注。

RSS 与 PSS

应用占用的物理内存大小,称为 "驻留内存大小(Resident Set Size,RSS)"

有了以上基础知识,接下来就可以对虚拟内存 VMA 中的分页进行归类了,这也就对应着前面 dumpsys meminfo 指令的输出结果。

  • 驻留状态(Resident) :该分页已经被映射到物理内存,可细分为两类。
    • 干净(Clean):分页内容与底层文件中的内容相同,在这种情况下,内核可以自由清除分页内容,而不必担心数据丢失。
    • 脏(Dirty):分页内容与底层文件内容不相同,在大多数情况下甚至不存在底层映射文件。此时,不允许内核清除分页内容。
  • 交换状态(Swapped):脏分页被写入到交换文件(在 Android 中是 ZRAM),这是一个临时状态,当下次缺页异常(page fault)发生时,会将这部分内容重新转移到内存。
  • 未占用状态(Not present):该分页从未使用过。

在 Android 系统中,应当重点关注脏分页 Private Dirty,因为脏分页无法被转移到文件系统,因此不能被内核回收再利用。

作为操作系统,Android 对于多个应用都要使用到的库(例如 libc.soframework.dex)进行了优化,系统将它们作为共享资源进行加载。这些数据最初属于 zygote 进程,当系统 fork() 新的应用进程时,使用的是相同的页面地址。同一时刻即使系统里运行了 100 个应用,在这部分上公有库占用的内存仍然只有一份。

因此引入了 "按比例内存占用(Proportional Set Size,PSS)" 的概念,对于共享内存按比例划分。例如我们将 4KB 的分页共享给4条进程,每一条进程占用的内存则是 1KB

cat /proc/[pid]/status:查看应用启动以来 RSS 的最大值

前文介绍过,dumpsys meminfo 可以返回应用当前的详细内存使用状态,如果说我们想要跟踪自应用进程启动以来,所申请过的最大 RSS,则可以通过 cat /proc/[pid]/status 来查看。

bash 复制代码
λ adb shell cat /proc/19947/status
[略]
VmPeak: 37292428 kB
VmSize: 32416288 kB
VmLck:       192 kB
VmPin:         0 kB
VmHWM:   1187096 kB <=== RSS High Watermark
VmRSS:    521832 kB
RssAnon:          122076 kB
RssFile:          362528 kB
RssShmem:          37228 kB
VmData:  1669512 kB
VmStk:      8192 kB
VmExe:         8 kB
VmLib:    224720 kB
VmPTE:      5572 kB
VmSwap:   258472 kB
[略]

VmHWM 记录了进程自启动以来,最大的瞬时 RSS 值,可以看到高达 1187M

使用 Perfetto 记录内存(RSS)增长曲线

配置文件 config.pbtx

plaintext 复制代码
buffers: {
    size_kb: 8960
    fill_policy: DISCARD
}
buffers: {
    size_kb: 1280
    fill_policy: DISCARD
}
data_sources: {
    config {
        name: "linux.process_stats"
        target_buffer: 1
        process_stats_config {
            scan_all_processes_on_start: true
        }
    }
}
data_sources: {
    config {
        name: "linux.ftrace"
        ftrace_config {
            ftrace_events: "mm_event/mm_event_record"
            ftrace_events: "kmem/rss_stat"
            ftrace_events: "kmem/ion_heap_grow"
            ftrace_events: "kmem/ion_heap_shrink"
        }
    }
}
duration_ms: 30000

使用命令 cat config.pbtx | adb shell perfetto -c - --txt -o /data/misc/perfetto-traces/trace.pftrace 开启抓取,操作界面,30s 后结束抓取。

这里我采用官方给出的配置并未能正常抓取 trace 文件,只能获取到一个大约 830KB 的空文件。先采用官方的图片作为讲解,如果有读者找到问题原因,欢迎不吝赐教。

从这个图可以看出,在大约时间进行到2/3时,mem.rss.ion 发生增长,说明此时用户操作引起了内存分配。

am dumpheap [packageName]:抓取 JVM 内存快照

bash 复制代码
λ adb shell am dumpheap com.google.samples.apps.nowinandroid.demo.debug
File: /data/local/tmp/heapdump-20251212-093630.prof
Waiting for dump to finish...

抓取对应包名的 prof 文件后,将其 pull 到电脑上,改名为 *.hprof后,就可以在 AndroidStudio 中查看当前进程的 JVM 内存快照。

也可以用 Perfetto 在线打开该快照,会以火焰图的方式展示对象占用堆大小,对于类型相同的对象聚合后展示,优点是更加直观,缺点是无法查看对象内部数据。

showmap [pid]:展示内存-文件映射

底层通过读取 /proc/PID/smaps 文件来实现。

首先获取 NowInAndroid 的 PID=19147。

bash 复制代码
λ adb shell ps -A | grep nowinandroid
u0_a225   19147   996   28342616 303680 do_epoll_wait       0 S com.google.samples.apps.nowinandroid.demo.debug

随后查看其内存中的基于文件的 VMA 映射。

输入:

bash 复制代码
λ adb shell showmap 19147

输出:

在下文中,输出内容中已经省略去了大部分,原本的映射文件数量有869个。这里面有 jar 包、so 库、ttf 字体文件、dex 文件等。可以说,占据大多数的是应用使用到的资源文件。

虽然数量多,但由于是"共享"的原因,真正的 PSS 和 private dirty 并不多。

区域 大小(b) 备注
RSS 296680 RSS = shared clean + shared dirty + private clean + private dirty
PSS 131754
shared dirty 34224
private dirty 55536
plaintext 复制代码
 virtual                     shared   shared  private  private                   Anon      Shmem     File      Shared   Private
    size      RSS      PSS    clean    dirty    clean    dirty     swap  swapPSS HugePages PmdMapped PmdMapped Hugetlb  Hugetlb    Locked    # object
-------- -------- -------- -------- -------- -------- -------- -------- -------- --------- --------- --------- -------- -------- -------- ---- ------------------------------
     292       96       19       88        0        8        0        8        0         0         0         0        0        0        0    1 /apex/com.android.adbd/lib64/libadbconnection_client.so
     492      184        8      184        0        0        0        0        0         0         0         0        0        0        0    1 /apex/com.android.adservices/javalib/framework-adservices.jar
    1172      208       97      144        0       64        0        0        0         0         0         0        0        0        0    1 /apex/com.android.art/javalib/apache-xml.jar
      72       60       32        0       56        0        4       12        0         0         0         0        0        0        0    1 /memfd:gralloc_shared_memory (deleted)
   65536        0        0        0        0        0        0       12        0         0         0         0        0        0        0    1 /memfd:jit-zygote-cache (deleted)
       8        0        0        0        0        0        0        0        0         0         0         0        0        0        0    1 /product/overlay/GmsConfigOverlayCommon.apk
      24       20        0       20        0        0        0        4        0         0         0         0        0        0        0    1 /system/bin/app_process64
    2716      988      777      384        0      604        0        0        0         0         0         0        0        0        0    1 /system/fonts/NotoColorEmoji.ttf
    
[中间略去大部分]

      36       36       36        0        0       36        0        0        0         0         0         0        0        0        0    1 /system/fonts/NotoSansSymbols-Regular-Subsetted2.ttf
      48       32        2       32        0        0        0        4        0         0         0         0        0        0        0    1 /system/framework/arm64/boot-mediatek-ims-base.oat
       4        4        0        4        0        0        0        0        0         0         0         0        0        0        0    1 /system/framework/boot-vivo-vgcclient.vdex
     700      264       19      260        0        4        0        4        0         0         0         0        0        0        0    1 /vendor/lib64/egl/libMEOW_gift.so

-------- -------- -------- -------- -------- -------- -------- -------- -------- --------- --------- --------- -------- -------- -------- ---- ------------------------------
 virtual                     shared   shared  private  private                   Anon      Shmem     File      Shared   Private
    size      RSS      PSS    clean    dirty    clean    dirty     swap  swapPSS HugePages PmdMapped PmdMapped Hugetlb  Hugetlb    Locked    # object
-------- -------- -------- -------- -------- -------- -------- -------- -------- --------- --------- --------- -------- -------- -------- ---- ------------------------------
28342616   296680   131754   143316    34224    63604    55536    30336    14411         0         0         0        0        0        0  869 TOTAL

getprop dalvik.vm.heapgrowthlimit 和 dalvik.vm.heapsize:设备分配给应用的内存上限

在不同硬件的设备上,应用能够使用的最大内存值不一样,这一般是由 ROM 厂商决定。通常来说,硬件越好,应用内存上限也越高。可以通过 adb 命令查看设备的分配给应用的内存上限。这两个值是写死在 /system/build.prop 文件当中的。

在控制台获取这两个值:

bash 复制代码
λ adb  shell getprop dalvik.vm.heapgrowthlimit
256m
λ adb  shell getprop dalvik.vm.heapsize
512m

在应用代码,可以使用 ActivityManager 提供的静态函数获取这两个值,其实现本质上也是访问上述系统参数。

java 复制代码
// ActivityManager.java
// 获取 heapgrowthlimit
static public int staticGetMemoryClass() {
        // Really brain dead right now -- just take this from the configured
        // vm heap size, and assume it is in megabytes and thus ends with "m".
        String vmHeapSize = SystemProperties.get("dalvik.vm.heapgrowthlimit", "");
        if (vmHeapSize != null && !"".equals(vmHeapSize)) {
            return Integer.parseInt(vmHeapSize.substring(0, vmHeapSize.length()-1));
        }
        return staticGetLargeMemoryClass();
}

// 获取 heapsize
static public int staticGetLargeMemoryClass() {
        // Really brain dead right now -- just take this from the configured
        // vm heap size, and assume it is in megabytes and thus ends with "m".
        String vmHeapSize = SystemProperties.get("dalvik.vm.heapsize", "16m");
        return Integer.parseInt(vmHeapSize.substring(0, vmHeapSize.length() - 1));
}

如何理解这两个值的含义?

  • heapgrowthlimit:应用默认允许使用的 dalvik/arm 虚拟机内存上限。
  • heapsize:在声明 largeHeap="true" 后,应用可以扩展到的内存上限。

已前文我使用的手机为例,其 heapgrowthlimit/heapsize256m/512m,这意味着,应用在运行时,最多可以申请 256m 的内存,当实时内存接近(未超过)这个值,JVM 会频繁触发 GC 以回收内存,伴随着大量的 STW。而一旦内存使用超过这个值,则会发生 OOM 崩溃。

xml 复制代码
<application
    android:largeHeap="false"/>

那可能就会有聪明的同学提问,是否可以在 AndroidManifext.xml 里开启 largeHeap 续一波命?通常来说这并不是一个好的选择,站在系统的角度,largeHeap 是用来提供给某些需要大内存的应用显式声明,例如相册、相机、编辑器、短视频等。如果你的应用不属于此类,还是建议从代码上寻找原因,做好内存的管控。

参考资料

相关推荐
PineappleCoder2 小时前
性能数据别再瞎轮询了!PerformanceObserver 异步捕获 LCP/CLS,不卡主线程
前端·性能优化
PineappleCoder2 小时前
告别字体闪烁 / 首屏卡顿!preload 让关键资源 “高优先级” 提前到
前端·性能优化
国科安芯4 小时前
国产RISC-V架构MCU在工控系统中的节能性分析
网络·单片机·嵌入式硬件·fpga开发·性能优化·架构·risc-v
云宏信息5 小时前
运维效率提升实战:如何用轻量化云管平台统一纳管与自动化日常资源操作
运维·服务器·网络·架构·云计算
hour_go5 小时前
微服务架构的故障演练数字化:方法解析与实践优势
微服务·云原生·架构
天天进步20155 小时前
【Cradle 源码解析一】架构总览与通用计算机控制 (GCC) 的实现思路
架构
Surpass余sheng军6 小时前
AI 时代下的网关技术选型
人工智能·经验分享·分布式·后端·学习·架构
小蝙蝠侠7 小时前
12 个“大 TPS 规模效应问题”——现象 + 排查 + 常见解决
jmeter·性能优化
Xの哲學8 小时前
Linux电源管理深度剖析
linux·服务器·算法·架构·边缘计算