关于V8内存的一些学习

文章目录
  1. V8的内存大小限制
  2. V8的内存管理机制
    1. 内存分代
    2. 新生代内存回收机制
    3. 对象晋升
    4. 老生代内存回收机制
    5. 增量标记
  3. 查看V8垃圾回收日志
  4. 查看内存使用情况
    1. 查看进程的内存占用
    2. 查看系统占用内存
    3. 堆外内存
  5. 内存泄漏
    1. 缓存导致的内存泄漏
    2. 缓存限制策略
    3. 缓存的解决方案
    4. 队列导致的内存泄漏
    5. 作用域未释放导致的内存泄漏
    6. 慎用闭包
    7. node-heapdump
    8. node-memwacth
  6. 大文件操作
  7. 总结

V8的内存大小限制

V8引擎最初为web浏览器设计,作为js解释器,其考虑到js的单线程特性和网页的低内存占用率,将可使用内存限制在1.4G(64位)和0.7G(32位)以下。因为按其官方公布的内存回收速度来讲,当申请的内存堆达到1.5g时,哪怕是一次小的垃圾回收也要耗时50ms,而进行一次分增量性垃圾回收更是需要1s,这将造成1s的线程阻塞,对于用户操作体验来说是不可接受的。但后来Node也采用V8引擎作后台开发,考虑到后台内存的占用可能会非常大,所以它提供了选项来改变内存操作的方式。Node在启动时可以传递–max-old-space-size(MB为单位)或–max-new-space(KB)来调整内存。其在V8初始化时生效,一旦生效就不能再发生改变。

V8的内存管理机制

内存分代

V8将内存堆按对象存活时间分为新生代和老生代两个部分,新生代中的对象为存活时间较短的对象, 老生代对象为存活时间较长的对象。然后对不同分代的内存施以更适合和高效的算法。其分代示意图如下:
Alt text
V8中可以使用前面提到过的–max-old-space-size 和–max-new-space来设置老生代和新生代内存的空间大小。一般老生代内存占用了整个很大一部分,其在64位系统和32位下默认大小为1.4GB与0.7GB,而新生代内存的默认大小分别为32MB和16MB。

新生代内存回收机制

在分代的基础上,新生代对象主要通过Scavenge算法进行垃圾回收。而Scavenge算法的具体实现又主要采用的是Cheney算法,该算法由C.J.Cheney于1970年首次在ACM上提出。
Cheney算法采用复制的方式实现垃圾回收,它把内存空间分为闲置状态(To)的空间和使用状态(From)的空间,称其为semi空间。然后分配对象给内存时,只能先分配到From空间,只有在进行垃圾回收的时候才将From空间中存活的对象复制到To空间中,然后释放掉非存活对象,并对From空间和To空间之间的角色。其内存示意图为:


Alt text

所以Scavenge的缺点是只能使用被分配的堆内存中的一半,不过由于它只需要复制存活的对象,而对生命周期较短的新生代对象来讲存活对象只占很小的部分,所以该算法对新生代对象的回收的时间效率较高。虽然Scavenge算法是典型的以空间换取时间的算法,但新生代对象所占用本身较小,所以不会浪费太多的内存,因此该算法非常适合于回收新生代对象。

对象晋升

新老内存之间还存在一种晋升的关系,在某个对象经过多次复制后仍然存活时,我们就认为它是生命周期较长的对象,并将其移动到老生代内存中。这种将对象从新生代内存移动到老生代内存的过程,就是我们说的晋升。所以在使用Scavenge算法对对象进行回收时,要对每个从From空间复制到To空间的存活对象进行检查,判断它是否符合对象晋升条件。该条件主要又两个,一个是对象是否经历过一次Scavenge回收,另一个是To空间的内存占用比是否超过限制。
凡已经经历过一次Scavenge回收的对象将会被老生代空间中。除此之外,当To空间中的内存已经使用了25%以上时,这个对象也会晋升到老生代空间中。设置25%阈值的理由是To空间在Scavenge回收完成时将变成From空间,接下来内存分配会在这个空间中进行,所以必须预留一部分空间保证内存分配正常进行。
对象在晋升后,将会和存活周期较长的对象一样接受老生代的内存回收算法处理。

老生代内存回收机制

对于老生代内存来讲,由于其本身占用的内存空间很大,所以Scavenge算法浪费一半空间的问题会显得特别严重。此外,老生代内存中存活对象占的比重较大,复制存活对象的效率也大幅度降低。这两个问题导致Scavenge算法并不适用于方案 ,所以它采用的是Mark-Sweep和Mark-Compact相结合的方式进行垃圾回收。
Mark-Sweep是标记清除的意思,它分为标记和清除两个阶段,Mark-Sweep在标记阶段遍历堆中的所有对象,并标记其中存活活的对象,在清除阶段只清除没有被标记的对象。可以看出,Scanvenge是只复制活着的对象,而Mark-Sweep是只清除死亡的对象。由于老生代内存中死对像在老生代中只占一小部分,所以该算法能够高效进行内存回收,且它不会将内存空间。
Mark-Sweep最大的问题在于进行一次标记清除后,内存空间会出现不连续的状态。这种内存碎片会对后续的内存分配造成问题,比如当需要分配给对象一个较大内存时,可能因为所有碎片都没有足够的空间完成这次分配而提前触发一次不必要的垃圾回收。为了解决内存碎片问题,Mark-Compact被提了出来。
Mark-Compact是一种内存整体算法,它是在Mark-Sweep的基础上演变而来,它们的差别在于对象在标记为死亡后,在整理过程中,将活着的对象往一端移动,移动完成后,直接清理掉边界外的内存。
但是由于存在大量对象移动操作,所以Mark-Compact算法的回收效率是最低的,所以V8主要还是使用Mark-Sweep回收内存,只有在空间不足以为新生代中晋升过来的对象分配内存时才使用Mark-Compact算法。

增量标记

为了避免出现JavaScript应用逻辑与垃圾回收器出现不一致的情况,在进行垃圾回收时要先把应用逻辑暂停下来,待垃圾回收完后再恢复执行。这种行为叫做“全停顿”,对于新生代内存,由于其本身占用空间较小,加上存活对象的占比也比较小,所以一次回收所用的时间很短,即使是全停顿对它的影响也不大,但是V8的老生代通常配置较大,且其中存活对象较多,进行垃圾回收时产生的停顿会非常严重。
为了降低全堆垃圾回收带来的停顿时间,在回收老生代内存时V8先从标记阶段入手,将原本要一次形完成的标记,改成增量式标记,即将标记过程拆分为多个小步骤,每做完一步就反过来让JavaScript应用逻辑执行一会,然后又继续进行标记,存活对象标记与应用逻辑将如此交替运行直到标记阶段完成。
据统计,在进行增量标记后V8的垃圾回收最大停顿时间可以减少到原来的1/6左右。
V8后续还引入了延迟清理和增量式整理,让清理和整理阶段也变为了增量式的。同时还引入了并行标记与并行清理方案,进一步利用多核性能降低每次停顿的时间。

查看V8垃圾回收日志

查看垃圾回收的命令 node –trace_gc,它会显示关于垃圾回收的所有信息,包括新生代和老生代内存空间的变化和耗时,以及晋升情况。通过分析垃圾回收运行情况,我们可以找出垃圾回收的那些阶段比较耗时,以及触发的原因是什么。
然后,可以通过node –prof得到V8执行时的性能分析数据,其中包含垃圾回收执行时占用的时间。
统计内容较多,其中垃圾回收部分如下:
[GC]:
ticks total nonlib name
2 5.4%
它代表垃圾回收所占用的时间为5.4%。即如果事件执行时间为1000毫秒,就要给出54毫秒的时间用于垃圾回收。

查看内存使用情况

查看进程的内存占用

node中可以调用process.memoryUsage()方法查看Node进程占用内存的情况,示例代码如下:

1
2
3
4
5
6
7
$node
> proccess.memoryUsage()
{ rss:22753280,
heapTotal:10522624,
heapUsed: 5326248

}

rss是resident set size的缩写,即进程的常驻内存部分。进程的内存总共分为几个部分,一部分是rss,其余部分在交换区(swap)或者文件系统(filesystem)中。除了rss外,heapTotal和heapUsed对应的V8的堆内存信息。heapTotal是堆中总共申请的内存量,heapUsed表示目前堆中使用的内存量。这3个值的单位都是字节。

查看系统占用内存

除了可以查看进程使用的内存情况,我们还可以通过os模块中的totalmem()和freemem()方法查看系统的内存使用情况。示例代码如下:

1
2
3
4
5
$node
> os.totalmem()
17096097792
> os.freemem()
> 10900299776

从输出的信息中我们可以看出电脑的总内存为16GB,当前闲置内存约为10GB。

堆外内存

通过process.memoryUsage()的结果我们发现,进程内存堆上的总量总是小于进程的常驻内存总量,这是因为Node中的内存使用的并非都是通过V8进行分配的,我们将那些不是通过V8分配的内存称为堆外内存
一般情况下,node的能使用的内存不能超过堆内存的总量,但Buffer对象却是一个例外,它不会受到堆内存大小的限制,可以使用远超过堆内存总量的内存,这是由于它不经过V8的内存分配机制,所以也不会有堆内存的大小限制。Buffer对象是node专门设计出来满足处理网络流和文件I/O流等耗内存较高的服务器业务。

内存泄漏

Node对内存泄漏比较敏感,因为Node处理线上应用情况较多,常常会接收成千上万的流量,内存泄漏会造成不可回收的内存堆积,垃圾回收将消耗更多的时间去扫描对象,进而影响应用响应。
Node中造成内存泄漏的原因实质只有应当回收的对象出现意外而没有被回收,变成了常驻在老生代中的内存对象。
造成内存泄漏的原因:
缓存
队列消费不及时
作用域未释放

缓存导致的内存泄漏

缓存是将经常访问到的数据直接存放在内存中,节省I/O所花的时间提升效率,但在Node中,一旦对象被当作缓存来使用就意味着它将常驻于老生代内存中。缓存中存储的数据越多,长期存活的对象就越多,这将导致垃圾回收时这些对象做无用功。
并且我们一般做缓存时都是使用js对象的键值来缓存数据, 但普通对象与严格意义上的有着很大的区别,它没有完善的过期策略,也不会对缓存对象的大小进行限制。可能造成内存无限制增长,缓存中的对象永远无法被释放,进而形成内存泄漏。
这里给出可能无意识造成内存泄漏的场景:memoize。下面是著名的类库underscore对memoize的实现:

1
2
3
4
5
6
7
8
_.memoize = function (func, hasher) {
var memo = {};
hasher || (hasher = _.identity);
return function () {
var key = hasher.apply(this, arguments);
return _.has(memo, key) ? memo[key] : (memo[key] = func.apply(this, arguments));
}
}

它的意图是用参数生成键进行缓存,以内存空间来换取CPU的执行时间,但问题在于该函数没有清除缓存的机制,在前端这种短时应用场景中不会存在大的问题,但在服务器端持续缓存参数时,会导致内存占用一直增长得不到释放。
所以在node中,应该对内存缓存机制加上一层限制。

缓存限制策略

为了解决缓存中对象永远无法释放的问题,我们需要加入一种策略来限制缓存的无限增长,下面是一种简单限制缓存策略。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
var LimitableMap = function (limit) {
this.limit = limit || 10;
this.map = [];
this.keys = [];
}
var hasOwnProperty = Object.prototype.hasOwnProperty;
LimitableMap.prototype.set = function (key, value) {
var map = this.map;
var keys = this.keys;
if (!hasOwnProperty.call(map, key)) {
if (keys.length === this.limit) {
var firstKey = keys.shift();
delete map[firstKey];
}
keys.push(key);
}
map[key] = value;
}
LimitableMap.prototype.get = funtion (key) {
return this.map[key];
}
module.exports = LimitableMap;

上面代码限制缓存的策略很简单,就是用一队列来保存键值,一旦队列中键值的数量的超过规定的上限,就以先进先出的方式进行淘汰。
另外,为了加速模块的引入,所有模块都会通过编译执行然后被缓存起来,如果我们通过exports导出的函数对模块文件内部的变量进行访问,其变量将因为模块缓存和闭包的影响,无法得到释放。所以我们要尽量避免模块内部局部变量的无限增长,比如:

1
2
3
4
var leakArray = [];
exports.leak = function () {
leakArray.push("leak" + Math.random());
}

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

缓存的解决方案

将内存直接作为缓存的方案要十分慎重,除了限制缓存的大小外,还要考虑进程之间无法共享内存,如果在进程内使用缓存,这些缓存不可避免的会有重复,造成内存浪费。那么在大量使用缓存的场景,我们可以采用在进程外存放缓存的方式,比如借助于一些成熟的内存级数据库Redis和Memcached来存放共享缓存。

队列导致的内存泄漏

解决了缓存带来的内存泄漏问题之后,还有一种可能产生内存泄漏的因素则是队列,在js中可以通过队列来完成许多特殊需求,比如Bagpipe。队列在生产消费模型下经常充当中间产物,在一般情况下,消费速度远高于生产速度,此时内存泄漏不易厂产生,但一旦消费速度低于生产者速度时将会形成堆积。
比如收集日志时,若使用数据库来记录日志,则因为数据库写入效率低的原因,造成大量的日志的写操作堆积在队列中等待进行,此时形成相关作用域的内存将长时间无法释放,形成内存泄漏。
对于这种场景表面的解决方案是使用更高效的写入方式,比如直接写入文件,但当出现日志量突增或写入系统故障的情况仍有可能形成内存泄漏。深度方式则是监控队列长度,一旦形成堆积,应当通过监控系统 产生报警。或是对异部写入设置一定时限,若在改时限内仍未完成响应,则通过回调函数传递超时异常。
Bagpipe也提供了超时模式和拒绝模式以防止内存泄漏,启用超时模式时,调用加入到队列时 开始计时一旦超时就返回一个超时错误,启用拒绝模式时,当队列长拥塞时,新的调用就会直接响应拥塞错误,不进入队列,这两种模式都能有效防止内存泄漏问题。

作用域未释放导致的内存泄漏

在默认情况情况下,变量在其声明的作用域销毁之前都不会释放,其占用的内存也不会被自动回收,比如我们在全局作用域下声明变量,则其在整个进程退出时都不会被释放,此时分配给它的对象将常驻于老生代内存中,造成内存泄漏。如果我们想提前释放掉它占用的内存,我们可以采取两种方案。一是用delete操作来删除它和对象间的引用关系或是对其重新赋值,让它与旧的对象脱离引用关系。示例代码如下:

1
2
3
4
5
6
global.foo = 'global object';
console.log(global.foo);
// 删除引用关系
delete global.foo;
// 重新赋值
global.foo = undefined;

在V8通过delete删除对象的属性有可能干扰V8优化,所以通过赋值方式解除引用更好。

慎用闭包

闭包是我们在编程时经常使用的方法,它可以让外部用域访问到内部作用域中的变量。利用它可以很巧妙的实现一些功能,比如为一组按钮绑定不同的响应事件等,但闭包的问题在于如果外部变量引用了闭包产生的中间函数,那么在外部变量释放前,中间函数不会释放,进而中间函数中引用的外部变量的作用域也不会释放,这就导致了大量内存被占用无法得到释放,造成内存所以只在必要的时候使用闭包且在使用完后要及时释放掉引用闭包中间函数的变量。

#####内存泄漏排查
前面提及了几种导致内存泄漏的常见类型,在Node中,由于V8的堆内存大小的限制,它对内存泄漏非常敏感。当在线服务请求量过大时,一个字节的内存泄漏也会导致内存占用率过高。
现在有许多工具用于定位Node应用的内存泄漏,下面主要介绍两种常用的工具,node-heapdump和node-memwatch。

node-heapdump

我们先构造一份内存泄漏代码:

1
2
3
4
5
6
7
8
9
10
var leakArray = [];
var leak = function () {
leakArray.push("leak" + Math.random());
}
http.createServer(function (req, res) {
leak();
res.writeHead(200, {'Content-Type': ''});
res.end('Hello World\n');
}).listen(1337);
console.log('Server running at http://127.0.0.1337/');

在上面这段代码中,每次访问服务服务进程都将引起leakArray数组中元素增加,且在进程结束前都得不到回收。然后我们安装好node-heapdump模块,在代码的第一行添加如下代码将其引入:

1
var heapdump = require('heapdump');

引入node-heapdump后,就可以启动服务进程,并接收客户端请求。访问多次之后,leakArray中就会具备大量元素。然后我们
对上面代码进行修改让heapdump能抓取快照:

1
2
3
4
5
6
7
8
http.createServer(function (req, res) {
leak();
heapdump.writeSnapshot(function (err, filename) {
console.log('dump write to', filname)
});
res.writeHead(200, {'Content-Type': ''});
res.end('Hello World\n');
}).listen(1337);

这份快照将会将目录下以heapdump-< sec >.< usec >.heapsnapshot的格式存放。这是一份较大的JSON文件,需要通过Chrome的开发者工具打开查看。具体操作是:先打开Chrome开发者工具,然后选中Profiles面板并点集load选项,将该JSON文件加载出来,就可以查看内存堆中的详细信息了,如下图所示。
Alt text
其中leakArray数组出现的大量leak字符就是未能得到回收的数据。

node-memwacth

node-memwatch比起node-heapdump能够更直观的显示内存的回收情况和是否产生内存泄漏无需我们从大量的数据中进行查询和分析(注:在高版本的node上施一公npm install memwatch-next来按装),其示例代码如下:

1
2
3
4
5
6
7
8
9
var memwatch = require('memwatch-next');
memwatch.on('leak', function (info) {
console.log('leak:');
console.log(info);
});
memwatch.on('stats', function (stats) {
console.log('stats:');
console.log(stats);
});

上面代码中stats在进程每次垃圾回收时触发并把内存统计信息传递回来。某次stats事件返回的数据格式如下:

1
2
3
4
5
6
7
8
9
10
stats: {
num_full_gc: 4 //第几次全堆垃圾回收
num_inc_gc: 23 //第几次增量垃圾回收
heap_compactions: 4 //第几次对老生代进行整理
usage_trend: 0 //使用趋势
estimate_base: 7152944, //预估基数
current_base: 7152944, // 当前基数
min: 6702776, // 最小
max: 7152944 // 最大
}

如果经过了5次垃圾回收后,内存仍然没有释放,这意味着内存泄漏的产生,node-memwatch会发出一个leak事件。leak事件得到的数据格式如下:

1
2
3
4
5
6
7
leak:
{
start: Mon Oct 07 2013 13:46:27 GMT+0800(CST)
end: Mon Oct 07 2013 13:54:40 Gm(CST)
growth: 6222576,
reason: 'heap growth over 5 consecutive GCs (8m 13s) - 43.33 mb/hr'
}

这个数据显示了5次垃圾回收的过程中内存增长了多少。
leak事件的信息只是告诉我们应用中存在内存泄漏,具体问题产生在何处还需要从V8内存堆上定位。node-memwatch提供了抓取快照和比较快照的功能,它能比较堆上对象的名称和分配数量,从而找出导致内存泄漏的元凶。
下面是它比较两次内存快照变化的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
var memwatch = require('memwatch');
var leakArray = [];
var leak = function () {
leakArray.push('leak' + Math.random());
};
// Take first snapshot
var hd = new memwatch.HeapDiff();
for (var i = 0; i < 100000; i++) {
leak();
}
Take second snapshot and compute the diff
var diff = hd.end();
console.log(JSON.stringfy(diff, null, 2));

代码的输出结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
 {
"before": {
"nodes": 29244,
"size_bytes": 4613808,
"size": "4.4 mb"
},
"after": {
"nodes": 227529,
"size_bytes": 14080256,
"size": "13.43 mb"
},
"change": {
"size_bytes": 9466448,
"size": "9.03 mb",
"freed_nodes": 1817,
"allocated_nodes": 200102,
"details": [
{
"what": "Array",
"size_bytes": 1042192,
"size": "1017.77 kb",
"+": 55,
"-": 608
},
{
"what": "ArrayBuffer",
"size_bytes": 64,
"size": "64 bytes",
"+": 1,
"-": 0
},
{
"what": "Code",
"size_bytes": -345744,
"size": "-337.64 kb",
"+": 18,
"-": 332
},
{
"what": "Float64Array",
"size_bytes": 80,
"size": "80 bytes",
"+": 1,
"-": 0
},
{
"what": "Native",
"size_bytes": 512,
"size": "512 bytes",
"+": 1,
"-": 0
},
{
"what": "String",
"size_bytes": 4785256,
"size": "4.56 mb",
"+": 100000,
"-": 202
}
]
}
}

上面的输出结果中,我们通过freed_nodes和allocated_nodes来判断是否发生内存泄漏,它们分别代表释放的节点数量和分配的节点数量,这里由于分配的节点数量远远多于释放的节点数量,所以存在内存泄漏。进一步在details下可以看到每种数据类型的分配和释放节点数量和大小。然后问题主要出现在下面这段输出中:

1
2
3
4
5
6
7
{
"what": "String",
"size_bytes": 4785256,
"size": "4.56 mb",
"+": 100000,
"-": 202
}

上述代码中,加号和减号分别代表分配和释放的字符串对象数量,所以我们可以判断该代码有大量的字符串没有释放。

大文件操作

在node中不可避免的会出现操作大文件的场景,但由于node堆内存的限制,我们无法通过fs.readFile()与fs.writeFile()直接进行大文件操作。对此node提供了fs.createReadStream()和fs.createWriteStream()方通过流的方式实现对大文件的操作。下面代码演示了如何读取一个大文件并将其数据写入到另一个文件的过程:

1
2
3
4
5
6
7
8
var reader = fs.creatReadStream('in.txt');
var writer = fs.createWriteStream('out.txt');
reader.on('data', function(chunk) {
writer.write(chunk);
});
reader.on('end', function () {
writer.end;
});

如果读写内容完全一致,上述方法可以简化为:

1
2
3
var reader = fs.creatReadStream('in.txt');
var writer = fs.createWriteStream('out.txt');
reader.pipe(writer);

可读流提供了管道方法pipe(),封装了data事件和写入操作,通过流的方式,上述代码不会受到V8内存的限制。
当然若不需要进行字符串操作,则可以直接使用Buffer进行文件操作,同样也不会受V8堆内存的限制。

总结

通过学习,我们明白内存在node中并不是可以随心所欲的使用的,存在不完善的缓存机制与内存限制等问题,需要谨慎的每一份内存资源,规避其中的禁忌,才能用node写出完善健壮的应用。