一、概述
内存作为计算机程序运行最重要的资源之一,需要运行过程中做到合理的资源分配与回收,不合理的内存占用轻则使得用户应用程序运行卡顿、ANR、黑屏,重则导致用户应用程序发生 OOM(out of memory)崩溃。如果需要在各种设备资源上保持流畅性和稳定性,内存优化是性能优化中最重要的一环。
内存问题普遍是大问题,每一行代码都涉及到内存申请以及回收等过程,但是缺少关注度。为什么缺乏关注,因为它的相对比较隐蔽,表现形式并不明显。Android 使用的是 Java 语言,对于内存回收是自动的,程序员在普遍情况下是不用关注的,所以对内存的申请以及回收过程并不重视。
我们会在一些监控平台上面看到一些堆栈信息,内存溢出 out of menory,但是出现问题的地方仅仅是表现的地方,并不是一个深层次的原因,相对内存问题来说有点复杂。它是一个累积挤压的过程,上报过来的堆栈信息的代码,其实就是压死骆驼的最后一根稻草,并不是真真正正引起内存泄漏的原因。
1. 内存优化的意义
优化内存的意义不言而喻,总的来说可以归结为如下四点:
- 减少OOM,提高应用稳定性。
- 减少卡顿,提高应用流畅度。
- 减少内存占用,提高应用后台运行时的存活率。
- 减少异常发生和代码逻辑隐患。
二、内存管理机制
在内存管理上,JVM 拥有垃圾内存回收的机制,自身会在虚拟机层面自动分配和释放内存,因此不需要像使用 C/C++ 一样在代码中分配和释放某一块内存。
Android 系统的内存管理类似于 JVM,通过 new
关键字来为对象分配内存,内存的释放由 GC 来回收。并且系统在内存管理上有一个 Generational Heap Memory
模型,当内存达到某一个阈值时,系统会根据不同的规则自动释放可以释放的内存。
1. Java 内存管理机制
(1)Java 的内存分配
Java 虚拟机所管理的内存包含了5个区域:程序计数器,虚拟机栈,本地方法栈,Java 堆,方法区:
- 方法区:是被线程共享的区域,一般用来存储不容易改变的数据(也被称为「永久代」)。存储了每个类的信息、静态变量、常量以及编译器编译后的代码等内容。当方法区无法满足内存分配需求时,将抛出 OOM 异常。
- Java虚拟机栈:栈内存,它是 Java 方法执行的内存模型。Java 栈中存放的是一个个的栈帧,每个栈帧对应的是一个被调用的方法。存放的是局部变量表、操作数栈、方法返回地址等信息,会指向堆中真正存储的对象。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。扩展时无法申请到足够的内存,就会抛出 OOM 异常。
- 本地方法栈:与 Java 虚拟机栈的作用和原理非常相似,区别在本地方法栈为执行 Native 方法服务的,而 Java 虚拟机栈是为执行 Java 方法服务的。本地方法栈区域也会抛出 OOM 异常。
- Java堆:堆内存,内存最大区域,被所有线程共享,唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配。它是 Java 的垃圾收集器管理的主要区域,内存泄漏也都是发生在这个区域。当无法再扩展时,将会抛出 OOM 异常。
- 程序计数器:是一块较小的内存空间,也称为 PC 寄存器。它保存的是程序当前执行的指令的地址,用于指示执行哪条指令。这块内存中存储的数据所占空间的大小不会随程序的执行而发生改变,此内存区域不会发生内存溢出问题。
(2)Java 垃圾回收机制
垃圾回收机制也称为 GC(Garbage Collection
),即回收无用内存,使其对未来实例可用的过程。由于设备的内存空间有限,为了防止内存空间被占满导致应用程序无法执行,就需要对无用对象占用的内存进行回收。
Java 堆内存中存放着所有对象实例,在对这些对象回收前,需要确认哪些对象是可以回收的,哪些对象是不可以回收的。主要有以下两种方式判断:
1. 引用计数法:每个对象有一个引用计数器,当对象被引用一次则计数器加1,引用失效一次则减1,当计数为0时就可以被 GC 回收了。该算法由于无法处理对象之间相互循环引用的问题,现在虚拟机基本上不再使用这种方式。
2. 可达性分析算法:通过 GC Roots
的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链。GC Roots
可以通过引用链达到某个对象则该对象称为可达对象。如果通过 GC Roots
到某个对象没有任何引用链可以达到则该对象称为不可达对象。
对于 GC Roots
无法到达的对象便成了垃圾回收的对象,随时可能被 GC 回收。
GC 是需要 2 次扫描才回收对象,通过 GC Roots
经过可达性分析算法,得到某对象不可达时,进行第一次标记该对象。接着进行一次筛选此对象是否有必要执行 finalize()
,没有必要则这个对象可被回收了。有必要执行 finalize()
则会把对象放入 F-Queue
队列中,如果此对象的 finalize()
方法中搭上引用链则又会变成可达对象,那该对象就完成自救。
(3)Java 内存回收算法
1. 标记-清除算法
「标记-清除」算法分为两个阶段:标记阶段和清除阶段。在「标记」阶段,垃圾收集器遍历堆中的对象,将不再使用的对象进行标记。在「清除」阶段,垃圾收集器将标记的对象从内存中移除。
「标记-清除」算法的主要缺点是标记、清除过程效率低,产生大量不连续的内存碎片,可能导致后续对象分配时找不到足够的连续内存,提高了垃圾回收的频率。
2. 标记-整理算法
「标记-整理」算法在「清除」阶段对「标记-清除」算法的内存碎片化问题进行了优化。在「标记」阶段与「标记-清除」算法相同,都是对不再使用的对象进行标记。在「清除」阶段,「标记-整理」算法会将存活的对象压缩到内存的一端,清理剩余内存,从而避免内存碎片化。
这种算法优点是避免复制算法的空间浪费,解决了「标记-清理」算法存在的内存碎片问题。缺点是仍需要进行局部对象移动,一定程度上降低了效率。
3. 复制算法
「复制」算法将堆内存分为两个相等的区域,每次只使用其中一个区域。当这个区域的内存用完进行垃圾收集时,遍历当前使用的区域,把存活对象复制到另外一个区域中,最后将当前使用区域的可回收的对象进行回收。
这种算法避免了内存碎片化和对象移动的问题,实现简单,运行高效;但代价是可用内存空间减半。
4. 分代收集算法
因为大部分对象的生命周期都很短暂,分代收集算法将堆内存划分为新生代和老年代。「新生代」使用「复制」算法,「老年代」使用「标记-整理」算法。
当前商业虚拟机的垃圾收集都采用「分代回收」算法,这种算法并没有什么新的思想,只是根据对象存活周期的不同将内存划分为为「新生代」和「老年代」,这样可以根据各个年代的特点采用最合适的搜集算法。
在「新生代」中对象生命周期短、存活率低、回收频繁,选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。
而「老年代」中对象生命周期长、存活率高、回收没年轻代频繁,使用「标记-清除」或者「标记-整理」算法来进行回收。
当对象在「新生代」中经历了一定次数的垃圾回收后,它将被晋升到「老年代」。分代收集算法充分利用了对象生命周期的特点,提高了垃圾回收的效率。
2. Android 内存管理机制
(1)对象回收分代算法
Android 的内存堆是分代的,它会根据分配对象的预期寿命和大小跟踪不同的分配存储分区。例如,最近分配的对象属于「新生代」。当某个对象保持活动状态达足够长的时间时,可将其提升为「老年代」,然后是「永久代」。
1. 新生代(Young Generation)
所有新生成的对象首先都是放在「年轻代」中。年轻代的目标就是尽可能快速地回收哪些生命周期短的对象。
由一个 Eden 区和两个 Survivor 区按照 8:1:1 比例组成,程序中生成的大部分新的对象都在 Eden 区中,当 Eden 区满时,还存活的对象将被复制到 From 区,然后清空 Eden 区;当此 From 区满时,则将 Eden 和 From 区中存活对象复制到 To 区,然后清空 Eden 和 From 区,将 To 区和 From 区交换,即保持 From 区为空,如此往复。
通常情况下,在 Survivor 区连续存活超过一定 GC 次数(默认:15)的对象,会升级到「老年代」存放;但如果一个对象在「新生代」 Minor GC 后依然无法存放(Eden 和 Survivor 都放不下),也会直接存放到「老年代」。「新生代」发生的 GC 也叫做 Minor GC,Minor GC 发生频率比较高,不一定等 Eden 区满了才会触发。
2. 老年代(Old Generation)
「老年代」中存放的都是一些生命周期较长的对象。当「老年代」中存满时触发 Full GC,Full GC 发生频率比较低,「年老代」对象存活时间较长,存活率比较高。
「老年代」的垃圾回收采用了「标记-清理」算法,相对比较耗时,并且为了保证在清理过程中引用不发生改变,通常需要暂停所有其他的用户线程(STW,Stop The World),这也是为什么 Full GC 会对进程产生较大的性能影响。
3. 持久代(Permanent Generation)
「持久代」用于存放静态的类和方法,该区域比较稳定,不属于堆区,不会进行垃圾回收,对 GC 没有显著影响,这一部分也被称为运行时常量。(在 JDK 1.8 及之后的版本,在本地内存中实现的元空间(Meta-space)已经代替了「持久代」)
Android 的内存对象在分代算法中处理过程如下:
- 对象创建后在 Eden 区。
- 执行 GC 时,如果对象仍然存活,则复制到 From 区。
- 当 From 区满时,该区存活对象将复制到 To 区,然后 From 区清空,接下来 From 和 To 角色互换。
- 当第3步达到 15 次后,存活对象将被复制到「老年代」。
- 当这个对象在「老年代」区停留的时间达到一定程度时,它会被移动到「老年代」,最后累积一定时间再移动到「持久代」区域。
(2)应用内存分配和回收
为了能够使得 Android 应用程序安全且快速的运行,每个应用程序都会使用一个专有的 Dalvik 虚拟机实例来运行,它是由 Zygote 服务进程孵化出来的,也就是说每个应用程序都是在属于自己的进程中运行的。
Android 为不同类型的进程分配了不同的内存使用上限,同设备的确切堆大小上限取决于设备的总体可用 RAM 大小。Google 原生 OS 虚拟机(Android 5.0之前是 Dalvik,5.0及之后是 ART)默认给每个 App 分配的内存大小为16M,分配值和最大值受具体设备影响。
不同的厂商在不同的设备上会设置默认的上限值,可以通过在 AndroidManifest 的 application 节点中设置属性 Android:largeHeap="true"
来突破这个上限。可以在 /system/build.prop
文件中查询到这些信息(需要有 root 权限,当然也可以通过代码的方式获取)。
如果程序在运行过程中出现了内存泄漏的而造成应用进程使用的内存超过了这个上限,则会被系统视为内存泄漏抛出 OutOfMemoryError
,从而被 kill 掉,而不会影响其他进程(如果是 system_process
等系统进程出问题的话,则会引起系统重启)。
Low Memory Killer 机制:内存不足,针对所有进程回收,低优先级进程优先回收。进程分类,回收收益,能保证进程大部分情况下不会出现内存不足的情况。
Android 运行时 (ART) 和 Dalvik 虚拟机使用分页和内存映射来管理内存。Dalvik 与 Art 的区别:Dalvik 仅固定一种回收算法,Art 回收算法可运行期选择。Art 具备内存整理能力,减少内存空洞。如果应用修改的任何内存,无论修改的方式是分配新对象还是清除内存映射的页面,都会一直驻留在 RAM 中,并且无法换出。若要从应用中释放内存,只能释放应用保留的对象引用,使内存可供垃圾回收器回收。
一旦确定程序不再使用某块内存,它就会将该内存重新释放到堆中,无需开发者进行任何干预。尽管垃圾回收速度非常快,但仍会影响应用的性能。
三、内存问题和现象
即便有了内存管理机制,但是如果不合理地使用内存,也会造成一系列的性能问题,比如 内存泄漏、内存抖动、短时间内分配大量的内存对象 等等。
内存问题主要是有内存抖动、内存泄露、内存溢出这三类问题。
1. 内存抖动
内存频繁的分配与回收,在短时间内大量的对象被创建又马上被释放。内存使用情况现象程锯齿状,瞬间产生大量的对象会严重占用内存区域,当达到阈值,剩余空间不够的时候,会触发 GC 从而导致刚产生的对象又很快被回收。即使每次分配的对象占用了很少的内存,但是他们叠加在一起会增加 Heap 的压力,触发更多其他类型的 GC,从而导致卡顿。
频繁创建对象,导致内存不足以及碎片(不连续),不连续的内存无法被分配,最终产生 OOM 。这个操作有可能会影响到帧率,并使得用户感知到性能问题。
2. 内存泄露
应用中长期保持对某些对象的引用,导致垃圾收集器无法回收这些对象所占的内存,这种现象被称为内存泄漏。在使用资源的时候,系统为此开辟一段内存空间,使用完后忘记释放资源,这时资源一直被占用(未被回收)。虚拟机宁愿抛出 OOM,也不愿意去回收被占用的内存。
内存泄漏的本质原因:本该被回收的对象没有被回,继续停留在内存空间中,导致内存被占用。其实是持有引用者的生命周期 > 被持有引用者的生命周期。准确地说,程序运行分配的对象回收不及时或者无法被回收都会导致内存泄漏。
3. 内存溢出
Android 给每个 App 分配一个 VM ,让 App 运行在 Dalvik 上,这样即使 App 崩溃也不会影响到系统。系统给 VM 分配了一定的内存大小, App 可以申请使用的内存大小不能超过此硬性逻辑限制,就算物理内存富余,如果应用超出 VM 最大内存,就会出现内存溢出 crash,也就是内存溢出。
只有当系统在申请内存空间时,没有总够的内存空间供其使用。导致 App 占用的内存超过了系统允许的范围(也就是前面提到的内存限制)时,才会导致内存溢出。简单来说就是系统不能再分配你所需要的内存空间,比如你申请需要 100M 的内存空间,但是系统仅剩 90M 了,这时候就会内存溢出。
导致内存溢出的原因有两个:
- 当前使用的对象过大或过多,这些对象所占用的内存超过了剩余的可用空间。
- 内存泄漏:内存泄漏不一定导致内存溢出,但是过多内存泄漏会把内存空间占用完,最终会导致内存溢出。
4. 内存问题的表现
(1)异常
内存问题造成的第一个现象是异常。异常包括 OOM、内存分配失败等;这些崩溃也包括因为整体内存不足导致应用被杀死、设备重启等问题。
(2)卡顿
内存问题造成的第二个现象是卡顿。可用内存不足会导致频繁 GC 从而导致卡顿,这个问题在 Dalvik 虚拟机会更加明显。而 ART 虚拟机在内存管理跟回收策略上都做大量优化,内存分配和 GC 效率相比提升了 5~10 倍。
四、常见内存泄漏场景
如果在内存泄露发生后再去原因并修复会增加开发的成本,最好在编写代码时就能够很好地考虑内存问题,写出高质量代码,这里列出一些常见的内存泄露场景,在以后开发过程中需要避免这类问题。
1. 非静态内部类持有外部类引用
在 Java 中,非静态(匿名)内部类默认持有外部类的引用,而静态内部类不会持有外部类的引用。如果外部类对象不再使用,但内部类还持有它,因此外部类对象也无法被垃圾回收,导致内存泄漏。
(1)非静态内部类创建静态实例
该静态实例的生命周期和应用生命周期一样长,这就导致该静态实例一直持有该 Activity 的引用,Activity 的内存资源不能正常回收。
此时,可以将该内部类设为静态内部类或将该内部类抽取出来封装成一个单例,如果需要使用 Context,尽量使用 Application Context,如果需要使用 Activity Context,就记得用完后置空让 GC 可以回收,否则还是会内存泄漏。
(2)Handler造成的内存泄漏
通过内部类创建的 Handler 对象,会隐式持有 Activity 的引用,Hander 装入到 Message 中,如果消息未处理完,间接持有 Activity 的引用,所以导致 Activity 无法被回收利用。
解决方案是:当外部类结束时,清空 Handler 内消息队列 mHandler.removeCallbacksAndMessages(null)
;将匿名内部类改为静态内部类,并对上下文或者 Activity 使用弱引用。
(3)AsyncTask的使用
在 AsyncTask 处理耗时操作时,任务还没完成就将 Activity 退出,此时 AsyncTask 已然持有 Activity 的引用,导致 Activity 无法被回收处理。
在 Activity 销毁时也取消 AsyncTask 相关的任务 AsyncTask.cancel()
,避免任务在后台浪费资源,避免内存泄漏。
2. 静态成员变量
单例模式中的静态变量持有 Context 实例,Context 生命周期小于静态变量的生命周期,造成内存泄漏。
保证 Context 的生命周期与应用的生命周期一致,使用弱引用代替强引用持有实例 val weak = WeakReference<>(context)
另外,静态方法创建的对象如果全局化会造成内存泄漏。在全局化的对象加静态修饰 static。
3. 资源性对象未关闭
在使用 IO 流、File 文件类、Sqlite、Cursor 等对象性资源时要及时调用它的 close()
函数,将其关闭,这些资源在读写操作时一般都行了缓冲,如果不及时关闭,这些缓冲对象就会一直被占用得不到释放,以致内存泄漏。
对于图片资源 Bitmap,Andorid 分配给图片的资源只有8M,如果1个 Bitmap 对象占用资源较多时,当不再使用时应该 recycle()
回收对象像素所占用的内存,最后赋值为 Null。
动画相关资源,在动画结束或者不需要动画或者 Activity 销毁时结束动画animation.cancel()
。
4. 注册对象未注销
事件注册后未注销,会导致观察者列表中维持着对象的引用。例如 BraodcastReceiver、ContentObserver 未注销造成的内存泄漏,我们应该在不需要时及时注销。
5. 容器中对象没清理导致内存泄露
对于所有的集合类,存储了对象,如果该集合类实例的生命周期比里面存储的元素还长,那么该集合类将一直持有所存储的短生命周期对象的引用,就会产生内存泄漏,尤其是使用 static 修饰该集合类对象。
存储的对象使用完后将其 remove 掉,或者使用完集合类后清空集合 arrayList.clear()
,然后设置为 null。
6. 第三方库使用不当造成的内存泄漏
使用第三方库的时候,务必要按照官方文档指定的步骤来做,否则使用不当也可能产生内存泄漏,比如:
- EventBus:也是使用观察者模式实现的,同样注册和反注册要成对出现。
- Rxjava中:上下文销毁时,Disposable 没有调用
dispose()
方法。 - Glide中:在子线程中大量使用
Glide.with(applicationContext)
,可能导致内存溢出。
7. WebView
WebView 都存在内存泄漏的问题,在应用中只要使用一次 WebView,内存就不会被释放掉。
五、内存优化策略
没有内存泄漏,并不意味着内存就不需要优化。由于物理设备的存储空间有限, Android 系统对每个应用也都分配了有限的堆内存,因此使用最小内存对象或者资源可以减少内存开销,同时让 GC 更高效地回收资源,让堆内存保持充足的可用内存,使应用更稳定高效地运行。
1. 对象引用
为了便于对象被回收,常常需要根据实际需要与对象建立不同程度的引用,这里简单介绍一下。Java 中的对象引用方式有如下4种:
引用类型 | 调用方式 | GC | 是否内存泄漏 |
---|---|---|---|
强引用 | 直接调用 | 不回收,宁可抛出异常也不回收强引用指向的对象 | 是 |
软引用 | .get() | 视内存情况回收,内存不足时,GC会回收 | 否 |
弱引用 | .get() | GC了就回收,内存足不足 | 不可能 |
虚引用 | null | 任何时候都可能被回收,相当于没有引用一样 | 否 |
对于强引用的对象,即使是内存不够用了,GC 时也不会被 JVM 作为垃圾回收掉,只会抛出 OutOfMemmory 异常,所以我们在解决内存泄漏的问题时,很多情况下需要处理强引用的问题。
2. 减少不必要的内存开销
(1)自动装箱
在没有特殊原因的情况下,尽量使用基本数据类型来代替封装数据类型,int 比 Integer 要更加有效,其它数据类型也是一样。
自动装箱的核心就是把基础数据类型转换成对应的复杂类型。在自动装箱转化时,都会产生一个新的对象,这样就会产生更多的内存和性能开销。如 int 只占4字节,而 Integer 对象有16字节,特别是 HashMap 这类容器,进行增、删、改、查操作时,都会产生大量的自动装箱操作。
(2)内存复用
对于内存复用,有效利用系统自带的资源,对象池,Bitmap 对象的复用,有如下三种可行的方式:
- 资源复用:通用的字符串、颜色定义、简单页面布局的复用。
- 对象池:显示创建对象池,实现复用逻辑,对相同的类型数据使用同一块内存空间。
- Bitmap对象的复用:使用 inBitmap 属性可以告知 Bitmap 解码器尝试使用已经存在的内存区域,新解码的 Bitmap 会尝试使用之前那张 Bitmap 在 heap 中占据的 pixel data 内存区域。
3. 使用最优的数据结构
比如针对数据类容器结构,可以使用 ArrayMap、SparseArray 数据结构,避免使用枚举类型。
(1)SparseArray与ArrayMap
Android 自身还提供了一系列优化过后的数据集合工具类,如 SparseArray、SparseBooleanArray、LongSparseArray,使用这些 API 可以让我们的程序更加高效。
HashMap 工具类会相对比较低效,因为它需要为每一个键值对都提供一个对象入口,而 SparseArray 就避免掉了基本数据类型转换成对象数据类型的时间。
ArrayMap 提供了和 HashMap 一样的功能,但避免了过多的内存开销,方法是使用两个小数组,而不是一个大数组。并且 ArrayMap 在内存上是连续不间断的。总体来说,在 ArrayMap 中执行插入或者删除操作时,从性能角度上看,比 HashMap 还要更差一些,但如果只涉及很小的对象数,比如1000以下,就不需要担心这个问题了。因为此时 ArrayMap 不会分配过大的数组。
(2)避免使用枚举类型
枚举最大的优点是类型安全,但在 Android 平台上,枚举的内存开销是直接定义常量的三倍以上。
每一个枚举值都是一个单例对象,在使用它时会增加额外的内存消耗,所以枚举相比与 Integer 和 String 会占用更多的内存。大量使用 Enum 会增加 DEX 文件的大小,会造成运行时更多的 IO 开销,使我们的应用需要更多的空间。特别是分 Dex 多的大型 App,枚举的初始化很容易导致 ANR。
5. 避免不必要的对象创建
-
我们可以在字符串拼接的时候尽量少用
+=
,多使用 StringBuffer,StringBuilder。 -
不要在
onMeause()
、onLayout()
、onDraw()
中去刷新 UI(requestLayout()
)。 -
自定义 View 的
onLayout()
、onDraw()
、列表遍历等频繁调用的方法里创建对象。这些方法会被多次调用,在其内部创建对象会导致系统频繁申请存储空间并触发 GC,导致内存抖动,严重会导致 OOM。
6. 谨慎使用Service
如果应用程序当中需要使用 Service 来执行后台任务的话,一定要注意只有当任务正在执行的时候才应该让 Service 运行起来。另外,当任务执行完之后去停止 Service 的时候,要小心 Service 停止失败导致内存泄漏的情况。
启动一个 Service 时,系统会倾向于将这个 Service 所依赖的进程进行保留,这样就会导致这个进程变得非常消耗内存。并且系统可以在 LruCache 当中缓存的进程数量也会减少,导致切换应用程序的时候耗费更多性能,严重的话,甚至有可能会导致崩溃。因为系统在内存非常吃紧的时候可能已无法维护所有正在运行的 Service 所依赖的进程了。
为了能够控制 Service 的生命周期,Android 官方推荐的最佳解决方案就是使用 [IntentService],这种 Service 的最大特点就是当后台任务执行结束后会自动停止,从而极大程度上避免了 Service 内存泄漏的可能性。
通常避免使用持久性服务,它们会持续请求使用可用内存。建议采用 [WorkManager]等替代实现方式。
7. 移除会占用大量内存的资源和库
代码中的某些资源和库可能会消耗内存。应用的总体大小(包括第三方库或嵌入式资源)可能会影响应用的内存用量。可以通过从代码中移除冗余、不必要或臃肿的组件、资源和库,降低应用的内存用量。
三方库代码通常不是针对移动环境编写的,在移动客户端上运行时效率可能并不高。使用三方库时,可能需要针对移动设备优化相应库。在使用前,提前规划,并在代码大小和 RAM 用量方面对库进行分析。
避免仅针对库中数十个功能中的一两个功能而使用三方库。不要引入大量不使用的代码和开销。在考虑是否使用某个库时,请查找与需求十分契合的实现库。否则可能需要自行创建实现。
8. Activity 的兜底内存回收策略
在 Activity 的 onPause()
,onStop()
,onDestory()
中根据场景释放其引用到的 Bitmap、DrawingCache 等资源,以降低发生内存泄漏时对应用内存的压力。
9. 其它的内存优化注意事项
除了上面的一些内存优化点之外,这里还有一些内存优化的点:
- 使用增强型 for 循环语法。
- 使用
static final
优化成员变量。 - 在合适的时候适当采用软引用和弱引用。
- 采用内存缓存和磁盘缓存。
- 视频渲染使用
OpenGL.ES
,不要使用 Java 代码转换 RGB 格式或者 Bitmap 资源,这很消耗内存。 - static 会由编译器调用
clinit()
方法进行初始化,之后访问的时候会需要先到它那里查找,然后才返回数据。static final
不需做多余的查找动作,打包在 dex 文件中可以直接调用,并不会在类初始化申请内存。基本数据类型的成员,可以全写成static final
。
六、图片优化策略
图片一直是 APP 的内存消耗大户,一张图片从几 KB 到几M不等,有时候一张图片就会吃掉我们吃紧的内存,稍有使用不当也会导致 OOM。
1. 图片格式优化
Android 目前的格式有三种:PNG、JPEG、WEBP,分别来了解一下:
- PNG:无损压缩图片方式,支持 Alpha 通道,切图素材大多用这种格式。
- JPEG:有损压缩图片格式,不支持背景透明和多帧动画,适用于色彩丰富的图片压缩,不适合于 logo。
- WEBP:支持有损和无损压缩,支持完整的透明通道,也支持多帧动画,是一种比较理想的图片格式。
- 使用.9图:点九图实际上仍然是 png 格式图片,它是针对 Andorid 平台特殊的图片格式,体积小,拉伸变形,能指定位置拉伸或者填充,能很好的适配机型。
从 Google 官网来看,无损 webp 平均比 png 小26%,有损 jpeg 平均比 webp 少24%-35%,无损 webp 支持 Alpha 通道,有损 webp 在一定条件下也支持。采用 webp 在保持图片清晰情况下,可以优先减少磁盘空间大小。可以将 drawable 中的 png、jpg 格式图片转换为 webp 格式图片。
2. 图片像素格式优化
类型 | 内存 | 色彩组成 | 说明 |
---|---|---|---|
ALPHA_8 | 1B | 透明度 | 比较少用到 |
RGB_565 | 2B | 颜色 | 资源优化设置无处不在,如果不需要 Alpha 通道的,特别是 .JPG 格式的 |
ARGB_4444 | 2B | 颜色+透明度 | 尽管内存只占 ARGB_8888 的一半,不过已经被官方废弃。 |
ARGB_8888 | 4B | 颜色+透明度 | 系统默认的像素点格式 |
系统默认图片是以 ARGB_8888 格式进行处理,ARGB_8888 最占内存,一个像素点占4B,改变这个格式就能降低图片占据内存的大小。
val options = BitmapFactory.Options()
options.inPreferredConfig = Bitmap.Config.RGB_565
BitmapFactory.decodeStream(is, null, options)
通常是通过替换系统 drawable 默认色彩通道,将部分没有透明通道的图片格式由 ARGB_8888 替换为 RGB565,在图片质量上的损失几乎肉眼不可见,而每张图片可以节省1/2的内存。但是此方案并不通用,取决于图片是否有透明度需求,当然也可以使用 ARGB_4444,但是会降低图片质量。
3. 图片采样率优化
在把图片载入内存之前,我们需要先计算出一个合适的 inSampleSize 缩放比例,降低图片像素,来达到降低图片占用内存大小的目的,避免不必要的大图载入。
但是这个采样率 inSampleSize 只能是整数(甚至只能是2的次方),所以不能很好保证图片的质量。如果 inSampleSize=2
,则最终内存占用就会是原来的1/4,适用于图片过大的情况。
val options: BitampFactory.Options = BitmapFactory.Options()
// 设置为2就是宽和高都变为原来1/2大小的图片
options.inSampleSize = 2
BitmapFactory.decodeSream(is, null, options)
还有一个是降低图片的大小,可能你的 ImageView 只有你图片的一半大,则这部分内存就大大浪费了,项目服务端可以根据前端的宽高参数做动态切图。
4. 图片放置优化
只需要 UI 提供一套高分辨率的图,图片建议放在 drawable-xxhdpi
文件夹下,这样在低分辨率设备中放大倍数是小于1的,图片的大小只是压缩,在保证画质的前提下,内存也是可控的。如若遇到不需缩放的文件,放在 drawable-nodpi
文件夹下。如果放到分辨率低的目录如 hdpi 目录,则可能会造成内存问题。
5. 统一图片库
图片内存优化的前提是收拢图片的调用,这样我们可以做整体的控制策略。例如低端机使用 565 格式、更加严格的缩放算法。而且需要进一步将所有 Bitmap.createBitmap
、BitmapFactory
相关的接口也一并收拢。
另外 Glide 和 Fresco 第三方图片库功能全面,能高效处理 Bitmap,具有优秀的缓存策略,加载速度快且内存开销小。后者适用于需要高性能加载大量图片的场景。
6. 重复图片监控
重复图片指的是 Bitmap 的像素数据完全一致,但是有多个不同的对象存在。[《重复图片检测》]的原理其实就是使用内存 Hprof 分析工具,自动将重复 Bitmap 的图片和引用堆栈输出。下图是一个简单的例子,两张图片的内容完全一样,通过解决这张重复图片可以节省 1MB 内存。
7. 不合理大图检测
图片的大小不应该超过 View 的大小,否则会造成资源浪费。比如需要的尺寸是100X100,实际给的是200X200。我们可以重载 drawable 与 ImageView 设置图片的方法,检测图片大小与 View 的大小,若超过一定比例,可以上报或提示,避免不必要的大图载入。
open class CheckBigImageView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : AppCompatImageView(context, attrs) {
override fun setImageDrawable(drawable: Drawable?) {
super.setImageDrawable(drawable)
drawable?.let {
checkBitmap(it.intrinsicWidth, it.intrinsicHeight)
}
}
// 检测实际数据大小
private fun checkBitmap(bitmapWidth: Int, bitmapHeight: Int) {
val width = this.width
val height = this.height
if (width > 0 && height > 0) {
// 2、图标宽高都大于view的2倍以上,则警告
if (bitmapWidth >= width shl 1 && bitmapHeight >= height shl 1) {
warn(
bitmapWidth,
bitmapHeight,
width,
height,
RuntimeException("Bitmap size too large")
)
}
} else {
// 3、当宽高度等于0时,说明ImageView还没有进行绘制,使用ViewTreeObserver进行大图检测的处理。
val stackTrace: Throwable = RuntimeException()
this.viewTreeObserver.addOnPreDrawListener(object : ViewTreeObserver.OnPreDrawListener {
override fun onPreDraw(): Boolean {
val w = this@CheckBigImageView.width
val h = this@CheckBigImageView.height
if (w > 0 && h > 0) {
if (bitmapWidth >= w shl 1 && bitmapHeight >= h shl 1) {
warn(bitmapWidth, bitmapHeight, w, h, stackTrace)
}
this@CheckBigImageView.viewTreeObserver.removeOnPreDrawListener(this)
}
return true
}
})
}
}
private fun warn(bitmapWidth: Int, bitmapHeight: Int, viewWidth: Int, viewHeight: Int, t: Throwable) {
val warnInfo = """Bitmap size too large: bitmap: [$bitmapWidth,$bitmapHeight],
view: [$viewWidth,$viewHeight],
位置:${(context as? Activity)?.javaClass?.simpleName},
call stack trace: ${Log.getStackTraceString(t)}
"""
LogUtil.w(warnInfo, tag = "BigBitmap")
}
}
在开发过程中,如果检测到不合规的图片使用,应该立即弹出对话框提示图片所在的 Activity 和堆栈,让开发同学更快发现并解决问题。在灰度和线上环境下可以将异常信息上报到后台,我们可以计算有多少比例的图片会超过屏幕的大小,也就是图片的“超宽率”。
但是这种方式侵入性强,并且不通用。可以通过 [《Hook》]的方式或者 [《Aop 切面编程》]的方式更优雅地实现。
七、Bitmap 优化策略
1. 释放Bitmap对象
Bitmap 使用完后需要调用 recycle()
方法回收资源,否则会发生内存泄漏。
bitmap.recycle()
该方法用于释放与当前 Bitmap 对象相关联的 Native 对象,并清理对像素数据的引用。但不能同步地释放像素数据,而是在没有其它引用的时候,简单地允许像素数据被作为垃圾回收掉。
Bitmap 在内存中的存储分两部分 :一部分是 Bitmap 对象,另一部分为对应的像素数据,前者占据的内存较小,而后者才是内存占用的大头。
- 在 Android 3.0 之前:
Bitmap 对象放在 Java 堆,而像素数据是放在 Native 内存中
。如果不手动调用 Bitmap 的 recycle() 方法,Bitmap 的 Native 内存的回收就依赖于finalize()
回调,finalize()
是 Object 类的一个方法,当一个堆空间中的对象没有被栈空间变量指向时,这个对象会等待被 Java 回收,时机不可控。 - 在Android 3.0~Android 7.0:
将 Bitmap 对象和像素数据统一放到 Java 堆中,即使不调用 recycle(),Bitmap 内存也会随着对象一起被回收
。不过 Bitmap 是内存消耗的大户,把它放在 Java 堆中就压缩了其他资源的可用内存,而且这么做还会引发大量的 GC ,也没有充分利用系统内存。 - 在Android 8.0 后:
重新将 Bitmap 像素数据放回到 Native 中
。新增了一个叫 NativeAllocationRegistry 的辅助回收 Native 内存的机制,像素放在了 Native 内存中,而且还新增了可以减少图片内存并提升绘制效率的硬件位图 Hardware Bitmap ,做到了 Bitmap 的 Native 内存可和对象一起快速释放,而且 GC 时会避免这些内存被滥用。
Java 的 GC 机制只能回收 Dalvik 内存中的垃圾,而对 Native 层无效,Native 内存中的像素数据以不可预测的方式释放。所以需要调用 recycle()
方法,来回收 Native 内存中的像素数据,避免应用内存泄露。
2. Bitmap复用
Bitmap 对象可以被复用,以避免频繁创建和销毁导致的内存开销。可以通过 LruCache 等缓存机制来管理 Bitmap 对象的复用。
LruCache 最近最少使用缓存算法,内部维护了一个由 LinkedHashMap 组成的双向列表,添加了线程安全操作。当其中的一个值被访问时,它被放到队列的尾部,当缓存将满时,队列头部的值(最近最少被访问的)被丢弃,之后可以被 GC 回收。
class MemoryCache {
val maxMemory = (Runtime.getRuntime().maxMemory() / 1024).toInt()
val cacheSize = maxMemory / 8
private val mMemoryCache by lazy {
object : LruCache<String, Bitmap>(cacheSize) {
fun sizeOf(key: String?, value: Bitmap): Int {
return getBitmapSize(value) / 1024
}
fun entryRemoved(evicted: Boolean, key: String?, oldValue: Bitmap, newValue: Bitmap) {
super.entryRemoved(evicted, key!!, oldValue, newValue)
}
}
}
@TargetApi(Build.VERSION_CODES.KITKAT)
fun getBitmapSize(bitmap: Bitmap): Int {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
return bitmap.allocationByteCount
}
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB_MR1) {
bitmap.byteCount
} else bitmap.rowBytes * bitmap.height
}
fun getCacheBitmap(url: String): Bitmap? {
return mMemoryCache[url]
}
fun addBitmapToCache(url: String?, bitmap: Bitmap?) {
if (url == null || bitmap == null) {
return
}
if (getCacheBitmap(url) != null) {
mMemoryCache.put(url, bitmap)
}
}
fun clearCache() {
mMemoryCache.evictAll()
}
}
sizeOf()
方法用来返回每个缓存对象的大小。entryRemoved()
表示当一个缓存对象被丢弃时调用的方法。当第一个参数为 true,表明缓存对象是为了腾出空间而被清理。否则,表明缓存对象的 entry 是被 remove 移除或者被 put 覆盖。
除此之外,可以使用 BitmapFactory.Options 的 inBitmap 属性来指定一个可复用的 Bitmap 对象。
options.inBitmap = bitmap
3. 主动释放ImageView的图片资源
很多情况是在 xml 布局文件中设置 ImageView 的 src 或者在代码中调用setImageResource()
、setImageURI()
、setImageDrawable()
等方法设置图像,下面代码可以回收这个 ImageView 所对应的资源:
private fun recycleImageViewBitMap(imageView: ImageView?) {
imageView?.let {
val bd = it.drawable as BitmapDrawable
recycleBitmapDrawable(bd)
}
}
private fun recycleBitmapDrawable(bitmapDrawable: BitmapDrawable) {
var bitmapDrawable: BitmapDrawable? = bitmapDrawable
bitmapDrawable?.let {
val bitmap = it.bitmap
recycleBitmap(bitmap)
bitmapDrawable = null
}
}
private fun recycleBitmap(bitmap: Bitmap) {
var bitmap: Bitmap? = bitmap
if (bitmap != null && !bitmap.isRecycled) {
bitmap.recycle()
bitmap = null
}
}
如果 ImageView 是有 Background,主动释放 ImageView 的背景资源:
fun recycleBackgroundBitmap(view: ImageView?) {
view?.let {
val bd = it.background as BitmapDrawable
recycleBitmapDrawable(bd)
}
}
fun recycleImageViewBitmap(imageView: ImageView?) {
imageView?.let {
val bd = it.drawable as BitmapDrawable
recycleBitmapDrawable(bd)
}
}
fun recycleBitmapDrawable(bitmapDrawable: BitmapDrawable) {
var bitmapDrawable: BitmapDrawable? = bitmapDrawable
bitmapDrawable?.let {
val bitmap = it.bitmap
recycleBitmap(bitmap)
}
bitmapDrawable = null
}
内存优化相关策略概括了很多,这里并不能将内存优化中用到的所有技巧都一一说明,而且随着 Android 版本的更替,可能很多方法都会变的过时。更重要的是我们能持续的发现问题,精细化的监控,而不是一直处于哪个有坑填哪里的
的窘况。
由于篇幅有限,内存优化实战将在下一篇讲解,敬请期待……
点关注,不迷路
如果你看到了这里,觉得文章写得不错就给个赞呗?
更多Android进阶指南 可以扫码 解锁更多Android进阶资料
敲代码不易,关注一下吧。ღ( ´・ᴗ・` )