Android内存优化分析总结,这一篇就够了!

6,335 阅读10分钟

本文已收录到 GitHub/Android-Notes 中,这里有 Android 进阶成长知识体系,有志同道合的朋友,关注公众号 [小尘Android专栏] 跟我一起成长。

一、内存优化概念

1.1 为什么要做内存优化?

内存优化一直是一个很重要但却缺乏关注的点,内存作为程序运行最重要的资源之一,需要运行过程中做到合理的资源分配与回收,不合理的内存占用轻则使得用户应用程序运行卡顿、ANR、黑屏,重则导致用户应用程序发生 OOM(out of memory)崩溃。在你认真跟踪下来可能会发现内存出现问题的地方仅仅只是一个表现的地方,并非深层次的原因,因为内存问题相对比较复杂,它是一个逐渐挤压的过程,正好在你出现问题的代码那里爆了,所以针对应用的内存问题开发者必须多加关注。

1.2 内存问题表现形式

  • 内存抖动:锯齿状、GC频繁导致卡顿
  • 内存泄露:可用内存逐渐减少、频繁GC
  • 内存溢出:OOM、程序异常

二、常用内存分析工具

要解决内存问题,就必须要有强大的内存分析工具,让我们更快更方便的定位内存问题。目前主流的内存分析工具主要有 LeakCanary、Memory Profiler、MAT。

2.1 LeakCanary

LeakCanary是 Square 开源的一个内存泄露监控框架,在应用运行时出现的内存泄露会被 LeakCanary 监控记录。

image.png

上图是 LeakCanary 内存泄漏的 trace 分析,主要看 Leaking:NO 到 Leaking:YES 这段的 trace,可以发现 TextView 出现了内存泄漏,因为它持有了被销毁的 Activity 的上下文 Context。

更具体的 trace 分析,具体可以查看官方文档 Fixing a memory leak

使用 LeakCanary 虽然很方便,但是也有一定弊端:

  • 直接引用依赖使用的 LeakCanary 一般用于线下调试,应用发布到线上时需要关闭
  • 应用调试时有时候会引起卡顿

所以 一般使用 LeakCanary 只是一种简便定位内存泄露的方式,但如果需要更好的做内存优化,比如定位内存抖动、Bitmap 优化等还是需要其他的分析工具,主要常用的有 Memory Profiler 和 MAT。

2.2 NativeSize、Shallow Size、Retained Size、Depth

后续说明 Memory Profiler 和 MAT 时,会经常出现几个比较重要的指标:Shallow Size 和 Retained Size。在 Memory Profiler 还会提供 Native Size 和 Depth。

当您拿到一段 Heap Dump 之后,Memory Profiler 会展示出类的列表。对于每个类,Allocations 这一列显示的是它的实例数量。而在它右边则依次是 Native Size、Shallow Size 和 Retained Size:

image.png

我们用下图来表示某段 Heap Dump 记录的应用内存状态。注意红色的节点,在这个示例中,这个节点所代表的对象从我们的工程中引用了 Native 对象;这种情况不太常见,但在 Android 8.0 之后,使用 Bitmap 便可能产生此类情景,因为 Bitmap 会把像素信息存储在原生内存中来减少 JVM 的内存压力。

先从 Shallow Size 讲起,这列数据其实非常简单,就是 对象本身消耗的内存大小,即为红色节点自身所占内存:

image.png

Native Size 同样也很简单,它是类对象所引用的 Native 对象 (蓝色节点) 所消耗的内存大小:

image.png

Retained Size 稍复杂些,它是下图中所有橙色节点的大小:

image.png

由于一旦删除红色节点,其余的橙色节点都将无法被访问,这时候它们就会被 GC 回收掉。从这个角度上讲,它们是被红色节点所持有的,因此被命名为 Retained Size。

还有一个前面没有提到的数据维度。当您点击某个类名,界面中会显示这个类实例列表,这里有一列新数据 —— Depth

image.png

Depth 是从 GC Root 到达这个实例的最短路径,图中的这些数字就是每个对象的深度 (Depth)。

一个对象离 GC Root 越近,它就越有可能与 GC Root 有多条路径相连,也就越可能在垃圾回收中被保存下来

以红色节点为例,如果从其左边来的任何一个引用被破坏,红色节点就会变成不可访问的状态并且被垃圾回收回收掉。而对于右边的蓝色节点来说,如果您希望它被垃圾回收,那您需要把左右两边的路径都破坏才行。

值得警惕的是,如果您看到某个实例的 Depth 为 1 的话,这意味着它直接被 GC Root 引用,同时也意味着它永远不会被自动回收

下面是一个示例 Activity,它实现了 LocationListener 接口,高亮部分代码 requestLocationUpdates() 将会使用当前 Activity 实例来注册 locationManager。如果您忘记注销,这个 Activity 就会泄漏。它将永远都待在内存里,因为位置管理器是一个 GC Root,而且永远都存在:

image.png

您能在 Memory Profiler 中查看这一情况。点击一个实例,Memory Profiler 将会打开一个面板来显示谁正在引用这个实例:

image.png

我们可以看到位置管理器中的 mListener 正在引用这个 Activity。您可以更进一步,通过引用面板导航至堆的引用视图,它可以让您验证这条引用链是否是您所预期的,也能帮您理解代码中是否有泄露以及哪里有泄露。

2.3 Memory Profiler

Memory Profiler 是内置在 Android Studio 适用于查看实时内存情况 的内存分析工具。

2.3.1 Memory Profiler 界面说明

官方文档:使用 Memory Profiler 查看 Java 堆和内存分配

2.3.2 Memory Profiler 查找内存抖动

查找内存抖动还是比较简单的,运行的程序在 Memory Profiler 会呈现为在短时间内内存上下波动频繁触发 GC 回收

内存抖动比较常见的地方:

  • 自定义View的 onMeasure()、onLayout()、onDraw() 直接 new 创建对象
  • 列表比如 RecyclerView 的 onBindViewHolder() 直接 new 创建对象
  • 有循环的代码中创建对象

用一个简单的案例模拟内存抖动:

public class MainActivity extends AppCompatActivity {
​
    @SuppressWarnings("HandlerLeak")
    private Handler mHandler = new Handler() {
        @Override
        public void handleMessage(Message msg) {
            // 模拟内存抖动
            for (int i = 0; i < 100; i++) {
                String[] args = new String[100000];
            }
​
            mHandler.sendEmptyMessageDelayed(0, 30);
        }
    };
​
    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
​
        findViewById(R.id.button).setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                mHandler.sendEmptyMessage(0);
            }
        });
    }
}

案例非常简单,就是点击按钮时频繁的创建对象。在真机上运行上面的程序也许不会出现锯齿状的内存波动,但是会有非常频繁的 GC 回收,如下图:

image.png

那应该怎么具体的定位到是哪里发生的内存抖动呢?

image.png

按照上面的步骤操作:

  • 位置①:在程序处于运行时,点击 Record 按钮录制内存情况,然后点击 Stop 停止录制,会显示上图
  • 位置②:我们可以点击 Allocations 按降序从大到小或升序从小到大查看分配对象的数量,一般我们会选择降序从大到小看数量最多的对象。上图对象数量最多的是 String 对象
  • 位置③:在 Instance View 随便选择一个 String 对象,会显示下面的 Allocation Call Stack,它会显示这个对象的调用栈位置
  • 位置④: 从 Allocation Call Stack 可以看到,String 对象是在 MainActivity 的第 18 行 handleMessage() 创建的,从而也定位到内存抖动的位置

上面的操作还有一些小技巧:

  • 在位置①操作前,为了排除干扰,一般在录制前会先手动 GC 后再录制变化的内存;在 Android 8.0 以上的设备可以实时的拖动 Memory Profiler 选择查看的内存波动范围
  • 位置②上例是直接查看 Arrange by class,但实际项目中更多的会是选择 Arrange by package 查看自己项目包名下的类

2.3.3 Memory Profiler 查找内存泄露

上面讲到内存泄露的表现是会出现内存抖动,因为出现内存泄露时可用内存不断减少,系统需要内存时获取内存不足就会 GC,所以产生内存抖动。

出现内存泄露时 Memory Profiler 会呈现一个类似阶梯型的内存上升趋势,而且内存没有降下来:

image.png

上图的内存泄漏比较明显,实际项目开发中出现内存泄漏时可能不会特别明显,运行时间比较久才能发现内存是在缓慢上升的。这时候就需要 dump heap 帮助定位。

接下来会使用 Handler 内存泄露的案例简单讲解怎么使用 Memory Profiler 分析内存泄露。

public class HandlerLeakActivity extends AppCompatActivity {
    private static final String TAG = HandlerLeakActivity.class.getSimpleName();
​
    private Handler handler = new Handler() {
        @Override
        public void handleMessage(Message msg) {
            if (msg.what == 0) {
                Log.i(TAG, "handler receive msg");
            }
        }
    };
​
    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        handler.sendEmptyMessageDelayed(0, 10 * 1000);
    }
}

上面代码非常简单,就是启动 app 后,每次进入 HandlerLeakActivity 就使用 Handler 延迟 10s 发送消息,在 10s 内退出界面,不断重复操作。

1、重复多次可能内存泄露的操作,Memory Profiler 堆转储出 hprof 文件(建议在操作前先 GC 排除干扰):

在这里插入图片描述

2、在 Memory Profiler 查看查看堆转储文件 hprof:

image.png

可以发现经过手动 GC 后,Allocations 显示有 5 个 HandlerLeakActivity,堆转储 Instance View 下也仍显示有多个 Activity 实例,说明已经内存泄露,具体的内存泄露定位可以在 Instance View 泄露的实例类对象中点击查看,Instance View 下面的 Reference 会显示具体的引用链。

在新版本的 Memory Profiler 提供了 Activity/Fragment Leaks 复选框,选中它可以直接找到可能内存泄露的位置:

image.png

2.4 MAT

2.4.1 MAT简介

  • 强大的Java Heap分析工具,查找内存泄漏及内存占用
  • 生成整体报告、分析问题等
  • 线下深入使用

官网下载地址:www.eclipse.org/mat/downloa… ,这个地址是不是有你熟悉的单词,嗯,没错啦,MAT是Eclipse中的一个插件,因为现在开发过程中很多人都使用了IDEA或者Android Studio,所以你不想下载Eclipse的话呢,你可以去下载MAT的独立版,解压之后里面有一个MemoryAnalyzer.exe的可执行文件,直接点击就可以使用了。

这个工具很多时候我们需要结合Android Studio的堆转储能力配合使用,但是需要注意,AS3.0之后生成的hprof文件不是标准的hprof文件了,需要使用命令转换一下:hprof-conv 原文件路径 转换后文件路径

2.4.2 MAT用法简介

①、Overview:概览信息

Top Consumers

  • 通过图表展示出占用内存比较多的对象,此栏在做降低内存占用时比较有帮助
  • Biggest Objects:相对详细的信息

Leak Suspects

  • 快速查看内存泄露的可疑点

image.png

②、 Histogram:直方图

  • Class Name:具体检索某一个类
  • Objects:某一个具体的Class有多少实例
  • Shallow Heap:某单一实例自己占了多少内存
  • Retained Heap:在这个引用链之上这些对象总共占了多少内存

image.png

Group by packge:将类对象以包名形式展示

image.png

List objects

  • with outgoing references:自身引用了哪些类
  • with incoming references:自身被哪些类引用

image.png

③、dominator_tree

  • 每个对象的支配树
  • percentage:占所有对象的百分比

image.png

在条目上右键它也有List objects,它和Histogram之间有啥区别呢?主要区别就是下面两点:

  • Histogram:基于类的角度分析
  • dominator_tree:基于实例的角度分析

④、OQL:对象查询语言,类似于从数据库中检索内容

image.png

⑤、thread_overview:详细的展示线程信息,可以查看出当前内存中存在多少线程

image.png

三、实战内存抖动解决

3.1 内存抖动简介

  • 定义:内存频繁分配和回收导致内存不稳定
  • 表现:频繁GC、内存曲线呈锯齿状
  • 危害:导致卡顿、严重时会导致OOM

3.2 内存抖动导致OOM

  • 频繁创建对象,导致内存不足及碎片(不连续)
  • 不连续的内存片无法被分配,导致OOM

3.3 实战分析

这一部分我会模拟一次内存抖动,并通过Profiler分析内存情况,定位到具体内存抖动的代码。

首先先来创建一个布局文件activity_memory.xml,里面就一个按钮,用来触发模拟内存抖动的那部分代码:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical" android:layout_width="match_parent"
    android:layout_height="match_parent">
    <Button
        android:id="@+id/btn_memory"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="模拟内存抖动"/>
</LinearLayout>

然后定义一个MemoryShakeActivity页面,加载刚才的布局,并且在页面中定义一个Handler,当点击模拟内存抖动的按钮时,我们定时执行handleMessage中的模拟抖动的代码,整个代码都是很容易能看懂的那种:

/**
 * 说明:模拟内存抖动页面
 */
public class MemoryShakeActivity extends AppCompatActivity implements View.OnClickListener {
 
    @SuppressLint("HandlerLeak")
    private static Handler mHandler = new Handler(){
        @Override
        public void handleMessage(@NonNull Message msg) {
            super.handleMessage(msg);
            //模拟内存抖动的场景,每隔10毫秒执行一次,循环执行100次,每次通过new分配大内存
            for (int i=0;i<100;i++){
                String[] obj = new String[100000];
            }
            mHandler.sendEmptyMessageDelayed(0,10);
        }
    };
 
    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_memory);
        findViewById(R.id.btn_memory).setOnClickListener(this);
    }
 
    @Override
    public void onClick(View view) {
        if (view.getId() == R.id.btn_memory){
            mHandler.sendEmptyMessage(0);
        }
    }
 
    @Override
    protected void onDestroy() {
        super.onDestroy();
        mHandler.removeCallbacksAndMessages(null);
    }
}

然后跑起来,我截了两张图给大家看一下,第一张是没有执行模拟抖动的代码之前的,第二张是执行之后的:

image.png

image.png

从上面两张图中可以清晰的看到第一张内存比较平稳,第二张内存图有锯齿状出现,突然出现了频繁的GC,看到下面好多小垃圾桶了没,这个时候可以初步判定应该是出现了内存抖动现象,因为比较符合它的特征,然后在面板上拖动一段距离它就会将这段时间内的内存分配情况给我们展示出来:

image.png

首先双击Allocations,然后将这一列按照从大到小的顺序排列好,然后你会发现String数组居然有这么多,它占用的内存大小也是最高的(值得关注的点我都用矩形标出了),此时我们就应该锁定这个目标,为什么String类型的数组会有这么多,这里很有可能是有问题的。然后排查究竟是哪里导致的这个问题,很简单点击String[]这一行,在右侧Instance View面板中随便点击一行,下方Allocation Call Stack面板中就出现了对应的堆栈信息,上面也列出了具体哪个类的哪一行,右键jupm to source就可以跳转到指定的源码位置,这样就找到了内存抖动出现的位置,然后我们分析代码作相应的修改即可。

流程总结

  1. 使用Memory Profiler初步排查;
  2. 使用Memory Profiler或CPU Profiler结合代码排查

内存抖动解决技巧:找循环或者频繁调用的地方

四、实战内存泄露解决

4.1 内存泄露简介

定义:内存中存在已经没有用的对象

表现:内存抖动、可用内存逐渐变少

危害:内存不足、GC频繁、OOM

4.2 实战分析

这里还是通过代码来真实的模拟一次内存泄露的场景,对于一般的APP程序来说,最大的问题往往都是在Bitmap上,因为它消耗的内存比较多,拿它来模拟会看的比较明显。好首先来看布局文件activity_memoryleak.xml,里面就一个ImageView控件:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">
    <ImageView
        android:id="@+id/iv_memoryleak"
        android:layout_width="50dp"
        android:layout_height="50dp" />
</LinearLayout>

然后定义了一个模拟处理某些业务的Callback回调接口,和一个统一管理这些回调接口的Manager类:

//模拟回调处理某些业务场景
public interface CallBack {
    void dpOperate();
}
 
//统一管理Callback
public class CallBackManager {
    public static ArrayList<CallBack> sCallBacks = new ArrayList<>();
 
    public static void addCallBack(CallBack callBack) {
        sCallBacks.add(callBack);
    }
 
    public static void removeCallBack(CallBack callBack) {
        sCallBacks.remove(callBack);
    }
}

然后在我们的模拟内存泄露的页面上设置Bitmap,并设置回调监听:

/**
 * 说明:模拟内存泄露页面
 */
public class MemoryLeakActivity extends AppCompatActivity implements CallBack{
    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_memoryleak);
        ImageView imageView = findViewById(R.id.iv_memoryleak);
        Bitmap bitmap = BitmapFactory.decodeResource(getResources(), R.mipmap.big_bg);
        imageView.setImageBitmap(bitmap);
        CallBackManager.addCallBack(this);
    }
 
    @Override
    public void dpOperate() {
 
    }
}

OK,我们的代码就写完了,现在来实际运行一下,然后将这个页面连续打开关闭多次,看看这段代码会不会造成内存泄露呢?

image.png

这是我用Profiler截取的内存图片,可以看到整个内存在经过了我的反复开关页面之后虽然有的地方出现了一个小抖动,但是整体是呈阶梯状上升的,可用内存在逐渐减少,此时基本上可以断定这个界面出现了内存泄露。Profiler工具虽然可以初步帮我们断定出现了内存泄露,但是它却无法断定具体是哪里出现了内存泄露,意思就是我们还是不知道该修改哪里的代码,所以此时需要用到强大的Java Heap工具了,来有请MAT出场。

首先需要在Profiler中点击Dump Java Heap按钮,使用堆转储功能转换成一个文件,然后点击保存按钮将文件保存到本地目录下,比如我这里保存为H盘中的memoryleak.hprof文件,然后使用hprof-conv命令将其转换为标准的hprof文件,我这里是转换后的名称是:memoryleak_transed.hprof,如下所示:

image.png

然后打开MAT工具,导入刚刚生成的转换后的文件:

image.png

点击Histogram查看内存中所有存活的对象,然后我们在Class Name中可以输入内容搜索想要查找的对象:

image.png

然后可以看到该对象的具体信息,以及数量和所占用的内存大小,我这里发现内存中居然存在6个MemoryLeakActivity对象:

image.png

然后右键List objects---->with incoming references找到所有引向它的强引用:

image.png

然后右键Path To GC Roots----->with all references,将所有引用都计算在内然后算出来这个对象和GCRoot之间的路径:

image.png

来看结果,最后是到了sCallBacks这里,而且它左下角有个小圆圈,这就是我们真正要找的位置,也就是说MemoryLeakActivity是被CallBackManager这个类的sCallBacks这个对象引用了:

image.png

根据上面找的结果到代码中去找CallBackManager的sCallBacks看看这里究竟是做了什么引发的?

public static ArrayList<CallBack> sCallBacks = new ArrayList<>();

MemoryLeakActivity是被sCallBacks这个静态变量引用着,由于被static关键字修饰的变量的生命周期是和App的整个生命周期一样长的,所以当MemoryLeakActivity这个页面关闭时,我们应该将变量的引用关系给释放掉,否则就出现了上面的内存泄露的问题。所以解决这个问题也很简单了,添加如下几行代码:

@Override
protected void onDestroy() {
    super.onDestroy();
    CallBackManager.removeCallBack(this);
}

流程总结

  1. 使用Memory Profiler初步观察(可用内存逐渐减少);
  2. 通过Memory Analyzer结合代码确认

五、线上内存监控方案

线上内存问题最大的就是内存泄露,对于内存抖动和内存溢出它们一般都和内存泄露导致的内存无法释放相关,如果能够解决内存泄露,则线上内存问题就会减少很多。线上内存监控其实还是比较困难的,因为我们无法使用线下的这些工具来直观的发现分析问题。

5.1 常规方案

①、设定场景线上Dump

比如你的App已经占用到单个App最大可用内存的较高百分比,比如80%,通过:Debug.dumpHprofData();这行代码可以实现将当前内存信息转化为本地文件。

整个流程如下超过内存80%——>内存Dump——>回传文件(注意文件可能很大,保持在wifi状态回传)——>MAT手动分析

总结:

  • Dump文件太大,和对象数正相关,可裁剪
  • 上传失败率高、分析困难

②、LeakCanary线上使用

  • LeakCanary带到线上
  • 预设泄露怀疑点
  • 发现泄露回传

总结:

  • 不适合所有情况,必须预设怀疑点,限制了全面性
  • 分析比较耗时,也容易OOM(实践发现LeakCanary分析过程较慢,很有可能自己在分析的过程中自身发生OOM)

5.2 LeakCanary定制

  • 预设怀疑点——》自动找怀疑点(谁的内存占用大就怀疑谁,大内存对象出现问题的概率更大)
  • 分析泄露链路慢(分析预设对象的每一个对象)——》分析Retain Size大的对象(减少它的分析工作量,提高分析速度)
  • 分析OOM(将内存堆栈生成的所有文件全部映射到内存中,比较占用内存)——》对象裁剪,不全部加载到内存

5.3 线上监控完整方案

  • 待机内存、重点模块内存、OOM率
  • 整体及重点模块GC次数、GC时间
  • 增强的LeakCanary自动化内存泄露分析

六、内存优化技巧

优化大方向:

  • 内存泄露
  • 内存抖动
  • Bitmap

优化细节:

  • LargeHeap属性(虽然有点耍流氓,但还是应该向系统申请)
  • onTrimMemory、onLowMemory(系统给的低内存的回调,可以根据不同的回调等级去处理一些逻辑)
  • 使用优化过的集合:SparseArray
  • 谨慎使用SharedPreference(一次性load到内存中)
  • 谨慎使用外部库(尽量选择经过大规模验证的外部库)
  • 业务架构设计合理(加载的数据是你能用到的,不浪费内存加载无用数据)

如果你觉得这篇内容对你还蛮有帮助,我想邀请你帮我三个小忙: 点赞,转发,有你们的 『点赞和评论』,才是我创造的动力。微信搜索公众号 [小尘Android专栏] ,第一时间阅读更多干货知识!