Android优化 - 内存

参考文章

一、概念

定义 通过减少内存使用量、减少对内存(资源)的消耗、提高内存利用率来实现。
原因 系统对每个应用程序都有一定的内存限制,当应用程序的内存超过了上限,就会出现 OOM (Out of Memory),也就是 APP 的异常退出。因此需要通过对内存的优化来改善系统的运行效率、延长电池寿命、减少卡顿崩溃。
分析 重点关注四个阶段:应用停留在闪屏页面内存固定值、MainActivity大HomeActivity内存波动值、应用运行十分钟后回到HomeActivity内存波动值、应用内存使用量分配至总汇。
维度 主要是降低APP运行时内存的占用,优化目的有三个:防止APP发生OOM、降低使用内存过大被LMK杀死概率、避免不合理使用内存导致GC次数增多从而卡顿。
时机 当系统内存充足时多用一些内存提高性能,当系统内存不足时做到“用时分配,及时回收”。
价值 带来三点好处:减少OOM提高稳定性、减少卡顿提升流畅度、减少内存占用提高后台运行存活率。
痛点 分为三类:内存抖动、内存泄漏、内存溢出。

1.2 ART & Dalvik 虚拟机

ART 和 Dalvik 虚拟机使用分页和内存映射来管理内存,ART 相比 Dalvik,在性能和稳定性方面有了很大的提升,但是由于 ART 把字节码编译成机器码,因此空间占用更大。

Dalvik 是 Android 系统首次推出的虚拟机,它是一个字节码解释器,把 Java 字节码转换为机器码执行。由于它的设计历史和硬件限制,它的性能较差,
ART 是 Android 4.4(KitKat)发布后推出的一种新的 Java 虚拟机,它把 Java 字节码编译成机器码,在安装应用时一次性编译,因此不需要在运行时解释字节码,提高了性能,带来了更快的应用启动速度和更低的内存消耗。

1.3 LMK 内存管理机制

全称 Low Memory Killer,是 Android 系统内存管理机制中的一部分。底层原理是利用内核 OOM(Out-of-Memory)机制来管理内存。当系统内存不足时,内核会根据各进程的优先级将内存分配给重要的进程,同时会结束一些不重要的进程,以避免系统崩溃,保证系统的正常运行。

二、针对痛点

2.1 内存抖动

当内存频繁分配和回收导致内存不稳定出现抖动,通常表现为频繁 GC、内存曲线呈锯齿状。使用 MemoryProfiler 结合代码可找到内存抖动出现的地方,查看循环或频繁调用的地方即可。

Android优化 - 内存_第1张图片

2.1.1 字符串使用加号拼接

实际开发中应该使用 StringBuilder 替代,初始化时设置容量减少扩容次数。

//使用加号拼接字符串
fun useAdd() {
    var str = ""
    val startTime = System.currentTimeMillis()
    for (i in 0 until 10000) {
        str += "Hello"
    }
    val endTime = System.currentTimeMillis()
    val ram = (Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory()) / (1024 * 1024)
    println("使用加号拼接字符串的时间:${endTime - startTime} ms")   //253 ms
    println("使用加号拼接字符串的内存使用量:$ram MB")  //58 MB
}

//使用StringBuilder
fun useBuilder() {
    val sb = StringBuilder(5)
    val startTime = System.currentTimeMillis()
    for (i in 0 until 10000) {
        sb.append("Hello")
    }
    val endTime = System.currentTimeMillis()
    val ram = (Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory()) / (1024 * 1024)
    println("使用StringBuilder的时间:${endTime - startTime} ms") //2 ms
    println("使用StringBuilder的内存使用量:$ram MB")    //7 MB
}

2.1.2 资源复用

使用全局缓存池,避免频繁申请和释放对象。使用单例创建一个 ObjectPool 类,并提供添加、获取、删除对象的方法。

object ObjectPool {
    private val pool = HashMap()
    fun add(key: String, obj: Any) {
        pool[key] = obj
    }
    fun get(key: String): Any? {
        return pool[key]
    }
    fun remove(key: String) {
        pool.remove(key)
    }
}

2.1.3 减少不合理的对象创建

一些会频繁执行的方法例如 onDraw(),每次创建局部变量时内存都会分配给它,但在循环结束后它们不会被立即回收,这将导致内存的不断增加,最终导致内存抖动。

2.1.4 避免在循环中不断创建局部变量 

//错误示范
for (i in 0 until 10000) {
    val bitmap = BitmapFactory.decodeResource(resources, R.drawable.icon_menu)
}
//正确示范
var bitmap: Bitmap = null
for (i in 0 until 10000) {
    bitmap = BitmapFactory.decodeResource(resources, R.drawable.icon_menu)
}

 2.1.5 使用合理的数据结构

使用 SparseArray 类族、ArrayMap 来替代 HashMap。

2.2 内存溢出 OOM

        Android 中的内存是弹性分配的,分配值与最大值受具体设备影响。系统对每个 APP 都有一定的内存限制(设备出厂以后 JAVA 虚拟机对单个 APP 的最大内存分配就确定下来了,位于 /system/build.prop 文件中的 dalvik.vm.heap growth limit),当应用程序使用的内存超过了上限就会出现 OOM (Out of Memory),也就是异常退出。

        除了因内存泄漏累积到一定程度导致 OOM 的情况以外,也有一次性申请很多内存,比如说一次创建大的数组或者是载入大的文件如图片的时候会导致 OOM。

//试图创建大的数组
val intArray = IntArray(Int.MAX_VALUE)
//试图载入大的图片
val bitmap = BitmapFactory.decodeResource(resources, R.drawable.icon_menu)
imageView.setImageBitmap(bitmap)

2.2.1 频繁创建对象,导致内存不足和不连续碎片

每次点击都创建 100000 个大约为 1MB 的数组,如果内存不够用则会导致OOM。

onClick() {
    for (i in 0 until 100000) {
        val data = ByteArray(1024 * 1024)
    }
}

2.2.2 不连续碎片无法被分配

每次点击都会创建大量 1MB 的数组并添加到列表中,由于内存是不连续的,在较大的数组中分配这些不联系的内存碎片可能导致OOM。

onClick() {
    val dataList = arrayListOf()
    for (i in 0 until 100000) {
        val data = ByteArray(1024 * 1024)
        dataList.add(data)
    }
}

2.3 内存泄漏

Android系统虚拟机的垃圾回收是通过 JVM 虚拟机 GC 机制来实现的。GC会选择一些还存活的对象作为内存遍历的根节点 GC Roots,通过对 GC Roots 的可达性来判断是否需要回收。内存泄漏是在当前应用周期内不再使用的对象被 GC Roots 引用,导致不能回收,使实际可使用内存变小。对象被持有导致无法释放或不能按照对象正常的生命周期进行释放,内存泄漏导致可用内存减少和频繁GC,从而导致内存溢出 App 卡顿。

每次加载图片并添加到集合中都不会释放内存,因为 list 引用了这些图片,导致无法释放最终造成内存溢出。可以考虑使用低内存占用的图片格式,或者在不需要使用图片时主动调用 recycle() 释放图片的内存。

val bitmaps = arrayListOf()
while (true) {
    val bitmap = BitmapFactory.decodeResource(resources, R.drawable.icon_menu)
    bitmaps.add(bitmap)
}

三、指导

3.1 流程

分析现状 如果发现 APP 在内存方面可能存在很大的问题,一是线上的 OOM 率比较高,二是经常会看到在 Android Studio 的 Profiler 工具中内存的抖动比较频繁。
确认问题 在知道了初步的现状之后,进行了问题的确认,经过一系列的调研以及深入研究,最终发现项目中存在以下几点大问题,比如说:内存抖动、内存溢出、内存泄漏,还有 Bitmap 粗犷使用。
问题优化 如果想解决内存抖动,Memory Profiler 会呈现了锯齿张图形,然后我们分析到具体代码存在的问题(频繁被调用的方法中出现了日志字符串的拼接),就能解决内存泄漏或内存溢出。
提升体验 为了不增加业务工作量,使用一些工具类或 ARTHook 大图检测方案,没有任何的侵入性。同时,将技术进行团队分享,团队的工作效率上会有本质提升。对内存优化工具如 Profiler Memory、MAT 的使用,可以针对一系列不同问题的情况,写一系列解决方案文档,整个团队成员的内存优化意识会更强。

3.2 原则

不断积累经验 首先应该学习 Google 内存方面的文档,如 Memory Profiler、MAT 等工具的使用,当在工程遇到内存问题,才能对问题进行排查定位。而不是一开始并没有分析项目代码导致内存高占用问题,就依据自己看的几篇企业博客,不管业务背景,瞎猫碰耗子做内存优化。
结合业务优化 如果不结合业务背景,直接对 APP 运行阶段进行内存上报然后内存消耗进行内存监控,那么内存监控一旦不到位,比如存在使用多个图片库,因为图片库内存缓存不公用的,应用内存占用效率不会有质的飞跃。因此技术优化必须结合业务。
方案系统科学 在做内存优化的过程中,Android 业务端除了要做优化工作,Android 业务端还得负责数据采集上报,数据上报到 APM 后台后,无论是 Bug 追踪人员或者 Crash 追踪人员,对问题"回码定位"都提供好的依据。
内存劣化Hook魔改 大图片检测方案,大家可能想到去是继承 ImageView,然后重写 ImageView 的 onDraw 方法实现。但是,在推广的过程中,因为耦合度过高,业务同学很难认可,ImageView 之前写一次,为什么要重复造轮子呢? 替换成本非常高。所以我们可以考虑使用类似 ARTHook 这样的 Hook 方案。

四、JAVA

4.1 内存分区

程序计数器 计算当前线程的当前方法执行到多少行
对象
虚拟机栈 Java变量引用
方法区 主要存放静态常量
本地方法区 Native 变量引用

4.2 内存回收算法

4.2.1 标记清除算法

是最早的内存回收算法,先标记所有存活的对象,然后统一回收所有未标记的对象。

优点:实现比较简单。

缺点:效率不高,产生大量内存碎片。

Android优化 - 内存_第2张图片

4.2.2 复制算法

将内存分为两个区域,其中一个区域用于存储活动对象,另一个区域用于存储不再使用的对象。一块内存用完之后复制存活对象到另一块,然后清理之前的那块。

优点:实现简单,运行高效,每次仅需遍历标记一半的内存区域。

缺点:会浪费一半的空间,代价大。

Android优化 - 内存_第3张图片

4.2.3 标记整理算法

是标记清除算法和复制算法的结合。将内存分为两个区域,先标记所有存活的对象,存活对象往另一块区域进行移动,然后清理之前的那块。

优点:避免标记清除导致的内存碎片,避免复制算法的空间浪费。

缺点:

  • 时间开销:需要进行两次扫描,一次标记活动对象,一次整理内存,这增加了时间开销。
  • 空间开销:需要为活动对象留出足够的空间,因此必须移动内存中的一些对象,这会增加空间开销。
  • 内存碎片:在整理内存时可能会产生内存碎片,使得未使用的内存碎片不能被有效利用。
  • 速度慢:相对于其他算法速度较慢,不适合需要高效内存管理的场景。
  • 效率不稳定:效率受到内存使用情况的影响,如果内存使用情况不均衡,效率会不稳定。

Android优化 - 内存_第4张图片 

4.2.4 分代收集算法

原理:将内存分为几个代的算法,并对每个代进行不同的回收策略。主流的虚拟机一般用的比较多的是分代收集算法。

  • 分配新的对象:新创建的对象分配在新生代中,因为大多数新创建的对象都很快失效,并且删除它们的成本很低。
  • 垃圾回收:新生代中的垃圾对象被回收,并且回收算法只涉及到新生代的一小部分。如果一个对象存活到一定时间,它将被移动到老年代。
  • 老年代回收:在老年代中,回收算法进行全面的垃圾回收,以确保可以回收所有垃圾对象。
  • 整理内存:回收后,内存被整理,以确保连续的内存空间可以分配给新对象。

优点:

  • 减少垃圾回收的时间:通过将新生代和老年代分开,分代收集算法可以减少垃圾回收的时间,因为新生代中的垃圾对象被回收的频率较高。
  • 减少内存碎片:因为新生代的垃圾回收频率较高,分代收集算法可以防止内存碎片的产生。
  • 提高内存利用率:可以有效地回收垃圾对象,提高内存的利用率。
  • 减少内存消耗:可以减少对内存的消耗,因为它仅需要涉及小的内存区域,而不是整个 Java 堆。
  • 提高系统性能:可以提高系统性能,因为它可以缩短垃圾回收的时间,提高内存利用率,减少内存消耗。

缺点:

  • 复杂性:分代收集算法相对于其他垃圾回收算法来说更复杂,需要更多的内存空间来管理垃圾回收。
  • 内存分配不均衡:分代收集算法可能导致内存分配不均衡,这可能导致新生代内存不足,老年代内存过多。
  • 垃圾对象转移次数:分代收集算法需要移动垃圾对象,这可能导致更多的计算开销。
  • 时间开销:分代收集算法需要更长的时间来管理垃圾回收,这可能导致系统性能下降。
  • 停顿时间:分代收集算法可能导致长时间的停顿,这可能影响系统的实时性。

新生代使用场景推荐:

  • 对象生命周期短:适用于那些生命周期短的对象,因为它们在很短的时间内就会被回收。
  • 大量生成对象:对于大量生成对象的场景,新生代回收算法可以有效地减少回收时间。

老年代使用场景推荐:

  • 对象生命周期长:适用于生命周期长的对象,因为它们不会很快被回收。
  • 内存数据稳定:对于内存数据稳定的场景,老年代回收算法可以提高内存效率。

Android优化 - 内存_第5张图片

4.3 引用类型

回收时间 用途 生存时间
强引用 永不 对象的一般状态 JVM 停止运行时
软引用 内存不足时 对象缓存 内存不足时终止
弱引用 GC 对象缓存 GC 后终止
虚引用 未知 未知 未知

4.3.1 强引用

强引用是 Java 中最常见的引用类型,当对象具有强引用时,它永远不会被垃圾回收。只有在程序结束或者手动将对象设置为 null 时,才会释放强引用。

//创建对象before,将对象赋值给另一个变量after,此时它们指向同一个对象。
ArrayList before = new ArrayList<>();
ArrayList after= data;
//将before置为空断开引用让其成为可回收对象,但因为after仍保持对该对象的强引用,因此不会被GC回收
before = null;
System.gc();

4.3.2 软引用

软引用是比强引用更容易被回收的引用类型。当 Java 堆内存不足时,软引用可能会被回收,以腾出内存空间。如果内存充足,则软引用可以继续存在。

//创建before对象,并使用该对象作为参数创建软引用对象after
Object before = new Object();
SoftReference after = new SoftReference<>(referent);
//将after置为空断开引用让其成为可回收对象,因为after仅持有该对象的软引用,内存不足时可能被回收
//通过 after.get() 获取对象,已回收返回null。
before = null;
System.gc();

4.3.3 弱引用

一种用于追踪对象的引用,不会对对象的生命周期造成影响。在内存管理方面,弱引用不被认为是对象的“有效引用”。如果一个对象只被弱引用指向,那么在垃圾回收的时候,这个对象可能会被回收掉。常被用来在内存敏感的应用中实现对象缓存。在这种情况下,弱引用可以让缓存的对象在内存不足时被回收,从而避免内存泄漏。

String before = new String("Hello");
WeakReference after = new WeakReference<>(data);

4.3.4 虚引用

虚引用是 Java 中最弱的引用类型,对于虚引用,对象只存在于垃圾回收的最后阶段,在这个阶段,对象将被回收,而无论内存是否充足。虚引用主要用于监测对象被回收的状态,而不是用于缓存对象。

 

你可能感兴趣的:(android)