本文谨用于笔者集体了解和总结 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 的触发机制:
参考文献及图片出处:
- 一文搞懂 V8 引擎的垃圾回收
- Node —— V8 GC 浅析
- 浅谈 V8 引擎垃圾回收机制