上一篇文章中,我整理介绍了V8的新生代堆内存的垃圾回收策略,这里再简单概述下:V8将堆内存主要划分为新生代和老生代两块区域,新生代使用Scavenge算法,此算法将新生代内存等分为两个semi-space空间,其中只有一个semi-space空间为使用状态,称为From空间,另一块为闲置状态,称为To区;进行垃圾回收时,检查From空间的存活对象,并将存活对象复制进To空间,然后释放非存活对象所占的内存,最后From区和To区进行角色互换,这样便完成了一次新生代的垃圾回收。
接下来介绍老生代的垃圾回收策略。
老生代垃圾回收策略
新生代的Scavenge算法有个很明显的缺点:空间利用率很低,只有50%的空间处于使用状态。但由于新生代存放的是存活时间较短的对象,存活对象占比较小,所以这种算法时间效率上很高。但是老生代中的对象存活时间较长,存活对象数量占比较大,且老生代空间比较大,这时候如果再用Scavenge算法,浪费的空间将会很大,而且复制这么多对象效率将会很低。所以老生代摒弃了Scavenge,选择了Mark-Sweep和Mark-Compact算法:
Mark-Sweep(标记清除)
mark-sweep算法分为两个步骤:标记和清除。此算法不会将内存分为两块,所以不存在内存空间浪费的问题。
1. 标记阶段
mark-sweep遍历堆内存中所有的对象,并对存活对象进行标记。V8 使用每个对象的两个标记位和一个标记工作表来实现标记。两个标记位编码三种颜色:白色(00),灰色(10)和黑色(11)。最初所有的对象都是白色的,然后从根可达的对象会被染色为灰色,并推入到标记工作表中。当收集器从标记工作表中弹出对象并访问他的所有字段时,灰色就会变成黑色。这种方案被称做三色标记法。当没有灰色对象时,标记结束。所有剩余的白色对象无法达到,可以被完全的回收。下面是维基百科上对三色标记法的一个gif演示(其中白、灰、黑对象分别对应图中的浅灰色、黄色、蓝色):
2. 清除阶段
清除阶段只清除没有被标记过的对象(即非存活对象)。可以用下图表示两个阶段:
-
对存活对象进行标记
-
对非存活对象进行清除
可以看到,Mark-Sweep算法只针对非存活对象进行清除操作,由于老生代中非存活对象数量占比较小,这种算法的效率就很高。
但是Mark-Sweep有一个问题:每次进行对象清除之后,会有空间使用不连续的问题(如上图中浅色部分),这会很大程度上造成内存空间浪费的问题,若之后从新生代晋升上来了一个较大的对象,而此时所有的闲置空间都放不下这个对象,这时候就会提前触发一次垃圾回收,而这次垃圾回收其实是不必要的。
Mark-Compact(标记整理)
为了解决Mark-Sweep的内存碎片问题,Mark-Compact被提了出来,它由Mark-Sweep改进而来,与Mark-Sweep的区别是,在标记阶段之后,由原来的清除阶段变成紧缩阶段:首先遍历堆内存中所有对象并对存活对象进行标记,然后把所有的存活对象往堆内存的一端进行移动,移动完成之后释放掉存活对象边界外的内存区,可以用下列示意图进行说明:
-
标记存活对象
-
存活对象往内存的一端移动:
3.清理边界:
Mark-Compack算法涉及到很多对象的移动,所以时间效率比较低下,但是能保证不会出现空间碎片的问题。
Mark-Sweep & Mark-Compact结合
由于Mark-Sweep存在内存空间碎片的问题,而Mark-Compact存在对象的移动带来的效率不高的问题,所以V8并不是只使用其中一种方式进行老生代的垃圾回收的,而是通过两者相结合的方式进行垃圾回收,由于Mark-Sweep速度快,所以V8以Mark-Sweep为主,在老生代空间不足以存放更多从新生代晋升过来的对象时,会采用Mark-Compact进行空间的整理。
下面是对Scavenge、Mark-Sweep和Mark-Compact的简单对比:
垃圾回收带来的性能问题
我们可以看到V8在处理垃圾回收的时候考虑的已经很周到了,对不同的情况使用了不同的回收策略,这样带来了非常可观的效率提升。但是如果仅限如此,还是会有一些让人不舒服的情况出现:无论Scavenge还是Mark-Sweep或Mark-Compact,在进行回收的时候应用进程都必须被迫暂停下来,等待垃圾回收执行完成之后才能继续执行,这种行为被称为“全停顿”(stop-the-world)。由于新生代的配置比较小,所以新生代中进行一次垃圾回收的时间开销并不大,一般开销在50毫秒以上;但是老生代配置比较大,进行一次全堆垃圾回收的标记、清除、整理等操作,时间开销就比较大了,若V8不对堆内存大小进行限制,老生代的垃圾回收处理将更为复杂,时间开销甚至要1秒以上!这种规模的时间花销会导致应用的流畅度、响应能力等直线下降,这是在市场竞争激烈的今天所绝对不能忍受的产品缺陷。这也解释了上一篇文章中所说的,为什么V8要限制内存的使用上限了,这是最快最直接的避免性能低下的方式。
Incremental Marking(增量标记)
全堆垃圾回收带来的性能问题是很严重的,为了尽可能减小这种问题,V8引入了“增量标记”操作,在原来需要一步到位的标记阶段,分成许多的步进,每做完一个步进,就让JS程序执行一会儿,这样标记和应用程序交替执行,直至标记阶段结束。
一次非增量标记的垃圾回收,主线程的执行情况大致是下图所示的:
引入增量标记之后,主线程的执行情况如下所示:
该方式可以明显地减少一次性停顿的时间(最大可以减少为非增量式的1/6左右),极大地提高了应用的响应速度。与普通的标记一样,增量标记也使用黑白灰的三色标记法。
Lazy Sweeping(惰性清理)
当增量标记完成后,惰性清除开始,此时所有对象都已被标记成或生或死,堆已经准确知道可以回收多少内存,然而此时不必去一次全部回收死去的对象,可以采用延迟清理的处理手段,垃圾回收器可以根据需要来选择回收部分内存, 直到全部垃圾对象回收完毕,整个增量标记-惰性清除的周期结束。
其它优化
V8已经加入了并行清除,主线程不会操作死亡对象,由独立的线程来负责回收死对象的内存,整个过程只需要非常少量的同步操作。同时V8正在实验并行标记,并将在今后引入这一技术。
至此,整个新生代和老生代的垃圾回收算法已经全部介绍完了,在老生代的算法中,有很多值得深入学习的地方,比如三色标记法的具体过程、并行标记和并行清除的原理等等。
PS. 若本文有任何错误之处,欢迎指出!
参考资料
- 《深入浅出Node.js》
- V8之旅:垃圾回收器