Android 性能优化第二篇, 内存优化是减少崩溃率工作中非常关键的一部分,由于Android有垃圾自动回收机制不需要手动干预,但也因此,经常出现内存问题如内存泄漏、内存泄漏引发 GC 频繁导致页面卡顿和内存溢出等问题,如果不了解内存是如何管理的以及如何优化,会难以排查问题。
这篇文章集结了很多文章精华部分(个人认为),在这里整理分享出更多更完整内存知识,所以这篇文章会很长,目录我会分的很细,方便查找,文末会贴出参考文章。
注意:如果出现图片不显示问题请移步 图片不显示问题
本文主要记载
- 1 什么是内存?
- 2 JVM 如何分配内存?
- 2.1 所有线程共享的数据区域
- 2.2 线程私有的数据区域
- 3 JVM 如何管理内存?
- 3.1 引用计数算法
- 3.2 可达性算法
- 3.3 引用介绍
- 3.4 垃圾回收器
- 3.4.1 标记-清除算法(Mark-Sweep)
- 3.4.2 复制算法(Copying)
- 3.4.3 标记-整理算法(Mark-Compact)
- 3.4.4 分代收集算法(Generational Collection)
- 4 什么是 Dalvik?
- 4.1 Dalvik 与 JVM 的区别
- 4.2 Dalvik 堆大小
- 5 什么是 ART?
- 5.1 ART 与 Dalvik 的区别
- 6 内存引发的问题
- 6.1 内存泄漏
- 6.1.1 常见的内存泄漏
- 6.2 低杀
- 6.3 内存抖动
- 6.1 内存泄漏
- 7 Android Bitmap 内存分配的变化
- 8 内存优化的两个误区
- 8.1 内存占用越少越好
- 8.2 Native 内存不用管
1 什么是内存?
内存是计算机中重要的部件之一,是与 CPU 进行沟通的桥梁,是 CPU 能直接寻址的存储空间,由半导体器件制成。
如果说数据是商品,那硬盘就是商店的仓库,内存就是商店的货架,仓库里的商品你是不能直接买的,你只能买货架上的商品。
每一个程序中使用的内存区域相当于是不同的货架,当一个货架上需要摆放的商品超过这个货架所能容纳的最大值,就会出现放不下的情况,也就是内存溢出。
2 JVM 如何分配内存?
2.1 所有线程共享的数据区域
- 堆(Java Heap)
Java堆是JVM管理的内存中最大的一块内存区域。- 几乎所有的对象实例都是在堆中分配内存。
- 此区域也是垃圾回收器(
Garbage Collection)主要的作用区域,内存泄漏就发生在这个区域。
- 方法区(Method Area)
- 方法区存放的是 类信息、常量、静态变量
- 方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类,导致方法区溢出,虚拟机会抛出内存溢出异常
OutOfMemoryError。
2.2 线程私有的数据区域
程序计数器(Program Counter Register)
一块较小的内存空间,可看做是当前线程所执行的字节码的行号指示器。
为了线程切换后能恢复到正确的执行位置,每条线程都有一个私有的程序计数器。
如果线程在执行
Java方法,这个计数器记录的是正在执行的虚拟机字节码指令地址;如果执行的是
Native方法,这个计数器的值为空(Undefined)。程序计数器是唯一一个在 Java 虚拟机规范中没有规定任何
OOM情况的区域。
本地方法栈(Native Method Stack)
- 本地方法栈与虚拟机栈的区别是虚拟机栈为
Java方法服务,而本地方法栈为Native方法服务。 - 与虚拟机栈一样,本地方法栈也会抛出
StackOverflowError和OutOfMemoryError异常。
- 本地方法栈与虚拟机栈的区别是虚拟机栈为
虚拟机栈(Virtual Machine Stack )
- 每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。
- 虚拟机栈生命周期与线程相同。
- 当执行 Java 方法时会进行压栈的操作,在栈中会保存局部变量、操作数栈和方法出口等信息。
JVM规定了栈的最大深度,如果线程请求执行方法时栈的深度大于规定的深度,就会抛出栈溢出异常StackOverflowError。 - 如果虚拟机在扩展时无法申请到足够的内存,就会抛出内存溢出异常
OutOfMemoryError。
3 JVM 如何管理内存?
JVM 的管理主要有 引用计数算法、可达性算法、Java 引用 以及 垃圾回收器 知识点。
3.1 引用计数算法
给对象中添加一个引用计数器,每当有一个地方引用该对象时,计数器值加1;引用失效时,计数器值减1;任意时刻计数器为0的对象就是不可能再被使用的,表示该对象不存在引用关系。
优点:实现简单,判定效率也很高;
缺点:难以解决对象之间相互循环引用导致计数器值不等于0的问题。
3.2 可达性算法
判定对象是否是存活的
这个算法的基本思路就是通过一系列 GC Roots 对象作为起始点,从这些节点开始向下搜索,搜索走过的路径就叫引用链。
当一个对象到 GC Roots 没有任何引用链相连时(GC Roots 到这个对象不可达),则证明此对象是不可用的。
比如下图中的 object5、object6、object7,虽然它们互有关联,但是它们到 GC Roots 是不可达的,所以它们会被判定为可回收对象。

那么那些点可以作为 GC Roots 呢?一般来说,如下情况的对象可以作为 GC Roots :
虚拟机栈
虚拟机栈的栈帧中的局部变量表中引用的对象,比如某个方法正在使用的类字段。
方法区
类静态属性引用的对象
常量引用的对象
本地方法栈
本地方法栈中
Native方法引用的对象。
3.3 引用介绍
无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象的引用链是否可达,判定对象是否存活都与引用有关。
在 JDK 1.2 之后,Java 对引用的概念进行了扩充,将引用分为强引用、软引用、弱引用和虚引用四种,这四种引用强度按顺序依次减弱。如果没有指定对象引用类型,默认是强引用。
- 强引用:
- 强引用是指代码中普遍存在的,比如
Object obj = new Object()这类引用。 - 强引用可以直接访问目标对象。
- 强引用指向的对象在任何时候都不会被系统回收,虚拟机即使抛出
OOM异常,也不会回收强引用指向的对象。
使用obj = null不会触发GC,但是在下次GC的时候这个强引用对象就可以被回收了。 - 强引用可能导致内存泄漏。
- 强引用是指代码中普遍存在的,比如
- 软引用:
- 软引用用于描述一些还有用但非必需的对象。
- 对于软引用关联的对象,在系统即将发生内存溢出前,会把这些对象列入回收范围中进行二次回收。
- 如果二次回收后还没有足够的内存,就会抛出内存溢出异常。
- 在
JDK 1.2后,Java提供了SoftReference类来实现软引用。
- 弱引用:
- 弱引用的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次
GC前。 - 在
GC时,只要发现弱引用,不管系统堆空间使用情况如何,都会将对象进行回收。 - 软引用、弱引用适合保存可有可无的缓存数据。
JDK 1.2后,提供了WeakReference类来实现弱引用。
- 弱引用的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次
- 虚引用:
- 一个对象是否有虚引用的存在,都不会对其生存时间构成影响,也无法通过虚引用取得一个对象实例。
- 为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。
- 在
JDK 1.2后,提供了PhantomReference类来实现虚引用。
3.4 垃圾回收器
垃圾回收器就是我们经常说到的 GC(Garbage Collector),当我们操作不当导致某块内存泄漏时,GC 就不能对这块内存进行回收。
拿 Android 来说,进行 GC 时,所有线程都要暂停,包括主线程,16ms 是 Android 要求的每帧绘制时间,而当 GC 的时间超过 16ms,就会造成丢帧的情况,也就是界面卡顿。
垃圾回收器回收资源的方式就是垃圾回收算法
3.4.1 标记-清除算法(Mark-Sweep)
最基础的收集算法:分为 标记 和 清除 两个阶段,首先,标记出所有需要回收的对象,然后统一回收所有被标记的对象。
这种方法有两个不足点:
- 标记和清除的效率都不高
- 标记清除后会产生大量不连续的内存碎片,内存碎片太多会导致当程序需要分配较大的对象时,无法找到足够的连续内存而不得不提前触发 ·
GC

3.4.2 复制算法(Copying)
为了解决 标记-清除算法 效率问题,复制收集算法 出现了。
将内存按容量划分为大小相等的两块,每次只使用其中的一块,当这一块内存将用完了,就将还存活着的对象复制到另一块内存上面,然后再把已使用过的内存空间一次清理掉。
- 优点:实现简单,运行高效;每次都是对整个半区进行内存回收,内存分配时也不需要考虑内存碎片等情况,只要移动堆顶指针,按顺序分配内存即可;
- 缺点:粗暴的将内存一分为二,代价有点高。在对象存活率高时,要进行较多的复制操作,这时效率就变低了

3.4.3 标记-整理算法(Mark-Compact)
标记-整理算法 的标记过程与 标记-清除算法 一样,但后续步骤是让所有存活的对象向一端移动,然后直接清理掉边界外的内存。
- 避免了 标记-清除算法 内存碎片;
- 避免了 复制算法 50%的空间浪费;
- 主要针对对象存活率高的老年代。

3.4.4 分代收集算法(Generational Collection)
现代商业虚拟机的垃圾回收都采用 分代收集算法 ,这种算法会根据对象存活周期的不同将内存划分为几块,这样就可以根据各个区域的特点采用最适当的收集算法。
堆内存可分为 新生区 、养老区 和 永久存 储区三个区域。
新生区(Young Generation Space)
由 一个伊甸区(Eden space) 和 两个幸存者区(Survivor space)区 组成。
每次垃圾收集都有大批对象死去,只有少量存活,所以可以用复制算法。- 伊甸区
大多数情况下,对象都是在伊甸区中分配的,当伊甸区没有足够的空间进行分配时,虚拟机将发起一次
Minor GC。Minor GC是指发生在新生区的垃圾收集动作,Minor GC非常频繁,回收速度也比较快。
当伊甸区的空间用完时,GC会对伊甸区进行垃圾回收,然后把伊甸区剩下的对象移动到幸存 0 区。- 幸存 0 区
如果幸存 0 区满了,
GC会对该区域进行垃圾回收,然后再把该区剩下的对象移动到幸存 1 区。- 幸存 1 区
如果幸存 1 区满了,
GC会对该区域进行垃圾回收,然后把幸存 1 区中的对象移动到养老区。养老区(Tenure Generation Space)
对象存活率高、没有额外空间对它进行担保,就必须使用 标记-清理 或 标记-整理算法 进行回收。
用于保存从新生区筛选出来的 Java 对象。
当幸存 1 区移动尝试对象到养老区,但是发现空间不足时,虚拟机会发起一次 Major GC。Major GC 的速度一般比 Minor GC 慢 10 倍以上。
大对象会直接进入养老区,比如很大的数字和很长的字符串。
- 永久存储区(Permanent Space)
一个常驻内存区域,用于存放 JDK 自身携带的 Class Interface 元数据。
永久存储区存储的是运行环境必需的类信息,被装载进该区域的数据是不会被垃圾回收器回收掉的,只有 JVM 关闭时才会释放此区域的内存。

4 什么是 Dalvik?
Dalvik 是 Dalvik Virtual Machine(Dalvik 虚拟机)的简称,是 Android 平台的核心组成部分之一。Android 4.4 之前都是使用 Dalvik。
在 Androd 中,每一个应用都运行在一个 Dalvik VM 实例中,每一个 Dalvik VM 都运行在一个独立的进程空间,这种机制使得 Dalvik 运行在有限的内存中同时运行多个进程。
4.1 Dalvik 与 JVM 的区别
Dalvik 不是 Java 虚拟机,它并不是按照 Java 虚拟机规范实现的,两者之间并不兼容。
架构
JVM是基于栈的,需要在栈中读取数据,所需的指令会更多,这样会导致速度慢,不适合性能优先的移动设备。Dalvik是基于寄存器的,指令更紧凑和简洁。
由于显式指定了操作数,所以基于寄存器的指令会比基于栈的指令要大,但是由于指令数的减少,总的代码数不会增加多少。
执行代码不同
在
Java SE程序中,Java类会被编译成一个或多个.class文件,然后打包成jar文件,JVM会通过对应的.class文件和jar文件获取对应的字节码。而
Dalvik会用dx工具将所有的 .class 文件转换为一个.dex文件,然后会从该.dex文件读取指令和数据。
共享机制
Dalvik拥有预加载—共享机制,不同应用之间在运行时可以共享相同的类,拥有更高的效率。而
JVM不存在这种共享机制,不同的程序,打包后的程序都是彼此独立的,即使包中使用了同样的类,运行时也是单独加载和运行的,无法进行共享。
4.2 Dalvik 堆大小
每一个手机厂商都可以设定设备中每一个进程能够使用的堆大小,有关进程堆大小的值有下面三个。
如果我们想看堆内存大小应该怎么办呢?
1 | adb shell getprop dalvik.vm.heapsize |
dalvik.vm.heapstartsize
堆分配的初始值大小,这个值越小,系统内存消耗越慢,但是当应用扩展这个堆,导致
GC和堆调整时,应用会变慢。这个值越大,应用越流畅,但是可运行的应用也会相对减少。
dalvik.vm.heapgrowthlimit
如果在清单文件中声明
largeHeap为true,则App使用的内存到heapsize才会OOM,否则达到heapgrowthlimit就会OOM。dalvik.vm.heapsize
进程可用的堆内存最大值,一旦应用申请的内存超过这个值,就会
OOM。
5 什么是 ART?
ART 的全称是 Android Runtime,是从 Android 4.4 开始新增的 应用运行时环境,用于替代 Dalvik 虚拟机。
Dalvik VM 和 ART 都可以支持已转换为 .dex(Dalvik Executable)格式的 Java 应用程序的运行。
Dalvik 是为 32 位 CPU 设计的,而 ART 支持 64 位并兼容 32 位 CPU,这也是 Dalvik 被淘汰的主要原因。
5.1 ART 与 Dalvik 的区别
预编译
Dalvik中的应用每次运行时,字节码都需要通过即时编译器 JIT 转换为机器码,这会使得应用的运行效率降低。在
ART中,系统在安装应用时会进行一次预编译,将字节码预先编译成机器码并存储在本地,这样应用就不用在每次运行时执行编译了,运行效率也大大提高。
GC
在
Dalvik采用的垃圾回收算法是标记-清除算法,启动垃圾回收机制会造成两次暂停(一次在遍历阶段,另一次在标记阶段)。而在
ART下,GC速度比Dalvik要快,这是因为应用本身做了垃圾回收的一些工作,启动GC后,不再是两次暂停,而是一次暂停。而且
ART使用了一种新技术(packard pre-cleaning),在暂停前做了许多事情,减轻了暂停时的工作量。
6 内存引发的问题
- 内存造成的第一个问题是异常。异常包括
OOM、内存分配失败这些崩溃,也包括因为整体内存不足导致应用被杀死、设备重启等问题。 - 内存造成的第二个问题是卡顿。内存泄漏 导致
Java内存不足会频繁GC,常见现象是 内存抖动,这个问题在Dalvik虚拟机会更加明显。而 ART 虚拟机在内存管理跟回收策略上都做大量优化 - 除了频繁
GC造成卡顿之外,物理内存不足时系统会触发 low memory killer 机制,系统负载过高是造成卡顿的另外一个原因。
6.1 内存泄漏
内存泄漏指的是,当一块内存没有被使用,但无法被 GC 时的情况。
内存泄漏的表现就是可用内存逐渐减少,无法被回收的内存逐渐累积,直到无更多可用内存可申请时,就会导致 OOM。
6.1.1 常见的内存泄漏
非静态内部类
泄漏场景
非静态内部类会持有外部类的实例,比如匿名内部类。
匿名内部类指的是一个对象名称的类,但是在字节码中,它还是会有构造函数的,而它的构造函数中会包含外部类的实例。
比如在
Activity中以匿名内部类的方式声明Handler或AsyncTask,当Activity关闭时,由于Handler持有Activity的强引用,导致GC无法对Activity进行回收。当我们通过
Handler发送消息时,消息会加入到MessageQueue队列中交给Looper处理,当有消息还没发送完毕时,Looper会一直运行,在这个过程中会一直持有Handler,而Handler又持有外部类Activity的实例,这就导致了 Activity 无法被释放。解决
把
Handler或AsyncTask声明为静态内部类,并且使用WeakReference包住Activity,这样Handler拿到的就是一个Activity的弱引用,GC就可以回收Activity。这种方式适用于所有匿名内部类导致的内存泄漏问题。1
2
3
4
5
6
7
8
9
10
11public static class MyHandler extends Handler {
Activity activity;
public MyHandler(Activity activity) {
activity = new WeakReference<>(activity).get();
}
public void handleMessage(Message message) {
// ...
}
}
静态变量
泄漏场景
静态变量导致内存泄漏的原因是因为 长生命周期对象 持有了 短生命周期对象 的引用,导致短生命周期对象无法被释放。
比如一个单例持有了
Activity的引用,而Activity的生命周期可能很短,用户一打开就关闭了,但是单例的生命周期往往是与应用的生命周期相同的。解决
如果单例需要
Context, 可以考虑使用ApplicationContext,这样单例持有的Context引用就是与应用的生命周期相同的了。
资源未释放
泄漏场景
忘了注销
BroadcastReceiver打开了数据库游标(
Cursor)忘了关闭打开流忘了关闭
创建了
Bitmap但是调用recycle方法回收Bitmap使用的内存使用
RxJava忘了在Activity退出时取消任务使用协程忘了在
Activity退出时取消任务
Webview
泄漏场景
不同的
Android版本的Webview会有差异,加上不同厂商定制ROM的Webview的差异,导致Webview存在很大的兼容问题。一般情况下,在应用中只要使用一次
Webview,它占用的内存就不会被释放。解决
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
protected void onDestroy() {
if( mWebView!=null) {
// 如果先调用destroy()方法,则会命中if (isDestroyed()) return;这一行代码,需要先onDetachedFromWindow(),再
// destory()
ViewParent parent = mWebView.getParent();
if (parent != null) {
((ViewGroup) parent).removeView(mWebView);
}
mWebView.stopLoading();
// 退出时调用此方法,移除绑定的服务,否则某些特定系统会报错
mWebView.getSettings().setJavaScriptEnabled(false);
mWebView.clearHistory();
mWebView.clearView();
mWebView.removeAllViews();
mWebView.destroy();
}
super.on Destroy();
}
6.2 低杀
在内存不足时,这种机制就会针对于所有进程进行回收(优先级由高到低,优先回收低优先级)
进程优先级:
前台进程:优先级最高的进程,是正在于用户交互的进程
- 进程持有一个与用户交互的 Activity(该 Activity 的 onResume 方法被调用)
- 进程持有一个 Service,
[Service 与用户正在交互的 Activity 绑定][Service 调用了 startForeground() 方法(前台服务)][Service 正在执行以下生命周期函数(onCreate、onStart、onDestroy )] - 进程持有一个
BroadcastReceiver,这个BroadcastReceiver正在执行它的onReceive()方法
可见进程:不含有任何前台组件,但用户还能再屏幕上看见它
- 进程持有一个
Activity,这个Activity处于pause状态 - 进程持有一个
Service这个Service和一个可见的Activity绑定。 - 可见进程是非常重要的进程,除非前台进程已经把系统的可用内存耗光,否则系统不会终止可见进程。
- 进程持有一个
服务进程:可能在播放音乐或在后台下载文件,除非系统内存不足,否则系统会尽量维持服务进程的运行。
- 如果一个进程中运行着一个
Service,并且这个service是通过startService开启的,那这个进程就是一个服务进程。
- 如果一个进程中运行着一个
后台进程
系统会把后台进程(
Background Process)保存在一个LruCache列表中,因为终止后台进程对用户体验影响不大,所以系统会酌情清理部分后台进程。你可以在
Activity的onSaveInstanceState()方法中保存一些数据,以免在应用在后台被系统清理掉后,用户已输入的信息被清空,导致要重新输入。- 当进程持有一个用户不可见的
Activity(Activity的onStop()方法被调用),但是onDestroy方法没有被调用,这个进程就会被系统认定为后台进程。
- 当进程持有一个用户不可见的
空进程:当一个进程不包含任何活跃的应用组件,则被系统认定为是空进程。
系统保留空进程的目的是为了加快下次启动进程的速度。
6.3 内存抖动
当我们在短时间内频繁创建大量临时对象时,就会引起内存抖动,比如在一个 for 循环中创建临时对象实例。
内存检测表现如下
预防的方法
- 尽量避免在循环体中创建对象
- 尽量不要在自定义
View的onDraw()方法中创建对象,因为这个方法会被频繁调用 - 对于能够复用的对象,可以考虑使用对象池把它们缓存起来
7 Android Bitmap 内存分配的变化
- 在
Android 3.0之前,Bitmap对象放在Java堆,而像素数据是放在Native内存中。如果不手动调用recycle,Bitmap Native内存的回收完全依赖finalize函数回调,这个时机不太可控。 Android 3.0~Android 7.0将Bitmap对象和像素数据统一放到Java堆中,这样就算我们不调用recycle,Bitmap内存也会随着对象一起被回收。不过Bitmap是内存消耗的大户,把它的内存放到Java堆中似乎不是那么美妙。即使是华为Mate 20,最大的Java堆限制也才到 512MB,可能我的物理内存还有 5GB,但是应用还是会因为Java堆内存不足导致OOM。Bitmap放到Java堆的另外一个问题会引起大量的GC,对系统内存也没有完全利用起来。- 有没有一种实现,可以将
Bitmap内存放到Native 中,也可以做到和对象一起快速释放,同时GC的时候也能考虑这些内存防止被滥用?NativeAllocationRegistry可以一次满足你这三个要求,Android 8.0正是使用这个辅助回收Native内存的机制,来实现像素数据放到Native内存中。Android 8.0还新增了硬件位图Hardware Bitmap,它可以减少图片内存并提升绘制效率。
8 内存优化的两个误区
8.1 内存占用越少越好。
- 有些人认为内存是洪水猛兽,占用越少应用的性能越好,这种认识在具体的优化过程中很容易“用力过猛”。
- 应用是否占用了过多的内存,跟设备、系统和当时情况有关,而不是
300MB、400MB这样一个绝对的数值。当系统内存充足的时候,我们可以多用一些获得更好的性能。当系统内存不足的时候,希望可以做到“用时分配,及时释放”
8.2 Native 内存不用管
- 虽然
Android 8.0重新将Bitmap内存放回到Native中,那么我们是不是就可以随心所欲地使用图片呢? - 答案当然是否定的。正如前面所说当系统物理内存不足时,
lmk开始杀进程,从后台、桌面、服务、前台,直到手机重启。







