Android 的内存分页与大页,对 App 有什么影响
Android 的内存分页与大页,对 App 有什么影响
去年在做一款相机 App 的内存治理时,我碰到了一个蹊跷的现象。同一套代码在 Pixel 6(Android 13)上跑 4K 60fps 预览流,Native Heap 的 RSS 会比预期高出 80MB 左右。用 adb shell showmap <pid> 去看,发现内存分布里充斥着大量散落的 4KB匿名页,中间夹杂着不少 [anon:.bss] 和 [anon:libc_malloc] 的碎片。直觉上,连续的大块视频 buffer 不应该被切成这样。深入查下去,问题最后指向了一个平时很少被应用层开发者关注的点:内核的内存分页策略,以及 Android 系统对大页(Huge Page)的真实态度。
4KB 页在移动端的真实开销
绝大多数 Android 设备上,getpagesize() 返回 4096。这个 4KB 的标准页大小从 x86 时代延续至今,在 ARM64 上同样被当作默认配置。很多开发者把它当成一种“透明基础设施”,认为只要不去写 Native 代码做 mmap,分页就和自己没关系。这种认知在应用层业务代码里基本够用,但一旦涉及到性能敏感的大内存场景,4KB 粒度的代价就会暴露出来。
ARM64 架构使用多级页表做虚拟地址转换。以常见的 4 级页表(4KB granule, 39 位有效虚拟地址)为例,一次 TLB miss 最多需要遍历 4 次内存中的页表项才能拿到物理地址。移动端 SoC 的 L2 TLB 容量通常不大,比如骁龙 8 Gen 2 的 Cortex-X3 核心,L2 TLB 一般只有 2048 个 entry。这意味着它能直接覆盖的连续地址空间,4KB 页情况下只有 8MB 左右。如果你的 App 同时在做视频编解码、维护一个大的图片缓存、并且渲染管线里还有几张大的 GPU texture,working set 很容易超过这个范围。一旦超过,dTLB miss 就会直线上升,CPU 要花时间做 page table walk,功耗和延迟都会增加。
这里还有一个容易被忽略的点:页表本身也吃内存。64 位虚拟地址空间巨大,一个充分使用了地址空间的 App,其页表(Page Table Entries)消耗的物理内存可能达到几十甚至上百 MB。这部分开销在 dumpsys meminfo 的 Pss 里并不会单独列出来,你只能通过 /proc/self/status 里的 VmPTE 字段窥探一二。我在排查那款相机 App 时发现,它的 VmPTE 常年在 60MB 以上,而我们当时的虚拟地址空间映射确实非常零碎——大量的 4KB 页拉高了页表层级,让硬件 MMU 的负担变得很重。
Android 内核到底支不支持 THP
说到大页,Linux 桌面和服务器上最先让人想到的是 Transparent Huge Pages(THP)。它允许内核在后台自动把连续的 4KB 页合并成 2MB 的大页,不需要应用层修改代码。很多数据库和中间件会靠这个特性降低 TLB miss。
但 Android 完全是另一回事。我查看了手上几台设备的 /sys/kernel/mm/transparent_hugepage/enabled:
always [madvise] never这看起来像是支持 madvise,但点进去看内核源码和实际行为就知道,Android 通用内核(GKI)在编译配置里对 THP 的态度非常保守。从 Android 10 开始,Google 的 GKI 分支就倾向于把 THP 关闭或限制在极窄的场景。到了 Android 12(Linux Kernel 5.10)和 Android 13(Kernel 5.15),THP 在大多数消费级设备上实际上处于失效状态。Pixel 7 系列上这个节点读出来的值虽然是 madvise,但你通过 mmap 申请匿名内存后再调用 madvise(addr, len, MADV_HUGEPAGE),内核基本不会真的给你合并出 2MB 页。
背后的原因很现实。THP 的页合并与整理(compaction)需要 CPU 在后台搬动物理页,这个过程会持有内存管理相关的锁,并且产生不可忽略的延迟。服务器上可以接受 10ms 的卡顿换 TLB 效率,但手机上不行。Android 的功耗团队和流畅度团队对任何可能引发 jitter 的内核行为都非常敏感,THP 在这种权衡下自然被边缘化了。
这也解释了为什么你在 /proc/self/smaps 里几乎永远看到 AnonHugePages: 0 kB。不是 App 写得不对,而是整个 Android 生态从内核层面就放弃了让用户态透明享受大页的方案。
系统层的大页对齐:ART 和 Linker 偷偷做的事
用户态 App 拿不到 THP,不代表大页这个概念在 Android 上完全没用。恰恰相反,系统层在加载代码和数据时,已经在尽可能地利用 ARM64 对大块映射的支持。
| 拿 ART 来说。从 Android 10 开始,ART 在编译和加载 `.oat`、`.vdex` 文件时,会主动按 2MB 边界对齐代码段。你可以用 `adb shell cat /proc/<pid>/maps | grep app_process` 或查看任意 App 的 oat 映射来验证。通常你会看到类似这样的条目: |
|---|
.../base.odex r-xp 00000000 00000000 0 00012000注意映射的基地址和段大小。系统 linker 在 mmap 加载 so 和 oat 时,会使用 MAP_FIXED 配合按 2MB 对齐的地址。这虽然不能保证底层物理内存是大页(因为 THP 关着),但虚拟地址的连续性和对齐方式会让硬件 prefetcher 和 iTLB 的工作更高效。Google 在 ART 的源码注释里提到过,这种对齐主要是为了减少指令 TLB miss,对冷启动和运行期的代码执行效率都有帮助。
我在分析那款相机 App 的启动轨迹时,用 simpleperf 抓了一段 startup 事件。结果发现,so 加载阶段 __dl__ZL10soinfo_mapPK6soinfo 附近的 iTLB miss 占比不到 3%,远低于我们自己后来分配的 Native buffer 区的 dTLB miss。这说明系统库在代码段对齐上确实下了功夫,但数据段就完全不管了——那是应用层自己的责任。
手动对齐 Native Buffer:为什么仍然值得做
既然 THP 用不了,那我们在 Native 层手动把大块内存按 2MB 边界对齐,是不是在做无用功?实际测试下来,并不是。
Android 里很多硬件加速路径对用户态传入的 buffer 有强烈的对齐偏好。最典型的是 MediaCodec 和 ImageReader 的零拷贝链路。我在 Android 13 的 Pixel 7 Pro 上做过一组对比实验:处理 4K YUV 数据时,分别用标准 malloc 和 posix_memalign(ptr, 1 << 21, size) 分配输入 buffer,然后塞给 CCodec 的 queueInputBuffer。
void* buffer;
posix_memalign(&buffer, 1 << 21, 4 * 1024 * 1024 * 3 / 2); // 2MB align, 4K NV21 size通过 systrace 和 atrace 观察,标准 malloc 出来的 buffer 在某些帧上会引发额外的 memcpy 痕迹,出现在 Codec2Buffer::copy 的调用栈里。而手动按 2MB 对齐后,这条 memcpy 消失了,Minor Fault 的数量也下降了约 25%。
我的推测是,虽然内核没有给我们 2MB 的透明大页,但 Codec2 的 HAL 实现或者底层的 ION/DMA-BUF Heaps 驱动,在把用户态虚拟地址绑定到硬件 DMA 时,会对不对齐的 buffer 做内部拷贝。ARM64 的 MMU 虽然支持 4KB 粒度的映射,但视频编解码器通常工作在物理地址层面,它们更喜欢 2MB 甚至更大的连续物理块。如果用户态地址不对齐,驱动为了保证 DMA 安全,会临时申请一块对齐的物理内存做中转。
这个发现直接推动了我们工程上的改动:凡是超过 1MB 且需要频繁交给 MediaCodec、RenderScript(虽然现在已经被弃用,但老项目还在)或 GPU 做纹理上传的 buffer,一律通过 mmap 配合地址 hint 做 2MB 对齐,内部再自己做 arena 分配。代码大致长这样:
size_t align = 1 << 21;
size_t size = 16 * 1024 * 1024; // 16MB arena
uintptr_t addr = (uintptr_t)mmap(nullptr, size + align, PROT_READ|PROT_WRITE,
MAP_PRIVATE|MAP_ANONYMOUS, -1, 0);
uintptr_t aligned = (addr + align - 1) & ~(align - 1);
if (addr != aligned) {
munmap((void*)addr, aligned - addr);
munmap((void*)(aligned + size), (addr + size + align) - (aligned + size));
}
// aligned 现在是我们管理的 16MB 2MB-aligned arena这种“伪大页”分配策略没有减少物理内存占用,但显著降低了进程的地址空间碎片,也让内核在管理我们这块内存时的页表层级更扁平。showmap 的输出从原来密密麻麻的 4KB 条目,变成了几条连续的 2MB+ 区块,清爽很多。
Bitmap 和 Ashmem 的页对齐陷阱
应用层开发者接触最多的大内存对象大概是 Bitmap。从 Android 8.0 开始,Bitmap 像素数据默认放到 Native Heap。很多人以为 BitmapFactory 解码出来的内存是紧凑排列的,实际上它受到两层分页的影响。
第一层是 jemalloc。Android 的 Native 分配器基于 jemalloc,它的 chunk 管理和 tcache 策略会把不同 size class 的请求规整到特定的边界上。对于超过一定阈值的大分配(比如 1MB 以上的 Bitmap),jemalloc 通常会直接走 mmap 从系统拿内存。这些 mmap 出来的区域天然按页对齐,但如果你连续分配 100 个 1080x1920 的 RGBA Bitmap,每个约 8MB,它们在虚拟地址空间里会各自占据独立的 VMA(Virtual Memory Area)。每一个 VMA 都对应内核里的一组页表和权限记录,数量一多,页表压力就会上来。
第二层是 Hardware Bitmap。Android 8.0 引入的 Hardware Config 让 Bitmap 内存不再走普通堆,而是通过 GraphicBuffer -> Gralloc -> ION/DMA-BUF 分配物理连续内存。这里有个隐藏的对齐要求:Gralloc 的某些实现(尤其是高通和联发科平台)在分配时,如果请求的 width/height 组合导致 stride 不是 64 或 128 字节对齐,会在内部 padding。更甚者,如果总大小不是 2MB 的整数倍,底层可能分配了 2MB 对齐的物理块,却只用了前面一部分,尾部几百 KB 被浪费。
我在排查内存时写过一个脚本,读取 /proc/<pid>/smaps_rollup 对比前后状态。发现加载一张 12MB 的 Hardware Bitmap 后,Rss 增加了整整 16MB。多出来的 4MB 不是 Java Heap 或者 Native Heap 能解释的,最后定位到是 Gralloc 的实现按 2MB 边界向上取整了物理分配。这个行为在不同 OEM 上表现不一致,Pixel 上通常是 4KB 的整数倍,但某几款联发科天玑 9000 的机器上确实存在 2MB 对齐的 over-allocation。
对于开发者来说,这意味着在内存敏感场景下,Bitmap 的 width 最好能被 stride 对齐要求整除,而总像素内存(width height bpp)如果接近页边界,要小心向上取整带来的开销。不要 decode 一堆“刚好差一点就跨页”的尺寸。
16KB Page Size 的前兆
讨论 Android 分页,绕不开一个正在逼近的变化:16KB 页大小。
Apple 在 M1 和 A15 之后的芯片上全面转向 16KB 页,ARM 本身也持续在推 16KB granule 的支持。Android 目前仍然坚守 4KB,但风向已经变了。Android 14(API 34)的官方文档里,Google 明确加了一条提示:不要再假设 PAGE_SIZE 是 4096。sysconf(_SC_PAGESIZE) 和 getpagesize() 才是获取运行时页大小的正确方式。
这对存量 App 是个潜在炸弹。很多 Native 层代码里硬编码了 & ~4095 或者 4096 来做地址对齐、内存分块或环形 buffer 管理。如果未来 Android 设备像 iOS 一样切换到 16KB,这些代码要么直接崩溃,要么在页边界处理上出莫名其妙的错误。
我在检查项目代码时,就发现了几处这样的硬编码。最隐蔽的一处是某个开源编解码库里的内存池实现:
#define PAGE_SIZE 4096
#define ALIGN_TO_PAGE(x) (((x) + PAGE_SIZE - 1) & ~(PAGE_SIZE - 1))这行代码在 4KB 设备上跑得稳稳当当,一旦页大小变成 16KB,ALIGN_TO_PAGE 依然只按 4KB 对齐,传给某些需要真正页对齐的系统调用时就会触发 EINVAL。Android 14 的 CTS(Compatibility Test Suite)里据说已经加入了针对页大小假设的检测项,虽然还没有强制,但留给开发者改代码的时间并不充裕。
分页策略对 GC 停顿的间接影响
ART 的内存管理和分页机制还有一个少被提及的交集:Garbage Collector 的并发拷贝(Concurrent Copying)。
Android 10 之后,ART 默认使用 Generational Concurrent Copying GC。它的核心机制之一是在 from-space 和 to-space 之间搬运对象,并利用 read barrier 保证引用正确性。在 GC 的某些阶段,运行时需要通过 mprotect 修改页的保护属性,把 from-space 的页标记为 PROT_NONE,以此触发 SIGSEGV 来实现 forwarding pointer 的屏障机制。
这里就出现了一个和分页粒度强相关的问题:mprotect 的系统调用开销是按页计算的,但更大的成本在于 TLB shootdown 和页表同步。如果 Heap 非常碎片化,对象散落在大量不连续的 4KB 页上,GC 需要逐个或批量修改很多页的保护属性。虽然 ART 会尽量批量处理,但在极端情况下,这种页级操作会增加 GC 线程的停顿时间。
我在分析一款社交 App 的卡顿问题时,通过 atrace 看到 ConcurrentCopying 的 FlipThreadRoots 阶段偶尔会有 3-5ms 的尖刺。深入后发现,那个版本的 App 因为大量使用 DirectByteBuffer 做网络缓存,导致 Native 内存和 Managed Heap 的页交错分布,间接让 GC 的页屏障操作变得更加沉重。虽然这不能完全归咎于分页,但它提醒我们:内存的页级分布不是内核才需要关心的事,它会上翻到运行时的 GC 行为里。
从 smaps 里读出真相
要验证这些猜想,最直接的办法还是去读内核暴露的统计。/proc/self/smaps 在 Android 上是可以访问的(不需要 root,读自己的进程即可)。相比于 dumpsys meminfo,它能提供 VMA 级别的细节。
下面这段代码是我平时用来快速扫描进程页分布的:
void dumpSmapsSummary() {
FILE* fp = fopen("/proc/self/smaps", "r");
if (!fp) return;
char line[256];
size_t totalRss = 0, totalPss = 0, totalAnonymous = 0;
while (fgets(line, sizeof(line), fp)) {
if (strncmp(line, "Rss:", 4) == 0) totalRss += atoi(line + 4);
if (strncmp(line, "Pss:", 4) == 0) totalPss += atoi(line + 4);
if (strncmp(line, "Anonymous:", 10) == 0) totalAnonymous += atoi(line + 10);
}
fclose(fp);
LOGI("RSS=%zu KB, PSS=%zu KB, Anonymous=%zu KB", totalRss, totalPss, totalAnonymous);
}真正值得关注的是 AnonHugePages 和 KernelPageSize 这两个字段。在我测试过的所有 Android 设备上(Pixel 4/6/7、三星 S23、小米 13),AnonHugePages 在用户态的匿名映射里永远是 0。这再次证实了 THP 在 Android 上的名存实亡。
但 KernelPageSize 并不总是 4KB。极少数测试设备(主要是一些 ARM64 服务器板子刷的 Android 系统)会显示 64KB。这也提醒做 Native 开发的同学,永远不要假设页大小。
厂商定制的分页差异
Android 内核的分页策略还受到 OEM 魔改的影响。虽然 GKI 试图统一内核接口,但内存管理里的页回收(kswapd)、watermark 计算、以及 lowmemorykiller 的参数,各家差异巨大。
最直观的感受是,同样 12GB RAM 的手机,小米和三星在后台 App 的页回收激进程度上完全不同。某次我们在三星 Galaxy S22(Android 13)上测试,发现进入后台后,系统对 Native Heap 的匿名页回收非常积极,导致切回前台时出现大量的 Major Page Fault(需要从 zram 或 swap 换回)。而在一加的同款配置机器上,后台驻留的页更多,切回前台更流畅,但整机内存水位更高,杀后台也更狠。
这种差异本质上不是分页大小的问题,而是分页生命周期管理策略的分歧。但对 App 开发者来说,理解这一点有助于解释为什么同样的内存占用,在不同品牌上的流畅度和存活率表现迥异。如果你的 App 有大量可重建的 Native 缓存,在收到 onTrimMemory 时主动 madvise(MADV_DONTNEED) 释放物理页,会比被动等待系统回收更可控。当然,Android 上的 madvise 行为也受内核版本影响,Linux 5.10 之后的 MADV_DONTNEED 在匿名映射上立即释放页的行为比老版本更可靠。
写在工具链的最后一点观察
最后提一个工具层面的细节。Android Studio 的 Memory Profiler 展示的是 Java Heap,对 Native 页的分布无能为力。要观察页表级别的细节,还是得靠 simpleperf、showmap,或者自己读 /proc 节点。
Google 在 Android 13 里给 adb shell cmd activity memory-dump 加了一个原生级别的 dump 能力,能输出每个进程的 Dalvik Heap、Native Heap 和 Graphics 的 PSS 拆解。但它仍然不会告诉你页表本身吃了多少内存,也不会指出你的 Native buffer 是不是因为没对齐而触发了驱动层的拷贝。
那款相机 App 的最终优化方案,是在 Native 层引入了一个 2MB 对齐的 arena allocator,专门服务于视频流处理管线。上线后,4K 60fps 场景下的 Native RSS 下降了约 60MB,Minor Page Fault 每秒减少了 30% 左右。这个数字听起来不小,但考虑到我们并没有真正启用大页,只是通过更合理的对齐和分配策略减少了碎片与驱动开销,说明 Android 上的分页优化空间远比“开启 THP”要务实得多。
做移动端内存优化,很多时候不是去追求服务器端那种极致的 T