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 结合代码可找到内存抖动出现的地方,查看循环或频繁调用的地方即可。

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<String, Any>()
    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<ByteArray>()
    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<Bitmap>()
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 标记清除算法

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

优点:实现比较简单。

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

4.2.2 复制算法

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

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

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

4.2.3 标记整理算法

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

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

缺点:

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

4.2.4 分代收集算法

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

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

优点:

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

缺点:

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

新生代使用场景推荐:

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

老年代使用场景推荐:

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

4.3 引用类型

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

4.3.1 强引用

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

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

4.3.2 软引用

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

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

4.3.3 弱引用

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

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

4.3.4 虚引用

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

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值