V8引擎垃圾回收策略:
- V8的垃圾回收策略主要是基于分代式垃圾回收机制,其根据对象的存活时间将内存的垃圾回收进行不同的分代,然后对不同的分代采用不同的垃圾回收算法。
- 在新生代的垃圾回收过程中主要采用了Scavenge算法;在老生代采用Mark-Sweep(标记清除)和Mark-Compact(标记整理)算法。
V8的内存结构
在V8引擎的堆结构组成中,其实除了新生代
和老生代
外,还包含其他几个部分,但是垃圾回收的过程主要出现在新生代和老生代,所以对于其他的部分我们没必要做太多的深入,有兴趣的小伙伴儿可以查阅下相关资料,V8的内存结构主要由以下几个部分组成:
-
新生代(new_space)
:大多数的对象开始都会被分配在这里,这个区域相对较小但是垃圾回收特别频繁,该区域被分为两半,一半用来分配内存,另一半用于在垃圾回收时将需要保留的对象复制过来。 -
老生代(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空间。
Scavenge
算法是一种典型的牺牲空间换取时间的算法,对于老生代内存来说,可能会存储大量对象,如果在老生代中使用这种算法,势必会造成内存资源的浪费,但是在新生代内存中,大部分对象的生命周期较短,在时间效率上表现可观,所以还是比较适合这种算法。
流程图
- 假设我们在
From
空间中分配了三个对象A、B、C
-
当程序主线程任务第一次执行完毕后进入垃圾回收时,发现对象A已经没有其他引用,则表示可以对其进行回收
- 对象B和对象C此时依旧处于活跃状态,因此会被复制到
To
空间中进行保存
- 接下来将
From
空间中的所有非存活对象全部清除
- 此时
From
空间中的内存已经清空,开始和To
空间完成一次角色互换
- 当程序主线程在执行第二个任务时,在
From
空间中分配了一个新对象D
-
任务执行完毕后再次进入垃圾回收,发现对象D已经没有其他引用,表示可以对其进行回收
- 对象B和对象C此时依旧处于活跃状态,再次被复制到
To
空间中进行保存
- 再次将
From
空间中的所有非存活对象全部清除
-
From
空间和To
空间继续完成一次角色互换
通过以上的流程图,我们可以很清楚地看到,Scavenge
算法的垃圾回收过程主要就是将存活对象在From
空间和To
空间之间进行复制,同时完成两个空间之间的角色互换,因此该算法的缺点也比较明显,浪费了一半的内存用于复制。
对象晋升:
当一个对象在经过多次复制之后依旧存活,那么它会被认为是一个生命周期较长的对象,在下一次进行垃圾回收时,该对象会被直接转移到老生代中,这种对象从新生代转移到老生代的过程我们称之为晋升
。
对象晋升的条件主要有以下两个(满足其一即可):
- 对象是否经历过一次Scavenge算法
- To空间的内存占比是否已经超过25%
默认情况下,我们创建的对象都会分配在From
空间中,当进行垃圾回收时,在将对象从From
空间复制到To
空间之前,会先检查该对象的内存地址来判断是否已经经历过一次Scavenge
算法,如果地址已经发生变动则会将该对象转移到老生代中,不会再被复制到To
空间。
之所以有
25%
的内存限制是因为To
空间在经历过一次Scavenge
算法后会和From
空间完成角色互换,会变为From
空间,后续的内存分配都是在From
空间中进行的,如果内存使用过高甚至溢出,则会影响后续对象的分配,因此超过这个限制之后对象会被直接转移到老生代来进行管理。
老生代
在老生代中,因为管理着大量的存活对象,如果依旧使用Scavenge算法的话,很明显会浪费一半的内存,因此已经不再使用Scavenge算法,而是采用新的算法Mark-Sweep(标记清除)
和Mark-Compact(标记整理)
来进行管理。
在早前我们可能听说过一种算法叫做引用计数
,该算法的原理比较简单,就是看对象是否还有其他引用指向它,如果没有指向该对象的引用,则该对象会被视为垃圾并被垃圾回收器回收,示例如下:
// 创建了两个对象obj1和obj2,其中obj2作为obj1的属性被obj1引用,因此不会被垃圾回收
let obj1 = {
obj2: {
a: 1
}
}
// 创建obj3并将obj1赋值给obj3,让两个对象指向同一个内存地址
let obj3 = obj1;
// 将obj1重新赋值,此时原来obj1指向的对象现在只由obj3来表示
obj1 = null;
// 创建obj4并将obj3.obj2赋值给obj4
// 此时obj2所指向的对象有两个引用:一个是作为obj3的属性,另一个是变量obj4
let obj4 = obj3.obj2;
// 将obj3重新赋值,此时本可以对obj3指向的对象进行回收,但是因为obj3.obj2被obj4所引用,因此依旧不能被回收
obj3 = null;
// 此时obj3.obj2已经没有指向它的引用,因此obj3指向的对象在此时可以被回收
obj4 = null;
上述例子在经过一系列操作后最终对象会被垃圾回收,但是一旦我们碰到循环引用
的场景,就会出现问题,我们看下面的例子:
function foo() {
let a = {};
let b = {};
a.a1 = b;
b.b1 = a;
}
foo();
这个例子中我们将对象a的a1属性指向对象b,将对象b的b1属性指向对象a,形成两个对象相互引用,在foo函数执行完毕后,函数的作用域已经被销毁,作用域中包含的变量a和b本应该可以被回收,但是因为采用了引用计数
的算法,两个变量均存在指向自身的引用,因此依旧无法被回收,导致内存泄漏。
因此为了避免循环引用导致的内存泄漏问题,截至2012年所有的现代浏览器均放弃了这种算法,转而采用新的Mark-Sweep(标记清除)
和Mark-Compact(标记整理)
算法。在上面循环引用的例子中,因为变量a和变量b无法从window全局对象
访问到,因此无法对其进行标记,所以最终会被回收。
Mark-Sweep(标记清除)
Mark-Sweep(标记清除)
分为标记
和清除
两个阶段,在标记阶段会遍历堆中的所有对象,然后标记活着的对象,在清除阶段中,会将死亡的对象进行清除。Mark-Sweep算法主要是通过判断某个对象是否可以被访问到,从而知道该对象是否应该被回收,具体步骤如下:
- 垃圾回收器会在内部构建一个
根列表
,用于从根节点出发去寻找那些可以被访问到的变量。比如在JavaScript中,window
全局对象可以看成一个根节点。 - 然后,垃圾回收器从所有根节点出发,遍历其可以访问到的子节点,并将其标记为活动的,根节点不能到达的地方即为非活动的,将会被视为垃圾。
- 最后,垃圾回收器将会释放所有非活动的内存块,并将其归还给操作系统。
以下几种情况都可以作为根节点:
- 全局对象
- 本地函数的局部变量和参数
- 当前嵌套调用链上的其他函数的变量和参数
但是Mark-Sweep
算法存在一个问题,就是在经历过一次标记清除后,内存空间可能会出现不连续的状态,因为我们所清理的对象的内存地址可能不是连续的,所以就会出现内存碎片的问题,导致后面如果需要分配一个大对象而空闲内存不足以分配,就会提前触发垃圾回收,而这次垃圾回收其实是没必要的,因为我们确实有很多空闲内存,只不过是不连续的。
** Mark-Compact(标记整理)**
为了解决这种内存碎片的问题,Mark-Compact(标记整理)
算法被提了出来,该算法主要就是用来解决内存的碎片化问题的,回收过程中将死亡对象清除后,在整理的过程中,会将活动的对象往堆内存的一端进行移动,移动完成后再清理掉边界外的全部内存,我们可以用如下流程图来表示:
-
假设在老生代中有A、B、C、D四个对象
- 在垃圾回收的
标记
阶段,将对象A和对象C标记为活动的
- 在垃圾回收的
整理
阶段,将活动的对象往堆内存的一端移动
- 在垃圾回收的
清除
阶段,将活动对象左侧的内存全部回收
至此就完成了一次老生代垃圾回收的全部过程,我们在前文中说过,由于JS的单线程机制,垃圾回收的过程会阻碍主线程同步任务的执行,待执行完垃圾回收后才会再次恢复执行主任务的逻辑,这种行为被称为全停顿(stop-the-world)
。在标记阶段同样会阻碍主线程的执行,一般来说,老生代会保存大量存活的对象,如果在标记阶段将整个堆内存遍历一遍,那么势必会造成严重的卡顿。
因此,为了减少垃圾回收带来的停顿时间,V8引擎又引入了Incremental Marking(增量标记)
的概念,即将原本需要一次性遍历堆内存的操作改为增量标记的方式,先标记堆内存中的一部分对象,然后暂停,将执行权重新交给JS主线程,待主线程任务执行完毕后再从原来暂停标记的地方继续标记,直到标记完整个堆内存。这个理念其实有点像React
框架中的Fiber
架构,只有在浏览器的空闲时间才会去遍历Fiber Tree
执行对应的任务,否则延迟执行,尽可能少地影响主线程的任务,避免应用卡顿,提升应用性能。
得益于增量标记的好处,V8引擎后续继续引入了延迟清理(lazy sweeping)
和增量式整理(incremental compaction)
,让清理和整理的过程也变成增量式的。同时为了充分利用多核CPU的性能,也将引入并行标记
和并行清理
,进一步地减少垃圾回收对主线程的影响,为应用提升更多的性能。