js垃圾回收机制

探索内存管理的奥秘

Posted by Li Yucang on March 23, 2019

js 垃圾回收机制

JS 内存管理

内存基本概念

无论高级语言,还是低级语言。内存的管理都是:

  • 内存分配:申明变量、函数、对象,系统会自动分配内存
  • 内存使用:读写内存,使用变量、函数等
  • 内存回收:使用完毕,由垃圾回收机制自动回收不再使用的内存

像 C 语言这样的高级语言一般都有底层的内存管理接口,比如 malloc()和 free()。另一方面,JavaScript 创建变量(对象,字符串等)时分配内存,并且在不再使用它们时“自动”释放。 后一个过程称为垃圾回收。这个“自动”是混乱的根源,并让 JavaScript(和其他高级语言)开发者感觉他们可以不关心内存管理。

JS 内存空间分为栈(stack)、堆(heap)、池(一般也会归类为栈中)。 其中栈存放变量,堆存放复杂对象,池存放常量。

对象放在 heap(堆)里,常见的基础类型和函数放在 stack(栈)里,函数执行的时候在栈里执行。

基础数据类型与栈内存

JS 中的基础数据类型,这些值都有固定的大小,往往都保存在栈内存中(闭包除外),由系统自动分配存储空间。

stack 中主要存放一些基本类型的变量和对象的引用,存在栈中的数据大小与生存期必须是确定的。可以明确知道每个区块的大小,因此,stack 的寻址速度要快于 heap。

Number String Null Undefined Boolean
(es6新引入了一种数据类型,Symbol)

var a = 1

我们定义一个变量 a,系统自动分配存储空间。我们可以直接操作保存在栈内存空间的值,因此基础数据类型都是按值访问。

数据在栈内存中的存储与使用方式类似于数据结构中的栈数据结构,遵循后进先出的原则。

引用数据类型与堆内存

与其他语言不同,JS 的引用数据类型,比如数组 Array,它们值的大小是不固定的。引用数据类型的值是保存在堆内存中的对象。JavaScript 不允许直接访问堆内存中的位置,因此我们不能直接操作对象的堆内存空间。

在操作对象时,实际上是在操作对象的引用而不是实际的对象。因此,引用类型的值都是按引用访问的。这里的引用,我们可以粗浅地理解为保存在栈内存中的一个地址,该地址与堆内存的实际值相关联。

heap 是没有结构的,数据可以任意存放。heap 用于复杂数据类型(引用类型)分配空间,例如数组对象、object 对象。

var b = { xi : 20 }

比较

var a1 = 0;   // 栈
var a2 = 'this is string'; // 栈
var a3 = null; // 栈

var b = { m: 20 }; // 变量b存在于栈中,{m: 20} 作为对象存在于堆内存中
var c = [1, 2, 3]; // 变量c存在于栈中,[1, 2, 3] 作为对象存在于堆内存中

因此当我们要访问堆内存中的引用数据类型时,实际上我们首先是从栈中获取了该对象的地址引用(或者地址指针),然后再从堆内存中取得我们需要的数据。

再来看看堆和栈复制操作存在的差异:

var a = 20;
var b = a;
b = 30;
console.log(a)

var m = { a: 10, b: 20 }
var n = m;
n.a = 15;
console.log(m.a)

不难看出,基本数据类型的是复制的值,而引用数据类型是复制的值的引用。

内存机制我们了解了,又引出一个新的问题,栈里只能存基础数据类型吗,我们经常用的 function 存在哪里呢?

函数执行栈

对象放在 heap(堆)里,常见的基础类型和函数放在 stack(栈)里,函数执行的时候在栈里执行。

简单复习一下事件循环机制:

(1)所有同步任务都在主线程上执行,形成一个执行栈(execution context stack)。

(2)主线程之外,还存在一个”任务队列”(task queue)。只要异步任务有了运行结果,就在”任务队列”之中放置一个事件。

(3)一旦”执行栈”中的所有同步任务执行完毕,系统就会读取”任务队列”,看看里面有哪些事件。那些对应的异步任务,于是结束等待状态,进入执行栈,开始执行。

(4)主线程不断重复上面的第三步。

函数调用形成了一个栈帧:

function foo(b) {
  var a = 10;
  return a + b + 11;
}

function bar(x) {
  var y = 3;
  return foo(x * y);
}

console.log(bar(7));

当调用 bar 时,创建了第一个帧 ,帧中包含了 bar 的参数和局部变量

当 bar 调用 foo 时,第二个帧就被创建,并被压到第一个帧之上,帧中包含了 foo 的参数和局部变量。当 foo 返回时,最上层的帧就被弹出栈(剩下 bar 函数的调用帧 )。

当 bar 返回的时候,栈就空了。

堆与栈的大小

程序运行时,每个线程分配一个 stack,每个进程分配一个 heap,也就是说,stack 是线程独占的,heap 是线程共用的。此外,stack 创建的时候,大小是确定的,数据超过这个大小,就发生 stack overflow 错误,而 heap 的大小是不确定的,需要的话可以不断增加。所以这里只看 stack 的大小限制。下面是一个简单的测试:

var i=0;
function inc() {
    i++;
    console.log(i);
    inc();
}
inc();

测试环境是 16G 内存的电脑,需要注意的是:根据栈的定义可以知道如果 inc 函数里有变量申明的话也是会有内存占用的。

垃圾回收机制策略简介

js 具有自动垃圾回收机制。虽然开发的时候不用过于关心内存,因为分配和回收都实现了自动管理。但是了解自己写的代码,在执行的过程中发生了什么,有助于我们写出更加优秀的代码。

引用概念

垃圾回收算法主要依赖于引用的概念。

在内存管理的环境中,一个对象如果有访问另一个对象的权限(隐式或者显式),叫做一个对象引用另一个对象。例如,一个 Javascript 对象具有对它原型的引用(隐式引用)和对它属性的引用(显式引用)。

“对象”的概念不仅特指 JavaScript 对象,还包括函数作用域(或者全局词法作用域)。

引用计数垃圾收集

这是最初级的垃圾收集算法。此算法把“对象是否不再需要”简化定义为“对象有没有其他对象引用到它”。

引用计数跟踪几个每个值被引用的次数,当声明一个引用类型值赋给该变量时,则这个值的引用次数就是 1,如果同一个值被赋给另外一个变量,则该值的引用次数加 1。

相反,如果包含对这个值引用的变量又取了另外一个值,则这个值的引用次数减 1。当这个值的引用次数变成 0 时,就可以将其内存空间回收。当垃圾回收器再次运行时,它就会释放哪些引用次数为 0 的值所占用的内存。

var o = {
  a: {
    b:2
  }
};
// 两个对象被创建,一个作为另一个的属性被引用,另一个被分配给变量o
// 很显然,没有一个可以被垃圾收集


var o2 = o; // o2变量是第二个对“这个对象”的引用

o = 1;      // 现在,“这个对象”的原始引用o被o2替换了

var oa = o2.a; // 引用“这个对象”的a属性
// 现在,“这个对象”有两个引用了,一个是o2,一个是oa

o2 = "yo"; // 最初的对象现在已经是零引用了
           // 他可以被垃圾回收了
           // 然而它的属性a的对象还在被oa引用,所以还不能回收

oa = null; // a属性的那个对象现在也是零引用了
           // 它可以被垃圾回收了

引用计数缺陷

该算法有个限制:无法处理循环引用。在下面的例子中,两个对象被创建,并互相引用,形成了一个循环。它们被调用之后会离开函数作用域,所以它们已经没有用了,可以被回收了。然而,引用计数算法考虑到它们互相都有至少一次引用,所以它们不会被回收。

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

  return "azerty";
}

f();

IE 存在的问题

在 IE9 之前,IE 中有一部分对象并不是原生 JavaScript 对象。例如,BOM 和 DOM 中的对象就是 C++ 实现的 COM 对象,而 COM 对象的垃圾收集机制采用的是引用计数策略。因此,即使 IE 中的 JavaScript 引擎使用标记清除策略实现,但是 JS 访问的 COM 对象依然是基于引用计数策略的。可以在 IE 中涉及到 COM 对象,就会存在循环引用的问题。

var ele = document.getElementById('some_element')
var obj = new Object()
obj.ele = ele
ele.someObj = obj

在这个例子中一个 DOM 元素与一个原生 JS 对象之间创建了循环引用,由于 COM 的引用计数的垃圾回收策略,导致例子中的 DOM 从页面删除,也不会被垃圾回收。

解决办法:

obj.ele = null
ele.someObj = null

将变量设置为 null 意味着切断变量和它此前引用值之间的连接。当垃圾回收器下次运行时,就能删除这些值并回收它们占用的内存。

IE9 之后,DOM 和 BOM 对象都被转换成立真正的 JS 对象,这样就避免了两种垃圾回收算法并存导致的问题。

标记-清除算法

JavaScript 中最常用的垃圾回收方式就是标记清除(mark-and-sweep),当变量进入环境时,就将这个变量标记“进入环境”,当变量离开环境时,就将其标记为“离开环境”。

至于怎么标记有很多种方式,比如翻转某个特殊位来来记录一个变量何时进入环境、使用一个“进入环境”变量列表和一个“离开环境”列表等。

此算法可以分为两个阶段,一个是标记阶段(mark),一个是清除阶段(sweep)。

  1. 标记阶段,垃圾回收器会从根对象开始遍历。每一个可以从根对象访问到的对象都会被添加一个标识,于是这个对象就被标识为可到达对象。
  2. 清除阶段,垃圾回收器会对堆内存从头到尾进行线性遍历,如果发现有对象没有被标识为可到达对象,那么就将此对象占用的内存回收,并且将原来标记为可到达对象的标识清除,以便进行下一次垃圾回收操作。

从 2012 年起,所有现代浏览器都使用了标记-清除垃圾回收算法。所有对 JavaScript 垃圾回收算法的改进都是基于标记-清除算法的改进,并没有改进标记-清除算法本身和它对“对象是否不再需要”的简化定义。

JavaScript 的根对象

GC 的时候,从根对象开始遍历。在浏览器,根对象是 window;在 Node.js 中,是 global(或称为 root).

Node.js 中,每个文件被当做一个模块,所以,当你用 var/let/const 在文件的全局,声明变量的时候,作用域是当前文件(模块)。因此,图中 root.a 是 undefined。

标记-清除算法缺陷

  • 那些无法从根对象查询到的对象都将被清除
  • 垃圾收集后有可能会造成大量的内存碎片,如果一个对象由于需要占用较大内存单位而无法分配,那么就会导致 Mutator 一直处于暂停状态,而 Collector 一直在尝试进行垃圾收集,直到 Out of Memory。

V8 垃圾回收算法(分代回收)

概念

这个和 Java 回收策略思想是一致的。目的是通过区分「临时」与「持久」对象;多回收「临时对象区」(young generation),少回收「持久对象区」(tenured generation),减少每次需遍历的对象,从而减少每次 GC 的耗时。Chrome 浏览器所使用的 V8 引擎就是采用的分代回收策略。

「临时」与「持久」对象也被叫做作「新生代」与「老生代」对象。

V8 内存分代以及大小限制

在 V8 中,主要将内存分为新生代和老生代,新生代的对象为存活时间较短的对象,老生代的对象为存活时间较长或常驻内存的对象,如下图:

V8 堆的整体大小就是新生代所用内存空间加上老生代的内存空间。

在 node 中 javascript 能使用的内存是有限制的.

  • 64 位系统下约为 1.4GB。
  • 32 位系统下约为 0.7GB。

对应到分代内存中,默认情况下。

  • 32 位系统新生代内存大小为 16MB,老生代内存大小为 700MB。
  • 64 位系统新生代内存大小为 32MB,老生代内存大小为 1.4GB。

新生代平均分成两块相等的内存空间,叫做 semispace,每块内存大小 8MB(32 位)或 16MB(64 位)。

在这样限制下,将会导致 Node 无法直接操作大内存对象,比如无法将一个 2GB 的文件读入内存中进行字符串分析处理,即使物理内存有 32GB,这样在单个 Node 进程的情况下,计算机的内存资源无法得到充足的使用。

Node 提供 V8 内存使用量查看方式

$ node
$ process.memoryUsage();
{
  rss: 18702336,
  heapTotal: 10295296,
  heapUsed:5409936
}

heapTotal:已申请到的堆内存;

heapUsed:当前使用的量。

V8 的堆示意图如下:

JS 声明变量并赋值时,所使用对象的内存就分配在堆中。如果已申请的堆空闲内存不够分配新的对象,将继续申请堆内存,直到对的大小超过 V8 的限制为止。

V8 提供选择来调整内存大小的配置,需要在初始化时候配置生效,遇到 Node 无法分配足够内存给 JS 对象的情况,可以用如下办法来放宽 V8 默认内存限制。避免执行过程内存用的过多导致崩溃

node --max-old-space-size=1700 app.js //单位为MB
node --max-new-space-size=1024 app.js //单位为MB

要知晓 V8 为何限制了内存的用量,则需要回归到 V8 在内存使用上的策略:

  • 表面上的原因是 V8 最初是作为浏览器的 JavaScript 引擎而设计,不太可能遇到大量内存的场景。
  • 而深层次的原因则是由于 V8 的垃圾回收机制的限制。由于 V8 需要保证 JavaScript 应用逻辑与垃圾回收器所看到的不一样,V8 在执行垃圾回收时会阻塞 JavaScript 应用逻辑,直到垃圾回收结束再重新执行 JavaScript 应用逻辑,这种行为被称为“全停顿”(stop-the-world)。
  • 若 V8 的堆内存为 1.5GB,V8 做一次小的垃圾回收需要 50ms 以上,做一次非增量式的垃圾回收甚至要 1 秒以上。
  • 这样浏览器将在 1s 内失去对用户的响应,造成假死现象。如果有动画效果的话,动画的展现也将显著受到影响。

Scavenge 算法

在分代基础上,新生代中的对象主要通过 Scavenge 算法进行垃圾回收。在 Scavenge 的具体实现中,主要采用了 Cheney 算法

  • Cheney 算法是一种采用复制的方式实现的垃圾回收算法。它将堆内存一分为二,每一部分空间称为 semispace。在这两个 semispace 空间中,只有一个处于使用中,另一个处于闲置状态。处于使用状态的 semispace 空间称为 From 空间,处于闲置状态的空间称为 To 空间。

  • 当我们分配对象时,先是在 From 空间中进行分配。当开始进行垃圾回收时,会检查 From 空间中的存活对象,这些存活对象将被复制到 To 空间中,而非存活对象占用的空间将会被释放。

  • 完成复制后,From 空间和 To 空间的角色发生兑换。简而言之,在垃圾回收过程中,就是通过将存活对象在两个 semispace 空间之间进行复制。

Scavenge 的缺点是只能使用堆内存中的一半,这是由划分空间和复制机制所决定的。但 Scavenge 由于只复制存活的对象,并且对于生命周期短的场景存活对象只占少部分,所以它在时间效率上有优异的表现。

新生代内存示意图:

由于 Scavenge 是典型的牺牲空间换取时间的算法,所以无法大规模地应用到所有的垃圾回收中。但可以发现,Scavenge 非常适合应用在新生代中,因为新生代中对象的生命周期较短,恰恰适合这个算法。

晋升

实际使用的堆内存是新生代的两个 semispace 空间大小和老生代所用内存大小之和。当一个对象经过多次复制依然存活时,它将会被认为是生命周期较长的对象。这种较长生命周期的对象随后会被移动到老生代中,采用新的算法进行管理。对象从新生代中移动到老生代中的过程称为晋升。

在单纯的 Scavenge 过程中,From 空间中的存活对象会被复制到 To 空间中去,然后对 From 空间和 To 空间进行角色对换(又称翻转)。但在分代式垃圾回收前提下,From 空间中的存活对象在复制到 To 空间之前需要进行检查。在一定条件下,需要将存活周期长的对象移动到老生代中,也就是完成对象晋升。

对象晋升的条件主要有两个:

  1. 一个是对象是否经历过 Scavenge 回收
  2. 一个是 To 空间的内存占用比超过限制

(一)在默认情况下,V8 的对象分配主要集中在 From 空间中。对象从 From 空间中复制到 To 空间时,会检查它的内存地址来判断这个对象是否已经经历过一次 Scavenge 回收。如果已经经历过了,会将该对象从 From 空间复制到老生代空间中,如果没有,则复制到 To 空间中。这个晋升流程如图所示:

(二)另一个判断条件是 To 空间的内存占用比。当要从 From 空间复制一个对象到 To 空间时,如果 To 空间已经使用了超过 25%,则这个对象直接晋升到老生代空间中,这个晋升的判断示意图如下图:

设置 25%这个限制值的原因是当这次 Scavenge 回收完成后,这个 To 空间将变成 From 空间,接下来的内存分配将在这个空间中进行。如果占比过高,会影响后续的内存分配。对象晋升后,将会在老生代空间中作为存活周期较长的对象来对待,接受新的回收算法处理。

Mark-Sweep & Mark-Compact

对于老生代中的对象,由于存活对象占较大比重,再采用 Scavenge 的方式会有两个问题:一个是存活对象较多,复制存活对象的效率将会很低;另一个问题依然是浪费一半空间的问题。为此,V8 在老生代中主要采用 Mark-Sweep 和 Mark-Compact 相结合的方式进行垃圾回收。

这里简单讲一下标记的过程,采用 3 色标记:黑、白、灰,步骤如下:

  1. GC 开始,所以对象标记为白色。
  2. 根对象标记为黑色,并开始遍历其子节点(引用的对象)。
  3. 当前被遍历的节点,标记为灰色,被放入一个叫 marking bitmap 的栈。在栈中,把当前被遍历的节点,标记为黑色,并出栈,同时,把它的子节点(如果有的话)标记为灰色,并压入栈。(大对象比较特殊,这里不展开)
  4. 当所有对象被遍历完后,就只剩下黑和白。通过 Sweeping 或 Compacting 的方式,清理掉白色,完成 GC。

Mark-Sweep

Mark-Sweep 是标记清除的意思,它分为标记和清除两个阶段。与 Scavenge 相比,Mark-Sweep 并不将内存空间划分为两半,所以不存在浪费一半空间的行为。与 Scavenge 复制活着的对象不同,Mark-Sweep 在标记阶段遍历堆中所有对象,并标记活着的对象,在随后的清除阶段中,只清除没有被标记的对象。可以看出,Scavenge 中只复制活着的对象,而 Mark-Sweep 只清理死亡对象。活对象在新生代中只占较小部分,死对象在老生代中只占较小部分,这是两种回收方式能高效处理的原因。

下图为 Mark-Sweep 在老生代空间中标记的示意图,黑色部分标记为死亡对象

Mark-Sweep 最大的问题是在进行一次标记清除回收后,内存空间会出现不连续的状态。这种内存碎片会对后续的内存分配造成问题,因为很可能出现需要分配一个大对象的情况,这时所有的碎片空间都无法完成此次分配,就会提前触发垃圾回收,而这次回收是不必要的。

Mark-Compact

为了解决 Mark-Sweep 的内存碎片问题,Mark-Compact 被提出来。Mark-Compact 是标记整理的意思,是在 Mark-Sweep 的基础上演变而来的。它们的差别在于对象在标记为死亡后,在整理的过程中,将活着的对象往一端移动,移动完成后,直接清理掉边界外的内存。图为 Mark-Compact 完成标记并移动存活对象后的示意图,白色格子为存活对象,深色格子为死亡对象,浅色格子为存活对象移动后留下的空洞。

完成移动后,就可以直接清除最右边的存活对象后面的内存区域完成回收。

其他优化

Mark-Sweep、Mark-Compact、Scavenge 三种主要垃圾回收算法的简单对比:

回收算法 Mark-Sweep Mark-Compact Scavenge
速度 中等 最慢 最快
空间开销 少(有碎片) 少(无碎片) 双倍空间(无碎片)
是否移动对象

从表格上看,Mark-Sweep 和 Mark-Compact 之间,由于 Mark-Compact 需要移动对象,所以它的执行速度不可能很快,所以在取舍上,V8 主要使用 Mark-Sweep,在空间不足以对从新生代中晋升过来的对象进行分配时才使用 Mark-Compact。

增量标记(incremental marking)

为了避免出现 js 应用逻辑与垃圾回收器看到的不一致的情况,垃圾回收的 3 种基本算法都需要将应用逻辑暂停下来,待执行完垃圾回收后再恢复执行应用逻辑,这种行为被称为“全停顿”(stop-the-world)。

在 V8 的分代式垃圾回收中,一次小垃圾回收只收集新生代,由于新生代默认配置得较小,且其中存活对象通常较少,所以即便它是全停顿的影响也不大。但 V8 的老生代通常配置得较大,且存活对象较多,全堆垃圾回收(full 垃圾回收)的标记、清理、整理等动作造成的停顿就会比较可怕,需要设法改善。

为了降低全堆垃圾回收带来的停顿时间,V8 先从标记阶段入手,将原本要一口气停顿完成的动作改为增量标记(incremental marking),也就是拆分为许多小“步进”,每做完一“步进”就让 js 应用逻辑执行一小会,垃圾回收与应用逻辑交替执行直到标记阶段完成。

V8 在经过增量标记的改进后,垃圾回收的最大停顿时间可以减少到原本的 1/6 左右。

V8 后续还引入了延迟清理(lazy sweeping)与增量式整理(incremental compaction),让清理与整理动作也变成增量式的。同时还计划引入并行标记与并行清理,进一步利用多核性能降低每次停顿的时间。

小结

从 V8 的自动垃圾回收机制的设计角度可以看到,V8 对内存使用进行限制的缘由。新生代设计为一个较小的内存空间是合理的,而老生代空间过大对于垃圾回收并无特别意义。

V8 对内存限制的设置对于 Chrome 浏览器这种每个选项卡页面使用一个 V8 实例而言,内存的使用是绰绰有余,对于 Node 编写的服务器端来说,内存限制也并不影响正常场景下的使用。但是对于 V8 的垃圾回收特点和 js 在单线程上的执行情况,垃圾回收是影响性能的因素之一。想要高性能执行效率,需要注意让垃圾回收尽量少地进行,尤其是全堆垃圾回收。

以 Web 服务器中的会话实现为例,一般通过内存来存储,但在访问量大的时候会导致老生代中的存活对象骤增,不仅造成清理/整理过程费时,还会造成内存紧张,甚至溢出

JavaScript 内存泄漏

什么是内存泄漏

程序的运行需要内存。只要程序提出要求,操作系统或者运行时(runtime)就必须供给内存。

对于持续运行的服务进程(daemon),必须及时释放不再用到的内存。否则,内存占用越来越高,轻则影响系统性能,重则导致进程崩溃。

不再用到的内存,没有及时释放,就叫做内存泄漏(memory leak)。

常见的内存泄漏

1.意外的全局变量

JavaScript 处理未定义变量的方式比较宽松:未定义的变量会在全局对象创建一个新变量。在浏览器中,全局对象是 window 。

function foo(arg) {
    bar = "this is a hidden global variable";  // 意外挂在在 window 全局变量,导致内存泄漏
}

2.被遗忘的计时器

计数器函数,一直占用内存

// 计数器一直存在会一直占用内存,计数器结束需要做释放处理
var someResource = getData();
setInterval(function() {
    var node = document.getElementById('Node');
    if(node) {
        // 处理 node 和 someResource
        node.innerHTML = JSON.stringify(someResource));
    }
}, 1000);

3.闭包递归

let index = 0
function readData() {
  let buf = new Buffer(1024 * 1024 * 100)
  buf.fill('g')

  return function fn() { // 此处会把 return 出来的函数挂在在 window 下,作用域无法清除
    index++   // 引入局外变量,内存无法清除
    if (index < buf.length) {
      return buf[index-1]   // buf 不会被清除,需要手动清除
    } else {
      return ''
    }
  }
}

const data = readData()
const next = data()

内存泄漏的识别方法

浏览器

Chrome 浏览器查看内存占用,按照以下步骤操作。

  1. 打开开发者工具,选择 Performance 面板
  2. 在顶部的字段里面勾选 Memory
  3. 点击左上角的录制按钮。
  4. 在页面上进行各种操作,模拟用户的使用情况。
  5. 一段时间后,点击对话框的 stop 按钮,面板上就会显示这段时间的内存占用情况。

如果内存占用基本平稳,接近水平,就说明不存在内存泄漏。

反之,就是内存泄漏了。

命令行

命令行可以使用 Node 提供的 process.memoryUsage 方法。

process.memoryUsage();
// { rss: 27709440,
//  heapTotal: 5685248,
//  heapUsed: 3449392,
//  external: 8772 }

process.memoryUsage 返回一个对象,包含了 Node 进程的内存占用信息。该对象包含四个字段,单位是字节,含义如下。

rss(resident set size):所有内存占用,包括指令区和堆栈。 heapTotal:”堆”占用的内存,包括用到的和没用到的。 heapUsed:用到的堆的部分。 external: V8 引擎内部的 C++ 对象占用的内存。

判断内存泄漏,以 heapUsed 字段为准。

查看内存使用情况

写一个方法用于不停地分配内存但不释放内存,相关代码如下:

var showMem = function() {
  var mem = process.memoryUsage();
  var format = function(bytes) {
    return (bytes / 1024 / 1024).toFixed(2) + ' MB';
  };
  console.log('Process: heapTotal ' + format(mem.heapTotal) + ' heapUsed ' + format(mem.heapUsed) + ' rss ' + format(mem.rss));
  console.log('---------------------------------------------------------------------------');
};

var useMem = function() {
  var size = 20 * 1024 * 1024;
  var arr = new Array(size);
  for (var i = 0; i < size; i++) {
    arr[i] = 0;
  }
  return arr;
};
var total = [];
for (var j = 0; j < 15; j++) {
  showMem();
  total.push(useMem());
}
showMem();

将以上代码存为 outofmemory.js 并执行它,得到的输出结果如下:

node outofmemory.js

可以看到,每次调用 useMem 都导致了 3 个值的增长。在接近 1500MB 的时候,无法继续分配内存,然后进程内存溢出了,连循环体都无法执行完成,仅执行了 9 次。

堆外内存

通过 process.memoryUsage()的结果可以看到,堆中的内存用量总是小于进程的常驻内存用量。这意味着 Node 中的内存使用并非都是通过 V8 进行分配的。我们将那些不是通过 V8 分配的内存称为堆外内存。

这里将前面的 useMem()方法稍微改造一下,将 Array 变为 Buffer,将 size 变大,每一次构造 200MB 的对象,相关代码如下:

var useMem = function() {
  var size = 200 * 1024 * 1024;
  var buffer = new Buffer(size);
  for (var i = 0; i < size; i++) {
    buffer[i] = 0;
  }
  return buffer;
};

重新执行该代码,得到的输出结果如下所示:

15 次循环都完整执行,并且三个内存占用值与前一个示例完全不同。在改造后的输出结果中,heapTotal 和 heapUsed 的变化极小,唯一变化的是 rss 的值,并且该值已经远远超过 V8 的限制值。这其中的原因是 Buffer 对象不同于其他对象,它不经过 V8 的内存分配机制,所以也不会有堆内存的大小限制。

这意味着利用堆外内存可以突破内存限制的问题。

为何 Buffer 对象并非通过 V8 分配?这在于 Node 并不同于浏览器的应用场景。在浏览器中,js 直接处理字符串即可满足绝大多数的业务需求,而 Node 则需要处理网络流和文件 I/O 流,操作字符串远远不能满足传输的性能需求。

从上面得知,Node 内存构成主要由通过 V8 进行分配的部分和 Node 自行分配的部分。受 V8 的垃圾回收限制的主要是 V8 的堆内存。

WeakSet 和 WeakMap

前面说过,及时清除引用非常重要。但是,你不可能记得那么多,有时候一疏忽就忘了,所以才有那么多内存泄漏。

最好能有一种方法,在新建引用的时候就声明,哪些引用必须手动清除,哪些引用可以忽略不计,当其他引用消失以后,垃圾回收机制就可以释放内存。这样就能大大减轻程序员的负担,你只要清除主要引用就可以了。

ES6 考虑到了这一点,推出了两种新的数据结构:WeakSet 和 WeakMap。它们对于值的引用都是不计入垃圾回收机制的,所以名字里面才会有一个”Weak”,表示这是弱引用。

下面以 WeakMap 为例,看看它是怎么解决内存泄漏的。

const wm = new WeakMap();

const element = document.getElementById('example');

wm.set(element, 'some information');
wm.get(element) // "some information"

上面代码中,先新建一个 Weakmap 实例。然后,将一个 DOM 节点作为键名存入该实例,并将一些附加信息作为键值,一起存放在 WeakMap 里面。这时,WeakMap 里面对 element 的引用就是弱引用,不会被计入垃圾回收机制。

也就是说,DOM 节点对象的引用计数是 1,而不是 2。这时,一旦消除对该节点的引用,它占用的内存就会被垃圾回收机制释放。Weakmap 保存的这个键值对,也会自动消失。

基本上,如果你要往对象上添加数据,又不想干扰垃圾回收机制,就可以使用 WeakMap。

示例

首先,打开 Node 命令行。

$ node --expose-gc

上面代码中,--expose-gc参数表示允许手动执行垃圾回收机制。

然后,执行下面的代码。

// 手动执行一次垃圾回收,保证获取的内存使用状态准确
> global.gc();
undefined

// 查看内存占用的初始状态,heapUsed 为 4M 左右
> process.memoryUsage();
{ rss: 21106688,
  heapTotal: 7376896,
  heapUsed: 4153936,
  external: 9059 }

> let wm = new WeakMap();
undefined

> let b = new Object();
undefined

> global.gc();
undefined

// 此时,heapUsed 仍然为 4M 左右
> process.memoryUsage();
{ rss: 20537344,
  heapTotal: 9474048,
  heapUsed: 3967272,
  external: 8993 }

// 在 WeakMap 中添加一个键值对,
// 键名为对象 b,键值为一个 5*1024*1024 的数组
> wm.set(b, new Array(5*1024*1024));
WeakMap {}

// 手动执行一次垃圾回收
> global.gc();
undefined

// 此时,heapUsed 为 45M 左右
> process.memoryUsage();
{ rss: 62652416,
  heapTotal: 51437568,
  heapUsed: 45911664,
  external: 8951 }

// 解除对象 b 的引用
> b = null;
null

// 再次执行垃圾回收
> global.gc();
undefined

// 解除 b 的引用以后,heapUsed 变回 4M 左右
// 说明 WeakMap 中的那个长度为 5*1024*1024 的数组被销毁了
> process.memoryUsage();
{ rss: 20639744,
  heapTotal: 8425472,
  heapUsed: 3979792,
  external: 8956 }

上面代码中,只要外部的引用消失,WeakMap 内部的引用,就会自动被垃圾回收清除。由此可见,有了它的帮助,解决内存泄漏就会简单很多。

内存泄漏排查

在 Node 中,由于 V8 的堆内存大小的限制,它对内存泄漏非常敏感。当在线服务的请求量变大时,哪怕是一个字节的泄漏都会导致内存占用过高。下面介绍一下遇到内存泄漏时的排查方案。

有一些常见的工具来定位 Node 应用的内存泄漏:

  • v8-profiler:它可以用于对 V8 堆内存抓取快照和对 CPU 进行分析;
  • node-heapdump:它允许对 V8 堆内存抓取快照,用于事后分析;
  • node-mtrace:它使用 GCC 的 mtrace 工具来分析堆的使用;
  • dtrace:有完善的 dtrace 工具用来分析内存泄漏;
  • node-memwatch:来自 Mozilla 贡献的模块,采用 WTFPL 许可发布。

通过对这些工具的了解,发现排查内存泄漏主要通过对堆内存进行分析而找到。

大内存应用

stream 模块是 Node 的原生模块,直接引用即可。stream 继承自 EventEmitter,具备基本的自定义事件功能,同时抽象出标准的事件和方法。它分可读和可写两种。Node 中的大多数模块都有 stream 的应用,比如 fs 的 createReadStream()和 createWriteStream()方法可以分别用于创建文件的可读流与可写流,process 模块中的 stdin 和 stdout 则分别是可读流和可写流的示例。

由于 V8 的内存限制,我们无法通过 fs.readFile()和 fs.writeFile()直接进行大文件的操作,而改用 fs.createReadStream()和 fs.createWriteStream()方法通过流的方式实现对大文件的操作。下面的代码展示了如何读取一个文件,然后将数据写入到另一个文件的过程:

var reader = fs.createReadStream('in.txt');
var writer = fs.createWriteStream('out.txt');
reader.on('data', function (chunk) {
  writer.write(chunk);
});
reader.on('end', function () {
  writer.end();
});

可读流提供了管道方法 pipe(),封装了 data 事件和写入操作。通过流的方式,上述代码不会受到 V8 内存限制的影响,有效地提高了程序的健壮性。

如果不需要进行字符串层面的操作,则不需要借助 V8 来处理,可以尝试进行纯粹的 Buffer 操作,这不会受到 V8 堆内存的限制。但是这种大片使用内存的情况依然要小心,即使 V8 不限制堆内存的大小,物理内存依然有限制。

Node 将 JavaScript 的主要应用场景扩展到了服务器端,相应要考虑的细节也与浏览器端不同,需要更严谨地为每一份资源作出安排。总的来说,内存在 Node 中不能随心所欲地使用。