Android技术知识Android开发Android开发经验谈

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

2022-07-23  本文已影响0人  Android技术圈

一、内存优化概念

1.1 为什么要做内存优化?

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

各位看官阅读前不妨点个小赞支持一下,关注 @Android技术圈这里有 Android 进阶成长路线笔记 & 博客,有最全的面试指南,每天更新各种技术干货,分享更多最热程序员圈内事。

1.2 内存问题表现形式

二、常用内存分析工具

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

2.1 LeakCanary

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

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

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

使用 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:

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

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

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

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

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

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

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

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

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

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

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

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

我们可以看到位置管理器中的 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 回收

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

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

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 回收,如下图:

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

按照上面的步骤操作:

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

2.3.3 Memory Profiler 查找内存泄露

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

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

上图的内存泄漏比较明显,实际项目开发中出现内存泄漏时可能不会特别明显,运行时间比较久才能发现内存是在缓慢上升的。这时候就需要 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:


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

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

2.4 MAT

2.4.1 MAT简介

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

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

2.4.2 MAT用法简介

①、Overview:概览信息

Top Consumers

Leak Suspects

②、 Histogram:直方图

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

List objects

③、dominator_tree

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

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

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

三、实战内存抖动解决

3.1 内存抖动简介

3.2 内存抖动导致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);
    }
}

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

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

首先双击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,我们的代码就写完了,现在来实际运行一下,然后将这个页面连续打开关闭多次,看看这段代码会不会造成内存泄露呢?

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

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

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

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

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

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

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

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

根据上面找的结果到代码中去找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手动分析

总结:

②、LeakCanary线上使用

总结:

5.2 LeakCanary定制

5.3 线上监控完整方案

六、内存优化技巧

优化大方向:

优化细节:

Contanct Me

读到这的朋友可以点个小赞+关注 @Android技术圈这里有 Android 进阶成长路线笔记 & 博客,有最全的面试指南。(联系方式 & 笔记整理在 GitHub,点击我的简书头像看我主页个人介绍有跳转直达

上一篇下一篇

猜你喜欢

热点阅读