一、概念
定义 | 通过减少内存使用量、减少对内存(资源)的消耗、提高内存利用率来实现。 |
原因 | 系统对每个应用程序都有一定的内存限制,当应用程序的内存超过了上限,就会出现 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 中最弱的引用类型,对于虚引用,对象只存在于垃圾回收的最后阶段,在这个阶段,对象将被回收,而无论内存是否充足。虚引用主要用于监测对象被回收的状态,而不是用于缓存对象。