17、内存映射文件:mmap原理,匿名共享内存,Ashmem驱动
说到Android内存管理,有个东西绕不开——内存映射文件。说白了,就是把磁盘文件或者内存区域,直接映射到进程的虚拟地址空间里。你想想看,这样一来,读写文件就像读写内存一样简单,省去了传统read/write那套数据拷贝的麻烦。
我个人习惯把mmap比作“搭桥”。它直接在用户空间和内核空间之间搭了一座桥,数据不用绕路。嗯,今天我们就来聊聊这座桥是怎么搭的,以及Android里那个特殊的“匿名共享内存”——Ashmem。
17.1 mmap:内存映射的基石
mmap是Linux内核提供的一个系统调用。它的原型长这样:
#include <sys/mman.h>
void *mmap(void *addr, size_t length, int prot, int flags,
int fd, off_t offset);
参数看着多,其实核心就几个:
- addr:建议的映射起始地址,传NULL让内核自己选
- length:映射多长
- prot:保护标志,PROT_READ、PROT_WRITE这些
- flags:映射类型,MAP_SHARED还是MAP_PRIVATE
- fd:文件描述符,如果是匿名映射就传-1
- offset:文件偏移量,必须是页大小的整数倍
我在项目中遇到过一个问题:用mmap映射一个大文件后,进程的内存占用看着暴涨。其实那不是真的物理内存,只是虚拟地址空间被占用了。真正物理内存的分配,是等到你访问那一页的时候才发生的——这就是所谓的按需分页。
核心要点:mmap减少了数据在内核空间和用户空间之间的拷贝次数。传统read/write需要两次拷贝(磁盘→内核缓冲区→用户缓冲区),而mmap只需要一次(磁盘→用户空间,通过缺页中断)。
17.2 匿名共享内存:没有文件名的共享
普通的mmap需要关联一个文件描述符。但有时候我们只是想分配一块内存,让多个进程共享,并不想真的创建一个文件。这时候就需要匿名共享内存了。
怎么做?很简单,mmap的时候指定MAP_ANONYMOUS标志,fd传-1:
#include <sys/mman.h>
#include <unistd.h>
size_t size = 4096;
void *shared_mem = mmap(NULL, size,
PROT_READ | PROT_WRITE,
MAP_SHARED | MAP_ANONYMOUS,
-1, 0);
if (shared_mem == MAP_FAILED) {
perror("mmap failed");
return -1;
}
这样分配出来的内存,如果进程fork了,子进程也能看到同样的内容。因为MAP_SHARED保证了父子进程共享同一块物理内存。
但这里有个问题:如果两个不相关的进程想共享内存呢?fork不行,它们没有血缘关系。这时候就需要一个“名字”来标识这块共享内存。Linux上有POSIX共享内存(shm_open),但Android选择了自己的一套——Ashmem。
17.3 Ashmem驱动:Android的共享内存方案
Ashmem,全称Anonymous Shared Memory,是Android内核特有的一个驱动。它本质上是一个字符设备驱动,注册在/dev/ashmem节点上。
为什么Android不直接用POSIX共享内存?我个人觉得有几个原因:
- 内存回收:Ashmem支持“pin/unpin”机制,系统内存紧张时可以unpin掉,把物理页面回收掉
- 文件描述符传递:通过Binder传递fd,天然适合Android的IPC架构
- 大小可伸缩:Ashmem分配的内存可以设置一个最大尺寸,但实际使用按需分配
我曾经调试过一个SurfaceFlinger的内存泄漏问题。SurfaceFlinger用Ashmem来传递图形缓冲区,结果有个地方忘了unpin,导致物理内存一直被占用。嗯,从那以后我每次用Ashmem都会检查pin/unpin的配对情况。
17.4 Ashmem的使用流程
Ashmem的使用其实不复杂,核心就几步:
- 打开
/dev/ashmem设备,得到一个文件描述符 - 通过
ioctl设置名字和大小 - 用
mmap把这个fd映射到进程地址空间 - 通过Binder把这个fd传给其他进程
- 其他进程同样用
mmap映射,就共享了同一块物理内存
代码示例大概是这样:
#include <fcntl.h>
#include <linux/ashmem.h>
#include <sys/ioctl.h>
int create_ashmem(const char *name, size_t size) {
int fd = open("/dev/ashmem", O_RDWR);
if (fd < 0) return -1;
// 设置名字(可选,方便调试)
ioctl(fd, ASHMEM_SET_NAME, name);
// 设置大小
ioctl(fd, ASHMEM_SET_SIZE, size);
return fd;
}
// 使用示例
int fd = create_ashmem("my_shared_buffer", 4096);
void *ptr = mmap(NULL, 4096, PROT_READ | PROT_WRITE,
MAP_SHARED, fd, 0);
// 现在可以读写ptr指向的内存了
// 通过Binder把fd传给另一个进程...
小提示:Ashmem的名字不是必须的,但我建议你每次都设置。为什么?因为用dumpsys meminfo或者看/proc/pid/maps的时候,名字能帮你快速定位是哪块共享内存。我曾经在排查问题时,就是靠这个名字找到了一个第三方库偷偷分配的Ashmem。
17.5 Ashmem的pin/unpin机制
这是Ashmem最特别的地方。普通mmap映射的内存,内核没法主动回收。但Ashmem可以:
- pin:锁定物理页面,保证可用
- unpin:解锁物理页面,内核可以在内存紧张时回收
当你unpin了一页,内核可能把它回收掉。下次再访问时,会触发缺页中断,重新分配一页零填充的物理内存。数据当然就丢了。所以unpin之前,你得确保数据已经不再需要,或者已经同步到其他地方了。
警告:不要随意unpin别人正在使用的Ashmem页面。我曾经见过一个bug:A进程unpin了共享内存,B进程毫不知情,继续读写,结果读到全是零。排查了两天才找到原因——就是pin/unpin状态没同步好。
17.6 知识体系总览
下面这张图帮你理清mmap、匿名共享内存和Ashmem之间的关系:
17.7 实际应用场景
Ashmem在Android里用得非常多。我随便列几个:
| 场景 | 说明 |
|---|---|
| SurfaceFlinger | 图形缓冲区共享,App和SurfaceFlinger之间传递图像数据 |
| Binder传输大数据 | 超过1MB的数据用Ashmem传,避免Binder缓冲区限制 |
| ContentProvider | 跨进程共享文件内容时,用Ashmem做中间缓冲 |
| Zygote预加载 | Zygote进程用Ashmem共享预加载的类和资源 |
你想想看,如果没有Ashmem,这些场景要么用传统IPC反复拷贝数据,要么用POSIX共享内存但没法控制内存回收。Android选择Ashmem,说白了就是为了在性能和内存管理之间找到平衡。
总结一下:mmap是底层机制,匿名共享内存是它的一个应用模式,而Ashmem是Android对这个模式的增强实现。理解这三者的关系,你就能明白Android为什么能在有限的内存里跑得这么流畅。
好了,这一章就到这里。记住:下次你在Android上做跨进程大数据传输时,第一个想到的应该是Ashmem,而不是自己写个socket或者管道。用对了工具,事半功倍。
公众号:蓝海资料掘金营,微信deep3321