垃圾内存回收算法

垃圾内存回收算法

常见的垃圾回收算法有引用计数法(Reference Counting)、标注并清理(Mark and Sweep GC)、拷贝(Copying GC)和逐代回收(Generational GC)等算法,其中Android系统采用的是标注并删除和拷贝GC,并不是大多数JVM实现里采用的逐代回收算法。由于几个算法各有优缺点,所以在很多垃圾回收实现中,常常可以看到将几种算法合并使用的场景,本节将一一讲解这几个算法。

引用计数回收法(Reference Counting GC)

引用计数法的原理很简单,即记录每个对象被引用的次数。每当创建一个新的对象,或者将其它指针指向该对象时,引用计数都会累加一次;而每当将指向对象的指针移除时,引用计数都会递减一次,当引用次数降为0时,删除对象并回收内存。采用这种算法的较出名的框架有微软的COM框架,如代码清单14 - 1演示了一个对象引用计数的增减方式。

代码清单14 - 1 引用计数增减方式演示伪码

Object *obj1 = new Object(); // obj1的引用计数为1
Object *obj2 = obj1; // obj1的引用技术为2
Object *obj3 = new Object();

obj2 = NULL; // obj1的引用计数递减1次为1。
obj1 = obj3; // obj1的引用计数递减1次为0,可以回收其内存。

通常对象的引用计数都会跟对象放在一起,系统在分配完对象的内存后,返回的对象指针会跳过引用计数部分,如图14 - 1所示:

图 14 - 1 采用引用计数对象的内存布局示例

然而引用计数回收算法有一个很大的弱点,就是无法有效处理循环引用的问题。

标注并清理回收法(Mark and Sweep GC)

在这个算法中,程序在运行的过程中不停的创建新的对象并消耗内存,直到内存用光,这时再要创建新对象时,系统暂停其它组件的运行,触发GC线程启动垃圾回收过程。内存回收的原理很简单,就是从所谓的"GC Roots"集合开始,将内存整个遍历一次,保留所有可以被GC Roots直接或间接引用到的对象,而剩下的对象都当作垃圾对待并回收,如代码清单14 - 2:

代码清单14 - 2 标注并清理算法伪码

void GC()
{
     SuspendAllThreads();
     List<Object> roots = GetRoots();
     foreach ( Object root : roots ) {
         Mark(root);
     }

     Sweep();
     ResumeAllThreads();
}

算法通常分为两个主要的步骤:

  • 标注(Mark)阶段:这个过程的伪码如代码清单14 - 2所示,针对GC Roots中的每一个对象,采用递归调用的方式(第8行)处理其直接和间接引用到的所有对象:
    void Mark(Object* pObj) {
         if ( !pObj->IsMarked() ) {
             // 修改对象头的Marked标志
             pObj->Mark();
             // 深度优先遍历对象引用到的所有对象
             List<Object *> fields = pObj->GetFields();
             foreach ( Object* field : fields ) {
                 Make(field); // 递归处理引用到的对象
             }
         }
    }
    

    如果对象引用的层次过深,递归调用消耗完虚拟机内GC线程的栈空间,从而导致栈空间溢出(StackOverflow)异常,为了避免这种情况的发生,在具体实现时,通常是用一个叫做标注栈(Mark Stack)的数据结构来分解递归调用。一开始,标注栈(Mark Stack)的大小是固定的,但在一些极端情况下,如果标注栈的空间也不够的话,则会分配一个新的标注栈(Mark Stack),并将新老栈用链表连接起来。

    与引用计数法中对象的内存布局类似,对象是否被标注的标志也是保存在对象头里的,如图 14 - 2所示。

    图 14 - 2 标注和清理算法中的对象布局

    如图 14 - 2是垃圾回收前的对象之间的引用关系;GC线程遍历完整个内存堆之后,标识出所以可以被"GC Roots"引用到的对象-即代码清单14 - 2中的第4行,结果如图 14 - 3中高亮的部分,对于所有未被引用到(即未被标注)的对象,都将其作为垃圾收集。
    图 14 - 3 回收内存垃圾之前的对象引用关系

    图 14 - 4 GC线程标识出所有不能被回收的对象实例


  • 清理(SWEEP)阶段:即执行垃圾回收过程,留下有用的对象,如图 14 - 4所示,代码清单14 - 3是这个过程的伪码,在这个阶段,GC线程遍历整个内存,将所有没有标注的对象(即垃圾)全部回收,并将保留下来的对象的标志清除掉,以便下次GC过程中使用。

    代码清单14 - 4 标注和清理法中的清理过程伪码
    void Sweep() {
    Object *pIter = GetHeapBegin();
         while ( pIter < GetHeapEnd() ) {
             if ( !pIter->IsMarked() )
                 Free(pIter);
             else
                 pIter->UnMark();
    
             pIter = MoveNext(pIter);
         }
    }
    
    图 14 - 5 GC线程执行完垃圾回收过程后的对象图

    这个方法的优点是很好地处理了引用计数中的循环引用问题,而且在内存足够的前提下,对程序几乎没有任何额外的性能开支(如不需要维护引用计数的代码等),然而它的一个很大的缺点就是在执行垃圾回收过程中,需要中断进程内其它组件的执行。

标注并整理回收法(Mark and COMPACT GC)

这个是前面标注并清理法的一个变种,系统在长时间运行的过程中,反复分配和释放内存很有可能会导致内存堆里的碎片过多,从而影响分配效率,因此有些采用此算法的实现(Android系统中并没有采用这个做法),在清理(SWEEP)过程中,还会执行内存中移动存活的对象,使其排列的更紧凑。在这种算法中,,虚拟机在内存中依次排列和保存对象,可以想象GC组件在内部保存了一个虚拟的指针 – 下个对象分配的起始位置 ,如图 14 - 6中演示的示例应用,其GC内存堆中已经分配有3个对象,因此"下个对象分配的起始位置"指向已分配对象的末尾,新的对象"object 4"(虚线部分)的起始位置将从这里开始。

这个内存分配机制和C/C++的malloc分配机制有很大的区别,在C/C++中分配一块内存时,通常malloc函数需要遍历一个"可用内存空间"链表,采取"first-first"(即返回第一块大于内存分配请求大小的内存块)或"best-fit"( 即返回大于内存分配请求大小的最小内存块),无论是哪种机制,这个遍历过程相对来说都是一个较为耗时的时间。然而在Java语言中,理论上,为一个对象分配内存的速度甚至可能比C/C++更快一些,这是因为其只需要调整指针"下个对象分配的起始位置"的位置即可,据Sun的工程师估计,这个过程大概只需要执行10个左右的机器指令。

图 14 - 6 在GC中为对象分配内存

由于虚拟机在给对象分配内存时,一直不停地向后递增指针"下个对象分配的起始位置",潜台词就是将GC堆当做一个无限大的内存对待的,为了满足这个要求,GC线程在收集完垃圾内存之后,还需要压缩内存 – 即移动存活的对象,将它们紧凑的排列在GC内存堆中,如图 14 - 7是Java进程内GC前的内存布局,执行回收过程时,GC线程从进程中所有的Java线程对象、各线程堆栈里的局部变量、所有的静态变量和JNI引用等GC Root开始遍历。

图 14 - 7中,可以被GC Root访问到的对象有A、C、D、E、F、H六个对象,为了避免内存碎片问题,和满足快速分配对象的要求,GC线程移动这六个对象,使内存使用更为紧凑,如图 14 - 7所示。由于GC线程移动了存活下来对象的内存位置,其必须更新其他线程中对这些对象的引用,如图 14 - 7中,由于A引用了E,移动之后,就必须更新这个引用,在更新过程中,必须中断正在使用A的线程,防止其访问到错误的内存位置而导致无法预料的错误。

图 14 - 7 垃圾回收前的GC堆上的对象布局及引用关系

图 14 - 8 GC线程移动存活的对象使内存布局更为紧凑

注意现代操作系统中,针对C/C++的内存分配算法已经做了大量的改进,例如在Windows中,堆管理器提供了一个叫做"Look Aside List"的缓存针对大部分程序都是频繁分配小块内存的情形做的优化。

拷贝回收法(Copying GC)

这也是标注法的一个变种, GC内存堆实际上分成乒(ping)和乓(pong)两部分。一开始,所有的内存分配请求都有乒(ping)部分满足,其维护"下个对象分配的起始位置"指针,分配内存仅仅就是操作下这个指针而已,当乒(ping)的内存快用完时,采用标注(Mark)算法识别出存活的对象,如图 14 - 9所示,并将它们拷贝到乓(pong)部分,后续的内存分配请求都在乓(pong)部分完成,如图 14 - 10。而乓(pong)里的内存用完后,再切换回乒(ping)部分,使用内存就跟打乒乓球一样。

图 14 - 9 拷贝回收法中的乒乓内存块

图 14 - 10 拷贝回收法中的切换乒乓内存块以满足内存分配请求

回收算法的优点在于内存分配速度快,而且还有可能实现低中断,因为在垃圾回收过程中,从一块内存拷贝存活对象到另一块内存的同时,还可以满足新的内存分配请求,但其缺点是需要有额外的一个内存空间。不过对于回收算法的缺点,也可以通过操作系统地虚拟内存提供的地址空间申请和提交分布操作的方式实现优化,因此在一些JVM实现中,其Eden区域内的垃圾回收采用此算法。

逐代回收法(Generational GC)

也是标注法的一个变种,标注法最大的问题就是中断的时间过长,此算法是对标注法的优化基于下面几个发现:

  • 大部分对象创建完很快就没用了 – 即变成垃圾;
  • 每次GC收集的90%的对象都是上次GC后创建的;
  • 如果对象可以活过一个GC周期,那么它在后续几次GC中变成垃圾的几率很小,因此每次在GC过程中反复标注和处理它是浪费时间。

可以将逐代回收法看成拷贝GC算法的一个扩展,一开始所有的对象都是分配在"年轻一代对象池" 中 – 在JVM中其被称为Young,如图 14 - 11:

图 14 - 11 逐代(generational) GC中开始对象都是分配在年轻一代对象池(Young generation)中

第一次垃圾回收过后,垃圾回收算法一般采用标注并清理算法,存活的对象会移动到"老一代对象池"中– 在JVM中其被称为Tenured,如图 14 - 12,而后面新创建的对象仍然在"年轻一代对象池"中创建,这样进程不停地重复前面两个步骤。等到"老一代对象池"也快要被填满时,虚拟机此时再在"老一代对象池"中执行垃圾回收过程释放内存。在逐代GC算法中,由于"年轻一代对象池"中的回收过程很快 – 只有很少的对象会存活,而执行时间较长的"老一代对象池"中的垃圾回收过程执行不频繁,实现了很好的平衡,因此大部分虚拟机,如JVM、.NET的CLR都采用这种算法。

图 14 - 12 逐代GC中将存活的对象挪到老一代对象池

在逐代GC中,有一个较棘手的问题需要处理 – 即如何处理老一代对象引用新一代对象的问题,如图 14 - 13中。由于每次GC都是在单独的对象池中执行的,当GC Root之一R3被释放后,在"年轻一代对象池"中执行GC过程时,R3所引用的对象f、g、h、i和j都会被当做垃圾回收掉,这样就导致"老一代对象池"中的对象c有一个无效引用。

图 14 - 13 逐代GC中老一代对象引用新对象的问题

为了避免这种情况,在"年轻一代对象池"中执行GC过程时,也需要将对象C当做GC Root之一。一个名为"Card Table"的数据结构就是专门设计用来处理这种情况的,"Card Table"是一个位数组,每一个位都表示"老一代对象池"内存中一块4KB的区域 – 之所以取4KB,是因为大部分计算机系统中,内存页大小就是4KB。当用户代码执行一个引用赋值(reference assignment)时,虚拟机(通常是JIT组件)不会直接修改内存,而是先将被赋值的内存地址与"老一代对象池"的地址空间做一次比较,如果要修改的内存地址是"老一代对象池"中的地址,虚拟机会修改"Card Table"对应的位为 1,表示其对应的内存页已经修改过 - 不干净(dirty)了,如图 14 - 14。

图 14 - 14 逐代GC中Card Table数据结构示意图

当需要在 "年轻一代对象池"中执行GC时, GC线程先查看"Card Table"中的位,找到不干净的内存页,将该内存页中的所有对象都加入GC Root。虽然初看起来,有点浪费, 但是据统计,通常从老一代的对象引用新一代对象的几率不超过1%,因此"Card Table"的算法是一小部分的时间损失换取空间。

发布于 2016-04-06 00:06