初识内存优化
在 Android 的性能优化的各个部分里,内存的问题绝对是最令人头疼的一部分,虽然 Android 有垃圾自动回收机制不需要手动干预,但也恰因为此,出现内存问题如内存泄漏和内存溢出等,如果对内存管理机制不熟悉,会更加难以排查问题。
内存分配
谈 Android 的内存,就不能不提 Java 的内存管理。Java 程序在运行的过程中会将其管理的内存分为若干个不同的数据区
方法区:方法区存放的是类信息、常量、静态变量,所有线程共享区域。
虚拟机栈:每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息,线程私有区域。
本地方法栈:与虚拟机栈类似,区别是虚拟机栈为虚拟机执行Java方法服务,本地方法栈为虚拟机使用到的 Native 方法服务。
堆:JVM 管理的内存中最大的一块,所有线程共享;用来存放对象实例,几乎所有的对象实例都在堆上分配内存;此区域也是垃圾回收器(Garbage Collection)主要的作用区域,内存泄漏就发生在这个区域。
程序计数器:可看做是当前线程所执行的字节码的行号指示器;如果线程在执行Java方法,这个计数器记录的是正在执行的虚拟机字节码指令地址;如果执行的是 Native 方法,这个计数器的值为空(Undefined)。
备注:有一种习惯说法:把Java的内存区域分为堆内存(Heap)和栈内存(Stack),Stack 访问快,Heap 访问慢,Stack 中保存的是对象的引用(指针),Heap 中保存的是对象的实例。实际上这种说法是笼统、粗糙的,此处所说的 Stack 仅仅是虚拟机栈中的局部变量表部分。虚拟机栈与 JVM 运行时数据区涵盖的都比此种说法多。
内存回收
1、标记-清除算法
最基础的收集算法:分为“标记”和“清除”两个阶段,首先,标记出所有需要回收的对象,然后统一回收所有被标记的对象。
这种方法有两个不足点:
效率问题,标记和清除两个过程的效率都不高;
空间问题,标记清除之后会产生大量的不连续的内存碎片。
2、复制算法
将内存按容量划分为大小相等的两块,每次只使用其中的一块,当这一块内存将用完了,就将还存活着的对象复制到另一块内存上面,然后再把已使用过的内存空间一次清理掉。
这种方法的特点:
优点:实现简单,运行高效;每次都是对整个半区进行内存回收,内存分配时也不需要考虑内存碎片等情况,只要移动堆顶指针,按顺序分配内存即可;
缺点:粗暴的将内存缩小为原来的一半,代价实在有点高。
3、标记-整理算法
先标记需要回收的对象(标记过程与“标记-清除”算法一样),然后把所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。
这种方法的特点:
避免了内存碎片;
避免了“复制”算法50%的空间浪费;
主要针对对象存活率高的老年代。
4、分代收集算法
根据对象的存活周期的不同将内存划分为几块,一般是把 Java 堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。在新生代中,每次垃圾收集时都会发现有大量对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用标记—清除算法或标记—整理算法来进行回收。
对象是否回收的依据
1、引用计数算法
给对象中添加一个引用计数器,每当有一个地方引用该对象时,计数器值加 1;引用失效时,计数器值减 1;任意时刻计数器为 0 的对象就是不可能再被使用的,表示该对象不存在引用关系。
这种方法的特点:
优点:实现简单,判定效率也很高;
缺点:难以解决对象之间相互循环引用导致计数器值不等于 0 的问题。
2、可达性分析算法
以一系列成为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链相连(GC Roots 到这个对象不可达),则证明此对象是不可用的。
Android的内存管理
Android 系统的 ART 和 Dalvik 虚拟机扮演了常规的内存垃圾自动回收的角色, 使用 paging 和 memory-mapping 来管理内存,这意味着不管是因为创建对象还是使用使用内存页面造成的任何被修改的内存,都会一直存在于内存中,App 唯一释放内存的方法就是释放 App 持有的对象引用,使 GC 可以回收。
1、内存回收
在 Android 的高级系统版本里面针对 Heap 空间有一个 Generational Heap Memory 的模型,最近分配的对象会存放在 Young Generation 区域,当这个对象在这个区域停留的时间达到一定程度,它会被移动到 Old Generation,最后累积一定时间再移动到 Permanent Generation 区域。系统会根据内存中不同的内存数据类型分别执行不同的 gc 操作。例如,刚分配到 Young Generation 区域的对象通常更容易被销毁回收,同时在Young Generation 区域的 gc 操作速度会比 Old Generation 区域的 gc 操作速度更快。
2、共享内存
Android应用的进程都是从一个叫做 Zygote 的进程 fork 出来的。Zygote 进程在系统启动并且载入通用的framework 的代码与资源之后开始启动。为了启动一个新的程序进程,系统会 fork Zygote 进程生成一个新的进程,然后在新的进程中加载并运行应用程序的代码。这使得大多数的 RAM pages 被用来分配给 framework的代码,同时使得 RAM 资源能够在应用的所有进程之间进行共享。
大多数 static 的数据被 mmapped 到一个进程中。这不仅仅使得同样的数据能够在进程间进行共享,而且使得它能够在需要的时候被 paged out。常见的 static 数据包括 Dalvik Code,app resources,so 文件等。
大多数情况下,Android 通过显式的分配共享内存区域(例如 ashmem 或者 gralloc)来实现动态 RAM 区域能够在不同进程之间进行共享的机制。例如,Window Surface 在 App 与 Screen Compositor 之间使用共享的内存,Cursor Buffers 在 Content Provider 与 Clients 之间共享内存。
3、分配与回收内存
每一个进程的 Dalvik heap 都反映了使用内存的占用范围。这就是通常逻辑意义上提到的 Dalvik Heap Size,它可以随着需要进行增长,但是增长行为会有一个系统为它设定的上限。
逻辑上讲的 Heap Size 和实际物理意义上使用的内存大小是不对等的,Proportional Set Size(PSS)记录了应用程序自身占用以及和其他进程进行共享的内存。
4、限制应用的内存
为了整个 Android 系统的内存控制需要,Android 系统为每一个应用程序都设置了一个硬性的 Dalvik Heap Size 最大限制阈值,这个阈值在不同的设备上会因为 RAM 大小不同而各有差异。如果你的应用占用内存空间已经接近这个阈值,此时再尝试分配内存的话,很容易引起 OutOfMemoryError 的错误。
ActivityManager.getMemoryClass() 可以用来查询当前应用的 Heap Size 阈值,这个方法会返回一个整数,表明你的应用的 Heap Size 阈值是多少 Mb(megabates)。
5、应用切换
Android 系统并不会在用户切换应用的时候做交换内存的操作。Android 会把那些不包含 Foreground 组件的应用进程放到 LRU Cache 中。例如,当用户开始启动了一个应用,系统会为它创建了一个进程,但是当用户离开这个应用,此进程并不会立即被销毁,而是会被放到系统的 Cache 当中,如果用户后来再切换回到这个应用,此进程就能够被马上完整的恢复,从而实现应用的快速切换。
如果你的应用中有一个被缓存的进程,这个进程会占用一定的内存空间,它会对系统的整体性能有影响。因此当系统开始进入 Low Memory 的状态时,它会由系统根据 LRU 的规则与应用的优先级,内存占用情况以及其他因素的影响综合评估之后决定是否被杀掉。
需要特别注意的:在 Dalvik 下,大部分 Davik 采取的都是标记-清理回收算法,而且具体使用什么算法是在编译期决定的,无法在运行的时候动态更换。标记-清理回收算法无法对 Heap 中空闲内存区域做碎片整理。系统仅仅会在新的内存分配之前判断 Heap 的尾端剩余空间是否足够,如果空间不够会触发gc操作,从而腾出更多空闲的内存空间;这样内存空洞就产生了。
如上图所示,第一行,在开始阶段,内存分配较满;第二行,经过GC之后,大部分对象被释放。此时可能产生的问题是,因为没有内存整理功能,整个页面的 4KB 内存(内存分配的最小单位是页面,通常为 4KB)可能只有一个小对象,但是统计 PrivateDirty/Pss 时还是按照 4KB 计算。所以对于 Dalvik 虚拟机的手机来说,我们首先要尽量避免掉频繁生成很多临时小变量(比如说:getView, onDraw 等函数中 new 对象),另一个又要尽量去避免产生很多长生命周期的大对象。
ART 在 GC 上不像 Dalvik 仅有一种回收算法,ART 在不同的情况下会选择不同的回收算法。应用程序在前台运行时,响应性是最重要的,因此也要求执行的 GC 是高效的。相反,应用程序在后台运行时,响应性不是最重要的,这时候就适合用来解决堆的内存碎片问题。因此,Mark-Sweep GC 适合作为 Foreground GC,而Mark-Compact GC 适合作为 Background GC。由于有 Compact 的能力存在,内存碎片在ART上可以很好的被避免,这个也是 ART 一个很好的能力。
Android GC何时发生
由上文我们知道,GC 操作主要是由系统决定的,但是我们可以监听系统的 GC 过程,以此来分析我们应用程序当前的内存状态。
Dalvik 虚拟机,每一次 GC 打印内容格式:
D/dalvikvm: <GC_Reason> <Amount_freed>, <Heap_stats>, <External_memory_stats>, <Pause_time>
含义解析
GC_Reason:GC 触发原因
GC_CONCURRENT:当已分配内存达到某一值时,触发并发 GC;
GC_FOR_MALLOC:当尝试在堆上分配内存不足时触发的 GC;系统必须停止应用程序并回收内存;
GC_HPROF_DUMP_HEAP: 当需要创建 HPROF 文件来分析堆内存时触发的 GC;
GC_EXPLICIT:当明确的调用 GC 时,例如调用 System.gc() 或者通过 DDMS 工具显式地告诉系统进行 GC 操作等;
GC_EXTERNAL_ALLOC: 仅在 API 级别为 10 或者更低时(新版本分配内存都在 Dalvik 堆上)
Amount_freed_GC:回收的内存大小
Heap_stats:堆上的空闲内存百分比 (已用内存)/(堆上总内存)
External_memory_stats: API 级别为 10 或者更低:(已分配的内存量)/ (即将发生垃圾的极限)
Pause_time:这次 GC 操作导致应用程序暂停的时间。关于这个暂停的时间,在 2.3 之前 GC 操作是不能并发进行的,也就是系统正在进行 GC,那么应用程序就只能阻塞住等待 GC 结束。而自 2.3 之后,GC 操作改成了并发的方式进行,就是说 GC 的过程中不会影响到应用程序的正常运行,但是在 GC 操作的开始和结束的时候会短暂阻塞一段时间。
Art 虚拟机,每一次 GC 打印内容格式:
I/art:<GC_Reason><Amount_freed>,<LOS_Space_Status>,<Heap_stats>,<Pause_time>,<Total_time>
基本情况和 Dalvik 没有什么差别,GC 的 Reason 更多了,还多了一个 LOS_Space_Status.
LOS_Space_Status:Large Object Space,大对象占用的空间,这部分内存并不是分配在堆上的,但仍属于应用程序内存空间,主要用来管理 Bitmap 等占内存大的对象,避免因分配大内存导致堆频繁 GC。
获取内存使用情况
通过命令行 adb shell dumpsys meminfo packagename 查看内存详细占用情况:
其中几个关键的数据:
Private(Clean和Dirty的):应用进程单独使用的内存,代表着系统杀死你的进程后可以实际回收的内存总量 **。通常需要特别关注其中更为昂贵的 dirty 部分,它不仅只被你的进程使用而且会持续占用内存而不能被从内存中置换出存储。申请的全部 Dalvik 和本地 heap 内存都是 Dirty 的,和 Zygote 共享的 Dalvik 和本地 heap 内存也都是 Dirty 的。
Dalvik Heap:Dalvik虚拟机使用的内存,包含 dalvik-heap 和 dalvik-zygote,堆内存,所有的 Java 对象实例都放在这里。
Heap Alloc:累加了 Dalvik 和 Native 的 heap。
PSS:这是加入与其他进程共享的分页内存后你的应用占用的内存量,你的进程单独使用的全部内存也会加入这个值里,多进程共享的内存按照共享比例添加到 PSS 值中。如一个内存分页被两个进程共享,每个进程的 PSS 值会包括此内存分页大小的一半在内。
Dalvik Pss 内存 = 私有内存 Private Dirty + (共享内存 Shared Dirty / 共享进程数)
TOTAL:上面全部条目的累加值,全局的展示了你的进程占用的内存情况。
ViewRootImpl:应用进程里的活动窗口视图个数,可以用来监测对话框或者其他窗口的内存泄露。
AppContexts及Activities:应用进程里 Context 和 Activity 的对象个数,可以用来监测 Activity 的内存泄露。