你好,我是海纳。

上一节课中,我们讲到GC算法大致可以分为两大类:引用计数法和基于可达性分析的算法。在基于可达性分析的GC算法中,最基础、最重要的一类算法是基于copy的GC算法(后面简称copy算法)。

Copy算法是最简单实用的一种模型,也是我们学习GC算法的基础。而且,它被广泛地使用在各类语言虚拟机中,例如JVM中的Scavenge算法就是以它为基础的改良版本。所以,掌握copy算法对我们后面的学习是至关重要的。

这一节课,我们就从copy算法的基本原理开始讲起,再逐步拓展到GC算法的具体实现。这些知识将帮助你对JVM中Scavenge的实现有深入的理解,并且让你正确地掌握Scavenge GC算法的参数调优。

最简单的copy算法

基于copy的GC算法最早是在1963年,由Marvin Minsky提出来的。这个算法的基本思想是把某个空间里的活跃对象复制到其他空间,把原来的空间全部清空,这就相当于是把活跃的对象从一个空间搬到新的空间。因为这种复制具有方向性,所以我们把原空间称为From空间,把新的目标空间称为To空间。

分配新的对象都是在From空间中,所以From空间也被称为分配空间(Allocation Space),而To空间则相应地被称为幸存者空间(Survivor Sapce)。在JVM代码中,这两套命名方式都会出现,所以搞清楚这点比较有好处。我们这节课为了强调拷贝进行的方向,选择使用From空间和To空间来分别指代两个空间,而尽量不使用分配空间和幸存者空间的说法。

最基础的copy算法,就是把程序运行的堆分成大小相同的两半,一半称为From空间,一半称为To空间。当创建新的对象时,都是在From空间里进行内存的分配。等From空间满了以后,垃圾回收器就会把活跃对象复制到To空间,把原来的From空间全部清空。然后再把这两个空间交换,也就是说To空间变成下一轮的From空间,现在的From空间变成To空间。具体过程如图所示:

可以看到,上图中的from空间已经满了,这时候,如果想再创建一个新的对象是无法满足的。此时就会执行GC算法将活跃对象都拷贝到新的空间中去。

假设A对象作为根对象是存活的,而A引用了B和D,所以B和D是活跃的;D又引用了F,所以F也是活跃的。这时候,要是已经没有任何地方引用C对象和E对象,那么C和E就是垃圾了。当GC算法开始执行以后,就会把A、B、D、F都拷贝到To空间中去。拷贝完成后,From空间就清空了,并且From空间与To空间相互交换。

此时,top指针指向了新的From空间,并且是可用内存的开始处。如果需要再分配新的对象的话,就会从top指针开始进行内存分配。

我们知道,GC算法包括对象分配和回收两个方面。下面,我们分别从这两个方面对copy算法加以考察。先来看copy算法的内存分配是怎么做的。

对象分配

从上面的介绍里我们知道,在From空间里,所有的对象都是从头向后紧密排列的,也就是说对象与对象之间是没有空隙的。而所有的可用内存全部在From空间的尾部,也就是上图中top指针所指向的位置之后。

那么,当我们需要在堆里创建一个新的对象时,就非常容易了,只需要将top指针向后移动即可。top指针始终指向最后分配的对象的末尾。每当新分配一个新对象时,只需要移动一次指针即可,这种分配效率非常高。

如果按这种方式进行新对象的创建,那么对象与对象之间可以保证没有任何空隙,因为后一个对象是顶着前一个对象分配的,所以,这种方式也叫做碰撞指针(Bump-pointer)

了解了copy算法的内存分配过程后,我们再来看回收的过程。

Java对象的内存布局

在进行垃圾回收之前,我们首先要识别出哪些对象是垃圾对象。上一节课我们讲过,如果一个对象永远不会再被使用到,那么我们就可以认为这个对象就是垃圾。识别一个对象是否是垃圾的主要方法有两种:引用计数和基于可达性的方法。上一节课我们讲了引用计数,这节课,我们主要来看基于可达性分析,或者称为追踪(Tracing)的方法。

要想识别一个对象是不是垃圾,Tracing首先需要找到“根”引用集合。所谓根引用指的是不在堆中,但指向堆中的引用。根引用包括了栈上的引用、全局变量、JVM中的Handler、synchronized对象等。它的基本思想是把对象之间的引用关系构成一张图,这样我们就可以从根出发,开始对图进行遍历。能够遍历到的对象,是存在被引用的情况的对象,就是活跃对象;不能被访问到的,就是垃圾对象。

那怎么把对象之间的引用关系抽象成图呢?这就涉及到了Java对象的内存布局,我们先来看一下Java对象在内存中是什么样子的。

在JVM中,一个对象由对象头和对象体构成。其中,对象头(Mark Word)在不同运行条件下会有不同的含义,例如对象锁标识、对象年龄、偏向锁指向的线程、对象是否可以被回收等等。而对象体则包含了这个对象的字段(field),包括值字段和引用字段

每一个Java对象都有一个字段记录该对象的类型。我们把描述Java类型的结构称为Klass。Klass中记录了该类型的每一个字段是值类型,还是对象类型。因此,我们可以根据对象所关联的Klass来快速知道,对象体的哪个位置存放的是值字段还是引用字段。

如果是引用字段,并且该引用不是NULL,那么就说明当前对象引用了其他对象。这样从根引用出发,就可以构建出一张图了。进一步地,我们通过图的遍历算法,就可以找到所有被引用的活对象。很显然,没有遍历到的对象就是垃圾。

通常来说,对图进行遍历有两种算法,分别是深度优先遍历(Depth First Search, DFS)广度优先遍历(Breadth First Search,BFS)。接下来,我们一起看看这两种算法是如何完成图的遍历的。

深度优先搜索算法的实现

复制GC算法,最核心的就是如何实现复制。根据上面的描述,我们自己就可以很容易地写出一个基于深度优先搜索的算法,它的伪代码如下所示:

void copy_gc() {
    for (obj in roots) {
        *obj = copy(obj);
    }          
}
obj * copy(obj) {
    new_obj = to_space.allocate(obj.size);
    copy_data(new_obj, obj, size);
    for (child in obj) {
        *child = copy(child);
    }
    return new_obj;
}

可以看到,算法的开始是从roots的遍历开始的,然后对每一个roots中的对象都执行copy方法(第2~ 4行)。copy方法会在To空间中申请一块新的地址(第7行),然后将对象拷贝到To空间(第8行),再对这个对象所引用到的对象进行递归的拷贝(第9~11行),最后返回新空间的地址(第12行)。

第4节课我们讲解栈的递归特性时,曾经对深度优先搜索的递归写法做过深入分析。拿上面的代码和第4课中的代码进行比较,我们会发现,上面的代码中缺少了对重复访问对象的判断。

考虑到有两个对象A和B,它们都引用了对象C,而且它们都是活跃对象,现在我们对这个图进行深度优先遍历。

在遍历过程中,A先拷到to space,然后C又拷过去,这时候,空间里的引用是这种状态:

A和C都拷到新的空间里了,原来的引用关系还是正确的。但我们的算法在拷贝B对象的时候,先完成B的拷贝,然后你就会发现,此时我们还会把C再拷贝一次。这样,在To空间里就会有两个C对象了,这显然是错的。我们必须要想办法解决这个问题。

通常来说,在一般的深度优先搜索算法中,我们只需要为每个结点增加一个标志位visited,以表示这个结点是否被访问过。但这只能解决重复访问的问题,还有一件事情我们没有做到:新空间中B对象对C对象的引用没有修改。这是因为我们在对B进行拷贝的时候,并不知道它所引用的对象在新空间中的地址。

解决这个问题的办法是使用forwarding指针。也就是说每个对象的头部引入一个新的域(field),叫做forwarding。正常状态下,它的值是NULL,如果一个对象被拷贝到新的空间里以后,就把它的新地址设到旧空间对象的forwarding指针里。

当我们访问完B以后,对于它所引用的C,我们并不能知道C是否被拷贝,更不知道它被拷贝到哪里去了。此时,我们就可以在C上留下一个地址,告诉后来的人,这个地址已经变化了,你要找的对象已经搬到新地方了,请沿着这个新地址去寻找目标对象。这就是forwarding指针的作用。下面的图展示了上面描述的过程:

如果你还不太明白,我再给你举一个形象点儿的例子:你拿到一张画,上面写着武穆遗书在临安皇宫花园里。等你去花园里找到一个盒子,却发现里面的武穆遗书已经不在了,里面留了另一幅画,告诉你它在铁掌峰第二指节。显然,有人移动过武穆遗书,并把新的地址告诉你了,等你第二次访问,到达临安的时候,根据新的地址就能找到真正的武穆遗书了。

到这里,我们就可以将copy gc的算法彻底完成了,完整的算法伪代码如下所示:

void copy_gc() {
    for (obj in roots) {
        *obj = copy(obj);
    }          
}
obj * copy(obj) {
    if (!obj.visited) {
        new_obj = to_space.allocate(obj.size);
        copy_data(new_obj, obj, size);
        obj.visited = true;
        obj.forwarding = new_obj;
        for (child in obj) {
            *child = copy(child);
        }
    }
    return obj.forwarding;
}

这样一来,我们就借助深度优先搜索算法完成了一次图的遍历。

我们说,除了深度优先搜索外,广度优先搜索也可以实现图的遍历。那这两种算法有什么区别呢?我们进一步来看。

深度优先对比广度优先

我们已经详细描述了基于深度优先的copy算法,为了对比它与广度优先的copy算法,我们使用一个例子来进行说明。我把这个例子所使用到的对象定义列在下面:

class A {
    public B b1;
    public B b2;
    public A() {
        b1 = new B();
        b2 = new B();
    }
}
class B {
    public C c1;
    public C c2;
    public B() {
        c1 = new C();
        c2 = new C();
    }
}

class C {
}

假设,在From空间中对象的内存布局如下所示:

请你注意,图中的空白部分是我为了让图更容易查看而故意加的,真实的情况是每个对象之间的空白是不存在的,它们是紧紧地挨在一起的。

接下来,我们从A对象开始深度优化遍历,那么第一个被拷贝进To空间的就是A对象,如下图所示:

然后对A进行扩展,先访问它属性b1所引用的对象,把b1所指向的对象拷贝到To空间,这一步完成以后,空间中对象的情况如下图所示:

接下来的这一步,是一个关键步骤,由于我们的算法是深度优先遍历,所以接下来会拷贝c1所引用的C对象,而不是b2所引用的B对象。因为C的对象不包含指向其他对象的引用,所以,搜索算法拷贝完C对象以后就开始退栈。算法退到上一栈以后,就会继续搜索B对象中,c2所引用的那个C对象。经过这两步操作以后,堆空间的情况如下所示:

这样b1所引用的B对象就搜索完了,算法会继续退栈,继续搜索b2所引用的B对象,当b2所引用的对象也全部搜索完成以后,再把To空间和From空间对调。我们就完成了一次Copy GC的全部过程。算法完成以后的堆空间的情况如下所示:

从上面的图片中可以观察到一个特点:To空间中的对象排列顺序和From空间中的对象排列顺序发生了变化。从图5和图9的箭头的样子可以看得出来,图5中的箭头是比较混乱的,而图9中的箭头则简约很多。因为箭头代表了引用关系,这就说明具有引用关系的对象在新空间中距离更近了。

我们在第14节课介绍过,因为CPU有缓存机制,所以在读取某个对象的时候,有很大概率会把它后面的对象也一起读进来。通常情况下,我们在写Java代码时,经常访问一个变量后,马上就会访问它的属性。如果在读A对象的时候,把它后面的B和C对象都能加载进缓存,那么,a.b1.c1这种写法就可以立即命中缓存。

这是深度优先搜索的copy算法的最大的优点,同时,从代码里也能分析出它的缺点,那就是采用递归的写法,效率比较差。如果你熟悉数据结构的话,应该都知道,深度优先遍历也有非递归实现,它需要额外的辅助数据结构,也就是说需要手工维护一个栈结构。非递归的写法,可以使用以下伪代码表示:

void copy_gc() {
	for (obj in roots) {
		stack.push(obj);
	}
	while (!stack.empty()) {
		obj = stack.pop();
		*obj = copy(obj);
		for (child in obj) {
			stack.push(child);
		}
	}
}

与深度优先搜索相对应的是广度优先搜索。它的优缺点刚好与深度优先搜索相反。如果使用广度优先算法将对象从From空间拷贝到To空间,那么有引用关系的对象之间的距离就会比较远,这将不利于业务线程运行期的缓存命中。它的优点则在于可以节省GC算法执行过程中的空间,提升拷贝过程的效率。这部分内容请你作为练习,自己推导一遍广度优先搜索以后的堆空间,你就能掌握的更好了。

广度优先算法节省空间的原理是:使用scanned指针将非递归的广度优先遍历所需的队列,巧妙地隐藏在了To空间中。我使用伪代码写出来,你就能理解了:

void copy_gc() {
	for (obj in roots) {
		*obj = copy(obj);
	}
	while (to.scanned < to.top) {
		for (child in obj(scanned)) {
			*child = copy(child)
		}
		to.scanned += obj(scanned).size();
	}
}

其中,obj(scanned)代表把scanned所指向的对象强制转换为一个obj。

综上所述,深度优先搜索的非递归写法需要占用额外的空间,但有利于提高业务线程运行期的缓存命中率而广度优先搜索则与其相反,它不利于运行期的缓存命中,但算法的执行效率更高。所以JDK6以前的JVM使用了广度优先的非递归遍历,而在JDK8以后,已经把广度优先算法改为深度优先了,尽管这样做需要额外引用一个独立的栈。

到这里,基于copy算法的原理,我们就全部讲完了。总体来看,基于copy的算法要将堆空间分成两部分:一部分是From空间,一部分是To空间。不管什么时刻,总有一半空间是空闲的。所以,它总体的空间利用率并不高。为了提升空间的利用率,Hotspot对copy算法进行了改造,并把它称为Scavenge算法。那它是怎么实现的呢?

Scavenge算法

我们知道,每次回收中能存活下来的对象占总体的比例都比较小。那么,我们就可以结合这个特点,把To空间设置得小一点,来提升空间的利用率。

Hotspot在实现copy算法时做了一些改进。它将From空间称为Eden空间,To空间在算法实现中则被分成S0和S1两部分,这样To空间的浪费就可以减少了。Java堆的空间关系如下图所示:

在这张图里,Hotspot的内存管理器在Eden空间中分配新的对象,每次GC时,如果将S0做为To空间,则S1与Eden合起来成为From空间。也就是说To空间这个空闲区域就大大减小了,这样可以提升空间的总体利用率。

Scavenge算法是简单copy算法的一种改进。在这种算法中,人们习惯于把S0和S1称为幸存者空间(Survivor Space)。配置Survivor空间的大小是JVM GC中的重要参数,例如:-XX:SurvivorRatio=8,代表Eden:S0:S1=8:1:1。

讲到这,我们清楚地了解了Scavenge算法的原理和来龙去脉。由此我们也容易推知,基于Copy的GC算法有以下特点:

  1. 对象之间紧密排列,中间没有空隙,也就是没有内存碎片;
  2. 分配内存的效率非常高。因为每次分配对象都是把指针简单后移即可,操作非常少,所以效率高;
  3. 回收的效率取决于存活对象的多少,如果存活对象比较多,那么回收的效率就差,如果存活的对象少,则回收效率高。如果对象的生命周期比较短,也就是说存活的时候比较短,那么在进行GC的时候,存活的对象就会比较少,这种情况下采用基于copy的GC算法是比较高效的;
  4. 内存利用率并不高。因为在任一时刻总有一部分空间是无非被使用的,Scavenge算法也只能缓解这个问题,而不能彻底解决,这是由算法的设计所决定的;
  5. copy算法需要搬移对象,所以需要业务线程暂停。

总结

这节课我们重点学习了基于copy的GC算法的基本原理和它的具体实现。

因为copy算法每一次都会搬移对象,在搬移的过程中就已经完成了内存的整理,所以对象与对象之间是没有空隙的,也就是说没有内存碎片。这同时也让内存分配的实现非常简单:我们只需要记录一个头部指针,有分配空间的需求就把头部指针向后移就可以了。因为后一个对象是顶着前一个对象分配的,所以,这种方式也叫做碰撞指针

接下来,我们重点研究了Java对象的内存布局,从这里我们知道,Java对象并不只包含用户定义的字段,还包括了对象头和Klass指针。其中Klass用于描述Java对象的类型,它还记录了Java对象的布局信息,来指示对象中哪一部分是值,哪一部分是引用。

然后就是我们这节课的重点了。使用深度优先搜索算法对活跃对象进行遍历,在遍历的同时就把活跃对象复制到To空间中去了。活跃对象有可能被重复访问,所以人们使用forwarding指针来解决这个问题。图的遍历分为深度优先搜索和广度优先搜索,我们对两种做法都加以讲解,并对比了它们的特点:

最后,我们展示了Hotspot中的真实做法,也就是Scavenge算法,并总结了copy算法的五个特点:没有内存碎片、分配效率高、回收效率取决于存活对象比例、总的内存利用率不高和需要暂停

思考题

我们提到,copy算法需要暂停业务线程,那么假设业务线程很多,我们应该怎么样通知业务线程停下来呢?提示:参考加餐二中提到的信号机制和第5节课所讲的协程切换时机。欢迎在留言区分享你的想法,我在留言区等你。


好啦,这节课到这就结束啦。欢迎你把这节课分享给更多对计算机内存感兴趣的朋友。我是海纳,我们下节课再见!