6、内存优化:内存抖动检测与Bitmap复用

内存优化这个话题,说实话我每次讲都觉得又爱又恨。爱的是它带来的性能提升立竿见影,恨的是排查起来有时候真能把人逼疯。今天咱们就聊聊内存抖动、Bitmap复用和Native内存泄漏这三个硬骨头。

6.1 内存抖动:那个让界面卡成PPT的元凶

内存抖动,说白了就是短时间内频繁创建和销毁对象。GC频繁触发,就像有人在后台不停地打扫卫生,每次打扫都要暂停你的主线程。你想想看,界面能不卡吗?

我在项目中遇到过最典型的场景:一个列表滑动页面,每帧都在创建新的对象。用户滑得越快,GC越频繁,最后卡得连手指都跟不上。嗯,这就是典型的内存抖动。

用Profiler抓内存抖动

Android Studio自带的Profiler其实挺好用的。我个人习惯这样操作:

  1. 打开Profiler,选择Memory面板
  2. 录制一段操作,比如快速滑动列表
  3. 观察内存曲线——如果出现锯齿状波形,基本就是内存抖动了

关键指标:GC频率超过每秒5次,或者单次GC耗时超过10ms,就需要警惕了。

用MAT做深度分析

Profiler只能告诉你「有问题」,但具体问题在哪,还得靠MAT。我记得有一次排查一个图片加载的抖动问题,Profiler上看到内存曲线像心电图一样,但就是定位不到具体代码。后来用MAT dump了堆快照,发现是某个工具类里每次调用都new了一个byte数组。

MAT的使用步骤其实不复杂:

  1. 在Profiler中点击「Dump Java Heap」
  2. 导出hprof文件,用Android Studio的转换工具转成标准格式
  3. 用MAT打开,重点看「Dominator Tree」和「Histogram」

小技巧:在Histogram中按Retained Heap排序,最大的那几个对象往往就是问题所在。我曾经靠这个找到过一个缓存了整张图片的HashMap,直接省了30%的内存。

6.2 Bitmap内存复用:别让图片吃光你的内存

Bitmap是Android内存消耗的大户。一张1920x1080的图片,加载到内存里就是1920*1080*4字节,差不多8MB。你想想看,如果列表里同时显示10张图,那就是80MB。这还没算上其他开销。

Bitmap复用池的设计

我个人习惯用LruCache配合复用池来做。核心思路是:不重复创建Bitmap,而是从池子里拿已经用过的。

public class BitmapPool {
    private final LruCache<String, Bitmap> cache;
    private final Set<Bitmap> reusableBitmaps;
    
    public Bitmap get(int width, int height, Bitmap.Config config) {
        // 先从复用池找
        for (Bitmap b : reusableBitmaps) {
            if (canReuse(b, width, height, config)) {
                reusableBitmaps.remove(b);
                b.eraseColor(Color.TRANSPARENT);
                return b;
            }
        }
        // 找不到就创建新的
        return Bitmap.createBitmap(width, height, config);
    }
    
    private boolean canReuse(Bitmap b, int w, int h, Bitmap.Config c) {
        return b.getWidth() >= w && b.getHeight() >= h 
               && b.getConfig() == c;
    }
}

注意:复用Bitmap时,一定要检查宽高和配置。我见过有人直接复用不同尺寸的Bitmap,结果图片拉伸变形,用户反馈说「这App是不是瞎了」。

inBitmap的妙用

Android 3.0之后提供了inBitmap选项,可以让Bitmap解码时复用已有的内存。说白了就是告诉系统:「别给我分配新内存了,用这块旧的就行」。

BitmapFactory.Options options = new BitmapFactory.Options();
options.inMutable = true;
options.inBitmap = pool.get(); // 从池子里拿一个可复用的
Bitmap bitmap = BitmapFactory.decodeResource(getResources(), 
                                             R.drawable.image, options);

这里有个坑:inBitmap的尺寸必须大于等于要解码的图片。我曾经在这个问题上栽过跟头,解码一张小图时复用了大图的Bitmap,结果直接崩溃。嗯,从那以后我每次都会做尺寸校验。

6.3 Native内存泄漏:那个看不见的杀手

Java层的内存泄漏,MAT和Profiler都能抓到。但Native层的内存泄漏,说实话排查起来要麻烦得多。我遇到过最头疼的一次:一个视频播放器,播放半小时后内存涨了200MB,但Java堆只有50MB。剩下的150MB去哪了?答案就是Native堆。

排查Native内存泄漏的工具

我个人常用的工具链是这样的:

工具 用途 适用场景
AddressSanitizer 检测内存越界和泄漏 Debug版本
Malloc Debug 跟踪malloc/free调用 定位具体泄漏点
SimplePerf 分析Native内存分配 性能分析

实战:用Malloc Debug定位泄漏

我记得有一次排查一个OpenGL纹理泄漏的问题。纹理对象在Native层分配,Java层根本看不到。用Malloc Debug的步骤是这样的:

  1. 在AndroidManifest中开启Malloc Debug:android:debuggable="true"
  2. 用adb命令启动:adb shell setprop libc.debug.malloc 1
  3. 运行App,复现问题
  4. 抓取backtrace:adb shell dumpsys meminfo

核心思路:Native内存泄漏的排查,关键是要找到「谁分配了内存但没释放」。Malloc Debug能记录每次malloc的调用栈,配合脚本分析,就能定位到具体的代码行。

避免Native泄漏的编码习惯

说实话,Native内存泄漏最好的解决方式就是预防。我总结了几个习惯:

  • 所有Native资源都在Java层用finalize或Cleaner做兜底释放
  • JNI调用中,每次NewGlobalRef都要对应一个DeleteGlobalRef
  • OpenGL纹理、EGLSurface等资源,一定要在对应的生命周期回调中释放

个人经验:我习惯在Native代码里加一个引用计数。每次分配资源时计数+1,释放时-1。如果App退出时计数不为0,就说明有泄漏。这个方法虽然土,但真的管用。

6.4 知识体系总览

说了这么多,咱们用一张图来总结一下内存优化的核心脉络:

Android内存优化知识体系 内存抖动检测 Profiler内存曲线分析 MAT堆快照深度分析 GC频率与耗时监控 Bitmap内存复用 LruCache + 复用池 inBitmap复用机制 尺寸与配置校验 图片压缩策略 Native内存泄漏 AddressSanitizer Malloc Debug跟踪 SimplePerf分析 JNI引用管理 核心原则 减少对象创建 → 复用已有资源 → 及时释放Native内存 工具辅助定位 + 编码习惯预防 = 稳定的内存表现

这张图其实就概括了我们今天聊的所有内容。内存优化没有银弹,但只要你掌握了这三个方向——抖动检测、Bitmap复用、Native泄漏排查——大部分问题都能迎刃而解。

最后说一句:内存优化这件事,工具只是辅助,真正重要的是编码时的意识。每次new一个对象之前,先问问自己:「这个对象能不能复用?」养成这个习惯,你的App内存表现不会差。


公众号:蓝海资料掘金营,微信deep3321