Android性能优化(三)之内存管理

5,044 阅读13分钟

1、初识内存优化

在Android的性能优化的各个部分里,内存的问题绝对是最令人头疼的一部分,虽然Android有垃圾自动回收机制不需要手动干预,但也恰因为此,出现内存问题如内存泄漏和内存溢出等,如果对内存管理机制不熟悉,会更加难以排查问题。

因为内存方面的知识较多且不易理解,内存优化部分就分两篇文章进行,本文主要是关于Java、Android的内存分配、回收、GC等理论知识。

2、内存分配

谈Android的内存,就不能不提Java的内存管理。Java程序在运行的过程中会将其管理的内存分为若干个不同的数据区:

JVM运行时数据区

方法区:方法区存放的是类信息、常量、静态变量,所有线程共享区域。

虚拟机栈:每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息,线程私有区域。

本地方法栈:与虚拟机栈类似,区别是虚拟机栈为虚拟机执行Java方法服务,本地方法栈为虚拟机使用到的Native方法服务

JVM管理的内存中最大的一块,所有线程共享;用来存放对象实例,几乎所有的对象实例都在堆上分配内存;此区域也是垃圾回收器(Garbage Collection)主要的作用区域,内存泄漏就发生在这个区域

程序计数器可看做是当前线程所执行的字节码的行号指示器;如果线程在执行Java方法,这个计数器记录的是正在执行的虚拟机字节码指令地址;如果执行的是Native方法,这个计数器的值为空(Undefined)。

备注:
有一种习惯说法:把Java的内存区域分为堆内存(Heap)和栈内存(Stack),Stack访问快,Heap访问慢,Stack中保存的是对象的引用(指针),Heap中保存的是对象的实例。

实际上这种说法是笼统、粗糙的,此处所说的Stack仅仅是虚拟机栈中的局部变量表部分。虚拟机栈与JVM运行时数据区涵盖的都比此种说法多。

3、内存回收

3.1标记-清除算法

最基础的收集算法:分为“标记”和“清除”两个阶段,首先,标记出所有需要回收的对象,然后统一回收所有被标记的对象。
这种方法有两个不足点:

  1. 效率问题,标记和清除两个过程的效率都不高;
  2. 空间问题,标记清除之后会产生大量的不连续的内存碎片。

“标记-清除”算法示意图

3.2复制算法

将内存按容量划分为大小相等的两块,每次只使用其中的一块,当这一块内存将用完了,就将还存活着的对象复制到另一块内存上面,然后再把已使用过的内存空间一次清理掉。
这种方法的特点:

  • 优点:实现简单,运行高效;每次都是对整个半区进行内存回收,内存分配时也不需要考虑内存碎片等情况,只要移动堆顶指针,按顺序分配内存即可;
  • 缺点:粗暴的将内存缩小为原来的一半,代价实在有点高。

“复制”算法示意图

3.3标记-整理算法

先标记需要回收的对象(标记过程与“标记-清除”算法一样),然后把所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。
这种方法的特点:

  • 避免了内存碎片;
  • 避免了“复制”算法50%的空间浪费;
  • 主要针对对象存活率高的老年代。

“标记-整理”算法示例图.png

3.4分代收集算法

根据对象的存活周期的不同将内存划分为几块,一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。在新生代中,每次垃圾收集时都会发现有大量对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用标记—清除算法或标记—整理算法来进行回收。

4、对象是否回收的依据

4.1引用计数算法

给对象中添加一个引用计数器,每当有一个地方引用该对象时,计数器值加1;引用失效时,计数器值减1;任意时刻计数器为0的对象就是不可能再被使用的,表示该对象不存在引用关系。
这种方法的特点:

  • 优点:实现简单,判定效率也很高;
  • 缺点:难以解决对象之间相互循环引用导致计数器值不等于0的问题。

4.2可达性分析算法

以一系列成为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连(GC Roots到这个对象不可达),则证明此对象是不可用的。

可达性分析算法判定对象是否可回收

5、Android的内存管理

Android系统的ART和Dalvik虚拟机扮演了常规的内存垃圾自动回收的角色, 使用pagingmemory-mapping来管理内存,这意味着不管是因为创建对象还是使用使用内存页面造成的任何被修改的内存,都会一直存在于内存中,App唯一释放内存的方法就是释放App持有的对象引用,使GC可以回收。

Android Runtime内存堆划分

  • 5.1内存回收

    在Android的高级系统版本里面针对Heap空间有一个Generational Heap Memory的模型,最近分配的对象会存放在Young Generation区域,当这个对象在这个区域停留的时间达到一定程度,它会被移动到Old Generation,最后累积一定时间再移动到Permanent Generation区域。系统会根据内存中不同的内存数据类型分别执行不同的gc操作。例如,刚分配到Young Generation区域的对象通常更容易被销毁回收,同时在Young Generation区域的gc操作速度会比Old Generation区域的gc操作速度更快。

  • 5.2共享内存

    1. Android应用的进程都是从一个叫做Zygote的进程fork出来的。Zygote进程在系统启动并且载入通用的framework的代码与资源之后开始启动。为了启动一个新的程序进程,系统会fork Zygote进程生成一个新的进程,然后在新的进程中加载并运行应用程序的代码。这使得大多数的RAM pages被用来分配给framework的代码,同时使得RAM资源能够在应用的所有进程之间进行共享。
    2. 大多数static的数据被mmapped到一个进程中。这不仅仅使得同样的数据能够在进程间进行共享,而且使得它能够在需要的时候被paged out。常见的static数据包括Dalvik Code,app resources,so文件等。
    3. 大多数情况下,Android通过显式的分配共享内存区域(例如ashmem或者gralloc)来实现动态RAM区域能够在不同进程之间进行共享的机制。例如,Window Surface在App与Screen Compositor之间使用共享的内存,Cursor Buffers在Content Provider与Clients之间共享内存。
  • 5.3分配与回收内存

    1. 每一个进程的Dalvik heap都反映了使用内存的占用范围。这就是通常逻辑意义上提到的Dalvik Heap Size,它可以随着需要进行增长,但是增长行为会有一个系统为它设定的上限。
    2. 逻辑上讲的Heap Size和实际物理意义上使用的内存大小是不对等的,Proportional Set Size(PSS)记录了应用程序自身占用以及和其他进程进行共享的内存。
  • 5.4限制应用的内存

    1. 为了整个Android系统的内存控制需要,Android系统为每一个应用程序都设置了一个硬性的Dalvik Heap Size最大限制阈值,这个阈值在不同的设备上会因为RAM大小不同而各有差异。如果你的应用占用内存空间已经接近这个阈值,此时再尝试分配内存的话,很容易引起OutOfMemoryError的错误。
    2. ActivityManager.getMemoryClass()可以用来查询当前应用的Heap Size阈值,这个方法会返回一个整数,表明你的应用的Heap Size阈值是多少Mb(megabates)。
  • 5.5应用切换

    1. Android系统并不会在用户切换应用的时候做交换内存的操作。Android会把那些不包含Foreground组件的应用进程放到LRU Cache中。例如,当用户开始启动了一个应用,系统会为它创建了一个进程,但是当用户离开这个应用,此进程并不会立即被销毁,而是会被放到系统的Cache当中,如果用户后来再切换回到这个应用,此进程就能够被马上完整的恢复,从而实现应用的快速切换。
    2. 如果你的应用中有一个被缓存的进程,这个进程会占用一定的内存空间,它会对系统的整体性能有影响。因此当系统开始进入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的内存泄露。

参考:

欢迎关注微信公众号:定期分享Java、Android干货!

欢迎关注