可能是比较深入的探索Android内存优化

前言

内存优化是Android性能优化中非常重要且不可或缺的一环,理解掌握内存优化相关的原理、步骤和体系是成为一个优秀Android工程师所必要的的能力素质。本文就比较深入的探索谈论一下关于Android内存优化的相关问题。

本文大纲

  • JVM/Android内存管理机制
  • 内存问题相关原理以及相关工具使用
  • 内存优化问题解决方法论
  • 线上内存问题监控思路
  • 方法论总结

1、内存问题

因为java内存回收机制是自动的,这就导致比较Android内存的性能问题比较隐蔽,不易被察觉。往往一旦察觉了可能内存方面就出现大问题了(OOM),为了做到防患于未然,我们必须知道Android内存方面常见的问题。

1.1常见的内存问题

  • 内存抖动。GC频繁导致应用运行卡顿,甚至出现OOM导致应用Crash。
  • 内存泄露。造成不必要的内存占用,导致可用内存减少。
  • 内存溢出(OOM)。申请不到内存直接OOM。

2、内存管理机制

2.1 JVM内存管理机制

2.1.1、分区

image

  • 方法区: 在java的虚拟机中有一块专门用来存放已经加载的类信息、常量、静态变量以及方法代码的内存区域,叫做方法区;所有线程共享

  • 运行时常量: 常量池是方法区的一部分,主要用来存放常量(字面量)和类中的符号引用等信息。

  • 堆区:用于存放类的对象实例。所有线程共享。

  • 栈区:也叫java虚拟机栈,是由一个一个的栈帧组成的后进先出的栈式结构,栈桢中存放方法运行时产生的局部变量、方法出口等信息。当调用一个方法时,虚拟机栈中就会创建一个栈帧存放这些数据,当方法调用完成时,栈帧消失,如果方法中调用了其他方法,则继续在栈顶创建新的栈桢。线程独占。

对象大部分对象都会被分配在堆区,所以内存回收的重点就是在堆区。

2.1.2 如何判断一个对象是否应该被回收

(1)判断对象是否被回收
  • 引用计数法:计算引用的次数,但是会有问题比如A引用B,B引用C,C引用A。这个对象其实都没用了还不能被回收
  • GC可达性算法:直接或者间接被预先定义的GCRoot引用着的时候才判定为这个对象不应该被回收,避免循环引用的问题。
(2)常见的GCRoot
  • 处于活跃中的线程
  • 栈帧中的对象
  • 本地方法栈中的对象
  • 正在被用于同步的各种锁对象
  • JVM系统自身的对象,eg:系统类加载器。

2.1.3 内存回收算法

标记-清除算法

image

  • 原理:如上图,对于“活”的对象,一定可以追溯到其存活在堆栈、静态存储区之中的引用。这个引用链条可能会穿过数个对象层次。
    • 第一阶段:从GC roots开始遍历所有的引用,对有活的对象进行标记标记为粉色。
    • 第二阶段:对堆进行遍历,把未标记的对象(绿色)进行清除。
  • 缺点:
    • 1、暂停整个应用(stop the world);
    • 2、会产生内存碎片,如绿色被清楚变为灰色可使用空间,还是内存区域比较小产生内存碎片,不易被重新使用。
复制算法:

image

  • 原理:为了提升效率,把内存空间划分为2个相等A和B两片区域,每次只使用一个区域。垃圾回收时,遍历当前使用区域,把正在使用的对象复制到另外一个区域
  • 特点
    • 效率高一些(只会对1/2进行标记)
    • 避险内存碎片。
标记-整理算法
  • 原理:
    • 第一阶段标记活的对象。
    • 第二阶段把为标记的对象压缩到堆的其中一块,按顺序放。即将所有存活的对象都向一端移动,然后直接清除掉端边界以外的内存。
  • 特点:
    自带整理功能,这样不会产生大量不连续的内存空间
分代标记算法:

  • 如上图新生代朝生夕亡存活率低的情况采用复制算法。
    具体逻辑:
    • 年轻代分为了三部分:1个Eden区和2个Survivor区(分别叫From和To)
    • 每次新创建对象时,都会分配到Eden区,当Eden区没有足够的空间进行分配时,虚拟机将发起一次Minor GC 。
    • 这些对象经过第一次Minor GC后,如果仍然存活,将会被移到Survivor区“From”,在Minor GC开始时,对象只会存在于Eden区和名为“From”的Survivor区,Survivor区“To”是空的,然后开始进行GC,Eden区中所有存活的对象都会被复制到“To”,而在“From”区中,仍存活的对象会根据他们的年龄值来决定去向。
    • 年龄达到一定值(年龄阈值,可以通过-XX:MaxTenuringThreshold来设置,默认15)的对象会被移动到年老代中,没有达到阈值的对象会被复制到“To”区域。经过这次GC后,Eden区和From区已经被清空。这个时候,“From”和“To”会交换他们的角色,也就是新的“To”就是上次GC前的“From”,新的“From”就是上次GC前的“To”。
    • 不管怎样,都会保证名为“To”的Survivor区域是空的。Minor GC会一直重复这样的过程,直到“To”区被填满,“To”区被填满之后,会将所有此时还存活的对象移动到年老代中。
  • 老年代存活率高采用标记整理算法。

2.2、Android 内存管理机制

  • 应用可用内存是动态的分配的,使用的时候会向系统申请
  • 出现OOM的情况:
    • 1.确实不足(单个App的内存最大可用的)。
    • 2.当前系统确实没有内存空间,不单单是自己问题。
    • 3.可能是内存碎发。 out of Memory。
  • 回收算法
    • Dalvik是一种回收算法。
    • Art回收算法运行期可选择。(前台:标记清楚。后台:标记整理)
  • LowMemoryKiller。针对所有进程进行一次回收,根据进程优先级和回收收益。

3、探究解决内存问题

3.1、内存问题照妖镜

工欲善其事必先利其器,好用的内存分析工具还是很有必要滴,我们常用的工具主要是以下三种。

  • MemoryProfiler:这是AndroidStudio自带的一个内存分析工具。
    主要特点:
    • 图表展现形式很直观、而且也能查看具体堆栈内存信息。
    • 很适合线下初步分析、开发调试使用。
  • MAT(MemoryAnalyer)
    • java heap分析工具。
    • 自动分析预览,线下详细分析使用。
  • LeakCanery:内存泄露分析工具。
    • 线下开发调试使用。
    • 比较占内存,有产生OOM的风险。

3.2、内存泄露问题

3.2.1、什么是内存泄露

简而言之,本应该被回收的对象,没有被及时的回收就是内存泄露

什么是本应该被回收的资源,就是上一章我们将的判断一个对象是否被回收惯常的就是通过GC可达性算法。结合实例说明一下,比如Activity被关闭了,这时候Activity这个对象通常情况下应该被回收,但是他会一些GCRoot直接或者间接的引用着导致不能被回收,这就产生了内存泄露。

内存泄露会带来严重的性能问题具体表现

  • 内存占用过高,申请到内存产生OOM。这里多说一句,OOM产生的原因不单单是因为内存的大小不够,准确的说应该是可用内存不足。举个例子,App可用内存为128M,当前已用内存110M,此时应用申请一个2M的内存空间也会产生OOM。其中原因之一:剩余的那18M内存是内存碎片导致申请不到连续的2M的内存空间就会产生OOM。另外一个原因:系统中已经没有18的内存空间能够给应用了也会产生OOM。
  • 内存不足,频繁GC导致应用卡顿。

3.2.2、揪出内存泄露元凶

为了方便说明先制造一次案发现场:

  • 代码比较简单,Activity的OnCreate()方法中,注册了一个本地广播。
//一个草率的注册容器
public class ContainerManager {
    private static List<DummyListener> dummyListenerList = new ArrayList<>();

    public static void registerListener(DummyListener listener) {
        dummyListenerList.add(listener);
    }

    public static void unregisterListener(DummyListener listener) {
        if (listener == null) return;
        dummyListenerList.remove(listener);
    }

    interface DummyListener {
        public void onAction();
    }
}

//一个草率的Activity
public class LeakActivity extends Activity implements ContainerManager.DummyListener {
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_leak);
    }

    @Override
    protected void onStart() {
        super.onStart();
        ContainerManager.registerListener(this);
    }

    @Override
    protected void onStop() {
        super.onStop();
        ContainerManager.unregisterListener(this);
    }

    @Override
    public void onAction() {

    }
}
  • 连续打开关闭LeakActivity十次,点击MemoryProfiler中的GC按钮,然后强制GC,确保该及时GC。
分析原因
  • 这时候会看到MemoryProfiler的内存会有一个升高,且降不下来的情况。于是有个初步的判断,内存泄露。
  • 在MemoryProfiler中具体查找分析原因,可以看到LeakActivity这个对象在内存中还存在着10,口怕 -_—!!

image

  • 使用MemoryProflier工具能查看具体的内存回收和分配情况如下图,然后查看调用栈能找到相应的对象。能看到使我们在dummyListenerList中持有引用着 。

image

3.2.3、 解决内存泄露

上面我们已经比较清楚的知道,什么情况下对象不会被回收,就是通过可达性计数算法,得出这个对象还被引用着的时候。我们看到这个LeakActivity对象被dummyListenerList中引用着呢。解决方案比较明显其中之一就是可以再onStop()方法中把对象从dummyListenerList中移除。

其实Android中大部分的内存泄露问题,都出现在Activity或者Fragment上。

  • 或者被线程引用着,线程是一种GCRoot。
  • 有长生命周期的对象持有短声明周期的对象,如ApplicationContext引用着Activity会导致Activity不能被及时回收。
  • 静态变量引用着的对象,会被长时间的引用着不能被及时回收。

总之就是,应该被回收的对象,被直接或者间接的被GCRoot引用着,不能回收导致内存泄露,我们就要在这些地方多加注意,防止内存泄露。

另外作为补充,大家熟悉Glide的话,会知道Glide会感知在Activity/Fragment生命周期做相应处理,来解决Activity/Fragment内存泄露的问题。这个思路在很多熟悉的框架中都有运用,想了解其内部原理的话可以异步我的另一篇文章 横向对比Jetpack、RxJava、Glide框架中对组件生命周期Lifecycle感知原理

3.3、内存抖动问题

3.3.1 什么是内存抖动

内存抖动是一种表现,内存在很短的时间内频繁的分配内存,回收内存。

  • 原因:内存频繁分配和回收,短时间内创建对象,释放对象。
  • 后果: 后果也很严重,短时间内存创建大量对象回收不及时OOm;频繁GC,占用CPU调度致使应用卡顿。

3.3.1、排查内存抖动

参照上面内存泄露的问题。

  • 使用MemoryProfiler进行初步的排查,直观查问出现的问题,查看内存分配的情况。
  • 录制一段查看新增的对象,跟踪排查问题。
  • 然后查看调用栈系统的排查出问题。

3.3.3、解决内存抖动

在循环逻辑和被频繁调用的地方容易产生内存抖动。

  • 可以采用池化技术,重复利用对象,避免对象频繁被回收创建,这一思想在好多地方都被用到,图片加载库Glide中会缓存一部分Bitmap。广泛意义上讲线程池也是一种池化技术,顺便打波广告,即将发布一篇关于Thread的长文,敬请期待。
  • 内存抖动的也可能是因为内存空间确实不足,导致内存频繁GC。这时候就要排查控制珍惜内存的使用。

4、线上内存监控方案。

内存问题主要是内存泄漏问题,内存泄漏问题最终会导致内存溢出。所以我们重点关注的就是线上内存泄漏问题。而常规的内存泄漏监控都是线下方案,在线上难以实施。

方案1.

思路

  • 在特定场景下,比如当内存占用超过单个APP可用内存的80%、检测到内存泄漏时,抓取一次内存快照存在问题。
  • 在合适的时机将dump文件传到服务器,因为dump文件比较大,不要在应用出于前台的时候上传。
  • 使用MAT对其进行分析。

问题:

  • 文件会比较大,和对象数量直接正相关。需要考虑dump文件裁剪。
  • 上传失败的问题比较严重
  • 分析比较困难,可能会存在多个dump文件。

方案2.

思路

  • 将LeakCanary带到线上,在怀疑点使用LeakCanary进行监控,
  • 监控到问题的时候,上报相关信息。

问题

  • (1)需要人工寻找怀疑点,容错性低,监控不全面
  • (2)LeakCanery分析性能比较低,内存占用比较高,存在LeakCanary本身发生OOM的风险

方案3.

思路
定制LeakCanay
image
LeakCanary分为监控组件和分析组件。

  • 使用LeakCanary的监控组件,自动寻找怀疑点,寻找大对象。
  • 针对分析组件的分析行为占用内存大的问题,只对大对象进行分析,大对象往往是内存泄漏的元凶,从而解决分析性能低的问题。
  • 针对LeakCanary可能会发生OOM问题。解决方案对HPROF文件进行裁剪,分析的时候,不把堆栈信息全部加载到内存,只把相应同一种类型的对象只记录数量和占用内存的情况记录下来(如上图)我们只记录这条GC链路上对象甲乙的数量和内存占用,而不是对象甲A、甲B对记录分析,从而降低其内存占用。

综合方案

  • 监控记录App占用内存情况,重点模块的内存占用情况。
  • 整体GC次数,重点模块GC次数,GC耗费时间。
  • 对LeakCanary改造带到线上。当然关于HPROF文件的裁剪要找到准确性和效率的平衡,对在在客户段分析失败的情况以回传裁剪过的dump文件作为补充。

5、内存优化方法论

1、优先优化,方便简单见效快。

  • 内存泄露, 内存抖动,结合工具容易排查和解决
  • bitmap,bitmap是占用内存大户结合成熟的图片加载库,同时比较深入的理解其内部机制和原理。

2、 体系化建设

  • 保护劳动果实,比如,制作性能自查表跟同时共享,使用Hook手段持续监测性能情况。
  • 线上性能问题监控。

3、一些细节

  • 将App注册为largeHeap,申请更多的内存。
  • App内系统组件都有onTrimMemory() onLowMemory()回调方法,要跟根据不同的等级(TRIM_MEMORY_RUNNING_MODERATE、TRIM_MEMORY_RUNNING_LOW、TRIM_MEMORY_RUNNING_CRITICAL)采取不同的操作释放内存资源。
  • 优化集合使用,使用Android中占用内存更小的ArrayMap等。
  • 谨慎使用SharedPreference,SharedPreference在初始化的时候被将数据都加入到内存中,如果SP存的数据较大会持续的占用内存。
  • 谨慎使用外部库,没有经过大规模验证,质量低不可控;不要引用同质化的框架触发类加载之后也会占内存啊!
  • 针对Bitmap这种内存大户可以使用ARTHook方案检测是否使用合理,如Bitmap尺寸是否超过了ImageView的显示尺寸。

最后