盒子
文章目录
  1. 一、内存是什么?
  2. 二、V8 内存大小以及原因?
  3. 三、V8 内存分配的时机?
  4. 四、内存与 GC 理论知识
    1. 4.1 内存结构概览
    2. 4.2 新生代
    3. 4.3 对象晋升
    4. 4.4 老生代
      1. 1. 引用计数
      2. 2. 标记清除
      3. 3. 标记整理
  5. 五、全停顿
    1. Orinoco 优化
      1. 增量标记(Incremental marking)
      2. 惰性清理(Lazy sweeping)
      3. 并发(Concurrent)
      4. 并行
  6. 六、V8当前的垃圾回收机制
    1. 副垃圾回收器
    2. 主垃圾回收器
  7. 参考资料

V8 的内存管理

一、内存是什么?

**内存(Memory)**是计算机的重要部件,也称内存储器主存储器,它用于暂时存放 CPU中 的运算数据,以及与硬盘外部存储器交换的数据。它是外存CPU进行沟通的桥梁,计算机中所有程序的运行都在内存中进行,内存性能的强弱影响计算机整体发挥的水平。只要计算机开始运行,操作系统 就会把需要运算的数据从内存调到 CPU 中进行运算,当运算完成,CPU 将结果传送出来。

如下图,我的电脑配置是 8G、通过「活动监视器」能够查看各个应用程序的内存占用。

Untitled

二、V8 内存大小以及原因?

V8 引擎在64位系统下最多只能使用约1.4GB的内存,在32位系统下最多只能使用约0.7GB的内存。在这样的限制下,必然会导致在 Node 中无法直接操作大内存对象,比如将一个2GB大小的文件全部读入内存进行字符串分析处理,即使物理内存高达32GB也无法充分利用计算机的内存资源,那么为什么会有这种限制呢?

  1. V8 引擎的设计之初,起初只是作为浏览器端 JavaScript 的执行环境,在浏览器端我们其实很少会遇到使用大量内存的场景,因此也就没有必要将最大内存设置得过高;
  2. JS单线程机制:作为浏览器的脚本语言,JS 的主要用途是与用户交互以及操作 DOM,那么这也决定了其作为单线程的本质,单线程意味着执行的代码必须按顺序执行,在同一时间只能处理一个任务。试想如果 JS 是多线程的,一个线程在删除 DOM 元素的同时,另一个线程对该元素进行修改操作,那么必然会导致复杂的同步问题。既然 JS 是单线程的,那么也就意味着在 V8 执行垃圾回收时,程序中的其他各种逻辑都要进入暂停等待阶段,直到垃圾回收结束后才会再次重新执行 JS 逻辑。因此,由于 JS 的单线程机制,垃圾回收的过程阻碍主线程逻辑的执行;
  3. 垃圾回收机制:垃圾回收本身也是一件非常耗时的操作,假设 V8 的堆内存为1.5G,那么V8做一次小的垃圾回收需要 50ms以上,而做一次非增量式回收甚至需要 1s 以上,可见其耗时之久,而在这 1s 的时间内,浏览器一直处于等待的状态,会失去对用户的响应,如果有动画正在运行,也会造成动画卡顿掉帧的情况,严重影响应用程序的性能。因此如果内存使用过高,那么必然会导致垃圾回收的过程缓慢,也就会导致主线程的等待时间越长,浏览器也就越长时间得不到响应。

基于以上,V8 引擎为了减少对应用的性能造成的影响,采用了一种比较粗暴的手段,那就是直接限制堆内存的大小,毕竟在浏览器端一般也不会遇到需要操作几个 G 内存这样的场景。但是在Node 端,涉及到的 I/O 操作可能会比浏览器端更加复杂多样,因此更有可能出现内存溢出的情况。不过 V8 为我们提供了可配置项来让我们手动地调整内存大小, Node 初始化的时候进行配置,我们可以通过如下方式来手动设置:

// 设置新生代内存中单个半空间的内存最小值,单位MB
node --min-semi-space-size=1024 xxx.js

// 设置新生代内存中单个半空间的内存最大值,单位MB
node --max-semi-space-size=1024 xxx.js

// 设置老生代内存最大值,单位MB
node --max-old-space-size=2048 xxx.js

通过以上方法便可以手动放宽 V8 引擎所使用的内存限制,同时node也为我们提供了process.memoryUsage()方法来让我们可以查看当前 Node 进程所占用的实际内存大小。

三、V8 内存分配的时机?

> process.memoryUsage()
{
rss: 27508736, // 表示驻留集大小,是给这个 Node 进程分配了多少物理内存,这些物理内存中包含堆,栈和代码片段。对象,闭包等存于堆内存,变量存于栈内存,实际的JavaScript源代码存于代码段内存。使用Worker线程时,rss将会是一个对整个进程有效的值,而其他字段则只针对当前线程。
heapTotal: 6668288, //表示V8当前申请到的堆内存总大小。
heapUsed: 4847376, // 表示当前内存使用量
external: 900930, // 表示 V8 内部的 C++ 对象所占用的内存。
arrayBuffers: 11158
}
  1. 值的初始化
  2. 通过函数调用分配内存

四、内存与 GC 理论知识

4.1 内存结构概览

V8的垃圾回收策略主要是基于分代式垃圾回收机制,其根据对象的存活时间将内存的垃圾回收进行不同的分代,然后对不同的分代采用不同的垃圾回收算法。

在 V8 引擎的堆结构组成中,一共包含以下几个部分, 回收的过程主要出现在新生代老生代

  • 新生代(new_space):大多数的对象开始都会被分配在这里,这个区域相对较小但是垃圾回收特别频繁,该区域被分为两半:一半用来分配内存,另一半用于在垃圾回收时将需要保留的对象复制过来;
  • 老生代(old_space):新生代中的对象在存活一段时间后就会被转移到老生代内存区,相对于新生代该内存区域的垃圾回收频率较低。老生代又分为老生代指针区老生代数据区,前者包含大多数可能存在指向其他对象的指针的对象,后者只保存原始数据对象,这些对象没有指向其他对象的指针;
  • 大对象区(large_object_space):存放体积超越其他区域大小的对象,每个对象都会有自己的内存,垃圾回收不会移动大对象区;
  • 代码区(code_space):代码对象,会被分配在这里,唯一拥有执行权限的内存区域;
  • map区(map_space):存放 Cell 和 Map,每个区域都是存放相同大小的元素,结构简单。

Untitled

上图中的带斜纹的区域代表暂未使用的内存,新生代(new_space)被划分为了两个部分,其中一部分叫做inactive new space,表示暂未激活的内存区域,另一部分为激活状态。

4.2 新生代

在V8引擎的内存结构中,新生代主要用于存放存活时间较短的对象。新生代内存是由两个semispace(半空间)构成的,内存最大值在64位系统和32位系统上分别为32MB16MB,在新生代的垃圾回收过程中主要采用了Scavenge算法。

Scavange算法将新生代堆分为两部分,分别叫from-spaceto-space,工作方式也很简单,就是将from-space 中存活的活动对象复制到to-space 中,并将这些对象的内存有序的排列起来,然后将from-space 中的非活动对象的内存进行释放,完成之后,将from spaceto space进行互换,这样可以使得新生代中的这两块区域可以重复利用。

Untitled

具体步骤为以下4步:

  1. 标记活动对象和非活动对象
  2. 复制from-space的活动对象到to-space中并进行排序
  3. 清除from-space中的非活动对象
  4. from-spaceto-space进行角色互换,以便下一次 Scavenge算法 垃圾回收

通过以上流程,Scavenge 算法的垃圾回收过程主要就是将存活对象在From空间和To空间之间进行复制,同时完成两个空间之间的角色互换,因此该算法的缺点也比较明显,浪费了一半的内存用于复制。

4.3 对象晋升

当一个对象在经过多次复制之后依旧存活,那么它会被认为是一个生命周期较长的对象,在下一次进行垃圾回收时,该对象会被直接转移到老生代中,这种对象从新生代转移到老生代的过程我们称之为晋升
对象晋升的条件主要有以下两个:

  • 对象是否经历过一次Scavenge算法;
  • To空间的内存占比是否已经超过25%,如果内存使用过高甚至溢出,则会影响后续对象的分配,因此超过这个限制之后对象会被直接转移到老生代来进行管理。

4.4 老生代

在老生代中,因为管理着大量的存活对象,如果依旧使用Scavenge算法的话,会浪费一半的内存,因此已经不再使用Scavenge算法,而是采用新的算法Mark-Sweep(标记清除)Mark-Compact(标记整理)来进行管理。

1. 引用计数

该算法的原理比较简单,就是看对象是否还有其他引用指向它,如果没有指向该对象的引用,则该对象会被视为垃圾并被垃圾回收器回收。但循环引用的场景下会出现一些问题:

function foo() {
let a = {};
let b = {};
a.a1 = b;
b.b1 = a;
}
foo()

foo函数执行完毕后,函数的作用域已经被销毁,作用域中包含的变量ab本应该可以被回收,但是因为采用了引用计数的算法,两个变量均存在指向自身的引用,因此依旧无法被回收,导致内存泄漏。

2. 标记清除

因此为了避免循环引用导致的内存泄漏问题,截至2012年所有的现代浏览器均放弃了这种算法,转而采用新的Mark-Sweep(标记清除)Mark-Compact(标记整理)算法。在上面循环引用的例子中,因为变量a和变量b无法从window全局对象访问到,因此无法对其进行标记,所以无法回收。

Mark-Sweep(标记清除)分为标记清除两个阶段,在标记阶段会遍历堆中的所有对象,然后标记活着的对象,在清除阶段中,会将死亡的对象进行清除。Mark-Sweep算法主要是通过判断某个对象是否可以被访问到,从而知道该对象是否应该被回收,具体步骤如下:

垃圾回收器会在内部构建一个根列表,用于从根节点出发去寻找那些可以被访问到的变量。比如在JavaScript中,window全局对象可以看成一个根节点。然后,垃圾回收器从所有根节点出发,遍历其可以访问到的子节点,并将其标记为活动的,根节点不能到达的地方即为非活动的,将会被视为垃圾。最后,垃圾回收器将会释放所有非活动的内存块,并将其归还给操作系统。

以下几种情况都可以作为根节点:

  1. 全局对象
  2. 本地函数的局部变量和参数
  3. 当前嵌套调用链上的其他函数的变量和参数

但是 Mark-Sweep 算法存在一个问题,就是在经历过一次标记清除后,内存空间可能会出现不连续的状态,因为我们所清理的对象的内存地址可能不是连续的,所以就会出现内存碎片的问题,导致后面如果需要分配一个大对象而空闲内存不足以分配,就会提前触发垃圾回收,而这次垃圾回收其实是没必要的,因为我们确实有很多空闲内存,只不过是不连续的。

3. 标记整理

为了解决这种内存碎片的问题,Mark-Compact(标记整理)算法被提了出来,该算法主要就是用来解决内存的碎片化问题的,回收过程中将死亡对象清除后,在整理的过程中,会将活动的对象往堆内存的一端进行移动,移动完成后再清理掉边界外的全部内存,我们可以用如下流程图来表示:

Untitled

五、全停顿

JS 代码的运行要用到 JS 引擎,垃圾回收也要用到 JS 引擎,那如果这两者同时进行了,发生冲突了咋办呢?答案是,垃圾回收优先于代码执行,会先停止代码的执行,等到垃圾回收完毕,再执行JS代码。这个过程,称为全停顿

由于新生代空间小,并且存活对象少,再配合Scavenge算法,停顿时间较短。但是老生代就不一样了,某些情况活动对象比较多的时候,停顿时间就会较长,使得页面出现了卡顿现象

Orinoco 优化

增量标记(Incremental marking

即将原本需要一次性遍历堆内存的操作改为增量标记的方式,先标记堆内存中的一部分对象,然后暂停,将执行权重新交给 JS 主线程,待主线程任务执行完毕后再从原来暂停标记的地方继续标记,直到标记完整个堆内存。这个理念其实有点像React框架中的Fiber架构,只有在浏览器的空闲时间才会去遍历Fiber Tree执行对应的任务,否则延迟执行,尽可能少地影响主线程的任务,避免应用卡顿,提升应用性能。

惰性清理(Lazy sweeping)

上面说了,增量标记只是针对标记阶段,而惰性清理就是针对清除阶段了。在增量标记之后,要进行清理非活动对象的时候,垃圾回收器发现了其实就算是不清理,剩余的空间也足以让JS代码跑起来,所以就延迟了清理,让JS代码先执行,或者只清理部分垃圾,而不清理全部。这个优化就叫做惰性清理

整理标记和惰性清理的出现,大大改善了全停顿现象。但是问题也来了:增量标记是标记一点,JS运行一段,那如果你前脚刚标记一个对象为活动对象,后脚JS代码就把此对象设置为非活动对象,或者反过来,前脚没有标记一个对象为活动对象,后脚JS代码就把此对象设置为活动对象。总结起来就是:标记和代码执行的穿插,有可能造成对象引用改变,标记错误现象。这就需要使用写屏障技术来记录这些引用关系的变化

并发(Concurrent)

并发式 GC 允许在在垃圾回收的同时不需要将主线程挂起,两者可以同时进行,只有在个别时候需要短暂停下来让垃圾回收器做一些特殊的操作。但是这种方式也要面对增量回收的问题,就是在垃圾回收过程中,由于 JavaScript 代码在执行,堆中的对象的引用关系随时可能会变化,所以也要进行写屏障操作。

Untitled

并行

并行式 GC 主线程和辅助线程同时执行同样的GC工作,这样可以让辅助线程来分担主线程的 GC ,使得垃圾回收所耗费的时间等于总时间除以参与的线程数量(加上一些同步开销)。

六、V8当前的垃圾回收机制

2011年,V8应用了增量标记机制。直至2018年,Chrome64和Node.js V10启动并发标记(Concurrent),同时在并发的基础上添加并行(Parallel)技术,使垃圾回收时间大幅度缩短。

副垃圾回收器

V8 在新生代垃圾回收中,使用并行(parallel)机制,在整理排序阶段,也就是将活动对象从from-to复制到space-to的时候,启用多个辅助线程,并行的进行整理。由于多个线程竞争一个新生代的堆的内存资源,可能出现有某个活动对象被多个线程进行复制操作的问题,为了解决这个问题,V8 在第一个线程对活动对象进行复制并且复制完成后,都必须去维护复制这个活动对象后的指针转发地址,以便于其他协助线程可以找到该活动对象后可以判断该活动对象是否已被复制。

Untitled

垃圾回收器

V8在老生代垃圾回收中,如果堆中的内存大小超过某个阈值之后,会启用并发(Concurrent)标记任务。每个辅助线程都会去追踪每个标记到的对象的指针以及对这个对象的引用,而在 JavaScript 代码执行时候,并发标记也在后台的辅助进程中进行,当堆中的某个对象指针被JavaScript代码修改的时候,写入屏障(write barriers)技术会在辅助线程在进行并发标记的时候进行追踪。

当并发标记完成或者动态分配的内存到达极限的时候,主线程会执行最终的快速标记步骤,这个时候主线程会挂起,主线程会再一次的扫描根集以确保所有的对象都完成了标记,由于辅助线程已经标记过活动对象,主线程的本次扫描只是进行 check 操作,确认完成之后,某些辅助线程会进行清理内存操作,某些辅助进程会进行内存整理操作,由于都是并发,并不会影响主线程 JavaScript 代码的执行。

参考资料

  1. 一文搞懂V8引擎的垃圾回收
  2. 赠你13张图,助你20分钟打败了「V8垃圾回收机制」!!!
  3. V8 内存浅析
  4. V8 之旅:垃圾回收器