理解node.js内存控制

676 阅读6分钟

内存控制

Node内存控制在海量请求的前提下需要进行探讨。

V8的垃圾回收机制与内存限制

JavaScript由垃圾回收机制来进行自动内存管理,内存管理的好坏、垃圾回收状况是否优良,都会对服务构成影响。这一切都与Node的JavaScript执行引擎V8息息相关。

V8的内存限制

在一般的后端开发语言中,在基本的内存使用上没有什么限制,然而在Node中通过JavaScript使用内存时就会发现只能使用部分内存(64位系统下约为1.4GB,32位系统下约为0.7GB)。

因为Node中使用的JavaScript对象基本上都是通过V8自己的方式进行分配和管理的。而V8会限制使用的内存量。

V8的对象分配

在V8中,所有的JavaScript对象都是通过堆来进行分配的。Node提供了V8中内存使用量的查看方式

$ node
> process.memoryUsage();
{ rss: 14958592,
  heapTotal: 7195904,
  heapUsed: 2821496 }

heapTotal和heapUsed是V8的堆内存使用情况,前者是已申请到的堆内存,后者是当前使用的量。

如果已申请的堆空闲内存不够分配新的对象,将继续申请堆内存,直到堆的大小超过V8的限制为止。

V8限制堆内存的大小,表层原因为V8最初为浏览器而设计,不太可能遇到用大量内存的场景。深层原因是V8的垃圾回收机制的限制。以1.5GB的垃圾回收堆内存为例,V8做一次小的垃圾回收需要50ms以上,做一次非增量式的垃圾回收甚至需要1s以上。这是垃圾回收中引起JavaScript线程暂停执行的时间,在这样的时间花销下,应用的性能和响应能力都会直线下降。因此,直接限制堆内存是一个好的选择。

当然V8依然提供了选项让我们使用更多的内存。Node在启动时可以传递--max-old-space-size或--max-new-space-size来调整内存限制的大小

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

上述参数在V8初始化时生效,一旦生效就不能再动态改变。

V8的垃圾回收机制

1.V8主要的垃圾回收算法

V8的垃圾回收策略主要基于分代式垃圾回收机制。因为在实际应用中,对象的生存周期长短不一,不同的算法的算法只能针对特定情况具有良好的效果。

1.1-V8的内存分代

将内存分为新生代和老生代。新生代中的对象为存活时间较短的对象,老生代中的对象为存活时间较长或常驻内存的对象

V8堆的整体大小就是新生代所用内存空间加上老生代的内存空间。前面提到的内存限制调整的就是新老生代。这两个最大值需要在启动时就指定,这意味着V8使用的内存没有办法根据使用情况自动扩充,当内存分配过程中超过极限值,就会引起进程出错。

在64位系统和32位系统下会分别只能使用约1.4GB和约0.7GB的大小。这个限制在源码中可以找到。源码中老生代设置在64位系统下为1400MB,在32位系统下为700MB

新生代内存由两个reserved_semispace_size_所构成。reserved_semispace_size_在64和32位系统上分别为16MB和8MB

V8堆内存的最大保留空间为 4 * reserved_semispace_size_ + max_old_generation_size_;在64位系统上为1464MB,32位系统上为732MB.

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

Cheney算法一种采用复制方式实现的垃圾回收算法。它将堆内存一分为二,每一部分空间称为semispace。在这两个semispace空间中,只有一个处于使用中,另一个处于闲置状态。处于使用状态的semispace空间称为From空间,处于闲置状态的空间称为To空间。当我们分配对象时,先是在From空间中进行分配。当开始进行垃圾回收时,会检查From空间中的存活对象,这些存活对象被复制到To空间,而非存活对象占用的空间将会被释放。完成复制后,From空间和To空间的角色发生对换。

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

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

当一个对象经过多次复制依然存活时,它随后会被移动到老生代中,From空间中存活对象在复制到To空间之前需要进行检查。在一定条件下,需要将存活周期长的对象移动到老生代中,这个过程也叫晋升。

晋升的条件主要有两个,一个是对象是否经历过Scavenge回收,一个是To空间的内存占用比超过限制。

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

另一个判断条件是当要从From空间复制一个对象到To空间时,如果To空间已经使用了超过25%,则这个对象直接晋升到老生代空间中

设置25%这个限制值的原因是当这次Scavenge回收完成后,这个To空间将变成From空间,接下来的内存分配将在这个空间中进行。如果占比过高,会影响后续的内存分配。

1.3-Mark-Sweep & Mark-Compact

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

Mark-Sweep是标记清除的意思。在标记阶段遍历堆中所以对象,并标记活着的对象,清除阶段,清除没有被标记的对象。死对象在老生代中只占较少部分,这是Mark-Sweep能高效处理的原因。下图,黑色部分标记为死亡对象

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

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

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

在V8的回收策略中两者是结合使用的。

在取舍中,V8主要使用Mark-Sweep,在空间不足以对从新生代中晋升过来的对象进行分配时才使用Mark-Compact。

1.4-Incremental Marking

为了避免出现JavaScript应用逻辑与垃圾回收器看到的不一致的情况,垃圾回收的3种基本算法都需要将应用逻辑暂停下来,待执行完垃圾回收后再恢复执行应用逻辑,这种行为被称为“全停顿”。在V8的分代式垃圾回收中,一次小垃圾回收只收集新生代,由于新生代默认配置得较小,且其中活动对象通常较少,所以即便它是全停顿的影响也不大。但V8的老生代通常配置得较大,且存活对象较多,全堆垃圾回收的标记、清理、整理等动作造成的停顿就会比较可怕。

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

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

总结

想要高性能的执行效率,需要注意让垃圾回收尽量少地进行,尤其是全堆垃圾回收。

服务端在访问量大、内存大量占用的时候,老生代中的存活对象骤增,不仅造成清理/整理过程费时,还会造成内存紧张,甚至溢出。

查看垃圾回收日志

在启动时添加--trace_gc参数。执行结束后,将会在gc.log文件中得到所有垃圾回收信息:

node --trace_gc -e "var a = [];for (var i = 0; i < 1000000; i++) a.push(new Array(100));" > gc.log

通过分析垃圾回收日志,可以了解垃圾回收的运行状况,找出垃圾回收的哪些阶段比较耗时,触发的原因是什么。

通过在Node启动时使用--prof参数,可以得到V8执行时的性能分析数据,其中包含了垃圾回收执行时占用的时间。

// test01.js
for (var i = 0; i < 1000000; i++) {
  var a = {};
}

执行

$ node --prof test01.js

得到一个日志文件,该日志文件基本不具备可读性,v8提供了linux-tick-processor工具用于统计日志信息。将该目录添加到环境变量PATH中调用:

$ linux-tick-processor v8.log

其中垃圾回收部分

[GC]:
  ticks total nonlib name
   2      5.4%

由于不断分配对象,垃圾回收所占的时间为5.4%。这意味着事件循环执行1000毫秒要给出54毫秒的时间用于垃圾回收。

高效使用内存

作用域

提到如何触发垃圾回收,第一个要介绍的是作用域。在JavaScript中能形成作用域的有函数调用、with以及全局作用域

var foo = function () {
  var local = {};
};

foo函数在每次调用时会创建对应的作用域,函数执行结束后,该作用域将会销毁。同时作用域中声明的局部变量分配在该作用域上,随作用域的销毁而销毁。只被局部变量引用的对象存活周期较短。在这个示例中,由于对象非常小,将会分配在新生代中的From空间。在作用域释放后,局部变量local失效,其引用的对象将会在下次垃圾回收时被释放。 1.1 标识符查找

与作用域相关的即是标识符查找。所谓标识符,可以理解为变量名。

var bar = function () {
  console.log(local);
};

JavaScript在执行时会去查找该变量定义在哪里。它最先查找的是当前作用域,如果在当前作用域中无法找到该变量的声明,将会向上级的作用域里查找,直到查到为止。

1.2 作用域链

var foo = function () {
  var local = 'local var';
  var bar = function () {
    var local = 'another var';
    var baz = function () {
      console.log(local);// another var
    };
    baz();
  };
  bar();
};
foo();

存在作用域链,不断向上查找。了解作用域,有助于我们了解变量的分配和释放。

1.3 变量的主动释放

如果变量是全局变量(不通过var声明或定义在global变量上),由于全局作用域需要直到进程退出才能释放,此时将导致引用的对象常驻内存(常驻在老生代中)。可以通过delete操作来删除引用关系。或者将变量重新赋值,让旧的对象脱离引用关系。在接下来的老生代内存清理和整理的过程中,会被释放。

global.foo = 'a';
console.log(global.foo);
delete global.foo;// a
// 或者重新赋值
// global.foo = undefined; // or null
console.log(global.foo);

如果在非全局作用域中,想主动释放变量引用的对象,也可以通过这样的方式。虽然delete操作和重新赋值具有相同的效果,但是在V8中通过delete删除对象的属性有可能干扰V8的优化,所以赋值方式较好。

闭包

在JavaScript实现外部作用域访问内部作用域变量的方法叫做闭包

var foo = function () {
  var bar = function () {
    var local = "局部变量";
    return function () {
      return local;
    };
  };
  var baz = bar();
  console.log(baz());
};

foo();// "局部变量"

在外部作用域中还是无法直接访问local,但是若要访问它,只要通过这个中间函数稍作周转即可。闭包的问题在于,一旦有变量引用这个中间函数,这个中间函数将不会释放,同时也会使原始的作用域不会得到释放,作用域中产生的内存占用也不会得到释放。除非不再引用,才会逐步释放。

小结

在正常的JavaScript执行中,无法立即回收的内存由闭包和全局变量引用这两种情况。由于V8的内存限制,要注意此类变量的使用。

内存指标

一般而言,应用中存在一些全局性的对象是正常的,而且在正常使用中,变量都会自动释放回收。但是也会存在一些我们认为会回收但是却没有被回收的对象。

查看内存使用情况

除了process.memoryUsage(),os模块的totalmem()和freemem()也可以查看内存使用情况。

1.查看进程的内存占用

调用process.memoryUsage()可以查看Node进程的内存占用情况

$ node
> process.memoryUsage()
{ rss: 13852672,
  heapTotal: 6131200,
  heapUsed: 2757120 }

rss是resident set size的缩写,即进程常驻内存部分。进程的内存总共有几部分,一部分是rss,其余部分在交换区(swap)或者文件系统(filesystem)中。

heapTotal和heapUsed对应的是V8的堆内存信息。heapTotal是堆中总共申请的内存量,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();

执行结果

Process: heapTotal 6.23MB heapUsed 3.81MB rss 19.66MB
-----------------------------
Process: heapTotal 167.75MB heapUsed 164.33MB rss 181.11MB
-----------------------------
Process: heapTotal 327.76MB heapUsed 324.33MB rss 341.19MB
-----------------------------
Process: heapTotal 487.77MB heapUsed 484.34MB rss 501.32MB
-----------------------------
Process: heapTotal 647.78MB heapUsed 644.34MB rss 661.38MB
-----------------------------
Process: heapTotal 807.79MB heapUsed 804.34MB rss 821.39MB
-----------------------------
Process: heapTotal 967.80MB heapUsed 964.35MB rss 981.41MB
-----------------------------
Process: heapTotal 1130.32MB heapUsed 1123.57MB rss 1141.77MB
-----------------------------
Process: heapTotal 1290.33MB heapUsed 1283.57MB rss 1301.79MB
-----------------------------

<--- Last few GCs --->

[39083:0x103800000]     1338 ms: Mark-sweep 1283.5 (1287.8) -> 1283.5 (1287.8) MB, 110.8 / 0.0 ms  (average mu = 0.230, current mu = 0.003) last resort GC in old space requested
[39083:0x103800000]     1450 ms: Mark-sweep 1283.5 (1287.8) -> 1283.5 (1287.8) MB, 111.4 / 0.0 ms  (average mu = 0.131, current mu = 0.000) last resort GC in old space requested

可以看到,3个值都在不断增长,在接近1500MB的时候,无法继续分配内存,然后进程内存溢出了。

查看系统的内存占用

os模块的totalmem()和freemem()用于查看操作系统的内存使用情况,它们分别返回系统的总内存和闲置内存,以字节为单位

var os = require('os');

var showMem = function () {
  var format = function (bytes) {
    return (bytes / 1024 / 1024).toFixed(2) + 'MB';
  }
  console.log('os.totalmem', format(os.totalmem()));
  console.log('os.freemem', format(os.freemem()));
}

showMem();
// os.totalmem 8192.00MB
// os.freemem 452.36MB

可以看到我的电脑的总内存为8GB,当前闲置内存为452.36MB

堆外内存

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

我们将Array换成Buffer,将size变大

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;
}

结果

Process: heapTotal 6.23MB heapUsed 3.81MB rss 19.80MB
-----------------------------
Process: heapTotal 8.23MB heapUsed 4.53MB rss 221.43MB
-----------------------------
Process: heapTotal 8.23MB heapUsed 4.54MB rss 422.14MB
-----------------------------
Process: heapTotal 10.23MB heapUsed 4.01MB rss 593.79MB
-----------------------------
Process: heapTotal 10.23MB heapUsed 4.01MB rss 745.22MB
-----------------------------
Process: heapTotal 10.23MB heapUsed 3.77MB rss 945.27MB
-----------------------------
Process: heapTotal 10.73MB heapUsed 3.71MB rss 1145.29MB
-----------------------------
Process: heapTotal 10.73MB heapUsed 3.71MB rss 1142.21MB
-----------------------------
Process: heapTotal 10.73MB heapUsed 3.71MB rss 1194.72MB
-----------------------------
Process: heapTotal 10.73MB heapUsed 3.71MB rss 1306.51MB
-----------------------------
Process: heapTotal 10.73MB heapUsed 3.71MB rss 1320.05MB
-----------------------------
Process: heapTotal 10.73MB heapUsed 3.72MB rss 1382.52MB
-----------------------------
Process: heapTotal 10.73MB heapUsed 3.71MB rss 1582.52MB
-----------------------------
Process: heapTotal 10.73MB heapUsed 3.71MB rss 1782.52MB
-----------------------------
Process: heapTotal 10.73MB heapUsed 3.71MB rss 1955.04MB
-----------------------------
Process: heapTotal 7.73MB heapUsed 3.71MB rss 1961.53MB
-----------------------------
(node:39922) [DEP0005] DeprecationWarning: Buffer() is deprecated due to security and usability issues. Please use the Buffer.alloc(), Buffer.allocUnsafe(), or Buffer.from() methods instead.

heapTotal和heapUsed的变化极小,唯一变化的是rss值,并且该值已经超过V8的限制值。这是因为Buffer对象不同于其他对象,它不经过V8的内存分配机制,所以也不会有堆内存的大小限制。

小结

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

内存泄漏

一旦线上应用有成千上万的流量,那怕是一个字节的内存泄漏也会造成堆积,垃圾回收过程中将会耗费更多时间进行对象扫描,应用响应缓慢,直到进程内存溢出,应用崩溃。

内存泄漏通常产生于无意间,较难排查。尽管内存泄漏的情况不尽相同,但其实质只有一个,那就是应当回收的对象出现意外而没有被回收,变成了常驻在老生代中的对象。

通常由如下原因:

  1. 缓存
  2. 队列消费不及时
  3. 作用域未释放

慎将内存当做缓存

在Node中,一旦一个对象被当做缓存来使用,那就意味着它将会常驻在老生代中。

如果需要,只要限定缓存对象的大小,加上完整的过期策略以防止内存无限制增长,还是可以一用的

另一个案例在于模块机制。所有模块都会通过编译执行,然后被缓存起来。由于通过exports导出的函数,可以访问文件模块中的私有变量,这样每个文件模块在编译执行后形成的作用域因为模块缓存的原因,不会被释放

(function (exports, require, module, __filename, __dirname) {
  var local = "局部变量";

  exports.get = function () {
    return local;
  };
});

由于模块的缓存机制,模块是常驻老生代的。在设计模块时,要小心内存泄漏的出现。

var leakArray = [];
exports.leak = function () {
  leakArray.push('leak');
};

如果不可避免要这么设计,那么请添加清空队列的响应接口,以供调动者释放内存。

缓存的解决方案

直接将内存作为缓存的方案要十分慎重。除了限制缓存的大小外,另外要考虑的事情是,进程之间无法共享内存。这些缓存不可避免地有重复,对物理内存的使用是一种浪费。

目前较好的解决方案是采用进程外的缓存如Redis。外部的缓存软件有着良好的缓存过期淘汰策略以及自有的内存管理,不影响Node进程的性能且进程间可以共享缓存。

关注队列状态

队列(数组对象)在消费者-生产者模型中经常充当中间产物,如果消费者速度低于生产速度,将会形成堆积。

解决方案是监控队列的长队,一旦堆积,通过报警通知相关人员。另一个解决方案是设置超时机制,通过回调函数传递超时异常,给消费速度一个下限值。

内存泄漏排查

排查内存泄漏的原因主要通过对堆内存进行分析而找到的,有许多工具用来定位Node应用的内存泄漏,如node-heapdump和node-memwatch等。???暂略

大内存应用

在Node中,不可避免地还是会存在操作大文件的场景。好在Node提供了stream模块用于处理大文件。Node中的大多数模块都有stream的应用。

由于V8的内存限制,我们无法通过fs.readFile和fs.writeFile直接进行大文件的操作,而改用fs.createReadStream和fs.createWriteStream方法通过流的方式实现对大文件的操作。

如果不需要进行字符串层面的操作,则不需要借助V8来处理,可以尝试进行纯粹的Buffer操作,这不会受到V8堆内存的限制。

参考

  • 《深入浅出node.js》