8. Android 崩溃率直降90%!快手KOOM+Profiler+MAT组合拳暴打Bitmap泄漏

177 阅读6分钟

该案例中的图片未释放问题属于资源未释放(Resource Not Freed) ,而非典型的内存泄漏(Memory Leak)。关键区别在于:内存泄漏是对象被意外持有导致GC无法回收,而资源未释放是Java层引用已断开,但Native内存(如Bitmap像素数据)未被主动释放。尽管GC可回收Java对象,但未调用recycle()会导致Native内存累积,最终引发OOM。

1.案例: 图片没有主动释放

public class OomDestoryImageActivity extends AppCompatActivity {

    private ImageView mPhotoView;
    private TextView textView;
    private Bitmap currentBitmap;
    private LoadImageTask loadImageTask;

    private final View.OnAttachStateChangeListener attachListener =
            new View.OnAttachStateChangeListener() {
                @Override
                public void onViewAttachedToWindow(View v) {
                    Log.d("OomDestoryImageActivity", "onViewAttachedToWindow");
                }

                @Override
                public void onViewDetachedFromWindow(View v) {
                    Log.d("OomDestoryImageActivity", "onViewDetachedFromWindow");
                }
            };

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_mat_photo);
        mPhotoView = findViewById(R.id.photo_view);
        textView = findViewById(R.id.tv_click);

        // 添加状态变化监听器
        mPhotoView.addOnAttachStateChangeListener(attachListener);

        // 设置点击事件
        textView.setOnClickListener(v -> loadImage());
    }

    private void loadImage() {
        // 取消任何正在进行的任务
        cancelPendingTask();

        // 启动新任务
        loadImageTask = new LoadImageTask();
        loadImageTask.execute();
    }

    private void setNewBitmap(Bitmap newBitmap) {
        if (newBitmap == null) return;

        // 保存旧位图引用
        Bitmap oldBitmap = currentBitmap;

        // 更新当前位图和视图
        currentBitmap = newBitmap;
        mPhotoView.setImageBitmap(newBitmap);

        // 安全回收旧位图
        safeRecycleBitmap(oldBitmap);
    }

    private void safeRecycleBitmap(Bitmap bitmap) {
        if (bitmap == null || bitmap.isRecycled()) return;

        // Android 8.0+ 的硬件位图无需手动回收
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            if (bitmap.getConfig() == Bitmap.Config.HARDWARE) {
                return;
            }
        }

        // 回收非硬件位图
        bitmap.recycle();
    }

    private void cancelPendingTask() {
        if (loadImageTask != null) {
            loadImageTask.cancel(true);
            loadImageTask = null;
        }
    }

    @Override
    protected void onDestroy() {
        super.onDestroy();

        // 1. 取消任何正在进行的异步任务
        cancelPendingTask();

        // 2. 移除视图监听器
        mPhotoView.removeOnAttachStateChangeListener(attachListener);

    }

    private class LoadImageTask extends AsyncTask<Void, Void, Bitmap> {
        @Override
        protected Bitmap doInBackground(Void... params) {
            // 检查是否已取消
            if (isCancelled()) return null;

            try {
                // 在后台线程解码图片
                return decodeSampledBitmapFromResource(
                        getResources(), R.mipmap.smart, 200, 200);
            } catch (Exception e) {
                Log.e("LoadImageTask", "Error decoding bitmap", e);
                return null;
            }
        }

        @Override
        protected void onPostExecute(Bitmap bitmap) {
            // 检查Activity是否有效
            if (isFinishing() || isDestroyed()) {
                safeRecycleBitmap(bitmap); // 回收未使用的位图
                return;
            }

            // 更新UI
            setNewBitmap(bitmap);
        }

        @Override
        protected void onCancelled(Bitmap bitmap) {
            // 任务取消时回收位图
            safeRecycleBitmap(bitmap);
        }
    }

    // 采样解码方法保持不变
    public static Bitmap decodeSampledBitmapFromResource(Resources res, int resId,
                                                         int reqWidth, int reqHeight) {

        // 第一次解析将inJustDecodeBounds设置为true,获取图片尺寸
        final BitmapFactory.Options options = new BitmapFactory.Options();
        options.inJustDecodeBounds = true;
        BitmapFactory.decodeResource(res, resId, options);

        // 计算inSampleSize值
        options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight);

        // 使用获取到的inSampleSize值再次解析图片
        options.inJustDecodeBounds = false;
        return BitmapFactory.decodeResource(res, resId, options);
    }

    public static int calculateInSampleSize(BitmapFactory.Options options,
                                            int reqWidth, int reqHeight) {
        // 原始图片的宽高
        final int height = options.outHeight;
        final int width = options.outWidth;
        int inSampleSize = 1;

        if (height > reqHeight || width > reqWidth) {
            final int halfHeight = height / 2;
            final int halfWidth = width / 2;

            // 计算最大的inSampleSize值,该值是2的幂,
            // 且保持高度和宽度大于所需的尺寸
            while ((halfHeight / inSampleSize) >= reqHeight
                    && (halfWidth / inSampleSize) >= reqWidth) {
                inSampleSize *= 2;
            }
        }

        return inSampleSize;
    }
}
  1. 主动释放资源

    • 显式调用mPhotoView.setImageBitmap(null)断开View对Bitmap的引用
    • 直接回收currentBitmap并置null,避免Java层内存泄漏
@Override
protected void onDestroy() {
    super.onDestroy();
    
    // 1. 取消异步任务(触发未使用位图的回收)
    cancelPendingTask();
    
    // 2. 移除视图监听器
    mPhotoView.removeOnAttachStateChangeListener(attachListener);
    
    // 3. [新增] 清理当前显示的位图
    clearCurrentBitmap();
}

private void clearCurrentBitmap() {
    // 清空ImageView引用
    mPhotoView.setImageBitmap(null);
    
    // 回收并清除当前位图
    safeRecycleBitmap(currentBitmap);
    currentBitmap = null;
}

是否是内存泄露?

当然不是

  1. 不是内存泄漏的证据

    • 退出Activity后,currentBitmapmPhotoView的引用已置null
    • MAT分析显示无到GC Roots的引用链
    • 理论上这些Bitmap 可以被GC回收
特征内存泄漏 (Memory Leak)资源未释放 (Resource Not Freed)
对象可达性对象被意外持有(如静态引用)→ GC无法回收对象无外界引用 → GC可回收
Bitmap状态被泄漏的Bitmap仍被强引用持有Bitmap无引用但未调用recycle()
MAT分析表现存在到GC Roots的完整引用链无GC Roots引用但对象仍占Native内存
修复重点切断错误引用链主动调用释放方法

2.Koom分析案例

2.1 Koom分析报告

{
  "process": "com.example.app",
  "time": "2023-05-01T12:34:56Z",
  "memoryInfo": {
    "maxMemory": 268435456,       // 最大堆内存 (256MB)
    "usedMemory": 241591910,      // 实际使用内存 (230MB)
    "threshold": 214748364        // 触发阈值 (200MB)
  },
  "leakInfos": [
    {
      "className": "android.graphics.Bitmap",
      "instanceCount": 115,        // Bitmap 实例总数
      "leakInstanceCount": 15,     // 泄漏实例数
      "leakSize": 15728640,        // 泄漏内存大小 (15MB)
      "leakChains": [
        {
          "chainDepth": 4,
          "elements": [
            "static com.example.App.sImageCache",  // GC Root (静态变量)
            "→ LruCache.mMap",
            "→ HashMap$Node.value",
            "→ OomDestoryImageActivity.currentBitmap"  // 泄漏点定位
          ],
          "leakInstances": 8
        },
        {
          "chainDepth": 3,
          "elements": [
            "android.view.ViewRootImpl.mView",     // GC Root (视图根节点)
            "→ DecorView.mContentParent",
            "→ OomDestoryImageActivity.mPhotoView"  // ImageView 持有 Bitmap
          ],
          "leakInstances": 7
        }
      ]
    }
  ],
  "bitmapSummary": {
    "totalBitmaps": 115,
    "hardwareBitmaps": 32,         // Android 8.0+ 硬件位图
    "unrecycledBitmaps": 15,       // 未回收位图数
    "largeBitmaps": [              // 大尺寸位图列表
      {
        "width": 4096,
        "height": 3072,
        "size": 50331648,          // 48MB
        "allocationStack": "com.example.OomDestoryImageActivity.loadImage()"
      }
    ]
  }
}

2.2 Koom分析详情

 关键字段解析(针对图片泄漏)

  1. 泄漏标识字段

    • leakInstanceCount:确认泄漏的 Bitmap 实例数量
    • leakSize:泄漏的 Bitmap 总内存大小
    • unrecycledBitmaps:显式标记未回收的位图数量
  2. 泄漏路径分析 (leakChains)

    json

    "elements": [
      "static com.example.App.sImageCache",  // 根源:静态缓存
      "→ LruCache.mMap",
      "→ HashMap$Node.value",
      "→ OomDestoryImageActivity.currentBitmap"  // 最终泄漏点
    ]
    
    • 清晰展示从 GC Root 到泄漏对象的引用链
    • 定位到具体类和方法 (OomDestoryImageActivity.currentBitmap)
  3. 大图专项报告 (bitmapSummary.largeBitmaps)

    "largeBitmaps": [{
      "width": 4096,
      "height": 3072,
      "size": 50331648,
      "allocationStack": "loadImage()"  // 创建堆栈
    }]
    
    • 识别超过阈值的大图(默认 >1MB)
    • 提供创建位置的调用堆栈

3.Profiler分析案例

使用 Memory Profiler 分析步骤

步骤 1:捕获内存使用快照
  1. 运行应用 > 打开 Profiler > 选择 Memory 选项卡

  2. 执行以下操作序列:

    • 启动 OomDestoryImageActivity
    • 点击按钮多次加载图片
    • 退出 Activity(触发 onDestroy()
    • 关键:  手动触发 GC(点击垃圾桶图标)
  3. 点击 "Capture heap dump" 图标

步骤 2:分析堆转储

在 Heap Dump 分析界面:

  1. 过滤类名:

    OomDestoryImageActivity  // 检查Activity实例
    Bitmap                   // 检查Bitmap实例
    
  2. 识别泄漏对象:

    • 发现 OomDestoryImageActivity 实例在销毁后仍存在
    • 查看 Bitmap 实例数量异常(如多次加载后应归零但实际未归零)
步骤 3:追踪引用路径
  1. 右键点击泄漏的 OomDestoryImageActivity 实例 > "Go to Instance"

    → java.lang.ThreadLocal$ThreadLocalMap.table[4]
      → android.app.ActivityThread$ActivityClientRecord.activity
        → com.example.OomDestoryImageActivity  // 泄漏点
    
  2. 展开实例查看字段引用:

    OomDestoryImageActivity
    ├─ mPhotoView: ImageView
      └─ mDrawable: BitmapDrawable
         └─ mBitmapState: BitmapDrawable$BitmapState
            └─ mBitmap: Bitmap@0x6a3d  // 被ImageView持有
    ├─ currentBitmap: Bitmap@0x6a3d     // 被Activity直接持有
    └─ loadImageTask: LoadImageTask      // 可能未取消的任务
    
步骤 4:验证 Bitmap 泄漏
  1. 在过滤框输入 Bitmap,按 Retained Size 排序

  2. 选中大尺寸 Bitmap > 查看引用路径:

    Bitmap@0x6a3d (4.2MB)
    ├─ [Direct] OomDestoryImageActivity.currentBitmap
    ├─ [Via] BitmapDrawable.mBitmapState
    │  └─ ImageView.mDrawable
    │     └─ OomDestoryImageActivity.mPhotoView
    └─ [Thread] AsyncTask#1.this$0      // 异步任务持有外部类引用
    
  3. 检查关键数据:

    • Depth:  引用链长度(短链更危险)
    • Shallow Size:  对象本身大小
    • Retained Size:  对象+所有引用对象总大小

4.Mat分析案例

路径.png

MAT 分析步骤

步骤 1:打开堆转储文件
  1. 启动 MAT → 打开 mat-ready.hprof
  2. 选择  "Leak Suspects Report"  初步分析
步骤 2:识别可疑对象
  1. 在 Histogram 视图中:

    SELECT * FROM java.lang.Object 
    WHERE toString(dominator).contains("OomDestory") 
    OR toString(dominator).contains("Bitmap")
    
    • 按 Retained Heap 排序

    • 重点关注:

      • OomDestoryImageActivity 实例
      • Bitmap 实例
      • LoadImageTask 实例
步骤 3:分析 Activity 泄漏
  1. 查找 Activity 实例:

    SELECT * FROM INSTANCEOF android.app.Activity 
    WHERE toString(it).contains("OomDestoryImageActivity")
    
  2. 右键实例 → Merge Shortest Paths to GC Roots → exclude weak/soft references

  3. 分析引用链:

deepseek_mermaid_20250809_06e944.png

关键发现:Activity 被未完成的 AsyncTask 持有
步骤 4:分析 Bitmap 泄漏
  1. 查找 Bitmap 实例:

    SELECT * FROM INSTANCEOF android.graphics.Bitmap
    WHERE toHex(it) = "0x7a3d"
    
  2. 右键 → Path To GC Roots → with all references

  3. 分析双重持有链:

    ├─ OomDestoryImageActivity.currentBitmap
    │  └─ Bitmap@0x7a3d
    ├─ ImageView.mDrawable
    │  └─ BitmapDrawable
    │     └─ Bitmap@0x7a3d
    
步骤 5:支配树分析
  1. 打开 Dominator Tree 视图

  2. 过滤显示:

    OomDestoryImageActivity|Bitmap|LoadImageTask
    
  3. 查看关键对象的支配关系:

    Dominator Tree
    ├─ Thread (main) [Retained: 45MB]
    │  └─ LoadImageTask [Retained: 12MB]
    │     └─ OomDestoryImageActivity [Retained: 30MB]
    │        ├─ Bitmap@0x7a3d [Retained: 4.2MB]
    │        └─ ImageView [Retained: 4.2MB]
    
步骤 6:OQL 高级查询
  1. 查找未回收的 Bitmap:

    SELECT * FROM "android.graphics.Bitmap" 
    WHERE isRecycled = false
    
  2. 查找 Activity 销毁后仍存在的任务:

    SELECT * FROM "com.example.OomDestoryImageActivity$LoadImageTask" 
    WHERE activity.isDestroyed = true
    

不正常的图片

正常的图片

Activity的引用关系!

2.重点,软引用和其他的几个选项是 有区别!

5.总结

问题:图片没有在销毁的时候主动释放,用什么工具检测?

问题:第三方框架glide是如何释放图片,什么时候释放图片?

分析.png

MAT 分析技巧总结

功能使用场景关键命令/操作
Histogram快速定位内存大户ClassName 过滤 + Retained Heap 排序
Dominator Tree分析对象支配关系查看 OomDestoryImageActivity 子树
Path to GC Roots追踪泄漏路径exclude weak/soft references
OQL 查询精准定位问题对象SELECT * FROM Bitmap WHERE isRecycled=false
Compare Results验证修复效果对比修复前后的堆转储
验证指标体系
检测工具修复前修复后验证指标
Memory ProfilerActivity实例残留无Activity实例堆内存下降30%+
MATBitmap@0x7a3d Retained 4.2MB无Bitmap残留Dominator Tree无异常
KOOMleakInstanceCount=15leakInstanceCount=0JSON报告泄漏数为0
LeakCanaryGC Root路径显示泄漏无泄漏通知✅ PASSED标签

项目源码的地址:github.com/pengcaihua1…