将内存页返回给操作系统

在应用代码中释放对象或关闭临时流并不一定会将物理 RAM 归还给操作系统。自定义原生分配器、垃圾回收运行时和开放寻址哈希表经常会保留虚拟内存区域,这些区域在其中的逻辑数据消失后很长时间内仍由物理页面提供支持。同样,读取或内存映射大型资源归档会填充内核页面缓存,该缓存仍会记入应用的内存控制组 (memcg)。

本页面介绍了如何使用 madvise 和 posix_fadvise 系统调用将未使用的匿名和文件支持页面返回给操作系统,同时保持虚拟地址预留和文件描述符完好无损,以及如何在 adb shell 和 Perfetto 中验证影响。

为什么逻辑释放会保留物理 RAM

Linux 内核以硬件页面的粒度跟踪物理内存,在 Android 设备上,硬件页面大小为 4 KB 或 16 KB。当进程使用 mmap 预留虚拟地址空间区域时,内核会创建虚拟内存区域 (VMA),而不会分配物理 RAM。只有当进程首次访问某个物理页时,内核才会分配该页并将其计入进程的内存用量。对于匿名内存,首次读取会映射一个共享的零页,因此只有写入操作才会分配 RAM。

一旦页面被调入 RAM,内核就无法知道该页面内的字节是否仍代表应用运行时中的活跃对象。在应用使用完物理页面后,有两种常见模式会使物理页面保持常驻:

  • 匿名 arena 和池:自定义原生分配器(例如 slab、池或 frame-arena 分配器)、具有非移动堆空间的托管运行时以及大型哈希表会使用 MAP_PRIVATE | MAP_ANONYMOUS 通过 mmap 预留连续的虚拟地址范围。当应用重置 arena、将块释放回自定义池或通过将元数据归零来清除哈希表时,底层页面仍会保持脏状态。如果没有向内核提供明确提示,这些页面会继续计入进程的匿名常驻内存大小 (RSS) 和按比例分摊的内存大小 (PSS) 及其 memory.current cgroup 费用。在内存压力下,内核会将它们压缩到 ZRAM 中,在 ZRAM 中,它们仍会占用计入 memory.swap.current 的交换空间。
  • 来自资源流式传输的文件支持的页面缓存:当您的应用使用 read、pread 或文件支持的 mmap 读取大型资源包、关卡归档、媒体容器或机器学习模型时,内核会将每个读取的页面缓存在页面缓存中,并将这些页面计入进程 cgroup 的 memory.stat 文件中的 file 计数器。如果您的应用将该资源解压缩或复制到匿名运行时缓冲区,或将其上传到 GPU,那么在页面缓存中保留原始文件页面会在 RAM 中复制该资源。
使用 madvise 释放脏 arena 页面,并使用 posix_fadvise 逐出缓存的资源页面,同时保持虚拟范围和文件描述符不变。
图 1. 从虚拟 arena 和页面缓存中回收物理页面。

释放匿名页面

madvise 系统调用会向内核提供有关进程预期如何使用虚拟地址范围的建议。对于私有匿名映射 (MAP_PRIVATE | MAP_ANONYMOUS),传递 MADV_DONTNEED 会指示内核立即舍弃物理页以及支持指定页对齐范围的所有已交换 ZRAM 条目。

madvise 之后,MADV_DONTNEED 会返回:

  • 虚拟地址范围在进程的地址空间中保持预留和有效状态(VmSize 不会更改),因此指针和 arena 边界保持不变,无需调用 munmap 或 mmap。
  • 物理页面会立即取消映射并释放,从而减少 RssAnon、Pss、memory.stat 中的 anon 计数器以及 ZRAM 中任何页面的 memory.swap.current。
  • 随后对该范围内地址的任何读取或写入操作都会按需在新的零填充页面中发生故障。
建议标志 目标映射 Android 上的内核行为
MADV_DONTNEED 私密匿名 (MAP_PRIVATE | MAP_ANONYMOUS) 立即舍弃物理页面和 ZRAM 交换槽。后续访问会在零填充页面中出错。RssAnon 和 memory.current 中的确定性丢弃。
MADV_DONTNEED 文件支持(MAP_PRIVATE 或 MAP_SHARED) 从进程页表(RssFile 丢弃)中取消映射页面。MAP_PRIVATE 映射中的私有写入时复制页面会被舍弃,因此修改会丢失,下次访问时会重新读取文件。脏 MAP_SHARED 页面稍后会写回存储空间。干净页面会一直保留在页面缓存中,直到被逐出。
MADV_FREE 私密匿名 (MAP_PRIVATE | MAP_ANONYMOUS) 将页面标记为延迟可重用。如果不存在内存压力,内核会将页面保留在 RssAnon 中,并且仅在全局或 memcg 回收运行时才回收这些页面。
MADV_WILLNEED 文件支持或交换的匿名内存 为文件支持的映射启动异步预读,或提前将交换的匿名页面从 ZRAM 读回交换缓存。

将内部范围与运行时页面大小对齐

madvise 会对整个页面执行操作。它需要一个页面对齐的起始地址,并将长度向上舍入到下一个页面边界。如果您在共享 arena 中释放一个 span,而相邻字节仍包含活动对象,则向外舍入范围可能会将共享边界页面的活动分配清零。

请始终使用 sysconf(_SC_PAGESIZE) 查询运行时页面大小,以支持 4 KB 和 16 KB 页面设备,并将内部起始地址向上舍入到下一个页面边界,将内部结束地址向下舍入到上一个页面边界。如需有关与页面大小无关的分配器的更多指导,请参阅优化自定义内存分配器。

#include <sys/mman.h>
#include <unistd.h>
#include <cstdint>

// Reclaims physical pages strictly inside [region_start, region_start + length)
// without disturbing live objects on partially occupied boundary pages.
void ReleaseUnusedArenaSpan(void* region_start, size_t length) {
  const uintptr_t page_size = static_cast<uintptr_t>(sysconf(_SC_PAGESIZE));
  const uintptr_t page_mask = ~(page_size - 1);

  const uintptr_t raw_start = reinterpret_cast<uintptr_t>(region_start);
  const uintptr_t raw_end = raw_start + length;

  // Round start UP and end DOWN so only fully unused pages are discarded.
  const uintptr_t aligned_start = (raw_start + page_size - 1) & page_mask;
  const uintptr_t aligned_end = raw_end & page_mask;

  if (aligned_end > aligned_start) {
    madvise(reinterpret_cast<void*>(aligned_start),
            aligned_end - aligned_start,
            MADV_DONTNEED);
  }
}

使用 mmap 创建自定义匿名映射时,请使用 PR_SET_VMA_ANON_NAME 为 VMA 标记 prctl,以便您可以在 showmap 和 /proc/<pid>/smaps 中按名称跟踪其驻留情况。dumpsys meminfo 会统计 Unknown 下自定义命名的匿名区域。一些较旧的内核会保留指向名称的指针,而不是复制名称,因此请传递在映射生命周期内保持有效的字符串,例如字符串字面量:

#include <sys/mman.h>
#include <sys/prctl.h>

#ifndef PR_SET_VMA
#define PR_SET_VMA 0x53564d41
#define PR_SET_VMA_ANON_NAME 0
#endif

void* ReserveNamedArena(size_t capacity_bytes, const char* arena_name) {
  void* ptr = mmap(nullptr, capacity_bytes, PROT_READ | PROT_WRITE,
                   MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
  if (ptr != MAP_FAILED) {
    prctl(PR_SET_VMA, PR_SET_VMA_ANON_NAME,
          reinterpret_cast<unsigned long>(ptr), capacity_bytes,
          reinterpret_cast<unsigned long>(arena_name));
  }
  return ptr;
}

逐出文件缓存页面

当应用从文件描述符读取数据时,内核会将文件的页面缓存在页面缓存中,以备将来读取。虽然这可以加快对小型配置文件或共享库的重复访问,但当流式传输大型资源归档、纹理包或音频和视频块(这些内容会被解析一次并以解码形式存储在其他位置)时,这种做法会产生负面影响。

posix_fadvise 系统调用可让您的应用向内核告知其在打开的文件描述符上的预期文件访问模式:

  • POSIX_FADV_DONTNEED:指示内核在不久的将来不会访问指定的字节范围。内核会立即从页面缓存中逐出该页面对齐范围内干净、未映射的缓存页面,从而减少 memory.stat 中的 file 计数器和 cgroup 的 memory.current 费用。
  • POSIX_FADV_SEQUENTIAL:将文件描述符的内核预读窗口加倍,从而提高从头到尾流式传输大型归档时的吞吐量。在您使用尾随块时,将其与 POSIX_FADV_DONTNEED 配对,以便将页面缓存占用空间限制在较小的滑动窗口内。
  • POSIX_FADV_RANDOM:停用对文件描述符的推测性内核预读。在通过字节偏移量对大型归档或数据库文件执行稀疏、非连续查找时,请使用此选项,这样每个小型读取操作都不会将相邻的未使用页面拉取到页面缓存中。对于内存映射文件,请改为对映射调用 madvise 并传入 MADV_RANDOM。

以下示例以分块形式流式传输大型资源归档,从而提前启用顺序预读,并从页面缓存中逐出每个已处理的页面对齐 span:

#include <fcntl.h>
#include <unistd.h>
#include <cstdint>
#include <vector>

void StreamAssetArchive(int fd, size_t total_size) {
  const size_t page_size = static_cast<size_t>(sysconf(_SC_PAGESIZE));
  constexpr size_t kChunkSize = 4 * 1024 * 1024;  // 4 MB streaming window
  std::vector<uint8_t> buffer(kChunkSize);

  // Hint sequential access so the kernel prefetches upcoming chunks.
  posix_fadvise(fd, 0, total_size, POSIX_FADV_SEQUENTIAL);

  off_t offset = 0;
  while (offset < static_cast<off_t>(total_size)) {
    ssize_t bytes_read = pread(fd, buffer.data(), buffer.size(), offset);
    if (bytes_read <= 0) break;

    // Decode or upload buffer[0 .. bytes_read) into runtime structures here.

    // Evict full pages that have been completely consumed.
    const size_t aligned_len =
        (static_cast<size_t>(bytes_read) / page_size) * page_size;
    if (aligned_len > 0) {
      posix_fadvise(fd, offset, static_cast<off_t>(aligned_len),
                    POSIX_FADV_DONTNEED);
    }
    offset += bytes_read;
  }
}

回收文件支持的 mmap 区域

如果您的应用使用 mmap 对大型资源文件进行内存映射,而不是使用 pread 读取该文件,那么仅使用 POSIX_FADV_DONTNEED 调用 posix_fadvise 不会逐出仍映射到进程页表的页面。如需在不取消映射整个 VMA 的情况下,丢弃只读文件映射的已用 span,请执行以下操作:

  1. 对映射的虚拟地址范围调用 madvise(addr, length, MADV_DONTNEED),以从进程的页表中取消映射这些页面(RssFile 丢弃)。
  2. 在相应的文件偏移量上调用 posix_fadvise(fd, offset, length, POSIX_FADV_DONTNEED),以便内核也从页面缓存中逐出未映射的干净页面(memory.stat 丢弃中的 file)。

遵循页面建议指南

将 madvise 和 posix_fadvise 集成到分配器、资源加载器和生命周期处理程序中时,请遵循以下做法:

  • 批量调用粗略区域:每个 madvise 调用都会获取进程的内存映射锁 (mmap_lock),遍历页表,并使其他 CPU 核心上的匹配转译后备缓冲区 (TLB) 条目失效(在 Arm 上使用广播 TLB 维护指令,在 x86 上使用处理器间中断)。不要在每次小规模释放时都调用 madvise。 合并空闲 span 并以 64 KB 到 1 MB 或更大的块为单位通知内核,或在帧边界和后台转换期间扫描空闲 span(onStop 和 TRIM_MEMORY_UI_HIDDEN)。
  • 回收哈希表和池中的空容量:在场景转换或数据同步后,如果大型哈希表、对象池或暂存缓冲区被清除,请缩小其后备分配,或在清除的后备数组中发出 MADV_DONTNEED。如果保留一个分配有墓碑的 32 MB 开放寻址表,则所有 32 MB 的物理页面都会计入您的进程。
  • 在逐出脏文件数据之前将其清空:POSIX_FADV_DONTNEED 仅逐出干净的页面缓存页面。如果您的应用写入了不会立即读回的大型临时文件或缓存文件,请在 posix_fadvise(fd, 0, 0, POSIX_FADV_DONTNEED) 之前调用 fdatasync,以便将脏页写入存储空间,然后从 RAM 中丢弃。
  • 不要逐出热共享代码或资源:避免对 DEX、ODEX、VDEX、共享库 (.so) 或重复访问的界面资源调用 POSIX_FADV_DONTNEED。如压力停滞信息 (PSI) 中所述,逐出活跃代码或“素材资源”页面会强制执行同步存储重新故障,从而导致线程停滞并增加 I/O 压力。

在 shell 中验证页面回收

验证 madvise 和 posix_fadvise 是否使用 showmap 和应用的 cgroup v2 统计文件回收物理 RAM。

使用 showmap 检查虚拟大小与物理大小

当您使用 PR_SET_VMA_ANON_NAME 为匿名 arena 命名时,showmap 会同时显示其虚拟预留 (virtual size) 及其常驻物理页面(RSS 和 PSS)。以下示例输出来自 AOSP 中的 MemoryLab 示例应用,该应用将其 arena 命名为 memorylab-arena。将 com.example.app 和 memorylab-arena 替换为您的软件包名称和竞技场名称。在对已触及的 64 MB 竞技场使用 MADV_DONTNEED 调用 madvise 之前,请运行以下命令:

adb shell "showmap \$(pidof com.example.app) | grep -E '^ +virtual|^ +size|memorylab-arena' | head -3"
 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
   65536    65536    65536        0        0        0    65536        0        0         0         0         0        0        0        0    1 [anon:memorylab-arena]

调用 madvise(arena_ptr, 64 * 1024 * 1024, MADV_DONTNEED) 后,再次运行 showmap:

 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
   65536        0        0        0        0        0        0        0        0         0         0         0        0        0        0    1 [anon:memorylab-arena]

virtual size 保持在 65536 KB (64 MB),这证实了虚拟地址范围仍处于预留状态,而 RSS、PSS 和 private dirty 则降至 0 KB。

检查 cgroup memory.current 和 memory.stat

如需验证匿名页面回收和未映射的页面缓存逐出(如果使用 pread 读取,则不会显示在进程页表中),请检查进程的 cgroup v2 目录。在 Android 上,每个应用进程都有自己的 cgroup(例如 /sys/fs/cgroup/apps/uid_<UID>/pid_<PID>/,具体取决于设备)。以下命令会从 /proc/<PID>/cgroup 的 0:: 行中查找目录并输出计数器:

adb shell '
  PID=$(pidof com.example.app)
  CG=/sys/fs/cgroup$(sed -n "s/^0:://p" /proc/$PID/cgroup)
  echo "memory.current: $(cat $CG/memory.current)"
  echo "memory.swap.current: $(cat $CG/memory.swap.current)"
  grep -E "^(anon|file) " $CG/memory.stat
'

在释放 64 MB 匿名 arena 并从页面缓存中逐出 128 MB 内存映射资源文件之前:

memory.current: 242962432
memory.swap.current: 16633856
anon 100671488
file 140009472

在对 arena 和资源文件映射调用 madvise(使用 MADV_DONTNEED)后,立即对资源文件描述符调用 posix_fadvise(使用 POSIX_FADV_DONTNEED):

memory.current: 41766912
memory.swap.current: 16502784
anon 33722368
file 5914624

anon 减少 64 MB,file 减少 128 MB,从而在不关闭文件描述符或取消映射 arena 的虚拟地址空间的情况下,将 memory.current 减少约 192 MB。memory.swap.current 几乎没有变化,因为几乎没有 arena 页面被交换到 ZRAM。您的值取决于设备以及内核已自行逐出的文件量。如需详细了解内核如何按 cgroup 计费和回收内存,请参阅内存回收:逐出和交换。

在 Perfetto 中分析页面回收

借助 Perfetto,您可以在统一的时间轴上将 madvise 和 posix_fadvise 调用与内核页表和页缓存计数器相关联。

记录轨迹

使用 linux.process_stats(用于定期进程计数器)和 linux.ftrace(用于实时 RSS 和页面缓存事件)捕获跟踪记录,然后将其拉取到您的计算机:

adb shell perfetto -c - --txt -o /data/misc/perfetto-traces/page_advice.perfetto-trace <<EOF
buffers: {
    size_kb: 65536
    fill_policy: RING_BUFFER
}
data_sources: {
    config {
        name: "linux.process_stats"
        process_stats_config {
            scan_all_processes_on_start: true
            proc_stats_poll_ms: 100
        }
    }
}
data_sources: {
    config {
        name: "linux.ftrace"
        ftrace_config {
            ftrace_events: "kmem/rss_stat"
            ftrace_events: "filemap/mm_filemap_add_to_page_cache"
            ftrace_events: "filemap/mm_filemap_delete_from_page_cache"
        }
    }
}
duration_ms: 20000
EOF
adb pull /data/misc/perfetto-traces/page_advice.perfetto-trace

在 Perfetto 界面中检查轨道

在 Perfetto 界面中打开跟踪记录,展开应用的进程组,然后固定以下计数器轨道,使其保持在时间轴顶部:

  • mem.virt:在 MADV_DONTNEED 次调用中保持不变,确认虚拟地址预留 (mmap) 未发生变化。
  • mem.rss.anon:在应用针对脏匿名 arena 或堆区域调用 madvise(使用 MADV_DONTNEED)的确切时间戳处急剧下降。
  • mem.rss.file:当您的应用在文件支持的 mmap 区域中调用带有 MADV_DONTNEED 的 madvise 时,该指标会下降,同时当带有 POSIX_FADV_DONTNEED 的 posix_fadvise 从页面缓存中逐出干净页面时,会爆发 mm_filemap_delete_from_page_cache ftrace 事件。
  • mem.swap:当 MADV_DONTNEED 舍弃之前已压缩到 ZRAM 中的匿名页面时,此值会下降。
一个进程的 Perfetto 计数器轨迹。当 arena 和资源文件被填充时,mem.rss.anon 和 mem.rss.file 会上升,并在 madvise 和 posix_fadvise 之后下降,而 mem.swap 和 mem.virt 则保持平稳。
图 2. RSS 在页面建议调用后下降。

图 2 显示了 MemoryLab 示例应用的这些轨道。如需探索同一视图,请在 Perfetto 界面中打开示例跟踪记录。

使用 PerfettoSQL 查询峰值内存和最终内存

在 Perfetto 界面(Query (SQL) 标签页)中运行以下 PerfettoSQL 查询,以比较目标进程的 mem.virt、mem.rss.anon、mem.rss.file 和 mem.swap 的峰值和最终值:

WITH counters AS (
  SELECT t.name AS counter_name, c.ts, c.value
  FROM counter c
  JOIN process_counter_track t ON c.track_id = t.id
  JOIN process p USING (upid)
  WHERE p.name = 'com.example.app'
    AND t.name IN ('mem.virt', 'mem.rss.anon', 'mem.rss.file', 'mem.swap')
)
SELECT
  counter_name,
  CAST(MAX(value) / 1048576 AS INT) AS peak_mb,
  CAST((
    SELECT value FROM counters AS last
    WHERE last.counter_name = counters.counter_name
    ORDER BY ts DESC LIMIT 1
  ) / 1048576 AS INT) AS final_mb
FROM counters
GROUP BY counter_name
ORDER BY counter_name;

图 2 中跟踪记录的示例输出:

counter_name  peak_mb  final_mb
mem.rss.anon      111        48
mem.rss.file      212        84
mem.swap           36        36
mem.virt        13952     13952

mem.rss.anon 从峰值下降 63 MB,mem.rss.file 从峰值下降 128 MB,而 mem.virt 在峰值结束,因为虚拟预留未发生变化。

将逐出的页面解析为文件

mm_filemap_add_to_page_cache 和 mm_filemap_delete_from_page_cache 事件通过 inode 编号 (i_ino) 而不是路径来标识文件。如需确认被逐出的页面属于您建议的文件,请统计每个 inode 的逐出次数:

SELECT
  EXTRACT_ARG(arg_set_id, 'i_ino') AS inode,
  COUNT(*) AS evicted_pages
FROM ftrace_event
WHERE name = 'mm_filemap_delete_from_page_cache'
GROUP BY inode
ORDER BY evicted_pages DESC
LIMIT 5;

图 2 中跟踪记录的示例输出:

inode     evicted_pages
9761              35781
76792192            767
126                 668
0                   612
11587712            514

在记录跟踪记录的设备仍处于连接状态时,将每个 inode 解析为路径。inode 编号仅在单个文件系统中是唯一的,因此请将搜索范围限制在包含应用数据的文件系统中:

INODE=<inode from the query>
adb shell "find /data -xdev -inum $INODE 2>/dev/null"

inode 9761 的示例输出:

/data/user/0/com.android.memorylab/files/asset_pack.bin
/data/data/com.android.memorylab/files/asset_pack.bin

这两个路径引用的是同一个文件,因为 /data/user/0 和 /data/data 是同一个应用数据目录的两个名称。资源文件的逐出计数(35,781 个页面)大于文件本身(32,768 个页面,每个页面 4 KB),因为在应用重新读取文件并调用 posix_fadvise 之前,kswapd 已在内存压力下逐出了文件的一部分。

如需了解一次性解析多个 inode 的脚本,请参阅将故障解析为 inode,然后解析为文件。


← kswapd 和 lmkd 交互 | ↑ 向上 | 精简后台进程 →