2021-09-24

Android深度性能优化--内存优化

一、背景

在内存管理上,JVM拥有垃圾内存回收的机制,自身会在虚拟机层面自动分配和释放内存,因此不需要像使用C/C++一样在代码中分配和释放某一块内存。Android系统的内存管理类似于JVM,通过new关键字来为对象分配内存,内存的释放由GC来回收。并且Android系统在内存管理上有一个Generational Heap Memory模型,当内存达到某一个阈值时,系统会根据不同的规则自动释放可以释放的内存。即便有了内存管理机制,但是,如果不合理地使用内存,也会造成一系列的性能问题,比如内存泄漏、内存抖动、短时间内分配大量的内存对象等等。

二、优化工具

2.1 Memory Profiler

Memory profiler是Android Studio自带的一个内存检测工具,通过实时图表的方式展示内存信息,具有可以识别内存泄露,内存抖动等现象,并可以将捕获到的内存信息进行堆转储、强制GC以及跟踪内存分配的能力。

Android Studio打开Profiler工具

1.jpg

观察Memory曲线,比较平缓即为内存分配正常,如果出现大的波动有可能发生了内存泄露。

GC:可手动触发GC

Dump:Dump出当前Java Heap信息

Record:记录一段时间内的内存信息

点击Dump后

2.jpg

可查看当前内存分配对象

Allocations:分配对象个数

Native Size:Native内存大小

Shallow Size:对象本身占用内存的大小,不包含其引用的对象

Retained Size: 对象的Retained Size = 对象本身的Shallow Size + 对象能直接或间接访问到的对象的Shallow Size,也就是说 Retained Size 就是该对象被 Gc 之后所能回收内存的总和

点击Bitmap Preview可以进行预览图片,对查看图片占用内存情况比较有帮助

点击Record后

3.jpg

可以记录一段时间内内存分配情况,可查看各对象分配大小及调用栈、对象生成位置

2.2 Memory Analyzer(MAT)

比Memory Profiler更强大的Java Heap分析工具,可以准确查找内存泄露以及内存占用情况,还可以生成整体报告,用来分析问题等。

MAT一般用来线下结合Memory Profiler分析问题使用,Memory Profiler可以直观看出内存抖动,然后生成的hdprof文件,通过MAT深入分析及定位内存泄露问题。

具体使用下面会结合实例讲解一下

2.3 LeakCannary

Leak Cannary是一个能自动监测内存泄露的线下监测工具,具体原理可自行了解下。

github链接:https://github.com/square/leakcanary

三、内存管理

3.1 内存区域

Java内存划分为方法区、堆、程序计数器、本地方法栈、虚拟机栈五个区域;

线程维度分为线程共享区和线程隔离区,方法区和堆是线程共享的,程序计数器、本地方法栈、虚拟机栈是线程隔离的,如下图

4.jpg

方法区

  • 线程共享区域,用于存储类信息、静态变量、常量、即时编译器编译出来的代码数据
  • 无法满足内存分配需求时会发生OOM

  • 线程共享区域,是JAVA虚拟机管理的内存中最大的一块,在虚拟机启动时创建
  • 存放对象实例,几乎所有的对象实例都在堆上分配,GC管理的主要区域

虚拟机栈

  • 线程私有区域,每个java方法在执行的时候会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息。方法从执行开始到结束过程就是栈帧在虚拟机栈中入栈出栈过程
  • 局部变量表存放编译期可知的基本数据类型、对象引用、returnAddress类型。所需的内存空间会在编译期间完成分配,进入一个方法时在帧中局部变量表的空间是完全确定的,不需要运行时改变
  • 若线程申请的栈深度大于虚拟机允许的最大深度,会抛出SatckOverFlowError错误
  • 虚拟机动态扩展时,若无法申请到足够内存,会抛出OutOfMemoryError错误

本地方法栈

  • 为虚拟机中Native方法服务,对本地方法栈中使用的语言、数据结构、使用方式没有强制规定,虚拟机可自有实现
  • 占用的内存区大小是不固定的,可根据需要动态扩展

程序计数器

  • 一块较小的内存空间,线程私有,存储当前线程执行的字节码行号指示器
  • 字节码解释器通过改变这个计数器的值来选取下一条需要执行的字节码指令:分支、循环、跳转等
  • 每个线程都有一个独立的程序计数器
  • 唯一一个在java虚拟机中不会OOM的区域

3.2 对象存活判断

引用计数法

  • 给对象添加引用计数器,每当一个地方引用时,计数器加1,引用失效时计数器减1;当引用计数器为0时即为对象不可用
  • 实现简单,效率高,但是无法解决相互引用问题,主流虚拟机一般不使用此方法判断对象是否存活

可达性分析法

  • 从一些称为"GC Roots"的对象作为起点,向下搜索,搜索走过的路径称为引用链,当一个对象到GC Roots没有任何引用链时即为对象不可用,可被回收的
  • 可被称为GC Roots的对象:虚拟机栈中引用的对象、方法区中类静态属性引用的对象、方法区中常量引用的对象、本地方法栈中引用的对象

GC Root有以下几种:

  • Class-由系统ClassLoader加载的对象
  • Thread-活着的线程
  • Stack Local-Java方法的local变量或参数
  • JNI Local - JNI方法的local变量或参数
  • JNI Global - 全局JNI引用
  • Monitor Used - 用于同步的监控对象

3.3 垃圾回收算法

标记清除算法

标记清除算法有两个阶段,首先标记出需要回收的对象,在标记完成后统一回收所有标记的对象;

缺点:

  • 效率问题:标记和清除两个过程效率都不高
  • 空间问题:标记清除之后会导致很多不连续的内存碎片,会导致需要分配大对象时无法找到足够的连续空间而不得不触发GC的问题

复制算法

将可用内存按空间分为大小相同的两小块,每次只使用其中的一块,等这块内存使用完了将还存活的对象复制到另一块内存上,然后将这块内存区域对象整体清除掉。每次对整个半区进行内存回收,不会导致碎片问题,实现简单高效。

缺点:

  • 需要将内存缩小为原来的一半,空间代价太高

标记整理算法

标记整理算法标记过程和标记清除算法一样,但清除过程并不是对可回收对象直接清理,而是将所有存活对象像一端移动,然后集中清理到端边界以外的内存。

分代收集算法

当代虚拟机垃圾回收算法都采用分代收集算法来收集,根据对象存活周期不同将内存划分为新生代和老年代,再根据每个年代的特点采用最合适的算法。

  • 新生代存活对象较少,每次垃圾回收都有大量对象死去,一般采用复制算法,只需要付出复制少量存活对象的成本就可以实现垃圾回收;
  • 老年代存活对象较多,没有额外空间进行分配担保,就必须采用标记清除算法和标记整理算法进行回收;

四、内存抖动

内存频繁分配和回收导致内存不稳定

  • 频繁GC,内存曲线呈现锯齿状,会导致卡顿
  • 频繁的创建对象会导致内存不足及碎片
  • 不连续的内存碎片无法被释放,导致OOM

4.1 模拟内存抖动

执行此段代码

private static Handler mShakeHandler = new Handler() {
    @Override public void handleMessage(Message msg) {
        super.handleMessage(msg);
        // 频繁创建对象,模拟内存抖动
        for(int index = 0;index <= 100;index ++) {
            String strArray[] = new String[100000];
        }

        mShakeHandler.sendEmptyMessageDelayed(0,30);
    }
};

4.2 分析并定位

利用Memory Profiler工具查看内存信息

5.jpg

发现内存曲线由原来的平稳曲线变成锯齿状

6.jpg

点击record记录内存信息,查找发生内存抖动位置,发现String对象ShallowSize非常异常,可直接通过Jump to Source定位到代码位置

7.jpg

五、内存泄露

定义:内存中存在已经没有用确无法回收的对象

现象:会导致内存抖动,可用内存减少,进而导致GC频繁、卡顿、OOM

5.1 模拟内存泄露

模拟内存泄露代码,反复进入退出该Activity

/**
 * 模拟内存泄露的Activity
 */
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.splash);
        imageView.setImageBitmap(bitmap);

        // 添加静态类引用
        CallBackManager.addCallBack(this);
    }

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

    @Override
    public void dpOperate() {
        // do sth
    }

5.2 分析并定位

通过Memory Profiler工具查看内存曲线,发现内存在不断的上升

8.jpg

如果想分析定位具体发生内存泄露位置需要借助MAT工具

首先生成hprof文件

点击dump将当前内存信息转成hprof文件,需要对生成的文件转换成MAT可读取文件

执行一下转换命令(Android/sdk/platorm-tools路径下)

hprof-conv 刚刚生成的hprof文件 memory-mat.hprof

使用mat打开刚刚转换的hprof文件

9.jpg

点击Historygram,搜索MemoryLeakActivity

10.jpg

可以看到有8个MemoryLeakActivity未释放

11.jpg

查看所有引用对象

12.jpg

查看到GC Roots的引用链

13.jpg

可以看到GC Roots是CallBackManager

14.jpg

解决问题,当Activity销毁时将当前引用移除

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

六、MAT分析工具

Overview

当前内存整体信息

[图片上传失败...(image-c0bad7-1632471126749)]

Histogram

列举对象所有的实例及实例所占大小,可按package排序

[图片上传失败...(image-83fb18-1632471126749)]

可以查看应用包名下Activity存在实例个数,可以查看是否存在内存泄露,这里发现内存中有8个Activity实例未释放

[图片上传失败...(image-d501f9-1632471126749)]

查看未被释放的Activity的引用链

[图片上传失败...(image-4347e3-1632471126749)]

Dominator_tree

当前所有实例的支配树,和Histogram区别时Histogram是类维度,dominator_tree是实例维度,可以查看所有实例的所占百分比和引用链

[图片上传失败...(image-34cb1a-1632471126749)]

SQL

通过sql语句查询相关类信息

[图片上传失败...(image-c55ac1-1632471126749)]

Thread_overview

查看当前所有线程信息

[图片上传失败...(image-f62dea-1632471126749)]

Top Consumers

通过图形方式展示占用内存较高的对象,对降低内存栈优化可用内存比较有帮助

[图片上传失败...(image-d91270-1632471126749)]

[图片上传失败...(image-7c14ca-1632471126749)]

Leak Suspects

内存泄露分析页面

[图片上传失败...(image-e1c2e4-1632471126749)]

直接定位到内存泄露位置

[图片上传失败...(image-90bec3-1632471126749)]

七、通过ARTHook检测不合理图片

7.1 获取Bitmap占用内存

  • 通过getByteCount方法,但是需要在运行时获取
  • width * height * 一个像素所占内存 * 图片所在资源目录压缩比

7.2 检测大图

当图片控件load图片大小超过控件自身大小时会造成内存浪费,所以检测出不合理图片对内存优化是很重要的。

ARTHook方式检测不合理图片

通过ARTHook方法可以优雅的获取不合理图片,侵入性低,但是因为兼容性问题一般在线下使用。

引入epic开源库

implementation 'me.weishu:epic:0.3.6'

实现Hook方法

public class CheckBitmapHook extends XC_MethodHook {

    @Override protected void afterHookedMethod(MethodHookParam param) throws Throwable {
        super.afterHookedMethod(param);

        ImageView imageView = (ImageView)param.thisObject;
        checkBitmap(imageView,imageView.getDrawable());
    }

    private static void checkBitmap(Object o,Drawable drawable) {
        if(drawable instanceof BitmapDrawable && o instanceof View) {
            final Bitmap bitmap = ((BitmapDrawable) drawable).getBitmap();
            if(bitmap != null) {
                final View view = (View)o;
                int width = view.getWidth();
                int height = view.getHeight();
                if(width > 0 && height > 0) {
                    if(bitmap.getWidth() > (width <<1) && bitmap.getHeight() > (height << 1)) {
                        warn(bitmap.getWidth(),bitmap.getHeight(),width,height,
                                new RuntimeException("Bitmap size is too large"));
                    }
                } else {
                    final Throwable stacktrace = new RuntimeException();
                    view.getViewTreeObserver().addOnPreDrawListener(
                            new ViewTreeObserver.OnPreDrawListener() {
                                @Override public boolean onPreDraw() {
                                    int w = view.getWidth();
                                    int h = view.getHeight();
                                    if(w > 0 && h > 0) {
                                        if (bitmap.getWidth() >= (w << 1)
                                                && bitmap.getHeight() >= (h << 1)) {
                                            warn(bitmap.getWidth(), bitmap.getHeight(), w, h, stacktrace);
                                        }
                                        view.getViewTreeObserver().removeOnPreDrawListener(this);
                                    }
                                    return true;
                                }
                            });
                }
            }
        }
    }

    private static void warn(int bitmapWidth, int bitmapHeight, int viewWidth, int viewHeight, Throwable t) {
        String warnInfo = new StringBuilder("Bitmap size too large: ")
                .append("\n real size: (").append(bitmapWidth).append(',').append(bitmapHeight).append(')')
                .append("\n desired size: (").append(viewWidth).append(',').append(viewHeight).append(')')
                .append("\n call stack trace: \n").append(Log.getStackTraceString(t)).append('\n')
                .toString();

        LogUtils.i(warnInfo);

Application初始化时注入Hook

DexposedBridge.hookAllConstructors(ImageView.class, new XC_MethodHook() {
    @Override protected void afterHookedMethod(MethodHookParam param) throws Throwable {
        super.afterHookedMethod(param);
        DexposedBridge.findAndHookMethod(ImageView.class,"setImageBitmap", Bitmap.class,
                new CheckBitmapHook());
    }
});

八、线上内存监控

8.1 常规方案

常规方案一

在特定场景中获取当前占用内存大小,如果当前内存大小超过系统最大内存80%,对当前内存进行一次Dump(Debug.dumpHprofData()),选择合适时间将hprof文件进行上传,然后通过MAT工具手动分析该文件。

缺点:

  • Dump文件比较大,和用户使用时间、对象树正相关
  • 文件较大导致上传失败率较高,分析困难

常规方案二

将LeakCannary带到线上,添加预设怀疑点,对怀疑点进行内存泄露监控,发现内存泄露回传到server。

缺点:

  • 通用性较低,需要预设怀疑点,对没有预设怀疑点的地方监控不到
  • LeakCanary分析比较耗时、耗内存,有可能会发生OOM

8.2 LeakCannary定制改造

  1. 将需要预设怀疑点改为自动寻找怀疑点,自动将前内存中所占内存较大的对象类中设置怀疑点。
  2. LeakCanary分析泄露链路比较慢,改造为只分析Retain size大的对象。
  3. 分析过程会OOM,是因为LeakCannary分析时会将分析对象全部加载到内存当中,我们可以记录下分析对象的个数和占用大小,对分析对象进行裁剪,不全部加载到内存当中。

8.3 完整方案

  1. 监控常规指标:待机内存、重点模块占用内存、OOM率
  2. 监控APP一个生命周期内和重点模块界面的生命周期内的GC次数、GC时间等
  3. 将定制的LeakCanary带到线上,自动化分析线上的内存泄露

你可能感兴趣的:(2021-09-24)