当前位置: 首页 > 文档资料 > larva 中文文档 >

短停顿收集

优质
小牛编辑
131浏览
2023-12-01

即便用追踪式收集辅助引用计数,在很多时候停顿时间依然不可接受,原因有几点:

  1. 虽然引用计数可以保证不产生循环的对象能实时回收,但存在很多这类对象是挂在循环引用数据结构上的,比如两个对象互相引用,而他们各自又挂了很多int和string数据,即是一个例子。由于这些对象的回收也是在停顿期,实时性不是那么好
  2. 循环引用本身并不罕见,双链表、树等数据结构都存在这种情况,实际工作中很多复杂的业务也存在这种情况,比如游戏中各种对象的互相引用
  3. 虽然从整体来说,很多程序,尤其是逻辑比较简单的,不会产生循环引用(否则python还怎么在语言界混),但是对一个程序来说,对象的类型和创建频率符合二八原则,即最常用,最多创建的对象只是少数,如果这少数类型存在循环引用,问题就比较严重,换句话说,在这个停顿时间上做努力,性价比不是那么高,对某些程序来说,根本没有循环引用,没必要辅助收集,而对某些程序来说,循环引用又非常多,就算做了辅助收集,效果也一般

相对比较成熟的虚拟机,比如java和C#,都采用的是追踪式收集。对追踪式收集来说,为了减少停顿时间,需要对收集过程做了优化,尽量将收集过程平摊到运行时,主要是两个方向

第一个方向是渐进式和并发式的收集,对于一次垃圾收集来说,面对的依然是整个堆空间,只不过在收集过程和运行穿插在一起,比如说,先标记一部分,如果发现时间比较长了,就切换回去继续执行一段时间,然后切到垃圾收集继续标记一部分。。。直到垃圾收集完成,这样停顿时间就短了

渐进式收集存在两个问题:

class A:
pass
a = A()
a.a = A()

开启垃圾收集

a.a = None

在注释部分,如果已经标记了a.a引用的对象,然后切换回程序执行,a.a引用改变,再次切换回垃圾收集的时候,在回收阶段,a.a原先引用的A的实例因为已经标记过,就不会被释放,也就是说,在回收阶段之前,有标记过的对象成为垃圾,就会漏掉,不过这个问题不严重,因为在下一次垃圾收集的时候,这些垃圾会正常回收,只是浪费些内存

第二个问题就比较严重:

a = A()
b = A()
b.a = A()

开启垃圾收集,只标记到a,切换

a.a = b.a
del b.a

切回垃圾收集器,继续标记b

在第一个注释部分,只标记到了a,没有标记b,第二个注释的位置虽然标记到b了,但是b的元素已经转移给了a,由于a已经标记了,因此不会再处理,这样一来,中间被转移的这个对象实例就会被错当成垃圾,释放它会造成悬空引用 显然,由于a中途被改动了,因此a本身需要重新处理,但如果a含有很多元素,只修改了其中一个,没必要再循环一遍,应对这个问题的办法是,在垃圾收集进行阶段,所有已被标记的对象的引用修改都会被拦截并记录下来,而这些改动过的引用会被重新扫描标记 P.S.我猜在极端情况下,这个过程可能会很长,比如先做一部分标记工作,然后执行一段程序,执行过程中会有拦截记录,向待处理队列中增加任务,然后切回垃圾收集器再做一部分标记工作,如果拦截记录给任务队列增加任务的增长速度比标记速度还快,标记过程会一直做下去,直到堆内存耗尽,这时候就不得不将程序完全暂停,进行一次彻底的垃圾回收,其实上面第一个问题也存在这种情况,如果在标记完成之前堆内存就耗尽的话

因此,渐进式垃圾收集还是很复杂的,来回切换、拦截引用的修改操作也会带来不小的运行时性能损耗

虽然垃圾收集的停顿可能对程序运行产生影响,但整体来说停顿时间比例也不是特别高,比方说内存比较大的时候,运行10分钟会有一次持续1分钟的垃圾收集,如前所述,某些系统下1分钟的停顿时间就可能导致雪崩效应。如果能将这个过程缩短,哪怕只是缩短到原来的一半,情况就可能有很大的改善,由于硬件的升级,现在的机器基本都是多核的,在停顿阶段,可以利用多线程并发标记、收集的方式,减少停顿时间,当然这需要处理好多个收集器互相协作的问题

另一个方向是分区域局部收集,考虑追踪式收集,一般来说都是在内存不足的情况下启动,换句话说垃圾回收这时候的作用就是释放内存供将来使用,如果做全局的收集,自然可以释放大量内存,但对于程序的需求来说可能“过多”了,如果说,只针对一定大小的部分对象集合做收集,就可能回收到足以满足接下来一段时间的程序需求,那就减少了停顿时间

局部回收和全局回收类似,都是采用追踪式,所不同的有两点: 一、由于一个局部区域的对象可能被其他区域的对象引用,因此根集应该是所有指向这个区域的外部引用 二、在标记阶段,只需要标记当前区域的对象即可,如果有指向其他区域的引用,则不作处理

如图:

在这张图中有a到h共8个对象,箭头表示引用的指向关系,则很容易看出,a和b是被外部区域引用,因此是根集,从根集出发,abcd都是可达集,则省下的efgh就是垃圾,可以回收

可以看到,重要的是如何找到根集,然后标记清除就很简单了,将局部区域回收过程用算法来表述,有两个办法:

第一种是利用引用计数,对区域中所有对象,遍历其所有引用,如果引用在本区域内,则被引用对象的计数副本减一(但这时候计数为0不可回收,只是假装减一),这样做完后,上面的例子中,a和b的引用计数均为1,其余均为0,此时计数非0的即为根集,然后从根集开始,恢复可达集的引用计数,则abcd均恢复,再遍历所有对象,计数为0的就都能回收了(这是标记-清除,或者将abcd拷走也行,即节点复制法)

第一种办法虽然简单,但用了引用计数,增加了维护的开销,这里我们只需要找根集,也就是只需要记录跨区域的引用即可,事实上,很多有引用联系的对象存在聚堆的情况,比如A的实例引用一个B的实例,则B的这个实例很可能是在A的实例建立后不久就建立,而由于很多对象比较小,一个区域可能有成千上万的对象,相对来说,跨区域的引用就不是那么多了

因此第二种办法就是对每个区域做一张表,记录本区域中对象被其他区域引用的情况,在每个引用建立或拆除的时候,检查两端的对象是否在一个区域,如果不在,则更新被引用对象所在区域的表,虽然和引用计数一样存在实时维护的消耗,但相对还是小很多了

有了区域收集的技术基础,就可以设计一个比较实用的垃圾回收机制了,回想之前的节点复制回收机制,在可达集数量和空间比较小的时候,节点复制的效率很高,缺点是会浪费一半内存,且随着程序运行,可达集变大之后效率反而不如标记-清除。而有了区域收集的技术,就可以对其做改进,首先无需将整个堆空间分成两半,而是申请两块空间较小,大小相等的区域来做节点复制,经过几个周期后,可达集可能会比较大,这时候将可达集拷贝到另外一个新申请的区域,继续用这两块区域做节点复制,而其他保存拷贝出去节点的区域,则可以用标记-清除来收集,即对不同的区域采用不同的垃圾收集算法

结合分代假设,这一过程可以转化为分代收集,分代假设是从实际情况中总结出来的,它认为,绝大多数对象的生命周期都很短,而其他少部分对象的生命周期又非常长,对于最近新建立过的对象做收集有很高的性价比,对于已经存在比较久的对象,则可以不那么频繁收集,因为它们成为垃圾的概率会更低

由此,一开始还是在一个固定区域进行对象申请和垃圾收集,在收集过程中对每个对象进行寿命的计数,如果发现某些对象已经存活了比较长的时间,则将其移动到相对老代的区域,对于对象较年轻的区域可频繁进行垃圾收集,而对于年老的区域则可以较长时间才收集一次

针对区域的收集可能导致一个问题,由于只收集区域内部的对象,则如果出现跨区域的循环引用,则需要综合若干区域才能进行标记和回收,甚至是需要一次全局回收,事实上,分代收集虽然减少了平均停顿时间,但还是免不了full gc,只是频率相对低罢了。为解决这个问题,出现了火车算法,堆内存按列车和车厢进行分组区域划分,然后通过将对象拷贝到其他区域,尽量将相关对象聚在一起,避免全局的full gc。垃圾收集整体上分代进行,在新生代用节点复制,年老的代用火车算法,这套体系就是目前支撑着java的核心技术之一(sun的HotSpot)

按照龙书的说法,火车算法可以解决长期停顿的问题,但是这个问题也有争议,比如《深入jvm》一书中提到,在极端和病态的情况下,火车算法依然避免不了full gc,而且分代和火车算法也带来了更多的消耗,占用资源少和停顿时间短似乎就是一对矛盾,或许就应了那句话——世界没有银弹