本文谨用于笔者个人理解和总结V8引擎的垃圾回收机制,本文主要参考一文搞懂V8引擎的垃圾回收
在了解V8垃圾回收机制之前,我们先来阐述一些概念:
「全停顿」:垃圾回收算法在执行前,需要将应用逻辑暂停,执行完垃圾回收后再执行应用逻辑。
如果一次GC需要50ms,应用逻辑就会暂停50ms。为什么会暂停呢?
①因为js是单线程执行的,进入垃圾回收后,js应用逻辑需要暂停,以留出空间给垃圾回收算法运行。
②垃圾回收其实是非常耗时间的操作。
V8引擎垃圾回收策略:
- V8的垃圾回收策略主要是基于分代式垃圾回收机制,其根据对象的存活时间将内存的垃圾回收进行不同的分代,然后对不同的分代采用不同的垃圾回收算法。
- 在新生代的垃圾回收过程中主要采用了Scavenge算法;在老生代采用Mark-Sweep(标记清除)和Mark-Compact(标记整理)算法。
在了解新生代和老生代的垃圾管理算法之前,我们不妨先来了解一下V8引擎垃圾管理的内存结构;
V8引擎垃圾管理的内存结构:
- 新生代(new_space):大多数的对象开始都会被分配在这里,这个区域相对较小但是垃圾回收特别频繁,该区域被分为两半,一半用来分配内存,另一半用于在垃圾回收时将需要保留的对象复制过来。
(笔者看到了两种分区说法:①From区:To区=1:1②From区:To区:To区=8:1:1,本文仅用来了解回收机制,不对此处过多讨论) - 老生代(old_space):新生代中的对象在存活一段时间后就会被转移到老生代内存区,相对于新生代该内存区域的垃圾回收频率较低。老生代又分为老生代指针区和老生代数据区,前者包含大多数可能存在指向其他对象的指针的对象,后者只保存原始数据对象,这些对象没有指向其他对象的指针。
- 大对象区(large_object_space):存放体积超越其他区域大小的对象,每个对象都会有自己的内存,垃圾回收不会移动大对象区。
- 代码区(code_space):代码对象,会被分配在这里,唯一拥有执行权限的内存区域。
- map区(map_space):存放Cell和Map,每个区域都是存放相同大小的元素,结构简单。
新生代区:
新生代区主要采用Scavenge算法实现,它将新生代区划分为激活区(new space)又称为From区和未激活区(inactive new space)又称为To区。
程序中生命的对象会被存储在From空间中,当新生代进行垃圾回收时,处于From区中的尚存的活跃对象会复制到To区进行保存,然后对From中的对象进行回收,并将From空间和To空间角色对换,即To空间会变为新的From空间,原来的From空间则变为To空间。
因此,该算法是一个牺牲空间来换取时间的算法。
基于上述算法,算法图解实现如下(转载):
- 假设我们在From空间中分配了三个对象A、B、C
- 当程序主线程任务第一次执行完毕后进入垃圾回收时,发现对象A已经没有其他引用,则表示可以对其进行回收
- 对象B和对象C此时依旧处于活跃状态,因此会被复制到To空间中进行保存
- 接下来将From空间中的所有非存活对象全部清除
- 此时From空间中的内存已经清空,开始和To空间完成一次角色互换
- 当程序主线程在执行第二个任务时,在From空间中分配了一个新对象D
- 任务执行完毕后再次进入垃圾回收,发现对象D已经没有其他引用,表示可以对其进行回收
- 象B和对象C此时依旧处于活跃状态,再次被复制到To空间中进行保存
- 再次将From空间中的所有非存活对象全部清除
- From空间和To空间继续完成一次角色互换
对象晋升:
当一个对象在经过多次复制之后依旧存活,那么它会被认为是一个生命周期较长的对象,在下一次进行垃圾回收时,该对象会被直接转移到老生代中,这种对象从新生代转移到老生代的过程我们称之为晋升。
对象晋升的条件主要有以下两个(满足其一即可):
- 对象是否经历过一次Scavenge算法
- To空间的内存占比是否已经超过25%
默认情况下,我们创建的对象都会分配在From空间中,当进行垃圾回收时,在将对象从From空间复制到To空间之前,会先检查该对象的内存地址来判断是否已经经历过一次Scavenge算法,如果地址已经发生变动则会将该对象转移到老生代中,不会再被复制到To空间。
流程图表示:
如果对象没有经历过Scavenge算法,会被复制到To空间,但是如果此时To空间的内存占比已经超过25%,则该对象依旧会被转移到老生代,如下图所示:
之所以有25%的内存限制是因为To空间在经历过一次Scavenge算法后会和From空间完成角色互换,会变为From空间,后续的内存分配都是在From空间中进行的,如果内存使用过高甚至溢出,则会影响后续对象的分配,因此超过这个限制之后对象会被直接转移到老生代来进行管理。
老生代区:
在讲解老生代Mark-Sweep(标记清除)和Mark-Compact(标记整理)算法之前,先来回顾一下引用计数法:对于对象A,任何一个对象引用了A的值,计数器+1,引用失效时计数器-1,当计数器为0时责备回收,但是会存在循环引用的情况,可能会导致内存泄漏,自2012年起,所有的现代浏览器均放弃了这种算法。
function foo() {//循环引用样例
let a = {};
let b = {};
a.a1 = b;
b.b1 = a;
}
foo();
Mark-Sweep(标记清除)算法:
Mark-Sweep(标记清除)分为标记和清除两个阶段,在标记阶段会遍历堆中的所有对象,然后标记活着的对象,在清除阶段中,会将死亡的对象进行清除。Mark-Sweep算法主要是通过判断某个对象是否可以被访问到,从而知道该对象是否应该被回收,具体步骤如下:
- 垃圾回收器会在内部构建一个根列表,用于从根节点出发去寻找那些可以被访问到的变量。比如在JavaScript中,window全局对象可以看成一个根节点。
- 垃圾回收器从所有根节点出发,遍历其可以访问到的子节点,并将其标记为活动的,根节点不能到达的地方即为非活动的,将会被视为垃圾。
- 垃圾回收器将会释放所有非活动的内存块,并将其归还给操作系统。
但是经过标记清除之后的内存空间会⽣产很多不连续的碎⽚空间,这种不连续的碎⽚空间中,
在遇到较⼤的对象时可能会由于空间不⾜⽽导致⽆法存储。
为了解决内存碎⽚的问题,需要使⽤另外⼀种算法:标记-整理(Mark-Compact)。
标记-整理(Mark-Compact):
标记整理对待未存活对象不是⽴即回收,⽽是将存活对象移动到⼀边,然后直接清掉端边界以外的内存。
这里为了便于理解,引用两个流程图。
- 假设在老生代中有A、B、C、D四个对象
- 在垃圾回收的标记阶段,将对象A和对象C标记为活动的
- 在垃圾回收的整理阶段,将活动的对象往堆内存的一端移动
- 在垃圾回收的清除阶段,将活动对象左侧的内存全部回收
至此就完成了一次老生代垃圾回收的全部过程,但是由于前文提到的「全停顿」的存在,在标记阶段同样会阻碍主线程的执行,一般来说,老生代会保存大量存活的对象,如果在标记阶段将整个堆内存遍历一遍,那么势必会造成严重的卡顿。因此,V8引擎有引入了Incremental Marking(增量标记)的概念。
Incremental Marking(增量标记):
将原本需要一次性遍历堆内存的操作改为增量标记的方式,先标记堆内存中的一部分对象,然后暂停,将执行权重新交给JS主线程,待主线程任务执行完毕后再从原来暂停标记的地方继续标记,直到标记完整个堆内存。
即:把垃圾回收这个⼤的任务分成⼀个个⼩任务,穿插在 JavaScript任务中间执⾏
这个理念其实有点像React框架中的Fiber架构,只有在浏览器的空闲时间才会去遍历Fiber Tree执行对应的任务,否则延迟执行,尽可能少地影响主线程的任务,避免应用卡顿,提升应用性能。
得益于增量标记的好处,V8引擎后续继续引入了延迟清理(lazy sweeping)和增量式整理(incremental compaction),让清理和整理的过程也变成增量式的。同时为了充分利用多核CPU的性能,也将引入并行标记和并行清理,进一步地减少垃圾回收对主线程的影响,为应用提升更多的性能。
最后附上V8-GC的触发机制:
参考文献及图片出处:
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。