垃圾内存回收算法
在垃圾内存回收算法中,我们常见的垃圾回收算法有引用计数法(Reference Counting)、标注并清理(Mark and Sweep GC)、拷贝(Copying GC)和逐代回收(Generational GC)等算法。
引用计数回收法:
记录每个对象被引用的次数。每当创建一个新的对象,或者将其它指针指向该对象时,引用计数都会累加一次;而每当将指向对象的指针移除时,引用计数都会递减一次,当引用次数降为0时,删除对象并回收内存。通常对象的引用计数都会与对象放在一起,系统在分配完对象的内存后,返回的对象指针会跳过引用计数部分。其存储如图:
引用计数回收算法有一个很大的弱点,就是无法有效处理循环引用的问题。例如:对象A引用对象B,对象B也引用对象A,则这两个对象可能无法被垃圾收集器收集。
标注并清理回收法:
标注并清理回收法就是从所谓的”GC Roots”集合开始,将内存整个遍历一次,保留所有可以被GC Roots直接或间接引用到的对象,而剩下的对象都当作垃圾对待并回收。与引用计数法中对象的内存布局类似,对象是否被标注的标志也是保存在对象头中的,如图:
在清理过程中,即执行垃圾回收过程,被标记的对象会保留下来,但执行完成后,标志会清除掉,而没有被标记的对象占用的内存将会被回收,其执行结果如图:
这个方法的优点是很好地处理了引用计数中的循环引用问题,而且在内存足够的前提下,对程序几乎没有任何额外的性能开支(如不需要维护引用计数的代码等),然而它的一个很大的缺点就是在执行垃圾回收过程中,需要中断进程内其它组件的执行,并且容易留下过多的内存碎片。
标注并整理回收法
这个是前面标注并清理法的一个变种,系统在长时间运行的过程中,反复分配和释放内存很有可能会导致内存堆里的碎片过多,从而影响分配效率。因此该算法在执行过程中,会移动内存保留的对象,使其排列更紧凑,在这种算法中,,虚拟机在内存中依次排列和保存对象,可以想象GC组件在内部保存了一个虚拟的指针 – 下个对象分配的起始位置 ,如图 下面演示的示例应用,其GC内存堆中已经分配有3个对象,因此”下个对象分配的起始位置”指向已分配对象的末尾,新的对象”object 4”(虚线部分)的起始位置将从这里开始。
由于虚拟机在给对象分配内存时,一直不停地向后递增指针”下个对象分配的起始位置”,潜台词就是将GC堆当做一个无限大的内存对待的,为了满足这个要求,GC线程在收集完垃圾内存之后,还需要压缩内存 – 即移动存活的对象,将它们紧凑的排列在GC内存堆中,下面图是Java进程内GC前的内存布局,执行回收过程时,GC线程从进程中所有的Java线程对象、各线程堆栈里的局部变量、所有的静态变量和JNI引用等GC Root开始遍历。
在上面图中,可以被GC Root访问到的对象有A、C、D、E、F、H六个对象,为了避免内存碎片问题,和满足快速分配对象的要求,GC线程移动这六个对象,使内存使用更为紧凑,如上图所示。由于GC线程移动了存活下来对象的内存位置,其必须更新其他线程中对这些对象的引用,如上图中,由于A引用了E,移动之后,就必须更新这个引用,在更新过程中,必须中断正在使用A的线程,防止其访问到错误的内存位置而导致无法预料的错误。
拷贝回收法:
这也是标注法的一个变种, GC内存堆实际上分成乒(ping)和乓(pong)两部分。一开始,所有的内存分配请求都有乒(ping)部分满足,其维护”下个对象分配的起始位置”指针,分配内存仅仅就是操作下这个指针而已,当乒(ping)的内存快用完时,采用标注(Mark)算法识别出存活的对象,如图 14 - 9所示,并将它们拷贝到乓(pong)部分,后续的内存分配请求都在乓(pong)部分完成,如图 14 - 10。而乓(pong)里的内存用完后,再切换回乒(ping)部分,使用内存就跟打乒乓球一样。
回收算法的优点在于内存分配速度快,而且还有可能实现低中断,因为在垃圾回收过程中,从一块内存拷贝存活对象到另一块内存的同时,还可以满足新的内存分配请求,但其缺点是需要有额外的一个内存空间,但可以通过操作系统地虚拟内存提供的地址空间申请和提交分布操作的方式实现优化。
逐代回收法(Generational GC):
逐代回收法看成拷贝GC算法的一个扩展,一开始所有的对象都是分配在”年轻一代对象池” 中 – 在JVM中其被称为Young。第一次垃圾回收过后,垃圾回收算法一般采用标注并清理算法,存活的对象会移动到”老一代对象池”中– 在JVM中其被称为Tenured,如下图,而后面新创建的对象仍然在”年轻一代对象池”中创建,这样进程不停地重复前面两个步骤。等到”老一代对象池”也快要被填满时,虚拟机此时再在”老一代对象池”中执行垃圾回收过程释放内存。在逐代GC算法中,由于”年轻一代对象池”中的回收过程很快 – 只有很少的对象会存活,而执行时间较长的”老一代对象池”中的垃圾回收过程执行不频繁,实现了很好的平衡,因此大部分虚拟机,如JVM、.NET的CLR都采用这种算法。整个算法如图:
回收前:
GC Roots释放第一个指针对象:
GC 标记被保留的对象:
算法执行完后:
在逐代GC中,有一个较棘手的问题需要处理 – 即如何处理老一代对象引用新一代对象的问题,如图 14 - 13中。由于每次GC都是在单独的对象池中执行的,当GC Root之一R3被释放后,在”年轻一代对象池”中执行GC过程时,R3所引用的对象f、g、h、i和j都会被当做垃圾回收掉,这样就导致”老一代对象池”中的对象c有一个无效引用。为了处理这种情况,那就是记录所有老一代对象池对年轻一代对象池的引用,然后使用标志标记老一代对象池中引用年轻对象池中对象的那些对象,该过程如图:
在跟踪老一代对象池引用年轻一代对象池的对象中,一个名为”Card Table”的数据结构就是专门设计用来处理这种情况的,”Card Table”是一个位数组,每一个位都表示”老一代对象池”内存中一块4KB的区域 – 之所以取4KB,是因为大部分计算机系统中,内存页大小就是4KB。当用户代码执行一个引用赋值(reference assignment)时,虚拟机(通常是JIT组件)不会直接修改内存,而是先将被赋值的内存地址与”老一代对象池”的地址空间做一次比较,如果要修改的内存地址是”老一代对象池”中的地址,虚拟机会修改”Card Table”对应的位为 1,表示其对应的内存页已经修改过 - 不干净(dirty)了,如下图:
当需要在 “年轻一代对象池”中执行GC时, GC线程先查看”Card Table”中的位,找到不干净的内存页,将该内存页中的所有对象都加入GC Root。虽然初看起来,有点浪费, 但是据统计,通常从老一代的对象引用新一代对象的几率不超过1%,因此”Card Table”的算法是一小部分的时间损失换取空间。
安卓分配与回收
Android系统并不会对Heap中空闲内存区域做碎片整理。系统仅仅会在新的内存分配之前判断Heap的尾端剩余空间是否足够,如果空间不够会触发gc操作,从而腾出更多空闲的内存空间。在Android的高级系统版本里面针对Heap空间有一个Generational Heap Memory的模型,这个思想和逐代回收法很类似,就是最近分配的对象会存放在Young Generation区域,当这个对象在这个区域停留的时间达到一定程度,它会被移动到Old Generation,最后累积一定时间再移动到Permanent Generation区域。系统会根据内存中不同的内存数据类型分别执行不同的gc操作。例如,刚分配到Young Generation区域的对象通常更容易被销毁回收,同时在Young Generation区域的gc操作速度会比Old Generation区域的gc操作速度更快。如下图所示:
每一个Generation的内存区域都有固定的大小,随着新的对象陆续被分配到此区域,当这些对象总的大小快达到这一级别内存区域的阀值时,会触发GC的操作,以便腾出空间来存放其他新的对象。如下图所示:
通常情况下,GC发生的时候,所有的线程都是会被暂停的。执行GC所占用的时间和它发生在哪一个Generation也有关系,Young Generation中的每次GC操作时间是最短的,Old Generation其次,Permanent Generation最长。执行时间的长短也和当前Generation中的对象数量有关,遍历树结构查找20000个对象比起遍历50个对象自然是要慢很多的。
Logcat中的GC信息
当我们用Logcat打印一些信息,通常会看GC触发信息,其输出GC消息格式如下:
[GC的原因][回收的内存总量],[可用内存的百分比][已使用的内存信息],[总共申请的Java层内存空间],[线程运行中断时间][GC消耗的时间]。
其输出信息如图:
其中GC的原因有如下几种情况:
调查内存泄露工具MAT
Shallow size 和Retained size
Shallow size就是对象本身占用内存的大小,不包含其引用的对象。常规对象(非数组)的Shallow size有其成员变量的数量和类型决定。数组的shallow size有数组元素的类型(对象类型、基本类型)和数组长度决定。Shallow size of a set of objects represents the sum of shallow sizes of all objects in the set.在32位系统上,对象头占用8字节,int占用4字节,不管成员变量(对象或数组)是否引用了其他对象(实例)或者赋值为null它始终占用4字节。
Retained size是该对象自己的shallow size,加上从该对象能直接或间接访问到对象的shallow size之和。换句话说,retained size是该对象被GC之后所能回收到内存的总和。
把内存中的对象看成下图中的节点,并且对象和对象之间互相引用。这里有一个特殊的节点GC Roots,这就是reference chain的起点
从obj1入手,上图中蓝色节点代表仅仅只有通过obj1才能直接或间接访问的对象。因为可以通过GC Roots访问,所以左图的obj3不是蓝色节点;而在右图却是蓝色,因为它已经被包含在retained集合内。
所以对于左图,obj1的retained size是obj1、obj2、obj4的shallow size总和;右图obj1的retained size是obj1、obj2、obj3、obj4的shallow size总和。
对于obj2,它的retained size是:在左图中,是obj2和obj4的shallow size的和;在右图中,是obj2、obj3和obj4的shallow size的和。
支配树:
如果在对象图谱中,从任意一个对象到对象Y的路径都必须经过对象X,那么对象X处于对象Y的支配地位,立即支配对象是离对象Y最近的支配对象X。为了方便发现引起内存泄露的隐藏引用,通过分析内存并创建”支配树”来识别保留住内存最多的那些对象。对象支配树如图(左图是对象实际引用,右图是其支配树)所示:
其中的“C”结点不受任何对象支配,因为从“A”和“B”节点都可以访问到它,只有这两个节点的引用都释放后才能回收“C”节点,而“C”节点是“H”节点的支配对象,虽然“H”对象在左边的引用关系图中同时被“F”和“G”引用,但是要从GC Roots访问“H”对象,都必须要经过“C”对象,C,E都是对象G的支配对象,而E是G的立即支配对象。
那么其GC释放对应节点所释放内存空间实例如图:
MAT的使用:
HPROF文件是MAT能识别的文件,HPROF文件存储的是特定时间点,java进程的内存快照。有不同的格式来存储这些数据,总的来说包含了快照被触发时java对象和类在heap中的情况。由于快照只是一瞬间的事情,所以heap dump中无法包含一个对象在何时、何地(哪个方法中)被分配这样的信息。
这个文件可以使用DDMS或Android studio的Android monitor导出:
选择存储路径保存后就可以得到对应进程的HPROF文件。eclipse插件可以把上面的工作一键完成。只需要点击Dump HPROF file图标,然后MAT插件就会自动转换格式,并且在eclipse中打开分析结果。eclipse中还专门有个Memory Analysis视图,如果是用Android Studio 则要有下面的命令去转换了:
hprof-conv xxx.xxx.xxx.hprof xxx.xxx.xxx.hprof
转换过后的.hprof文件用MAT工具打开,结果如图:
Histogram:列出内存中的对象,对象的个数以及大小,如图:
Inspector:查看该类字段引用对象或数据类型,静态字段,父类,GC Roots等详细信息,如图:
支配数(Dominator Tree):列出最大的对象以及其依赖存活的Object (大小是以Retained Heap为标准排序的),如图:
MAT Path to GC Root
在Histogram或者Domiantor Tree的某一个条目上,右键可以查看其GC Root Path:
这里也要说明一下Java的引用规则:
从最强到最弱,不同的引用(可到达性)级别反映了对象的生命周期。
Strong Ref(强引用):通常我们编写的代码都是Strong Ref,于此对应的是强可达性,只有去掉强可达,对象才被回收。
Soft Ref(软引用):对应软可达性,只要有足够的内存,就一直保持对象,直到发现内存吃紧且没有Strong Ref时才回收对象。一般可用来实现缓存,通过java.lang.ref.SoftReference类实现。
Weak Ref(弱引用):比Soft Ref更弱,当发现不存在Strong Ref时,立刻回收对象而不必等到内存吃紧的时候。通过java.lang.ref.WeakReference和java.util.WeakHashMap类实现。
Phantom Ref(虚引用):根本不会在内存中保持任何对象,你只能使用Phantom Ref本身。一般用于在进入finalize()方法后进行特殊的清理过程,通过 java.lang.ref.PhantomReference实现。
点击Path To GC Roots –> with all references,如图: