深入理解V8的垃圾回收机制
垃圾回收的由来(GC)
每台电脑可使用的内存是有限的,假如没有垃圾回收,当程序不断申请内存,随着程序的体积变大,可使用的内存也会变少,当申请的空间过多时,就会造成内存泄露。这就是垃圾回收技术存在的意义。对于 JavaScript 来说,尽管在底层已经有了自动的垃圾回收机制,但是由于闭包的存在,会非常容易造成内存泄露,对程序造成不可逆的影响。而如何对垃圾进行回收,是非常值得研究的一个问题。
V8 的垃圾回收机制
主要垃圾回收算法
对于V8引擎来说,是将变量分配到堆内存上,再采用垃圾回收算法进行清理。而V8主要采用的就是分代式垃圾回收算法。
v8 的内存分代
在实际的应用场景中,人们发现没有一种合适的垃圾回收算法能够胜任所有场景。因为对于不同的变量具有不同的生命周期,不同的算法只能针对不同的场景。为此V8研究出了一种在大部分场景中都能适用的垃圾回收机制,也就是所谓的分代式垃圾回收。
在V8中,主要将内存分为新生代和老生代。新生代中主要是存活时间较短的对象,而老生代中主要是存活周期较长的对象,V8根据不同空间采取不同的回收算法。
Scavenge 算法
在分代的基础上,新生代中的对象主要采用 Scavenge算法,将新生代空间分为两个相等大小的空间,一部分称为From,另一部分称为To。在分配对象的过程中,我们会先将变量放到From中,而在垃圾回收的过程中,我们会将存活的对象复制到To空间中,没存活的对象将会自动被垃圾回收。当一次垃圾回收完成后,将From和To空间进行互换,也就是将原来的From当成下一轮的To空间。这样一来,在垃圾回收的过程中,我们只需要复制存活的对象。对于存活周期短的对象而言,存活率较低,需要复制的对象较少,所以它在时间效率上具有优秀的表现。
这是一种典型的牺牲空间换取时间的算法,但是新生代的对象存活周期都较短,所以适合这种算法。
当一个对象经过多次复制后,就会被认为存活周期较长的对象。对于存活周期较长的对象,会将他们移入老生代空间。对于这个移入的过程,我们称其为对象晋升。对象晋升只需要具备两个条件其中的一个就可以完成。
- 该对象已经经历过一次 Scavenge 算法
To空间内存占用比已经超出限制
在将对象从From复制到To空间之前,我们会检查它的内存地址判断是否经历过移动,如果已经移动过,会将该对象从From复制到老生代中,完成对象晋升。
第二个条件是当To空间中的内存比已经超出限制,如果内存占比过大,在完成复制过程后,该To会被当成From空间,这样在下一次内存分配的时候会造成空间不足以分配的情况,为了防止这种事情发生,Node底层会将其直接复制到老生代中。
而在老生代中,由于对象的存活周期较长,并且占用内存较大,盲目复制将会非常浪费性能,所以对老生代来说,Node采用了其它算法。
Mark-Sweep 和 Mark-Compact
为了解决上述两个问题,V8决定使用Mark-Sweep算法,也就是标记清除法。在垃圾回收的过程中,会将死亡的对象进行标记,在随后的清除阶段会将标记的对象进行清除。在老生代中,由于对象周期存活较长,所以死亡的对象比较少,只对死亡的对象进行标记效率较高,而在新生代中,由于存活周期较短,我们只复制存活的对象,这样就能大大的提高回收效率。
当老生代完成一次内存回收后,内存中间可能会有残留的碎片空间,这些碎片空间看似没有影响,但当往老生代复制较大的对象时,老生代内存中没有足够大的连续空间,这会造成垃圾回收提前进行,十分影响性能。
为了解决内存不连续的问题,Mark-Compact被提了出来。Mark-Compact意为标记整理。在一次垃圾回收之后,将剩余内存碎片向同一端进行移动,把不连续的碎片合成连续的内存空间。Mark-Compact和Mark-Sweep是相辅相成的,两种算法同时进行,对性能进行最大化利用。
Increment Marking
由于 Js 是单线程的脚本语言,不能同时进行逻辑处理和垃圾回收,这就意味着在垃圾回收的过程中,无法进行逻辑处理,十分影响性能。这种需要停止全部应用逻辑的行为成为”全停顿”。在V8的分代式回收算法中,新生代的对象占用内存较小,采用全停顿没有压力,而老生代中的对象占用空间较多,采用全停顿会占用较长时间,因此引申出了增量标记的算法。在标记的过程中会采用先标记一部分对象,再执行 Js 应用逻辑,反复进行此过程,将全部标记改为逐步标记,大大降低了对 Js 应用逻辑的影响。
总结
V8的性能十分优秀,这得益于他的垃圾回收机制,将堆内存划分为新生代和老生代,对于不同的情况进行不同的处理,使得V8在处理浏览器的内存过程时拥有较高的性能,这种思想非常值得学习。