移动性能测试 专项:Android 内存泄露实践分析

Heyniu · 2016年08月30日 · 最后由 jerry ding 回复于 2019年04月24日 · 525 次阅读
本帖已被设为精华帖!

专项:Android 内存泄露实践分析

定义

​ 内存泄漏也称作 “存储渗漏”,用动态存储分配函数动态开辟的空间,在使用完毕后未释放,结果导致一直占据该内存单元。直到程序结束。(其实说白了就是该内存空间使用完毕之后未回收)即所谓内存泄漏。

内存泄漏形象的比喻是 “操作系统可提供给所有进程的存储空间正在被某个进程榨干”,最终结果是程序运行时间越长,占用存储空间越来越多,最终用尽全部存储空间,整个系统崩溃。所以 “内存泄漏” 是从操作系统的角度来看的。这里的存储空间并不是指物理内存,而是指虚拟内存大小,这个虚拟内存大小取决于磁盘交换区设定的大小。由程序申请的一块内存,如果没有任何一个指针指向它,那么这块内存就泄漏了。

​ ——来自《百度百科》

影响

  • 导致 OOM
  • 糟糕的用户体验
  • 鸡肋的 App 存活率

成效

  • 内存泄露是一个持续的过程,随着版本的迭代,效果越明显
  • 由于某些原因无法改善的泄露(如框架限制),则尽量降低泄露的内存大小
  • 内存泄露实施后的版本,一定要验证,不必马上推行到正式版,可作为 beta 版持续观察是否影响/引发其他功能/问题

内存泄露实施后,项目的收获:

  • OOM 减少 30% 以上
  • 平均使用内存从 80M 稳定到 40M 左右
  • 用户体验上升,流畅度提升
  • 存活率上升,推送到达率提升

类型

  • IO
    • FileStream
    • Cursor
  • Bitmap
  • Context

    • 单例
    • Callback
  • Service

    • BraodcastReceiver
    • ContentObserver
  • Handler

  • Thread

技巧

  • 慎用 Context

    • Context 概念
    • 四大组件 Context 和 Application 的 context 使用参见下表

  • 善用 Reference

    • Java 引用介绍
    • Java 四种引用由高到低依次为:强引用  >  软引用  >  弱引用  >  虚引用
    • 表格说明
    类型 垃圾回收时间 生存时间
    强引用 永远不会 JVM 停止运行时终止
    软引用 内存不足时 内存不足时终止
    弱引用 垃圾回收时 垃圾回收时终止
    虚引用 垃圾回收时 垃圾回收时终止
  • 复用 ConvertView

  • 对象释放

    • 遵循谁创建谁释放的原则
    • 示例:显示调用 clear 列表、对象赋空值

分析

原理

根本原因

  • 关注堆内存

怎么解决

  • 详见方案

实践分析

  • 详见实践

方案

  • StrictMode

    • 使用方法:AppContext 的onCreate()方法加上
    StrictMode.setThreadPolicy(new StrictMode.ThreadPolicy
                        .Builder()
                        .detectAll()
                        .penaltyLog()
                        .build());
    StrictMode.setVmPolicy(new StrictMode.VmPolicy
                        .Builder()
                        .detectAll()
                        .penaltyLog()
                        .build());
    
    • 主要检查项:内存泄露、耗时操作等
  • Leakcanary

  • Leakcanary + StrictMode + monkey (推荐)

    • 使用阶段:功能测试完成后,稳定性测试开始时
    • 使用方法:安装集成了 Leakcanary 的包,跑 monkey
    • 收获阶段:一段时间后,会发现出现 N 个泄露
    • 实战分析:逐条分析每个泄露并改善/修复
    • StrictMode:查看日志搜索 StrictMode 关键字
  • Adb 命令

    • 手动触发 GC
    • 通过 adb shell dumpsys meminfo packagename -d 查看
    • 查看 Activity 以及 View 的数量
    • 越接近 0 越好
    • 对比进入 Activity 以及 View 前的数量和退出 Activity 以及 View 后的数量判断
  • Android Monitor

  • MAT

实践(示例)

Bitmap 泄露

Bitmap 泄露一般会泄露较多内存,视图片大小、位图而定

  • 经典场景:App 启动图

  • 解决内存泄露前后内存相差 10M+,可谓惊人

  • 解决方案:

App 启动图 Activity 的onDestroy()中及时回收内存

@Override
protected void onDestroy() {
    // TODO Auto-generated method stub
    super.onDestroy();
    recycleImageView(imgv_load_ad);
    }

public static void recycleImageView(View view){
        if(view==null) return;
        if(view instanceof ImageView){
            Drawable drawable=((ImageView) view).getDrawable();
            if(drawable instanceof BitmapDrawable){
                Bitmap bmp = ((BitmapDrawable)drawable).getBitmap();
                if (bmp != null && !bmp.isRecycled()){
                    ((ImageView) view).setImageBitmap(null);
                    bmp.recycle();
                    bmp=null;
                }
            }
        }
    }

IO 流未关闭

  • 分析:通过日志可知FileOutputStream()未关闭

  • 问题代码:

public static void copyFile(File source, File dest) {
        FileChannel inChannel = null;
        FileChannel outChannel = null;
        Log.i(TAG, "source path: " + source.getAbsolutePath());
        Log.i(TAG, "dest path: " + dest.getAbsolutePath());
        try {
            inChannel = new FileInputStream(source).getChannel();
            outChannel = new FileOutputStream(dest).getChannel();
            inChannel.transferTo(0, inChannel.size(), outChannel);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
  • 解决方案:

    • 及时关闭 IO 流,避免泄露
public static void copyFile(File source, File dest) {
        FileChannel inChannel = null;
        FileChannel outChannel = null;
        Log.i(TAG, "source path: " + source.getAbsolutePath());
        Log.i(TAG, "dest path: " + dest.getAbsolutePath());
        try {
            inChannel = new FileInputStream(source).getChannel();
            outChannel = new FileOutputStream(dest).getChannel();
            inChannel.transferTo(0, inChannel.size(), outChannel);
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            if (inChannel != null) {
                try {
                    inChannel.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
            if (outChannel != null) {
                try {
                    outChannel.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
    }
E/StrictMode: A resource was acquired at attached stack trace but never released. 
See java.io.Closeable for information on avoiding resource leaks.
java.lang.Throwable: Explicit termination method 'close' not called
    at dalvik.system.CloseGuard.open(CloseGuard.java:180)
    at java.io.FileOutputStream.<init>(FileOutputStream.java:89)
    at java.io.FileOutputStream.<init>(FileOutputStream.java:72)
    at com.heyniu.lock.utils.FileUtil.copyFile(FileUtil.java:44)
    at com.heyniu.lock.db.BackupData.backupData(BackupData.java:89)
    at com.heyniu.lock.ui.HomeActivity$11.onClick(HomeActivity.java:675)
    at android.support.v7.app.AlertController$ButtonHandler.handleMessage(AlertController.java:157)
    at android.os.Handler.dispatchMessage(Handler.java:102)
    at android.os.Looper.loop(Looper.java:148)
    at android.app.ActivityThread.main(ActivityThread.java:5417)
    at java.lang.reflect.Method.invoke(Native Method)
    at com.android.internal.os.ZygoteInit$MethodAndArgsCaller.run(ZygoteInit.java:726)
    at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:616)

单例模式泄露

  • 分析:通过截图我们发现 SplashActivity 被 ActivityUtil 的实例 activityStack 持有

  • 引用代码:

ActivityUtil.getAppManager().add(this);
  • 持有代码:
public void add(Activity activity) {
      if (activityStack == null) {
          synchronized (ActivityUtil.class){
              if (activityStack == null) {
                  activityStack = new Stack<>();
              }
          }
      }
      activityStack.add(activity);
  }
  • 解决方案:

    • 在 SplashActivity 的onDestroy()生命周期移除引用
@Override
    protected void onDestroy() {
        super.onDestroy();
        ActivityUtil.getAppManager().remove(this);
    }

静态变量持有 Context 实例泄露

  • 分析:长生命周期持有短什么周期引用导致泄露,详见上文四大组件 Context 和 Application 的 context 使用

  • 示例引用代码:

private static HttpRequest req;
public static void HttpUtilPost(Context context, int TaskId, String url, String requestBody,ArrayList<HttpHeader> Headers, RequestListener listener) {
      // TODO Auto-generated constructor stub
      req = new HttpRequest(context, url, TaskId, requestBody, Headers, listener);
      req.post();
  }
  • 解决方案:

    • 改为弱引用
    • pass:弱引用随时可能为空,使用前先判空
    • 示例代码:
    public static void cancel(int TaskId) {
          if(req != null && req.get() != null){
              req.get().AsyncCancel(TaskId);
          }
      }
    
    private static WeakReference<HttpRequest> req;
    public static void HttpUtilPost(Context context, int TaskId, String url, String requestBody,ArrayList<HttpHeader> Headers, RequestListener listener) {
            // TODO Auto-generated constructor stub
            req = new WeakReference<HttpRequest>(new HttpRequest(context, url, TaskId, requestBody, Headers, listener));
            req.get().post();
        }
    
    • 改为长生命周期
    private static HttpRequest req;
    public static void HttpUtilPost(Context context, int TaskId, String url, String requestBody,ArrayList<HttpHeader> Headers, RequestListener listener) {
            // TODO Auto-generated constructor stub
            req = new HttpRequest(context.getApplicationContext(), url, TaskId, requestBody, Headers, listener);
            req.post();
        }
    

Context 泄露

Callback 泄露

服务未解绑注册泄露

  • 分析:一般发生在注册了某服务,不用时未解绑服务导致泄露

  • 引用代码:

private void initSensor() {
        // 获取传感器管理器
        sm = (SensorManager) container.activity.getSystemService(Context.SENSOR_SERVICE);
        // 获取距离传感器
        acceleromererSensor = sm.getDefaultSensor(Sensor.TYPE_PROXIMITY);
        // 设置传感器监听器
        acceleromererListener = new SensorEventListener() {
        ......
        };
        sm.registerListener(acceleromererListener, acceleromererSensor, SensorManager.SENSOR_DELAY_NORMAL);
    }
  • 解决方案:

    • 在 Activity 的onDestroy()方法解绑服务
@Override
protected void onDestroy() {
  super.onDestroy();
  sm.unregisterListener(acceleromererListener,acceleromererSensor);
}

Handler 泄露

  • 分析:由于 Activity 已经关闭,Handler 任务还未执行完成,其引用了 Activity 的实例导致内存泄露

  • 引用代码:

handler.sendEmptyMessage(0);
  • 解决方案:

    • 在 Activity 的onDestroy()方法回收 Handler
@Override
protected void onDestroy() {
  super.onDestroy();
  handler.removeCallbacksAndMessages(null);
}
  • 图片后续遇到再补上

异步线程泄露

  • 分析:一般发生在线程执行耗时操作时,如下载,此时 Activity 关闭后,由于其被异步线程引用,导致无法被正常回收,从而内存泄露

  • 引用代码:

new Thread() {
  public void run() {
    imageArray = loadImageFromUrl(imageUrl);
  }.start();
  • 解决方案:

    • 把线程作为对象提取出来
    • 在 Activity 的onDestroy()方法阻塞线程
thread = new Thread() {
  public void run() {
    imageArray = loadImageFromUrl(imageUrl);
  };
thread.start();

@Override
protected void onDestroy() {
  super.onDestroy();
  if(thread != null){
    thread.interrupt();
    thread = null;
  }
}

后面

  • 欢迎补充实际中遇到的泄露类型
  • 文章如有错误,欢迎指正
  • 如有更好的内存泄露分享方法,欢迎一起讨论

未完待续。。。

如果觉得我的文章对您有用,请随意打赏。您的支持将鼓励我继续创作!
共收到 33 条回复 时间 点赞

很高产啊

高产男士,手工 mark 来学习

#1 楼 @mads 谢谢😃

#2 楼 @darker50 赶紧收归麾下啊😂

思寒_seveniruby 将本帖设为了精华贴 08月30日 15:42

加精理由: 列举了常见的内存泄漏检测方法和依赖工具. 并给了详细的解决过程

常见的泄露场景。赞下!

赞赞赞,你玩这么 6,你家珍妞知道吗😏

#8 楼 @thinker 😅 低调,低调

这是停不下来的节奏呀

Heyniu #12 · 2016年08月30日 Author

#11 楼 @sycing 😆 赶紧分享点干货来

膜拜,收藏了,慢慢消化~

房网的员工啊

好文章,收藏了。

Heyniu #16 · 2016年08月31日 Author

#14 楼 @kasi 暴露了,低调

很详细啊,赞一个

Heyniu #18 · 2016年08月31日 Author

#17 楼 @neyo 谢谢

写得很详尽,加油期待后续文章

每次跑 monkey 的时候,都会进入 Leakcanary 去删除内存泄露的点,请问如何避免进入 Leakcanary

Heyniu #21 · 2016年09月07日 Author

#20 楼 @testsina 目前还没办法,或许可以从 Leakcanary 源码入手,去掉那个删除按钮

#21 楼 @heyniu 请问你是如何做的?StrictMode 集成到每一个 Activity 或 Appliction 中 我看 logcat 中无 theadPlice 和 vmPlice

Heyniu #23 · 2016年09月07日 Author

#22 楼 @testsina StrictMode 集成到 Appliction 就行了,无的话说明代码质量(线程、IO、内存泄露)没什么问题
monkey 跑的时候,点到 Leakcanary 删除按钮的,我搞定了 M 你

Heyniu #25 · 2016年09月08日 Author

#24 楼 @testsina 搞定了,修改源码,重新打包,依赖到项目就好了

#25 楼 @heyniu 能否给个 QQ,请教下怎么修改呢?

列举的还是很全的,查内存泄漏原因应该是开发的基本任务之一,然而写起代码来就忘了一些细节。对 QA 我之前建议是,app 启动记录好初始内存,然后不停的跑,定时回到初始页面对比内存情况,差别大就 dump 一个 hprof 丢给开发。当然敏感的 QA 能感觉到哪个页面泄漏,只要反复进入退出该 activity,观察内存情况就能判断。凑贴,溜~

Heyniu [该话题已被删除] 中提及了此贴 09月13日 19:37

有 Leakcanary 的详细示例吗

LeakCanary 有时候蛮好用的,但是每个公司的 APP 的技术架构不一样,如 Native APP、Web APP、Hybird APP,所以 Leak 报的内容也不一样。
我们公司是模块化开发,用起来不是太方便,如果 AAR 改成 APP 会方便很多。我们的机制是 APP 引用各个 AAR,AAR 里面是获取不到 APP 的对象,AAR 是被引用关系。且如果 APP 中含第三方库必须在 application 中调用 install 方法,然后会返回一个 watcher 对象,不方便在于 AAR 都访问不到的 application 类。上次 3 个泄漏点只报了一个,所以其余 2 个是手动结合 Monitor 排查的。

如何准确定位某个问题导致卡顿或者 oom,这个 leakCanary 日志表示看不懂,求问如何根据它确定问题

Heyniu #33 · 2016年09月28日 Author

#32 楼 @xiaozheng_QM oom 可以直接看崩溃日志,卡顿通过性能分析,比如滑动下列表,然后查看此时的一些 cpu 占用等信息,通过 traceview

请问作者是如何选择 Android 机型进行测试呢?

Heyniu 内存泄露配置 (优化版) 中提及了此贴 07月25日 17:17

异步耗时线程导致的 activity 泄漏,等线程运算完成后,被持有的 activity 对象应该也会 gc 及时回收吧,所以应该算是暂时的内存泄漏?

需要 登录 后方可回复, 如果你还没有账号请点击这里 注册