垃圾回收算法
概念
垃圾回收(Garbage Collection),简称GC,主要是对那些已经分配的空间进行回收利用,实现再分配.
比如下面的Java代码
public void test() {
List<Integer> list = new ArrayList<>();
list.add(1);
System.out.println(list);
}
在这个函数中,我创建了一个ArrayList对象,Java则在内存堆上为其分配空间.但是一旦出了这个函数,我们就再也访问不到这块空间了,如果不进行回收,那么内存堆的这个位置就浪费了.
所以GC要完成的主要有两件事:
- 找到内存中的垃圾
- 回收这部分垃圾,以便之后可以再分配
关于对象在堆上的布局
一般一个对象在堆中存储两部分信息,一部分是对象的元数据,比如对象的大小,hashcode的值以及用来标识垃圾回收的信息等.另一部分就是对象的具体数据.这部分主要分两种,如果是基本数据类型,那么这个位置存的就是它的值.如果不是,那么这个位置存放的是指向具体内存地址的指针.
如何找到内存中的垃圾
这里采用可达性分析,首先我们会将一些对象作为根,这些对象我们把它标记为可达的,然后由这些根所能指向的其他对象,我们也标记为可达…这样一直传递下去,到最后,我们可以遍历整个堆,然后把那些不可达的对象给回收掉.
哪些对象可以作为根呢 —> 调用栈、寄存器以及全局变量
不过,这些根可能还有潜在的问题. 怎么判断这个值是指针的地址还是一个基本数据类型呢?
就概率上讲,数值刚好位于堆地址范围内,且刚好是对象头的地址,情况还是比较少的. 我们可以将错就错,不过在这种情况下,可能一个本该回收的对象就被我们列为根了.
当存在貌似指针的非指针时,保守式GC会把被引用的对象错误识别为活动对象。如果这个对象存在大量的子对象,那么它们一律都会被看成活动对象
要严格区别指针与非指针,需要语言处理程序的一些支持.这一块不作展开.
GC算法评价指标
- 吞吐量
如堆的大小为HEAP_SIZE,GC总共花费的时间为(A+B+C),则吞吐量为HEAP_SIZE/(A+B+C)。
- 最大暂停时间
最大暂停时间指的是“因执行GC而暂停执行mutator的最长时间”,比如下图指的就是B. 如果暂停时间太长,就会出现Stop-The-World
- 堆使用效率
不同的GC算法,会在堆中对象的对象头存放一些信息,大小不一,或者会将堆做划分.
- 访问的局部性
我们知道计算机是有高速缓存这么个东西.一般而言,具有引用关系的对象之间通常很可能存在连续访问的情况。所以如果在堆中这两个对象存放位置连续,那当读取第一个对象时,后面的对象也跟着被读进了缓存,后续访问的速度就加快了.
GC算法介绍
标记清除算法
这个其实在前面也有提到,从根集合出发,将能访问到的对象打上标记,然后遍历堆,将没有标记的对象清除.伪代码描述如下:
mark_sweep() {
//标记阶段
for(r : $roots) {
mark(*r)
}
//清除阶段
sweeping = $heap_start
while(sweeping < $heap_end)
if(sweeping.mark == TRUE)
sweeping.mark = FALSE
else
sweeping.next = $free_list
$free_list = sweeping
sweeping += sweeping.size
}
//标记函数
mark(obj) {
if(obj.mark == FALSE) {
obj.mark = TRUE
for(child : children(obj))
mark(*child)
}
}
这种方式不会挪动对象在堆中的位置,但是容易产生内存碎片.关于回收后的内存如何分配使用,可以找更相关的资料.比如参考malloc库的实现,在<<深入理解计算机系统>>一书中也有所涉及.
引用计数法
顾名思义,在对象头记录有多少个引用了这个对象.当计数变为0的时候,我们就可以立即对这部分内存进行回收.
它的最大暂停时间短,但是存在多个无用对象循环引用无法释放内存的问题. Python的GC采用了引用计数法,可以去了解它如何解决循环引用的
标记复制算法
这种算法把堆划分为两部分,每次分配对象的时候只使用其中一半.当标记过程结束后,把存活的对象全都挪到堆的另一半中.
这种的好处是避免了内存碎片,因为每次都有一整块的空闲空间用于分配对象.但显然堆的使用效率降低了
注意
在这种算法中,由于挪动了对象,所以原先指向这些对象的指针,要做一个映射,使其访问到新的内存位置. 解决方法可以采用句柄的方式间接处理对象. (采用指向指针的指针也可以)
通过引入句柄,当我们移动对象的时候,只需要修改句柄里的指针,而不用更新根的值.
标记整理算法
这种算法可以理解为先执行了标记清除算法,然后把存活的对象都挪到一起,这样空闲的空间就是连续的了.
同样的,由于移动了对象,所以指针的访问也要做些修改.
分代回收
在对象头部分多了对象的存活年龄,据此划分了新生代,老年代对象.在堆中也是划分存储.
根据经验显示,新生代的对象存活率较低,所以这部分会有大量对象回收.对这部分采取标记复制算法收益比较好;
而针对老年代对象,则采用标记清除算法. 当需要GC时,优先从新生代入手.
当我们对新生代进行GC时,如果有老年代的指向新生代的引用,怎么办?
如果遍历老年代去标记,那其实又跟遍历整个堆没区别了.
所以这里引入了记录集和写入屏障
记录集上面保存了老年代对象的指针. 它是在我们更新对象指针的时候,通过写入屏障的方式加入的.
write_barrier(obj, field, new_obj) {
if(obj >= $old_start && new_obj < $old_start && obj.remembered == FALSE)
$rs[$rs_index] = obj
$rs_index++
obj.remembered = TRUE
*field = new_obj
}
如上述伪代码那样,当老年代对象有某个属性指向新生代对象时,将其加入记录集中.
这样的好处是当新生代对象的内存位置移动时,能够知道要更新哪个老年代对象的指针
增量式垃圾回收
上面提到的GC都有一个问题,它们在进行垃圾回收时会阻塞主线程,直到垃圾回收结束.
这里主要讲一种增量GC的算法,它的GC要分好几个阶段完成,这样保证最大暂停时间变小.
三色标记算法
这是Edsger W. Dijkstra等人提出的三色标记算法(Tri-color marking),它将每个对象都标记了一个颜色:
-
白色:还未搜索过的对象
-
灰色:正在搜索的对象
-
黑色:搜索完成的对象
GC开始运行前所有的对象都是白色。GC一开始运行,所有从根能到达的对象都会被标记,然后被堆到栈里。GC只是发现了这样的对象,但还没有搜索完它们,所以这些对象就成了灰色对象。灰色对象会被依次从栈中取出,其子对象也会被涂成灰色。当其所有的子对象都被涂成灰色时,对象就会被涂成黑色。当GC结束时已经不存在灰色对象了,活动对象全部为黑色,垃圾则为白色。
它可以分为三个阶段: 根查找阶段,标记阶段,清除阶段
根查找阶段把能直接从根引用的对象涂成灰色。
标记阶段查找灰色对象,将其子对象也涂成灰色,查找结束后将灰色对象涂成黑色。
清除阶段则查找堆,将白色对象连接到空闲链表,将黑色对象变回白色.
// 在标记,清除阶段中,其都是增量式的,即每次只标记或清除一定数量的对象后切回主线程.
这里还有几个需要注意的地方.
问题1
如图,当A标记完所有子对象后,自己变黑. 此时切换回主线程,然后主线程的代码做了个操作,将B–C之间的引用删掉,然后加了A–C的引用. 这时再回来GC的时候,由于A已经黑了,所以不会再对其子对象做标记, 这样就会错过对象C, 这样会误回收对象!!
解决这个问题的办法同样使用写入屏障, 在添加A指向C的引用时, 如果C是白色的,就将其标黑.
write_barrier(obj, field, newobj) {
if(newobj.mark == FALSE) {
newobj.mark = TRUE
push(newobj, $mark_stack)
}
*field = newobj
}
问题2
如图,当GC开始清除阶段时,它会把没有标黑的对象给清除掉. 如果这时切换回主线程,而我们又要分配新的对象时就要注意了, 如果分配的新对象位置在$sweeping的右边, 那我们要把新分配的对象给标成黑色的,以防被误清除.
参考资料
<<垃圾回收的算法与实现>> ----- [日]中村成洋,[日]相川光