Android Low Memory排查实战:从dma_buf切入定位底层内存泄漏

发布时间:2026/9/28 17:16:34
Android Low Memory排查实战:从dma_buf切入定位底层内存泄漏 项目标题一出来我第一反应就是“又是个难啃的骨头”。Android的Low Memory问题十有八九不会老老实实躺在Java堆里等你用Android Studio的Profiler去抓它往往藏在Native层甚至内核层。记得年前有个项目测试反馈机器用个大半天就开始卡顿后台应用被杀得干干净净连桌面都频繁重建logcat里全是lowmemorykiller的斩杀日志。当时第一反应是检查Java堆结果各进程的Java Heap都很健康一度陷入僵局。后来把视线从应用层移开从dmesg和内核内存管理层面入手才慢慢逼近真相真正的元凶是一块块“有去无回”的dma_buf。这篇文章就把我当时排查这类Low Memory问题的完整思路和实操记录下来特别是怎么用dma_buf这个切入点顺藤摸瓜找到底层泄漏希望能帮到正在被内存问题折磨的Android系统工程师和应用开发同学。1. 先搞清楚你的设备是真的Low Memory吗很多同学一看到“内存不足”就先冲进Android Studio去抓Java Heap方向往往从一开始就偏了。Low Memory并不等于Java堆不够用它更常见的原因是系统整体可用内存包括内核管理的物理内存被吃干抹净。所以排查第一步不是打开Profiler而是先量化内存到底去哪了。1.1 别急着开Android Studio先看系统内存大盘连接设备后第一步我习惯先看几个基础指标把问题范围圈定下来。命令不复杂但每一条都有它的目的。# 查看系统整体内存状态 adb shell cat /proc/meminfo # 查看LMK杀进程的记录 adb shell dmesg | grep -i lowmemorykiller拿/proc/meminfo来说我最关注的不是MemTotal而是MemFree、MemAvailable和DmaAlloc这一类的字段。DmaAlloc这个字段在很多内核版本里就对应着dma_buf的物理内存占用它一旦涨起来不回落就是个非常危险的信号。再看dmesg里的LMK日志能看到类似这样的内容lowmemorykiller: Killing com.example.gallery (PID 1234), score 412, adj 100每次被杀进程的adj值从900逐步降到0甚至负数说明系统内存压力在持续恶化。如果日志里这种记录每隔几分钟就出现一次那么不管Java堆是否有问题底层一定存在内存黑洞。1.2 用dumpsys meminfo快速定位内存大户接下来用dumpsys meminfo把每个进程的内存占用拉出来排序看看是谁在吞噬内存。adb shell dumpsys meminfo这个命令会输出一张进程PSS排行的表像Native Heap、Java Heap、Graphics、GL等等各项都有。我遇到过不少情况Java Heap看着正常但某个进程的Graphics或者GL一项飙到几百MB这时候基本就能把怀疑对象锁定在GPU/渲染/硬件缓冲相关的分配路径上。尤其要注意Total PSS里那些数值异常的进程然后单独深挖adb shell dumpsys meminfo com.suspicious.process输出里专门有一项叫做“DMA-BUF”它会列出该进程持有的dma_buf数量和总大小。如果这个数字特别大或者持续增长那恭喜你已经摸到Low Memory问题的门口了。后面要做的就是顺着dma_buf这条线索一层层扒开它的真面目。2. dma_buf是什么凭什么它能成为排查切入点说实话很多做应用层的同学对dma_buf非常陌生甚至没听过。但它在Android系统里的地位相当于快递中转站在物流体系里的地位——所有需要跨硬件模块共享内存的搬运基本都绕不开它。2.1 一个buffer的“跨域”之旅先打个比方你在手机上拍照Camera传感器产生图像数据这些数据需要给GPU做预览渲染给ISP做算法处理最后交给编码器存成图片。传感器、GPU、ISP、编码器分别由不同硬件和驱动控制它们各自管理着自己的内存空间谁也不能直接访问谁的“私房钱”。那怎么办dma_buf就是那个“共享钱包”。它允许一个设备驱动申请一块物理内存然后把这块内存的访问权交给其他设备驱动通过fd文件描述符在不同的进程和内核模块之间传递。一个dma_buf可以同时被多个设备引用比如显示控制器在扫描这块buffer同时GPU也在往里面写数据。这么设计的核心好处有两个第一避免数据在设备间复制来复制去降低性能损耗第二实现零拷贝内存共享这对高帧率视频和相机流来说简直是生命线。2.2 从ION到dma_buf heaps的演进早期Android用的是ION内存分配器它在内核里维护着多个heap负责分配和管理可共享的内存。后来内核社区推动dma_buf成为统一标准Android也逐步向dma_buf heaps过渡像system heap、cma heap这些本质上就是让dma_buf这套机制来管理物理页面。对于排查内存泄漏的你来说并不需要把每个heap的细节都背下来但需要建立一个关键认知dma_buf的内存在内核中是被“记账”的而且这些内存大多是不可回收的普通内存或CMA内存。普通的内存页被进程申请后在内存压力下内核可以回收但dma_buf这类缓冲区一旦被某个驱动引用它对应的物理页就被锁定了kswapd再着急也没办法把它swap出去。2.3 为什么dma_buf泄漏会导致系统“低血压”这就是dma_buf泄漏的可怕之处。普通应用泄漏一个Java对象内存压力大了之后还有GC兜底实在不行LMK把那个进程杀了内存也能吐出来一部分。但如果一个系统服务或者硬件HAL泄漏了dma_buf情况就完全不同这些dma_buf的物理页被内核引用计数锁死进程被杀也不能释放内存压力只会越来越大直到系统可用内存被压到个位数MBLMK开始疯狂屠杀一切可以杀的后台进程。设备会出现应用秒退、桌面重启、动画掉帧、甚至系统重启。这种“低血压”症状不是靠优化应用逻辑能解决的必须顺着dma_buf的分配链路把钱追回来。所以dma_buf是排查底层内存泄漏一个特别理想的切入点它跨越内核态和用户态有明确的调试接口还能通过fd映射到持有它的进程。用Linux社区的说法这叫“顺着文件描述符摸到进程的命根子”。3. 实战从dumpsys到dma_buf的完整排查链路理论铺垫完毕下面进入实操。我当时的排查路径大致是先确认系统内存状况 → 再通过dma_buf的内核调试接口找到“只借不还”的buffer → 最后顺着fd定位到具体进程和调用栈。3.1 打开dma_buf的内核记账本Linux内核为dma_buf提供了一个非常直观的调试接口在debugfs里挂着路径通常是adb shell cat /sys/kernel/debug/dma_buf/bufinfo前提是设备有root权限并且内核没有禁用debugfs。如果连不上也可以用下面的命令先挂载adb shell su -c mount -t debugfs none /sys/kernel/debugbufinfo输出的内容非常详细每一行记录一个dma_buf对象包括大小size被引用次数count导出它的模块/设备名exporter name当前持有者进程的fd号如果被映射到用户态实际输出类似下面这样Dma-buf Objects: size flags mode count exp_name buf owner -------- ------ ---------------- ----- -------- ---------- ----- 421888 0000000b RW 1 ion_system 0000000012345678 camera 6324224 0000000b RW 2 ion_cma 00000000abcdef12 gralloc看到exp_name里有camera、gralloc这些字样心里大概就有数了。如果某个exporter的buffer数量在短时间内不断增长且size都不小那它基本就是泄漏源。3.2 用两份快照做差值锁定增长中的dma_buf排查泄漏最直接的办法就是对比不同时间点的bufinfo快照。我当时的做法分三步# 第一次抓快照 adb shell su -c cat /sys/kernel/debug/dma_buf/bufinfo /sdcard/buf1.txt # 等待一段时间建议30分钟到1小时期间正常操作设备 sleep 1800 # 第二次抓快照 adb shell su -c cat /sys/kernel/debug/dma_buf/bufinfo /sdcard/buf2.txt然后把两份文件拉到本地做差值分析。不需要特别复杂的工具用Python脚本或者grep sort就能搞定。adb pull /sdcard/buf1.txt ~/buf1.txt adb pull /sdcard/buf2.txt ~/buf2.txt # 简单对比各exporter的buffer数量 grep -c camera buf1.txt grep -c camera buf2.txt我这种场景下camera这个exporter的buffer数量从30分钟前的200多个涨到了500多个总大小从300MB涨到600MB而其他进程几乎没变化。这种单调递增且不下降的曲线基本坐实了泄漏。3.3 顺藤摸瓜从dma_buf的fd定位“真凶”进程光知道是camera exporter在泄漏还不够因为dma_buf最终的持有者可能是一个用户态进程这个进程没有正确释放fd。怎么找看fd。dma_buf在用户态就体现为一个文件描述符对于任何进程都可以通过/proc/[pid]/fd来查看它打开了哪些文件adb shell su -c ls -l /proc/[pid]/fd | grep dma_buf输出里你会看到类似这样的内容lrwx------ 1 root root 64 2024-01-15 10:23:45 12 - /dev/dma_buf/0000000012345678那些指向/dev/dma_buf的fd就是该进程持有的dma_buf。这个文件的编号还可以和bufinfo里的buf一一对应起来。如果某个进程持有的dma_buf fd数量一直在增加说明这个进程在不断地请求新的buffer却没有关闭旧的fd。这通常只有两种情况要么是代码逻辑bug忘了close要么是底层驱动分配新buffer后没有释放旧的。而无论哪种定位到有问题的进程就把范围缩小了一大半。我那次锁定的就是负责相机HAL的进程它在每次打开预览后都会申请一批新的dma_buf但关闭预览时只释放了一部分剩下的就变成了“僵尸buffer”被系统一直记着账。3.4 进阶用dmabuf_sysfs_stats辅助量化部分较新的内核Android 13引入了dmabuf sysfs统计不用root也能看到比较粗略的数据adb shell cat /sys/kernel/dmabuf/bufinfo另外Android的dumpsys里也有一个比较方便的统计入口adb shell dumpsys dmabuf这个命令会汇总系统中所有dma_buf的注册和导出信息输出比debugfs更友好。虽然不同厂商内核版本会有些差异但核心思路一样找增长点、找持有者、找泄漏路径。我习惯把这三个数据源交叉验证bufinfo提供物理视角fd提供进程视角dumpsys dmabuf提供Android框架视角。三个维度一对上基本就不会误判。4. 三种典型的dma_buf泄漏场景与修复姿势排查到这一步已经能确认泄漏发生在某个模块。接下来要谈的是怎么修。我在项目里遇到过三类比较高发的dma_buf泄漏在这里逐个拆解顺便附上对应的修复思路。4.1 SurfaceTexture/ImageReader忘记释放这类场景多出现在相机或者视频应用的开发中。ImageReader创建一个Surface底层会分配若干个dma_buf作为buffer队列。如果应用拿到Image之后没有调用close或者SurfaceTexture没有正确release这些buffer就会一直处于“被使用”状态驱动不会回收。尤其是在快速连拍、连续扫码这类高频使用相机的场景一两帧泄漏一个buffer积累下来就是几百MB。修复方法说起来简单Image对象用完后一定记得closeSurfaceTexture需要调用release。但问题是排查很难因为Android的GraphicBuffer架构把buffer生命周期包装得比较深Java层看不出明显的new对象。我的建议是在代码里打点每申请和释放一个Image就log一下buffer数量从日志趋势判断是否泄漏。4.2 硬件HAL层私自“保留”buffer不归还这种最坑因为它发生在供应商的闭源驱动里你连堆栈都看不到。症状表现为dumpsys meminfo里某个进程的DMA-BUF持续上涨但明显不是应用层代码导致的。我当时遇到的camera HAL泄漏就属于这类。那已经是相机模组驱动的问题每次打开camera previewHAL会从CMA heap申请几块大buffer给ISP用但关闭相机时由于驱动内部的引用计数逻辑bug有一块buffer没被释放。对于这种问题应用层和框架层的同学很难直接修复但可以用一个绕行方案兜底在检测到相机已经关闭后通过HAL提供的flush/reclaim机制强制释放缓存。如果厂商驱动不支持这么干那就只能提bug单让驱动团队修。不过作为排查者你能把bufinfo里连续增长的buffer截图、时间戳、操作步骤都整理清楚已经能帮驱动团队省下大把时间。4.3 过度使用CPU映射dma_buf且不unmap还有一种属于“新手式”泄漏。某些场景需要CPU直接操作dma_buf里的数据比如图像后处理、算法库推理等开发同学会通过mmap把内核buffer映射到用户空间。但有些人在用完数据之后只把指针置为null忘了munmap甚至忘了close fd。每一块未munmap的映射都会让内核的VMA虚拟内存区域记录一直存在对应的dma_buf引用计数也一直不归零。这种泄漏导致的内存黑洞同样会吃掉系统可用内存。排查的时候有个小技巧在/proc/[pid]/maps里搜dma_buf或deleted标记能看到进程里所有的mmap映射区域。如果发现有些映射长期存在且没有被release就需要回到代码里检查是不是漏掉了munmap。修复代码层面我一直强调RAII思想在C/C里的重要性。拿到fd之后立刻封装进智能指针或ScopedFD类里析构时自动closemmap映射也封装成对象析构时自动munmap。把资源的管理绑定到生命周期上才能从根上杜绝这类问题。5. 常见问题速查与排查工具清单这部分整理几个排查时最高频的坑和对应解法顺手放一个工具清单方便大家直接抄作业。5.1 排查dma_buf泄漏常见问题速查表问题现象可能原因定位手段解决方向dumpsys meminfo中DMA-BUF持续增长应用未释放ImageReader/SurfaceTexturebuf1/buf2快照对比、fd数量统计代码中补close/release加入buffer数量打点camera等进程的dma_buf只增不减供应商HAL内部引用计数异常cat /sys/kernel/debug/dma_buf/bufinfo聚焦exporter增长更新HAL驱动、加flush机制、提bug单进程fd中dma_buf数量暴涨代码里mmap后未munmap或未closels -l /proc/[pid]/fd pipe grep dma_buf引入ScopedFD/RAII检查mmap生命周期debugfs读不到dma_buf数据root权限不足或内核禁用debugfsmount -t debugfs debugfs /sys/kernel/debug尝试dumpsys dmabuf或sysfs接口某块dma_buf count值很高无法释放多个驱动/进程同时引用从bufinfo的count和owner字段交叉比对找引用持有者逐一排查释放路径这张表是我自己的习惯用法每次排查都对号入座效率比漫无目的地翻代码高很多。5.2 顺手好用的排查命令汇总# 看系统整体内存重点看MemFree和DmaAlloc adb shell cat /proc/meminfo # 看进程级内存分布 adb shell dumpsys meminfo # 看内核dma_buf对象详情需root adb shell su -c cat /sys/kernel/debug/dma_buf/bufinfo # 看某个进程持有的dma_buf fd adb shell su -c ls -l /proc/[pid]/fd | grep dma_buf # 看Android框架层dmabuf汇总 adb shell dumpsys dmabuf还有一个实用技巧在排查内存问题时可以用watch命令循环刷新bufinfo观察数值的动态变化而不是手动一条条敲adb shell su -c watch -n 5 cat /sys/kernel/debug/dma_buf/bufinfo | grep camera | wc -l通过这种动态监控能直接看到camera模块的buffer数量在预览打开和关闭时是否有回落。如果每次打开增加30个关闭只减少25个那平均一次操作就残留5个泄漏这个数据比任何理论分析都有说服力。5.3 一点补充当你拿不到root权限怎么办调试接口需要root但市面上不是所有测试机都有root权限。遇到这种情况我一般会退而求其次依赖dumpsys dmabuf和dumpsys meminfo这两个非特权接口做趋势分析。具体做法是写个简单的脚本每隔10秒抓一次dumpsys meminfo里的DMA-BUF总和记录到文件里然后连续跑几小时最后用Excel或者Python把曲线画出来。如果曲线整体斜率明显为正即使看不到内核细节也能确定存在dma_buf层面的泄漏这就够了。剩下的通过反复开关某个功能来二分法缩小触发源一样能找到问题代码。写在最后一点个人体会排查这种底层内存问题技术上占一半耐心和方法论占另一半。dma_buf本身只是个查找工具真正值钱的是你愿不愿意花时间去抓快照、做对比、看趋势。很多内存泄漏并不是一天爆出来的而是一点点“漏”出来的所以任何涉及buffer生命周期的模块我都建议在代码里埋好统计日志哪天系统出问题翻日志比翻源码快得多。另外不要迷信某一个工具的输出。dumpsys、bufinfo、fd列表、proc/meminfo每个工具都只展示内存问题的一个侧面。交叉验证、多维比对才是把问题定位到具体代码路径的唯一可靠办法。希望这篇实战记录能帮你在下次面对Low Memory问题时少走一些我走过的弯路。