MAT 工具显示 200 MB 位图泄漏,如何定位是图片库还是业务代码
解读
面试官抛出“MAT 报 200 MB 位图泄漏”这一场景,核心想考察两点:
- 能否把“内存泄漏”这一抽象结论拆成可落地的排查动作;
- 能否在“图片库”与“业务代码”之间做责任划分,给出让开发信服的数据。
国内互联网节奏快,性能测试工程师往往要“即插即用”——白天压测、晚上出报告、第二天就要给结论。因此答案必须兼顾“快”与“准”:既要体现对 Android 内存模型、图片加载链路的深度理解,又要给出 30 分钟内可跑完的实操步骤,最后还要能翻译成开发可改的方案。
如果仅回答“看 GC Root 路径”或“用 LeakCanary”会被认为太泛;如果直接说“换库”又显得武断。正确姿势是:用 MAT 做“定量”→ 用“运行时插桩”做“定性”→ 用“最小复现用例”做“归责”。
知识点
-
Android 位图内存分配演进
- 8.0 以前:Pixel 数据在 native,java 层只有 32 B 的引用;MAT 里看到的 size 很小,但 Retained 巨大。
- 8.0 及以后:Bitmap 全部搬到 Java Heap,MAT 直接能看到 200 MB。
→ 先确认系统版本,避免“看走眼”。
-
MAT 关键视图
- Dominator Tree:按 Retained Heap 排序,一眼锁定大对象。
- Path to GC Roots:exclude weak/soft,看“谁是最后一根稻草”。
- Histogram + Regex:“.bitmap.”过滤,配合 Retained 排序。
-
图片库加载链路
- Glide:Engine → ActiveResources → WeakRef;BitmapPool 用 Lru 算法复用。
- Coil:基于 Kotlin 协程,内存缓存是 WeakMemoryCache + StrongMemoryCache 两层。
- Fresco:底层 Skia 解码,native 层有 Ashmem 兜底,Java 层仅持 CloseableReference。
了解各库引用特征,才能在 Path to GC Roots 里一眼认出“这是 Glide 的 ActiveResources”还是“这是业务自己 new 的”。
-
运行时辅助工具
- adb shell dumpsys meminfo packagename -d:看 “Graphics” 与 “Unknown” 项,确认 native 是否也泄漏。
- Android Studio Profiler:dump Java Heap 后自动做“Activity/Fragment 泄漏”检测,可与 MAT 交叉验证。
- 字节码插桩:在图片库 decode 处加 AOP,记录调用栈与宽高,10 分钟就能画出“哪个业务模块、哪张图、多大尺寸”的 TopN 表格。
-
国内常见“坑”
- 列表滚动时 setImageResource(resId) 而不是 clear;Glide 默认会复用,但业务把 ImageView 放到静态 Map 里,导致整个 Activity 被钉死。
- 后台接口返回 4K 原图,前端只展示 100×100 缩略图;解码后 200 MB 瞬间吃满。
- 低端机(720P)跑高端机(2K)脚本,图片尺寸未做分级,导致内存放大系数 4×。
答案
现场回答采用“三步法”,每一步都给出可落地的命令或截图位置,时间控制在 30 分钟内。
第一步:确认 MAT 结论是否“真泄漏”
- 用 Dominator Tree 找到 Retained Heap ≈ 200 MB 的 android.graphics.Bitmap 对象;
- Path to GC Roots → exclude weak/soft,若仍有一条到 GC Root 的强引用链,则判定“无法回收”,属于泄漏;
- 记录对象数量:若只有 1~2 张,但单张 100 MB,多半是“大图未采样”;若 100 张、每张 2 MB,则偏向“未释放”。
第二步:30 秒区分“图片库”还是“业务”
- 看引用链关键词:
- 出现 com.bumptech.glide.load.engine.ActiveResources 或 com.facebook.imagepipeline… → 图片库框架层;
- 出现 xxx.xxx.ui.adapter.ImageBannerAdapter 里 HashMap<ImageView, Bitmap> 这种自定义容器 → 业务代码;
- 若引用链终止在 android.widget.ImageView.mBackground 或 mSrc,且 ImageView 被静态字段持有 → 业务泄漏;
- 若引用链终止在 BitmapPool 但 pool 大小远超配置(Glide 默认按屏幕大小 2× 计算),则可能是库缓存策略被业务篡改,仍算“业务责任”。
第三步:给出“可复现、可量化、可推动”的证据包
- 用 Android Studio Profiler 连续 dump 3 次,每次间隔 5 分钟,生成 hprof;
- 用 MAT 的 Compare Basket 功能,做“快照差分”,只看 +Bitmap 的对象,排除缓存抖动;
- 写 10 行 Python(jhat 或 hprof-parser)把差分后的 Bitmap 按 width×height×config 计算理论内存,与 MAT 实际值误差 <5% 即锁定“未采样”;
- 把最大一张图的调用栈贴出来:
- 如果是 Glide.with(fragment).load(url).into(imageView),且 imageView 尺寸 300×300,但 decode 出 2160×3840,则证明“业务没传 override()”;
- 如果是业务自己 BitmapFactory.decodeResource(res, id),则直接定位到行号;
- 输出一页 PPT:
- 标题:200 MB 位图泄漏根因报告
- 结论:业务模块 A 的 BannerAdapter 未使用 override()/未回收静态 Map,导致 47 张 4K 图无法释放,占 189.3 MB
- 修复:① 加 override(300,300) ② onViewRecycled 里主动 release ③ 静态 Map 改为 WeakHashMap
- 收益:低端机 OOM 率从 2.3% 降至 0.1%,滑动卡顿下降 30 ms
拓展思考
-
如果 MAT 发现 Bitmap 被 Native 层 malloc 引用(8.0 之前场景),而 Java 层只有 32 B,如何继续定位?
答:用adb shell am dumpheap -n生成带 native 的 hprof,再用 LLVM 的 AddressSanitizer 或 Android 10+ 的 Heapprofd 抓 native 分配,配合符号表即可看到 libhwui 或 libskia 的调用栈。 -
当图片库升级后,老版本缓存目录未清空,导致双份 Bitmap 常驻,如何量化?
答:在版本升级前后用dumpsys meminfo对比 “Graphics” 项,并用ls -l /data/data/packagename/cache/image_manager_disk_cache看目录大小;若升级后目录翻倍且时间戳仍被引用,即可推动在 UpgradeReceiver 里做缓存淘汰。 -
线上灰度阶段,如何 1% 采样就能提前发现此类泄漏?
答:在 App 启动时注入 MemoryPressureListener,当 Java Heap 使用率 > 85% 且连续 3 次 GC 后仍 > 80%,自动 dump hprof 并上传至 OSS;服务端用 MAT 脚本批量解析,Retained Heap > 100 MB 且 Bitmap 占比 > 60% 即报警,30 分钟内可回滚版本。