Node.js 垃圾回收机制

在计算机科学中, 垃圾回收是一种自动的内存管理机制. 当一个电脑上的动态内容不再需要时, 就应该予以释放, 以让出内存, 这种内存资源管理, 成为垃圾回收. 垃圾回收期可以让程序员减轻许多负担, 也减少程序员犯错的机会. 垃圾回收最早用于 LISP 语言, 由 John McCarthy 提出.

常用的两种内存回收机制

引用计数法

引用计数法是最简单的垃圾回收算法. 此算法把 "对象是否不再需要" 简化定义为 "对象有没有被其他对象引用". 如果没有引用指向对象, 对象就被垃圾回收机制回收.

引用计数法的限制在遇到循环引用的情况, 如下:

function f () {
  const o = {}
  const o2 = {}
  o.a = o2  // o 引用 o2
  o2.a = o  // o2 引用 o
}
f()

在该例子中, 由于 oo2 互相引用, 导致他们两者都没办法被回收.

IE 6, 7 使用引用计数法对 DOM 对象进行垃圾回收, 所以容易导致因循环引用产生的内存泄漏问题.

标记清除法 (mark-sweep)

标记清除法是最早开发出的 GC 算法. 标记清除法把 "对象是否不再需要" 定义为 "对象是否可以获得". 它定期的从根 (在 JavaScript 中, 根就是全局对象) 开始将可能被引用的对象用递归的方式进行标记, 然后将没有标记到的对象作为垃圾回收.

标记清除法的限制在于无法从根对象查询到的对象都将被清除, 但是这对于大多数人来说并不是问题.

从 2012 年开始, 所有现代浏览器都使用了标记清除垃圾回收算法. 所有对 JavaScript 垃圾回收算法的改进都是基于标记清除法的改进.

V8 的垃圾回收机制

接下来我们主要讨论下 Node.js, 或者说是 V8 的垃圾回收表现.

在 V8 中, 栈用于存储原始类型和对象的引用, 堆用于存储引用类型如对象, 字符串或闭包. 引用类型在没有引用之后, 会通过 V8 的 GC 自动回收. 值类型如果是处于闭包中, 要等闭包没有引用才会被 GC 回收, 非闭包的情况下等待 V8 的新生代切换的时候回收.

V8 堆的分配

V8 采用了一个分代垃圾回收器, 并将堆又分为了几个不同的区域:

  • 新生区: 存活时间较短的对象, 新分配对象. 新生区大小一般在 1-8 MB. 新生区的垃圾回收非常频繁也非常快.
  • 老生指针区: 包含大多数可能存在指向其他对象的指针的对象. 大多数在新生区存活一段时间之后的对象会被挪到这里.
  • 老生数据区: 存放只包含原始数据的对象(这些对象没有指向其他对象的指针). 字符串, 封箱的数字以及未封箱的双精度数字数组, 在新生区经历一次 Scavenge 后会被移动到这里.
  • 大对象区: 存放体积超过 1MB 大小的对象. 每个对象都有自己 mmap 产生的内存, 垃圾回收器从不移动大对象.
  • Code 区: 代码对象, 也就是包含 JIT 之后指令的对象, 会被分配到这里.
  • Misc 区: 因为存放的是相同大小的元素, 所以内存结构很简单.

leaks-post-021.jpeg

V8 的内存回收算法

V8 分别对新生区和老生区采用不同的垃圾回收算法来提升垃圾回收的效率.

  • Scavenge Collection

    新生代使用半空间(semi-space) 的分配策略, 其中新对象最初分配在新生代的活跃半空间内也叫为 From 区(某些特定类型的对象, 如可执行的代码对象是分配在老生区的).

    在 Scavenge 的具体实现上, 主要采用了 Cheney 算法. 关于 V8 的具体实现, 有两种说法 (不确定哪一种):

    1. 当开始垃圾回收的时候, 会检查 From 区的存活对象, 这些存活对象会被复制到 To 区中, 而非存活对象占用的空间将会被释放. 完成复制后, From 区和 To 区空间的角色发生兑换. 在一定条件下, 存活周期较长的对象会晋升到老生代中. (来源: 《深入浅出 Node.js》)

    2. 一旦 To 区已满, 一个 Scavenge 操作将交换 From 区和 To 区, 然后将 From 区中活跃的对象复制到 To 区或晋升到老生区中. (来源: https://alinode.aliyun.com/blog/14, https://phptouch.com/2016/06/07/does-my-nodejs-application-leak-memory-4/)

      如下图: 蓝色代表存活对象, 灰色代表非存活对象.

      leaks-post-025.jpeg

    新生代对象的 Scavenge 操作的持续时间取决于新生代中存活对象的数量. 在大部分新生代对象存活时间不长的情况下, 一个 Scavenge 操作非常快(< 1ms). 然而, 如果大多数对象都需要被 Scavenge 的时候, Scavenge 操作的持续时间显然会更长.

    Scavenge 操作对于快速回收, 紧缩小片内存效果很好, 但对于大片内存则消耗过大. 因为 Scavenge 操作需要出区和入区两个区域. 老生代所保存的对象大多数是生存周期很长的甚至是常驻内存的对象, 而且老生代占用的内存较多. 因此该算法明显不适合老生代.

    另外, 对于如何判断一个对象是否是存活的, V8 实际上在写缓冲区有一个列表记录所有老生区对象指向新生区的情况. 新对象诞生的时候并不会有指向它的指针, 而当老生区中的对象出现指向新生区对象的指针的时候, 我们便记录下来这样的跨区指向. 这种操作也叫写屏障.

  • Mark-Sweep Collection & Mark-Compact

    在 V8 老生代的垃圾回收采用的是标记清除(Mark-Sweep) 和 标记紧缩(Mark-Compact) 结合的策略. 当老生代的活动对象增长超过一个预设的限制的时候, 将对堆栈执行一个大回收. 老生代垃圾回收使用了 Mark-Sweep 策略, 并采用了几种优化方法来改善延迟和内存消耗. V8 采用了一种增量标记的方式标记活跃对象, 将完整的标记拆分成很多小的步骤, 每做一步旧停下来让 JavaScript 的应用线程执行一会.

    标记完成后, 所有对象都已经被标记, 即不是活跃对象就是死亡对象. 清除时, 垃圾回收期会扫描连续存放的死对象把其变为空闲空间. 这个任务是由专门的清扫线程同步执行. 最后, 为减少老生代对象产生的内存碎片, 还要执行内存紧缩. 内存紧缩可能是非常耗时的, 并且仅当内存碎片成为问题的时候才进行.

    在 Chrome 中, 带内存紧缩的完整垃圾回收只有在 Chrome 空闲足够长的时间才被执行.

    综上, V8 的垃圾回收有四个任务:

  1. 新生代对象的 Scavenge, 频繁且非常快
  2. 增量标记, 时间可以任意长, 一般每个标记步骤都低于 5 ms
  3. 完整垃圾回收 , 需要很长时间
  4. 带内存紧缩的完整垃圾回收, 需要很长的时间, 需要进行内存紧缩

一个有趣的内存泄漏问题

在 2013 年,Meteor 的作者宣布了他们碰到的关于内存泄露的发现. 原文: an-interesting-kind-of-javascript-memory-leak

var theThing = null;
var replaceThing = function () {
  var originalThing = theThing;
  theThing = {
    longStr: new Array(1000000).join('*')
  };
};
setInterval(replaceThing, 1000);

上面的代码, 每秒运行一次 replaceThing 方法, replaceThing 方法有两个步骤, 第一步是保存旧的 theThing, 第二步是给 theThing 重新安排一个新的值. replaceThing 方法执行完成之后, 我们知道 originalThing 已经是零引用了, 所以它会被垃圾回收. 因此这个例子并不会造成内存泄漏.

但如果我们这样写呢?

var theThing = null;
var replaceThing = function () {
  var originalThing = theThing;
  theThing = {
    longStr: new Array(1000000).join('*'),
    someMethod: function () {
      console.log(someMessage);
    }
  };
};
setInterval(replaceThing, 1000);

我们在 theThing 内部定义了一个新的方法 someMethod. 由于 GC 并不知道我们在 someMethod 内有没有使用 originalThing, 因此只要 someMethod 还存活着, originalThing 就不会被回收掉. 而该方法每次执行, theThing 又因为新的 theThingsomeMethod 方法而无法回收. 循环往复, 内存疯涨.

不过, 实际上, V8 是可以回收 originalThing 的, 它可以发现 originalThing 实际上并没有被 someMethod 使用. 所以它不会把 originalThing 放到 someMethod 的词法作用域里面.

但虽说如此, 我在本地运行上面的代码之后, 内存还是从 5M 一直涨到 700 多 M, 但之后就又将回 5M 了. 如果在上面代码最后面手动触发 GC 的话, 比如, 加上 setInterval(global.gc, 3000) 让每 3s 触发一次 GC(你需要 --expose-gc 才能使用这个方法). 我猜测这种情况可能是在完整垃圾回收的时候才会发现. global.gc 应该是触发完整垃圾回收的, 有没有内存紧缩不清楚. 这也就能解释为什么内存到达一定限度之后才进行回收. 另外我还发现, 上面这么一折腾之后, V8 似乎已经发现了这个函数有猫腻, 所以后面它的内存都在 5M - 25M 之间波动. 这个可能要研究下源码才能清楚.

global GC 默认用incremental marking + lazy sweeping为主,mark-compact为备份

来源: https://www.zhihu.com/question/32373436

不过又有人问了, 如果我把 console.log 重写为 eval, 然后再 eval 内的代码使用了 originalThing, 那这种情况会怎样? 事实上, 这种 eval 叫间接 eval, 间接 eval 得不到当前的词法作用域. 而如果你是在 someMethod 里面直接使用 eval, eval 是可以取得 originalThing 的, V8 就不会再把 originalThingsomeMethod 的词法作用域移除, 意味着这会导致内存泄漏.

那, 如果我们这样写呢?

var theThing = null;
var replaceThing = function () {
  var originalThing = theThing;
  var unused = function () {
    if (originalThing)
      console.log("hi");
  };
  theThing = {
    longStr: new Array(1000000).join('*'),
    someMethod: function () {
      console.log(someMessage);
    }
  };
};
setInterval(replaceThing, 1000);

如果你运行上面的代码, 将会导致内存泄露, 即使完全 GC 也无法阻挡. 但其实仔细看, 这段代码和我们最前面写的好像并没有什么区别. 只是多了 unused 这个方法, 但 unused 在方法执行完成后应该会被回收, 因为它没有被引用.

正如第一个例子中, 传统的认知是每个闭包都拥有一个词法作用域, 如果 replaceThing 中的所有方法都使用了外部的 originalThing, 它们的 originalThing 应该都是同一个, 即使 originalThing 在外面多次被分配. 也就是说这些方法共享着同样的词法环境. 现在 V8 已经足够智能可以识别这些方法中是否有在使用 originalThing, 如果没有的话就把 originalThing 从它们共享的词法环境中移除. 这也就是为什么第一个例子不会内存泄漏的原因.

但一旦有一个变量被其中一个方法所使用. 这种词法环境分享就不再起作用了. 从而导致内存泄露. 具体的原因可以看以下注释:

var theThing = null;
var replaceThing = function () {
  var originalThing = theThing;
  // Define a closure that references originalThing but doesn't ever
  // actually get called. But because this closure exists,
  // originalThing will be in the lexical environment for all
  // closures defined in replaceThing, instead of being optimized
  // out of it. If you remove this function, there is no leak.
  var unused = function () {
    if (originalThing)
      console.log("hi");
  };
  theThing = {
    longStr: new Array(1000000).join('*'),
    // While originalThing is theoretically accessible by this
    // function, it obviously doesn't use it. But because
    // originalThing is part of the lexical environment, someMethod
    // will hold a reference to originalThing, and so even though we
    // are replacing theThing with something that has no effective
    // way to reference the old value of theThing, the old value
    // will never get cleaned up!
    someMethod: function () {}
  };
  // If you add `originalThing = null` here, there is no leak. 
  // even though the name originalThing is still in the lexical 
  // environment of someMethod, there won't be a link to the big old value.
};
setInterval(replaceThing, 1000);

最后, 推荐一个非常不错的关于 Node.js 内存管理的专栏: https://phptouch.com/category/v8/


参考资料:

  1. an-interesting-kind-of-javascript-memory-leak
  2. JavaScript 内存管理
  3. Understanding V8's Memory Handling
  4. Understanding V8's Memory Handling 译
  5. Google V8的垃圾回收引擎
  6. does-my-nodejs-application-leak-memory-4
  7. V8 垃圾回收
  8. 《深入浅出 Node.js》(作者: 朴灵): 第五章 - 内存控制

标签: JavaScript, Node.js, GC

知识共享许可协议
本作品采用知识共享署名-相同方式共享 4.0 国际许可协议进行许可。

添加新评论