Android mmap内存映射:原理、OOM优化与大文件读写实战
1. 从一次OOM排查说起mmap在Android里到底藏得多深刚入行那会儿我以为Android内存映射mmap是个离应用层很远的东西属于那类面试会问、干活用不上的知识点。直到有次做一个相册类App用户反馈说滑到第三屏就闪退日志里全是java.lang.OutOfMemoryError。我当时的做法很朴素——把图片一张张read进byte[]再解码逻辑上没毛病但一台4G内存的机器硬是被我这么读几十张大图给撑爆了。后来把这条链路改成mmap映射文件、按需解码同样的机型同样的图片量RSS稳得像一条直线。也是从那次开始我才认真去翻/proc/pid/maps去看Binder驱动怎么用mmap做一次拷贝去看ART怎么把dex文件映射进进程地址空间。越看越发现内存映射几乎是Android这座大厦的地基之一只是平时被封装得很好不到出问题的时候你压根感觉不到它。这篇内容我打算把这几年摸到的东西整理一遍。适合谁写过Android应用、懂一点JNI、想搞明白为什么我的进程虚拟内存几十个G但物理内存才几百M的开发者也适合做音视频、大文件处理、性能优化方向的同学。看完你至少能搞清楚三件事mmap到底怎么工作、Android里哪些地方在偷偷用它、以及你自己该在什么场景下用它、怎么用不翻车。提醒本文所有原理描述和代码都基于AOSP的公开实现和POSIX标准示例代码在真机和模拟器上都跑过参数建议仅供参考具体以你目标平台的页大小和内核版本为准。2. mmap到底在干什么一次借地址不借数据的操作2.1 把文件页塞进你的虚拟地址空间先说结论mmap不会立刻读文件。它做的是在进程的虚拟地址空间里找一段空闲区间建立一段这段虚拟地址对应这个文件的这一段偏移的映射关系然后返回首地址。整个过程里文件内容一个字节都没被搬到内存。这就像你在图书馆办了一张借书卡卡上写着三楼A区第7排第3本但你根本没走过去把那本书拿下来。真正去取书是等你翻到某一页的时候。Linux下的函数原型长这样void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset); int munmap(void *addr, size_t length); int msync(void *addr, size_t length, int flags);几个参数里最容易踩坑的是offset——它必须是页大小的整数倍不是的话直接返回MAP_FAILEDerrno给EINVAL。length则会被内核向上取整到页边界比如你传100字节实际映射了一整页4096字节ARM64也可能是16384字节这个后面单独讲。prot控制访问权限常见组合是PROT_READ、PROT_READ|PROT_WRITE、PROT_NONE。flags里最关键的是两个MAP_SHARED你的写入会同步回文件其他映射了同一文件的进程也能看到。MAP_PRIVATE写时复制COW你改了只是改了你自己那份副本文件本身不动。还有一个MAP_ANONYMOUS不关联任何文件纯粹要一块内存glibc的malloc在大块分配时底层就会走这条路准确说是brk或mmap匿名映射二选一。2.2 缺页中断真正的数据搬运工映射建立之后你第一次访问某个地址CPU会触发一个缺页中断内核这时候才去页缓存里找对应的文件页。找到了就直接把页表项指过去没找到就从磁盘读进来放进页缓存再指过去。理解这一点非常关键因为它决定了mmap的两个特性第一次访问慢后续访问快。第一次是真实的磁盘IO之后都命中页缓存纯内存操作。内存可以被回收。这些页是file-backed的系统内存紧张时内核可以直接把它们丢掉下次访问再读一遍。而new出来的堆内存不行那是匿名页丢掉就没数据了只能压缩或者换出。这也是为什么我说mmap能解决OOM——你映射一个500MB的文件进程的虚拟内存VSZ直接涨500MB但物理内存RSS可能只有几MB因为你只碰了开头那几页。系统压力大了那些页还能被丢掉。同样500MB用read读进byte[]那是实打实的500MB匿名内存除非被杀进程否则谁也拿不走。2.3 一个容易忽略的细节映射完之后fd可以关掉很多人的代码里mmap之后一直攥着fd不放生怕一关映射就失效。其实不需要。mmap建立映射之后内核的vm_area_struct已经持有对文件的引用fget你把fdclose掉完全不影响映射的有效性。真正影响映射的是munmap或者进程退出。不过这里有个坑如果你close了fd后续就没法用msync了吗不是msync用的是映射的起始地址跟fd没关系。但如果你想用fstat再查文件大小那就得重新打开。所以我的习惯是先open fstat拿到大小mmap然后立刻close代码路径最干净。2.4 私有映射和共享映射选错了会很难受我见过有同事用MAP_PRIVATE映射一个日志文件然后往里写写完发现文件还是空的一脸茫然地来问我。这就是没搞清楚COW语义——MAP_PRIVATE的写入只在你的进程里可见内核会给你分配新页文件本身一个字节都不变。选型其实很简单场景推荐flags原因只读大文件图片、模型、日志MAP_PRIVATE语义就是只读COW不会被触发等价只读多进程共享一块内存MAP_SHARED 匿名改动互相可见修改文件内容并落盘MAP_SHARED需要写回加载可执行代码so等MAP_PRIVATEPROT_EXEC系统加载器就这么干的另外提一句msync它只对MAP_SHARED有意义。MS_SYNC会阻塞到数据真正落盘MS_ASYNC只是把页标记为脏、排队等待回写。如果你写入之后马上要断电测试、或者要让另一个进程立刻看到必须用MS_SYNC。3. Android体系里mmap的四大主战场3.1 Binder靠mmap把两次拷贝压成一次Binder的高效本质上就是内存映射换来的。流程大概是这样应用进程启动时ProcessState会open(/dev/binder)然后调一次mmap把驱动分配的一块内核缓冲区映射到自己的用户空间。回忆一下第2节的原理——这块内核缓冲区同时在内核空间和用户空间各有一份映射物理页是同一份。于是跨进程通信就变成了发送方把数据copy_from_user到内核那块映射区第一次拷贝接收方读的时候直接用自己用户空间的地址读零拷贝。如果没有mmapBinder就得是用户态→内核态→再回用户态两次拷贝慢一倍不止。这块缓冲区的大小AOSP里定义在ProcessState.cpp#define BINDER_VM_SIZE ((1 * 1024 * 1024) - sysconf(_SC_PAGE_SIZE) * 2)也就是1MB减去两个页留点余量。有些系统会通过binder:vm_size这个属性或者BINDER_VM_SIZE的编译配置来放大它跑大对象传输的定制ROM常见做法。注意正因为每个进程的Binder缓冲区就这么大单次Transaction的数据上限是有限制的大约1MB减去一点。你要传大Bitmap得走ashmem或者ParcelFileDescriptor而不是硬塞进Binder。传输超限会抛TransactionTooLargeException这个异常很多人误以为是内存不足其实是缓冲区溢出。3.2 AshmemAndroid为共享内存专门开的一扇门标准Linux有shmget/shmat这一套System V共享内存也有memfd_create。Android早期自己造了个/dev/ashmem用ioctl设置名字和大小然后mmap出来。好处是内核可以按需回收这些页——低内存时把没人用的共享内存区直接丢掉这叫unpinned ashmem。配合ASHMEM_PIN/ASHMEM_UNPIN系统能在内存和性能之间做平衡。到Android 10之后AOSP把ashmem的底层实现悄悄换成了memfd_create加文件链接的方式在libcutils里做了封装上层APIashmem_create_region的签名完全没变。所以如果你在维护老代码不用急着改但新项目我建议直接用memfd_create毕竟是标准Linux路线行为更可预测。3.3 图形系统Gralloc、SurfaceFlinger和那堆1024x1024做音视频或者相机开发的同学一定见过GraphicBuffer。它的底层就是一块dmabuf早期是ION通过mmap映射到用户空间让CPU能读写同时GPU也能通过dmabuf的fd直接访问。这带来的一个直接后果是CPU和GPU共享同一块物理内存不需要来回拷贝。你拍照预览时那一帧从CMOS出来进ISP再进GPU处理再交给SurfaceFlinger合成全程靠的就是这个共享映射。如果每一环都memcpy一次1080p 30fps的功耗能直接把手机烧成暖手宝。Android 12以后ION被DMA-BUF Heaps取代但mmap的用法没变。这块内容展开能写一整篇这里先点到为止。3.4 文件加载dex、oat、vdex和SQLite都在这条路上ART虚拟机加载一个APK会把里面的classes.dex、classes.oat、classes.vdex全部用mmap映射进来。你在/proc/pid/maps里能看到类似这样的行7a2f3c0000-7a2f6c0000 r--p 00000000 fe:0a 1234 /data/app/com.xxx/base.apk中间的r--p表示只读私有映射这就是dex区域。这样做的收益很直接dex文件页是file-backed的系统内存紧张时可以丢弃下次用到再读回来。而且多个进程比如同一个App的多个进程加载同一个APK时物理页是共享的内存占用只算一份。SQLite也在这条路上。Android从某个版本开始默认给数据库连接打开了mmap模式读操作可以直接在映射区上做省掉了read()系统调用和用户态缓冲区拷贝。你可以自己验证PRAGMA mmap_size;返回0说明没开返回正数就是当前mmap窗口大小。具体默认值随版本变动我在Android 11和13上实测同一张表拿到的数字不一样所以别背数值直接查最靠谱。4. 手把手实操在Android上写一个mmap读写大文件的小Demo4.1 NDK侧C层完整实现先说明一下这个Demo我特意写了错误处理因为mmap的错误处理是大多数人最容易糊弄过去的——return一个MAP_FAILED然后继续用不崩才怪。顺便提醒一句如果你还没配好NDK环境先用Android Studio的SDK Manager把CMake和NDK勾上。#include jni.h #include fcntl.h #include unistd.h #include sys/mman.h #include sys/stat.h #include string.h #include errno.h #include android/log.h #define TAG MmapDemo #define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, TAG, __VA_ARGS__) // 把data写入path指向的文件会用mmap覆盖已有内容 int write_via_mmap(const char *path, const void *data, size_t data_len) { int fd open(path, O_RDWR); if (fd 0) { LOGE(open failed: %s, strerror(errno)); return -1; } struct stat st; if (fstat(fd, st) ! 0) { LOGE(fstat failed: %s, strerror(errno)); close(fd); return -1; } // 关键文件必须足够大否则访问超出文件末尾的页会收到SIGBUS if ((size_t)st.st_size data_len) { LOGE(file too small: %lld %zu, (long long)st.st_size, data_len); close(fd); return -1; } size_t map_len data_len; // 内核会自动向上取整到页大小 void *addr mmap(NULL, map_len, PROT_READ | PROT_WRITE, MAP_SHARED, // 需要写回文件 fd, 0); // offset必须页对齐这里是0 if (addr MAP_FAILED) { LOGE(mmap failed: %s, strerror(errno)); close(fd); return -1; } // 映射建立后fd就可以关了映射依然有效 close(fd); memcpy(addr, data, data_len); // 确保数据落盘。只对MAP_SHARED有效 if (msync(addr, map_len, MS_SYNC) ! 0) { LOGE(msync failed: %s, strerror(errno)); } if (munmap(addr, map_len) ! 0) { LOGE(munmap failed: %s, strerror(errno)); return -1; } return 0; }对应读取的版本把PROT_READ|PROT_WRITE换成PROT_READ、flags换成MAP_PRIVATE就行其余一样。这里我不贴重复代码了。4.2 Java层FileChannel.map的甜与坑不想写JNI的话Java NIO也提供了映射接口try (RandomAccessFile raf new RandomAccessFile(file, rw); FileChannel channel raf.getChannel()) { long size channel.size(); MappedByteBuffer buffer channel.map( FileChannel.MapMode.READ_WRITE, 0, size); buffer.put(0, (byte) 0x42); buffer.force(); // 等价于msync(MS_SYNC) } catch (IOException e) { Log.e(MmapDemo, map failed, e); }看起来挺美但这里有两个真实的坑我必须单独拎出来讲。坑一Java层没有公开的unmap。映射的释放依赖GC和Cleaner你完全控制不了时机。后果就是文件句柄被映射占着你调用File.delete()会返回false在某些机型上反复映射大文件虚拟内存地址空间碎片化最终map抛IOException: Map failed。Android上要手动释放可以反射调用FileChannelImpl.unmapprivate static void unmap(MappedByteBuffer buffer) { if (buffer null) return; try { Class? cls buffer.getClass(); Method cleanerMethod cls.getMethod(cleaner); cleanerMethod.setAccessible(true); Object cleaner cleanerMethod.invoke(buffer); if (cleaner ! null) { Method cleanMethod cleaner.getClass().getMethod(clean); cleanMethod.setAccessible(true); cleanMethod.invoke(cleaner); } } catch (Throwable t) { Log.w(MmapDemo, unmap via cleaner failed, t); } }需要说明的是cleaner这个字段在ART和OpenJDK上的实现细节不一样有的版本是返回Cleaner对象有的是直接返回Runnable所以这段代码要加try-catch兜底拿不到就走GC。所以做严肃的大文件场景我基本都建议直接走NDK的mmap/munmap别在Java层赌反射。坑二MapMode.READ_WRITE的映射如果文件被别的地方截断了照样SIGBUS。这个跟语言无关是mmap本身的语义。4.3 我实测的量级对比在同一台骁龙8系机器上对一个128MB的文件做顺序写用三种方案各跑5次取中位数数据仅供参考实际跟文件系统和闪存状态关系很大方案耗时量级堆内存峰值备注FileOutputStreamBufferedOutputStream约1.0x中等约几MB缓冲区数据要经过用户态缓冲区RandomAccessFile逐块写约1.3x低系统调用次数多mmapmemcpy约0.8x极低几乎不占匿名内存大文件优势明显注意这里的堆内存峰值才是重点。小文件几百KB三者差异不大mmap甚至因为缺页中断的开销略慢。文件越大mmap越划算这个交叉点我一般按1MB来估低于这个数用普通IO更省心。5. 选型决策什么时候该用mmap什么时候别碰5.1 三种方案横向对比维度read/writemmapsendfile/splice数据拷贝2次内核→用户→内核0次页表映射0次内核内流转随机访问需要自己seekread直接按偏移访问不支持内存占用显式缓冲区可回收的file-backed页无适用场景小文件、流式处理大文件随机读、共享内存文件间零拷贝转发崩溃风险低SIGBUS/SIGSEGV需要处理低一句话总结选型思路你要随机读写一个大文件或者多个进程共享同一份数据mmap是最优解。你只是从头到尾顺序读一遍而且文件不大老实read就行代码简单还不容易错。你是在做代理转发、文件复制考虑sendfile那个才是真正的零拷贝终点。5.2 几个容易忽略的参数细节页大小不是常量。别在代码里硬写4096。ARM64设备上现在有4KB和16KB两种页配置Android 15开始明确要求应用支持16KB页。拿页大小的正确姿势long page_size sysconf(_SC_PAGESIZE);或者编译期用getpagesize()。为什么重要如果你的offset是4096对齐但设备页大小是16384mmap照样给你EINVAL。这个bug在4KB机上永远复现不了一上16KB的机器就炸排查起来能让人怀疑人生。MAP_POPULATE可以提前触发预读。加上这个flagmmap会同步把所有页都读进来。适合你确定马上要全量遍历的场景能省掉大量缺页中断。代价是mmap这一次调用会阻塞较久启动耗时敏感的地方慎用。madvise能给内核打小报告。遍历大文件之前调一次madvise(addr, len, MADV_SEQUENTIAL); // 我要顺序读帮我多预读几页 madvise(addr, len, MADV_RANDOM); // 我随机访问别浪费IO预读 madvise(addr, len, MADV_DONTNEED); // 这段我用完了页可以回收MADV_SEQUENTIAL和MADV_RANDOM我实测在顺序扫一个几十MB的模型文件时能拉开10%到20%的差距属于改一行代码就见效的优化。6. 踩坑实录Android上mmap最常见的几个问题6.1 SIGBUS99%是文件被截断了SIGBUS是mmap最容易让人懵的崩溃日志里就是一行Native crash堆栈指向你访问映射地址的那一行看不出任何上下文。它的根因基本只有一个你访问的那个地址对应的文件区域已经不存在了。常见触发方式有四种你映射了文件另一个进程或者你自己把文件truncate变小了。你在文件末尾附近访问了最后一页多出来的部分。举个例子文件大小是4096*3100字节你映射了整个文件内核会映射4页第4页只有100字节有效剩下3996字节是补零的。访问这3996字节会不会崩取决于文件系统实现——在ext4上通常还好但在某些文件系统上就会SIGBUS。存储空间写满内核回写脏页失败。网络文件系统或者FUSE挂载点断连。排查思路我总结成一句话先看崩溃地址落在哪个映射区间再看那个文件现在多大。adb shell ps -A | grep com.example.app # 拿到pid adb shell cat /proc/pid/maps | grep -i yourfile adb shell ls -l /path/to/your/file6.2 SIGSEGV和权限失败的区分SIGSEGV是访问了完全没映射的地址通常是偏移算错了或者映射长度不够。比如你映射了0到1000字节结果代码里写了个buffer[2000]。还有一种容易被忽略的情况Android 10以后对可执行映射做了严格限制W^X策略。你如果试图mmap一个可写文件并给它PROT_EXEC会直接失败errno给EACCES或EPERM。这也是为什么动态加载代码的路子在Android 10之后必须改成匿名可执行内存mmapPROT_READ|PROT_WRITE→mprotect加PROT_EXEC不能再靠映射so文件了。6.3 别被虚拟内存吓到VSZ大不代表内存泄漏这个我吃过亏。早期用Android Studio的Profiler看内存发现VSZ动不动几十GB吓得我以为是哪里泄漏了查了半天才明白——VSZ本来就该很大。原因在于每个进程都会映射一堆东西ART的dex/oat/vdex、Binder缓冲区、系统so、字体文件/system/fonts下面那一大堆、时区数据tzdata……全加起来几个G很正常。而且mmap的映射是按虚拟地址算的跟实际用量没关系。看内存要看三个指标指标含义什么时候关心VSZ虚拟内存映射总大小排查地址空间碎片、map失败的参考RSS常驻内存实际占用的物理页判断真实内存压力的主指标PSS按共享比例分摊后的RSS系统统计App内存占用用它所以排查内存问题的正确顺序是先看PSS有没有涨涨了再看/proc/pid/smaps里哪一段在涨。别一上来就盯着VSZ。6.4 常用排查命令速查表目的命令说明看进程的所有映射adb shell cat /proc/pid/maps每行一个vma含权限、偏移、文件路径看每个映射的内存明细adb shell cat /proc/pid/smaps比maps详细有RSS/PSS/Shared字段只想要汇总adb shell cat /proc/pid/smaps_rollup省得翻几千行按大小排序看映射adb shell showmap pidAndroid自带输出比手撸脚本友好看App内存总览adb shell dumpsys meminfo pkgNative Heap / Dalvik Heap / Graphics分开列showmap这个工具很多人不知道它是AOSP自带的输出按占用的PSS或RSS排序一眼就能看出是哪个映射吃掉了内存。我最常用它来看是不是Fonts或者某个apk的dex异常膨胀。提示/proc/pid/maps里的路径名可能被截断成/data/app/~~xxxx/com.xxx-yyy/base.apk这种带随机后缀的形式这是正常现象别以为文件被误删了。7. 几个只有踩过才知道的实战经验7.1 映射之前先把文件撑到目标大小这一条是我用血换来的。你要往一个新文件里写100MB数据绝对不要open完就直接映射文件大小是0映射完一写就SIGBUS。正确做法是先ftruncate到目标大小int fd open(path, O_RDWR | O_CREAT, 0644); ftruncate(fd, target_size); // 先占位再映射 void *addr mmap(NULL, target_size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);ftruncate不会真的写数据只是在文件系统里把长度标记上稀疏文件速度快得很。这一步在下载类、缓存类场景里几乎是标配。7.2 一个文件别同时开太多映射有的同学做图片加载每张图都channel.map一次加载两百张图就是两百个映射。Linux对单个进程的vma数量是有限制的vm.max_map_count默认65530而且每个vma本身有内核开销。我的做法是复用设一个固定大小的映射窗口滑动着读大文件而不是整个文件一次映射完。这个模式在处理超大日志或者地图瓦片的时候特别管用。7.3 小心munmap之后的野指针munmap之后那块地址就还给内核了。如果你的代码里还留着addr指针继续用那就是纯纯的use-after-free而且因为地址空间还在编译器不会给你任何警告崩得毫无规律。我的习惯是封装成一个RAII类C或者用try-finally包住Java/NIO确保munmap和指针的生命周期绑在一起。在C里我会额外把指针置NULL虽然不能根治但至少能多一层保险。7.4 低内存设备上mmap是好兄弟前面反复提过file-backed页可以回收。这句话在Android上意味着mmap映射的内存在lmkd低内存杀手扫描时不会算作不可回收所以你的进程更不容易被杀。反过来说如果你把一个200MB的文件read进byte[]那200MB就是实打实的匿名内存系统压力一大第一个被砍的可能就是你。做相机、相册、文档预览这类App的同学可以拿这个思路去做优化。7.5 关于跨进程不要自己造轮子做共享内存有次我看到有团队为了在App的两个进程之间共享一份配置自己用MAP_SHARED映射了一个文件。跑是能跑但并发控制全靠猜——一个进程写一半、另一个进程读到了中间态数据直接乱掉。如果两个进程真的需要共享内存标准路线是走ashmem/memfd配合同步机制futex或者Binder回调。文件映射做共享内存不是说不行而是你要自己处理一致性、崩溃恢复、加锁这一堆问题。除非有明确理由否则我建议直接用系统提供的方案。写了这么多最后再分享一个小技巧如果你在Android Studio里调试native代码想验证某个映射到底有没有生效可以直接在LLDB里敲image lookup -a 地址或者用vmmap命令看当前进程的映射快照。这个排查起来特别快比一行行翻/proc/self/maps高效得多。我个人在查SIGBUS的时候基本就靠这一套组合拳先看崩溃地址再用vmmap定位区间最后去查对应的文件状态三步下来基本没有找不到的根因。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →