V8中的垃圾回收机制

123 阅读7分钟

JavaScript中的垃圾收集

  • 程序的运行需要内存,只要程序要求,操作系统就必须提供内存
  • JavaScript使用自动内存管理,这被称为”垃圾回收机制”(garbage collector)
  • 优点是可以简化开发、节省代码,缺点是无法完整的掌握内存的分配与回收的具体过程

NodeJS中的内存管理

如果不再用到的内存没有及时释放,就叫做内存泄漏。

  • 浏览器端的内存泄漏(其实没有那么严重,用户直接是独立进程)
  • 对于持续运行的服务进程Node服务器端程序,必须及时释放不再用到的内存。否则,内存占用越来越高,轻则影响系统性能,重则导致进程崩溃。

V8内存管理

V8使用内存大小限制的,在64位操作系统最大可以使用1.4G,在32位操作系统中最大可以使用0.7G内存。

查看node中的内存使用情况

  • JS对象都是通过V8进行分配管理内存的
  • process.memoryUsage 返回一个对象,包含了 Node 进程的内存占用信息
// node 环境下
console.log(process.memoryUsage());
// {
//   rss: 17637376,    => 1.7m  所有内存占用,包括指令区和堆栈
//   heapTotal: 4210688,  => 4.2m 堆占用的内存,包括用到的和没用到的
//   heapUsed: 2054992,   => 2m 用到的堆的部分。判断内存泄漏,以 heapUsed 字段为准
//   external: 651324     => 6k V8 引擎内部的 C++ 对象占用的内存
// }

V8为什么要限制内存大小

  • 因为V8的垃圾收集工作原理导致的, 1.4G内存完全一次垃圾收集需要1秒以上
  • 这个暂停时间成为 Stop The World ,在这个期间,应用的性能和响应能力都会下降(扫描过程中,做不成其他事情~)

如何突破内存限制

一旦初始化成功,生效后不能再修改

  • –max-new-space-size,最大 new space 大小,执行 scavenge 回收,默认 16M,单位 KB
  • –max-old-space-size,最大 old space 大小,执行 MarkSweep 回收,默认 1G,单位 MB
node --max-old-space-size=2000 app.js 单位是M
node --max-new-space-size=1024 app.js 单位是kb

V8的垃圾回收机制♻️

  • V8是基于分代的垃圾回收
  • 不同代垃圾回收机制也不一样
  • 按存活的时间分为新生代和老生代

V8分代概念

  • 年龄小的是新生代,由From区域和To区域两个区域组成
    • 在64位系统里,新生代内存是32M,From区域和To区域各占一半
    • 在32位系统里,新生代内存是16M,From区域和To区域各占一半
  • 年龄大的是老生代,默认情况下,
    • 64为系统下老生代内存是1400M
    • 32为系统下老生代内存是700M

引用计数

  • 语言引擎有一张引用表,保存了内存里面所有的资源的引用次数(从GC ROOT查找)。
  • 如果一个值的引用次数是0,就表示这个值不再用到了,因此可以将这块内存释放。

此图中,F,D,E对象在扫描时,均应该被回收掉。为了确切的理解引用计数,我们来写一段代码:

<body>
  <script>
    function Person(name){
      this.name = name;
    }

    let p1 = new Person('ys1');
    let p2 = new Person('ys2');

    // 3s 之后干掉 p1
    setTimeout(() => p1 = null, 3000);
    // 10s 之后干掉 p2
    setTimeout(() => p2 = null, 10000);
  </script>
</body>

打开控制台,我们分别间隔 1s,3s,10s 拍下三个快照。 可以看到,解除引用的实例的内存被回收了。

再来看一个例子:

function Person(name){
  this.name = name;
}

let p1 = new Person('ys1');
let set = new Set();

set.add(p1); // set 堆中保存着 0 -> ys1 实例 的引用
p1 = null;

这种情况下,ys1 的实例所在的堆内存是不会被回收的,因为虽然 p1 不再引用了,但是 set 中依旧保持着对实例对象的引用。如果想让该实例对象被回收,直接 set = null 即可,这也会导致 set 所在的堆内存回收哦。

新生代的垃圾回收

  • 新生代区域一分为二,每个16M,一个使用,一个空闲
  • 开始垃圾回收的时候,会检查FROM区域中的存活对象,如果还活着,拷贝到TO空间,完成后释放空间
  • 完成后 FROM 和 TO 互换
  • 新生代扫描的时候是一种广度优先的扫描策略
  • 新生代的空间小,存活对象少
  • 当一个对象经历过多次的垃圾回收依然存活的时候,生存周期比较长的对象会被移动到老生代,这个移动过程被成为晋升或者升级
    • 经过5次以上的回收还存在
    • TO的空间使用占比超过25%,或者超大对象

scanPointer(扫描指针)、allocatePointer(分配指针)

  1. 如图,首先回收前新生代的 from 区域存在 A、B、C 三个变量,某个时间点,V8 引擎开始扫描新生代 from 区域,发现存在根对象引用的 A「广度优先,如果还有其他直接引用的,也会拷贝到老生代」,将 A 拷贝到老生代,老生代中扫描指针指向 A(开始扫描 A 的引用),然后分配指针往后移一位(接下来复制过来的对象要放在这个内存地址)。
  2. 扫描 A 的引用对象,发现 A 引用了 B,将 B 拷贝到老生代,分配指针继续后移。
  3. 继续扫描 A,发现没有其他引用了,扫描指针后移,开始扫描 B。
  4. B 也没其他引用了,扫描指针继续后移,此时扫描指针与分配指针重合,新生代扫描结束,释放 from 所有内存,并将 to 作为下一个新生代,from 则转变为老生代。

那什么时候使用到老生代呢,毕竟新生代那么小,老生代可大着呢,有两种情况会把变量丢给老生代处理:

  • 如果一个对象存活了五轮的新生代垃圾回收,那么它有资格放到老生代。
  • 如果新生代中 to 的空间使用超过 25%,或者直接丢给个放不下的对象,这时候会把变量放到老生代。

老生代的垃圾回收

  • 两种策略,mark-sweep(标记清除,比较快,但是会形成内存碎片),mark-compact(标记整理,略慢,内存连续)
  • 老生代空间大,大部分都是活着的对象,GC 耗时比较长,而且在此期间无法响应,STOP-THE-WORLD(全停顿),V8 有一个优化方案,增量处理,把一个大暂停换成多个小暂停,INCREMENT-GC(增量标记)。
  • mark-sweep(标记清除)
    • 标记活着的对象,随后清除在标记阶段没有标记的对象,只清理死亡对象
    • 问题在于清除后会出现内存不连续的情况,这种内存碎片会对后续的内存分配产生影响
    • 如果要分配一个大对象,碎片空间无法分配 (比如 window 电脑,有一个功能是可以对磁盘碎片整理)
  • mark-compact(标记整理)
    • 标记死亡后会对对象进行整理,活着的对象向左移动,移动完成后直接清理掉边界外的内存(haha,特别像我写的移动0)

两种垃圾回收的策略都会使用,只是 mark-sweep(标记清除) 使用的较多,因为它很快,偶尔使用 mark-compact(标记整理),去整理内存空间,我猜是 10 比 1,你也可以猜一下。

因为老生代比较大,一次 GC 可能需要 1s 多,这对服务端是无法接受的,所以 V8 采用了一个 INCREMENT-GC(增量标记) 的优化策略~

  • 以上三种回收时都需要暂停程序执行,收集完成后才能恢复, STOP-THE-WORLD(全停顿) 在新生代影响不大,但是老生代影响就非常大了,增量标记就是把标记改为了增量标记,把一口气的停顿拆分成了多个小步骤,做完一步程序运行一会儿,垃圾回收和应用程序运行交替进行,停顿时间可以减少到1/6左右。

新老生代三种 GC 回收算法对比

题外话,快照查看闭包

function Person(name){
  this.name = name;
}

let PersonFactory = function(name) {
  let p = new Person(name);

  return function() {
    console.log(p);
  }
}

let p1 = PersonFactory('ys1');

这样也可以观察到闭包行为哦,注意,上述代码,因为 p1 引用了匿名函数,匿名函数引用了 ys1 实例,才导致实例没有被销毁。所以,我们使 p1 = null 可以释放掉匿名函数和 ys1 实例的堆内存哦。