深入浅出nodejs笔记——内存管理

Node整体架构

分析node的内存管理,需要先了解下node的整体架构,node整体由以下几个部分组成:

image

  • Node Standard Library: node标准库,如Http, Buffer 模块
  • Node Bindings: 是沟通JS 和 C++的桥梁,封装V8和Libuv的细节,向上层提供基础API服务
  • V8 是Google开发的JavaScript引擎,提供JavaScript运行环境
  • Libuv 是专门为Node.js开发的一个封装库,提供跨平台的异步I/O能力
  • C-ares:提供了异步处理 DNS 相关的能力
  • http_parser、OpenSSL、zlib 等:提供包括 http 解析、SSL、数据压缩等其他的能力

从上图可以知道,Node的JavaScript虚拟机是V8,所以接下来分析内存相关都是基于V8的内存管理机制。

V8内存结构

image

V8内存限制

由于V8一开始是为JavaScript在浏览器端设计的,不太可能遇到使用大量内存的场景,所以V8设置的内存大小有限。在64位系统和32位系统下分别只能使用约1.4GB和0.7GB的大小。

尽管在服务端操作大内存也不是很常见的场景,但是有了限制之后,一不小心突破限制,就会造成进程退出,因此我们有必要了解下V8是如何进行内存管理的。知晓原理以后,才能避免问题并更好的进行内存管理

JS中的栈内存堆内存

在js引擎中对变量的存储主要有两种位置,堆内存和栈内存。

栈内存

栈内存主要用于存储各种基本类型的变量,包括Boolean、Number、String、Undefined、Null,**以及对象变量的指针。

基本数据类型占用空间小、大小固定,通过按值来访问,属于被频繁使用的数据。

堆内存

除了基本数据类型,Array,Function,Object等引用数据类型都是存储在堆中。

引用数据类型占据空间大、大小不固定。如果存储在栈中,将会影响程序运行的性能

栈内存和堆内存关系

引用数据类型在栈中存储了指针,该指针指向堆中该实体的起始地址。当解释器寻找引用值时,会首先检索其在栈中的地址,取得地址后从堆中获得实体。

image

V8堆内存构成

V8的堆其实并不只是由老生代和新生代两部分构成,可以将堆分为几个不同的区域:

  • 新生代内存区(new space):大多数的对象都会被分配在这里,这个区域很小但是垃圾回收比较频繁;
  • 老生代内存区(old space):属于老生代,这里只保存原始数据对象,这些对象没有指向其他对象的指针;
  • 大对象区(large object space):这里存放体积超越其他区大小的对象,每个对象有自己的内存,垃圾回收其不会移动大对象区;
  • 代码区:代码对象,也就是包含JIT之后指令的对象,会被分配在这里。唯一拥有执行权限的内存区
  • map 区(map space):存放 Cell 和 Map,每个区域都是存放相同大小的元素,结构简单。
     
    image

Node可以通过下面的代码来查看V8的内存信息。

1
2
3
4
5
6
7
process.memoryUsage()
//结果
{
rss: 14948492,
heapTotal: 7195904, //申请到的堆内存大小,如果申请的堆内存不够,将继续申请堆内存直到堆的大小超过V8的限制大小为止
heapUsed: 2821496, //已经使用的堆内存大小
}

V8的垃圾回收机制

栈内存中变量一般在它的当前执行环境结束就会被销毁被垃圾回收制回收, 而堆内存中的变量则不会,因为不确定其他的地方是不是还有一些对它的引用。 堆内存中的变量只有在所有对它的引用都结束的时候才会被回收。

如何确定哪些内存需要回收,哪些内存不需要回收,根据不同的回收策略,有很多不同的回收算法。由于不同对象的生存周期不同,所以无法只用一种回收策略来解决问题。

V8的主要垃圾回收策略是基于分代式垃圾回收算法,将内存分为两个生代:新生代和老生代。新生代的对象为存活时间较短的对象,老生代中的对象为存活时间较长或常驻内存的对象。分别对新生代和老生代使用不同的垃圾回收算法来提升垃圾回收的效率。

image

V8的分代内存

V8的内存大小就是新生代+老生代的内存的大小。在64位系统和32位系统下分别只能使用约1.4GB和0.7GB的大小。

老生代的内存大小限制:64位是1400MB,32位下是700MB。

新生代的内存大小限制:64位是32MB,32位是16MB。

新生代

Scavenge算法

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

Cheney算法将新生代一分为二,分为两个空间。一个处于正在使用的空间,成为From空间,一个处于空闲的空间,成为To空间。
当开始进行垃圾回收时,会检查From空间的存活对象,这些存活对象会被复制到To空间,完成复制以后,From空间和To空间进行交换。

当一个对象经过多次复制依然存活时,它将被认为是生命周期较长的对象。这种对象随后会被移动到老生代中,从新生代移动到老生代成为对象的晋升。

image

在Scavenge过程中,Form空间的存活对象在复制到To空间之前,需要进行检查。检查是否有对象晋升。

image

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

当对象从Form空间复制到To空间时,To空间使用超过25%,则这个对象会直接晋升到老生代中。设置25%限制的原因是因为当这次Scavenge完成以后,这个To空间将变成Form空间,接下来的内存分配将在这个空间进行。如果占用过高,会影响后续的内存分配。

老生代

新生代采用的Scavenge无法适用于老生代,因为老生代中对象比较多,复制存活对象的效率会比较低,另一个明显的问题是会浪费一半的内存空间。

Mark-Sweep & Mark-Compact

老生代中的垃圾回收采用的是Mark-Sweep算法,即标记清除法。

在标记阶段,遍历所有的对象,并标记活着的对象。在清除阶段,只清除没有被标记的对象。

Mark-Sweep的缺点是内存空间会出现不连续的情况。

为了解决Mark-Sweep的内存碎片问题,Mark-Compact被提出。Mark-Compact是标记整理的意思,是在Mark-Sweep的基础上演变而来的,Mark-Compact在标记清除后,将活着的对象往一边移动,移动完成后,直接清理掉边界外的内存。

image

几种算法的比较

image

增量标记

进行垃圾回收的过程,会占用JavaScript的线程,从而将应用逻辑停下来。待垃圾回收结束后再恢复应用逻辑。这种行为称为“全停顿”。

由于老生代的空间较大,对象较多,全堆垃圾回收造成的停顿会比较久,所有V8采用了增量标记的方法来进行改进。

增量标记,也就是每次只做一小步,然后让JavaScript应用逻辑执行一小会儿,垃圾回收与应用逻辑交替执行直到标记阶段完成。

image

内存泄漏

内存泄漏是指应当回收的对象出现意外而没有被回收,变成常驻在老生代中的对象。

造成内存泄漏的原因通常有如下几个

  • 缓存
  • 队列消费不及时
  • 作用域未释放

把内存当做缓存

以下是underscore中memory的实现,属于典型的空间换时间的处理方案。

1
2
3
4
5
6
7
8
_.memory = 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,argumtns))
}
}

这种方式在客户端没有什么影响,但是如果放在服务端,很容易造成内存泄漏。在这个基础上,增加一些缓存限制策略来防止内存泄漏。

缓存限制策略

只缓存一定数量的结果,防止内存无限制增值。下面是一种简单的限制策略

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 = function (key) {
return this.map[key];
};

上面的策略不是特别的高效,如果需要更高效的缓存,可以参考LRU算法:https://github.com/isaacs/node-lru-cache

缓存的解决方案

采用进程外的缓存,进程自身不存储状态。比如Redis和Memcached。

采用进程外的缓存,可以解决以下两个问题:

  • 讲缓存转移到外部,减少常驻内存的对象的数量,让垃圾回收更高效
  • 进程之间可以共享缓存

内存泄漏排查

分析内存泄漏可以借助一些工具,以下是一些常见的工具。

  • v8-profiler。用于对V8堆内存抓取快照和CPU进行分析,但改项目已经3年没有维护了
  • node-heapdump。允许对V8堆内存抓取快照,用于事后分析
  • node-mtrace。使用GCC的mtrace工具来分析堆的使用
  • dtrace。有完善的dtrace工具来分析内存泄漏
  • node-memwatch
  • Memeye 一个轻量级的 NodeJS 进程监控工具
  • Easy-Monitor 2.0

大内存应用

node中操作文件的api有,fs.readFile/fs.writeFile 和 fs.createReadStream/fs.createWriteStream

fs.readFile/fs.writeFile 会把整个文件内容全部读取到内存中,对于小文件没有问题,但是对于大文件,很容易造成内存 “爆仓” 。

fs.createReadStream/fs.createWriteStream 通过流的方式实现对大文件的操作。

1
2
3
4
5
6
7
8
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();
});

由于读写模型固定,上述方法有更加简洁的方式,具体如下:

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

参考: