nodejs深入学(6)内存控制

前言

因为node绝大多数时间都是运行在后端的服务器程序,因此,需要精确控制内存。在以前,js程序员不需要控制内存的原因是因为他们操作的都是短时间执行的场景,如网页或者命令行工具,这类场景下,都是运行在用户机器上,并且,一定出现内存问题,用户自己直接关机重启就好了,根本不会出现严重问题,另外,因为运行时间短,因此,内存在运行完成后,自动释放,根本就没有内存管理的必要。但是在服务器程序的开发中,只有控制好内存,才能满足海量请求和长时间运行等业务的需求,让一切资源都被高效的循环利用起来。

在第三章中,朴灵差不多已经介绍完了如何使用node最大限度的利用cpu和IO这两个服务器资源,而本章将介绍在node中如何合理高效的使用内存。

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

V8就是Chrome的js执行引擎,他的作者以前是写HotSpot的,因此,V8可以算的上是青出于蓝而胜于蓝了。因此,node会随着V8的升级而升级,比如支持最新的es6、es7语法等等。

既然V8的作者以前是搞java虚拟机开发的,因此,node和java一样,它也基于垃圾回收机制进行内存的自动管理。这样,我们就不必像c/c++程序员那样去关注内存的分配和释放了。(node就是基于v8、事件驱动、非阻塞IO模型设计出来的)

这种机制,在浏览器环境下,几乎是完美的,但是同java一样,在后端运行的node,如果想要更完美的运行,依然需要判断和管理内存,内存管理的好坏、垃圾回收状况是否优良,都会直接影响服务器的性能。

V8的内存限制

一般的后台开发语言中,内存使用的大小几乎没有限制。但是,V8最初是为浏览器打造的,在V8下,64位系统可以操纵1.4GB内存,32位系统可以操纵0.7GB内存。在这样的限制下,node几乎不能直接操纵大内存。

V8的对象分配

在V8中所有的js对象都是通过来进行分配的,可以使用node提供的V8内存使用量的查看方式查看内存分配及使用状况:

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

这三个属性分别是:

  1. rss:resident set size,进程的常驻内存

2.heapTotal: 已经申请到的堆内存。

3.heapUsed: 当前堆内存使用量。

v8堆示意图

当我们在代码中,声明变量,并赋值时,所使用的内存就分配在堆中。如果已经使用的堆空闲内存不够分配新的对象,将继续申请堆内存,直到堆的大小超过v8的限制为止。另外,官方文档说明,v8回收垃圾的速度还是不够快的,以1.5G垃圾回收队内存为例,v8做一次小垃圾回收需要50毫秒以上,做一次非增量的垃圾回收甚至要1秒以上。

当然,这个限制也可以通过v8提供的选项取出。我们可以通过在node启动时增加参数,来去掉限制

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

在此,我们看到了old-space和new-space这两个概念,因此,接下来,我们会详细的讲解这两个概念

v8的垃圾回收机制

v8主要的垃圾回收算法

v8的垃圾回收策略主要基于分代式垃圾回收机制。因为,没有一种垃圾回收算法,能够胜任所以场景,因此,只能针对特定情况作出最优的算法。因此,使用分代式垃圾回收机制,按照对象的存活时间将内存的垃圾回收进行不同的分代,然后,分别对不同的分代的内存再进行高效的垃圾回收算法。

v8内存的分代

刚才通过那两条命令,我们已经知道了node的内存分为old-space和new-space,我们姑且称之为老内存和新内存。

新内存中的对象存活时间短,老内存中的对象存活时间长,甚至一些常驻内存对象,也在老内存中。

v8的分代示意图

v8源码中,我们可以看到这个说明,在代码Page::kPageSize下:

// semispace_size_ should be a power of 2 and old_generation_size_ should be
// a multiple of Page::kPageSize
#if defined(V8_TARGET_ARCH_X64)
#define LUMP_OF_MEMORY(2 * MB)
code_range_size_(512 * MB),
#else
#define LUMP_OF_MEMORY MB
code_range_size_(0),
#endif
#if defined(ANDROID)
reserved_semispace_size_(4 * Max(LUMP_OF_MEMORY, Page:: kPageSize)),
        max_semispace_size_(4 * Max(LUMP_OF_MEMORY, Page:: kPageSize)),
        initial_semispace_size_(Page:: kPageSize),
        max_old_generation_size_(192 * MB),
        max_executable_size_(max_old_generation_size_),
#else
reserved_semispace_size_(8 * Max(LUMP_OF_MEMORY, Page:: kPageSize)),
    max_semispace_size_(8 * Max(LUMP_OF_MEMORY, Page:: kPageSize)),
    initial_semispace_size_(Page:: kPageSize),
    max_old_generation_size_(700ul * LUMP_OF_MEMORY),
    max_executable_size_(256l * LUMP_OF_MEMORY),
#endif

这个说明不仅限制了v8在不同操作系统下所能使用内存的上限,也展示了不同的内存结构。

对于新内存,它由两个reserved_semispace_size_所构成,所以新内存在64位系统上是16mb+16mb,在32位系统上是8mb+8mb,为啥有两个,后续会说明。

v8堆内存的最大保留空间可以从这个代码中看出,其公式为:4*reserved_semispace_size_+max_old_generation_size_:

// Returns the maximum amount of memory reserved for the heap. For
// the young generation, we reserve 4 times the amount needed for a
// semi space. The young generation consists of two semi spaces and
// we reserve twice the amount needed for those in order to ensure
// that new space can be aligned to its size
intptr_t MaxReserved() {
return 4 * reserved_semispace_size_ + max_old_generation_size_;
}

因此,默认情况下,64位系统v8的内存限制是1464mb,32位是732mb

scavenge算法

新内存主要通过scavenge算法进行垃圾回收,其主要是采用cheney算法进行具体处理。

cheney算法

cheney算法采用复制的方式实现垃圾回收,它将堆内存一分为二,每一部分空间都被称为semispace(半空间)

这两个semispace只有一个用于使用,另外一个处于闲置状态。处于使用状态的被称为from空间,处于闲置状态的被称为to空间。当分配内存时,会先在from空间进行分配,当开始进行垃圾回收时,会检查from中的存活对象,这些存活对象将被复制到to空间中,而非存活对象占用的空间将被释放。完成复制后,from和to进行对调,换言之,在垃圾回收过程中,就是通过将存活对象在两个semispace中来回复制实现的。(这个过程叫做翻转)

scavenge的缺点是通过空间换时间,也就是说,在堆的新内存中,只有一半内存可以使用,因为,scavenge只复制活的对象,且生命周期短的对象,只占少部分,因此,他在时间效率上具有优异的表现。

v8的分代示意图

当一个对象呗多次复制后,仍然存活,那么他就被认为是生命周期比较长的对象,之后,他会被移动到老内存中。这个过程叫做晋升。对象晋升的条件主要有两个,一个是对象是否经历过scavenge回收(通过检查内存地址来判断),另外一个是to空间的内存占用比是否超过限制。

v8的对象分配主要集中于from空间,晋升也是讲from空间的活对象转移到老内存中。

晋升示意图

另外,如果此时,to空间内存占比超过25%,则这个对象,就不会被复制到to中,直接进入到老内存里。(25%这个设置是为了让翻转后的to在变成from后,还有足够的空间可以使用)

晋升示意图

老内存垃圾回收算法Mark-Sweep & Mark-Compact

老内存中,大多是不死的老对象,用scavenge算法又费力,又占用空间,因此,采用了新的内存垃圾回收算法:Mark-Sweep & Mark-Compact。

Mark-Sweep 标记清扫

mark-sweep分为标记和清除两个阶段,mark阶段会遍历堆,然后标记处活着的对象,sweep阶段会清除没有被标记的对象。与scavenge算法相比,scavenge只复制活着的对象,在新内存中,活着的对象占比较少,mark-sweep只清理没有标记的对象,在老内存中,死了的对象占比较少,这也是这个算法高效的原因。

mark-sweep

mark-sweep的问题在于,每次sweep后,会存在内存碎片,这些不连续的内存碎片会占有大量空间,因此,下一次复制大对象时,将会发现空间不够,因而再次出发垃圾回收,这个回收时不必要的,也浪费了cpu的时钟。

为了解决这个问题,增加了mark-compact算法。

mark-compact 标记整理和压缩

mark-compact在整理过程中,将活着的对象往一端移动,移动完成后,直接将另外一端的内存清理掉。

mark-compact

我们看一下这三种算法的比较

三种算法的比较

因为,mark-compact需要移动内存,因此,垃圾回收主要使用mark-sweep,在内存不够时,才会触发一次mark-compact

Incremental Marking

因为,为了避免js应用逻辑与垃圾回收器看到的不一致情况,三种算法都需要将应用逻辑暂停,待垃圾回收完毕后,再恢复执行应用逻辑,这就是stop-the-world机制。为了降低stop-the-world机制造成的影响,v8采用增量的垃圾标记方法,也就是将一次垃圾回收分为几个步骤。垃圾清理一会,停顿一会,逻辑应用执行一会。

incremental marking

v8后续还引入了lazy sweeping与incremental compaction,同时还引入了,并行标记和并行清理,进一步的利用多核性能降低每次停顿的时间。

此处记录一个书中所说的内存溢出的情况,以web服务器的会话实现为例,一般通过内存来存储,但在访问量大的时候,会导致老内存中存活对象骤增的情况发生,不仅造成sweep/compact过程时间增长,还会造成内存紧张,甚至内存溢出,这部分,会在第8章中详细说明。当然,最还还是引入缓存机制来处理。

查看垃圾回收日志

通过命令node --trace_gc -e "var a = [];for (var i = 0; i < 1000000; i++) a.push(new Array(100));" > gc.log 来查看垃圾回收日志

[2489] 19 ms: Scavenge 1.9 (34.0) -> 1.8 (35.0) MB, 1 ms [Runtime::PerformGC].
...
[2489] 36 ms: Mark-sweep 9.1 (40.0) -> 9.0 (44.0) MB, 10 ms [Runtime::PerformGC] [promotion limit
reached].
...
[2489] Limited new space size due to high promotion rate: 1 MB
...
[2489] Increasing marking speed to 3 due to high promotion rate
...
[2489] 107 ms: Mark-sweep 38.4 (73.0) -> 38.0 (74.0) MB, 3 ms (+ 23 ms in 63 steps since start
of marking, biggest step 0.284180 ms) [Runtime::PerformGC] [promotion limit reached].
...
[2489] 188 ms: Mark-sweep 63.8 (100.0) -> 63.4 (100.0) MB, 45 ms [Runtime::PerformGC] [GC in old
space requested].
...
[2489] 395 ms: Scavenge 182.9 (220.3) -> 182.9 (221.3) MB, 1 ms (+ 2 ms in 7 steps since last GC)
[Runtime::PerformGC] [incremental marking delaying mark-sweep].
..

通过分析垃圾回收日志,可以了解垃圾回收的运行情况,找出垃圾回收的哪些阶段比较耗时,触发的原因是什么,进而找到系统性能的瓶颈。

另外,还可以在启动时增加--prof参数,来的到v8执行时的性能分析数据,其中包含了垃圾回收执行时占用的时间等。下面这个代码,不断的创建对象,并将其分配给局部变量a

for (var i = 0; i < 1000000; i++) {
var a = {};
}

然后执行命令:

$ node --prof test01.js

这将会在目录下得到一个v8.log日志文件,内容如下:

code-creation,LazyCompile,0x1dd61958ec00,396,"
/Users/jacksontian/git/diveintonode/examples/05/test01.js:1",0x38c53b008370,~
tick,0x10031daaa,0x7fff5fbfe4c0,0,0x34bb,2,0x1dd61958eb3e,0x1dd6195688bf,0x1dd6195689e5,0x1dd61956
7599,0x1dd619566efc,0x1dd619568e4b,0x1dd61952e78a
code-creation,LazyCompile,0x1dd61958eda0,532,"
/Users/jacksontian/git/diveintonode/examples/05/test01.js:1",0x38c53b008370,*
tick,0x1dd61958eecd,0x7fff5fbff3b8,0,0x16e3f,0,0x1dd6195688bf,0x1dd6195689e5,0x1dd619567599,0x1dd6
19566efc,0x1dd619568e4b,0x1dd61952e78a
tick,0x1dd61958ee55,0x7fff5fbff3b8,0,0x5082a,0,0x1dd6195688bf,0x1dd6195689e5,0x1dd619567599,0x1dd6
19566efc,0x1dd619568e4b,0x1dd61952e78a
tick,0x1dd61958ee77,0x7fff5fbff3b8,0,0x8c593,0,0x1dd6195688bf,0x1dd6195689e5,0x1dd619567599,0x1dd6
19566efc,0x1dd619568e4b,0x1dd61952e78a
tick,0x1dd61958ee71,0x7fff5fbff3b8,0,0xc8717,0,0x1dd6195688bf,0x1dd6195689e5,0x1dd619567599,0x1dd6
19566efc,0x1dd619568e4b,0x1dd61952e78a
code-creation,StoreIC,0x1dd61958efc0,185,"loaded"

这个日志,可以通过v8提供的工具,来读取,这个工具在node源码deps/v8/tools/linux-tick-processor,win下使用windows-tick-processor.bat,即可调用:$ linux-tick-processor v8.log,分析结果为:

Statistical profiling result from v8.log, (37 ticks, 1 unaccounted, 0 excluded).
[Unknown]:
ticks total nonlib name
1 2.7 %
    [Shared libraries]:
ticks total nonlib name
28 75.7 0.0 / usr / local / bin / node % %
    2 5.4 0.0 / usr / lib / system / libsystem_kernel.dylib % %
        2 5.4 0.0 % % /usr/lib / system / libsystem_c.dylib
        [JavaScript]:
ticks total nonlib name
3 8.1 60.0 LazyCompile: * <anonymous> % %
/Users/jacksontian/git/diveintonode/examples/05/test01.js:1
1 2.7 20.0 Stub: FastCloneShallowObjectStub % %
1 2.7 20.0 Function: ~NativeModule.compile node.js:613 % %
[C++]:
ticks total nonlib name
[GC]:
ticks total nonlib name
2 5.4
[Bottom up (heavy) profile]:
Note: percentage shows a share of a particular caller in the total
amount of its parent calls.
Callers occupying less than 2.0 are not shown. %
ticks parent name
28 75.7 /usr/local/bin/node %
...

统计结果较多,其中垃圾回收部分为:

[GC]:
ticks total nonlib name
2 5.4

由于不断分配对象,垃圾回收所占用的时间为5.4%,按照一次循环1毫秒来计算,这个过程,消耗了54毫秒。

高效使用内存

既然了解了node的垃圾回收机制,那么接下来就是编程人员如何结合垃圾回收机制,更高效的工作了。

作用域

在js中能够形成作用域的有函数调用、with、全局作用域,另外,es6也出现了块级作用域,这个当做函数作用域来考虑。

以函数作用域为例

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

foo函数,在每次被调用时会创建对应的作用域,函数执行结束后,该作用域将会销毁。同时,作用域上分配的局部变量所分配的内存也会一并销毁。也就是说,只被局部变量引用的对象,存活周期较短。这个就是基本的内存回收过程。

标识符查找

在下面执行的代码中,会遇到一个标识符查找

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

js会先查找当前作用域,如果没有,则会向上级查找,直到查到为止。

作用域链

这个查找过程,就是一个作用域链的查找过程,我们看一个复杂的代码:

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

local变量在baz()函数形成的作用域中查找不到,会到bar形成的作用域中寻找,以此类推,逐渐向上寻找,一直查到全局作用域。由于标识符查找的方向是自内而外的,也就是向上的,因此,变量只能向外访问,不能向内访问。我们看一个示意图:

变量在作用域中的查找示意图

在这个例子中,由于在bar中有了local变量,因此,就会停止查找,不会去继续查找foo中的local变量了。在了解了作用域后,有助于我们了解变量的分配和释放。

变量的主动释放

如果变量是全局变量,那么,全局作用域要等进程全部退出才会释放,此时将会导致引用的对象常驻内存。如果需要释放常驻内存的对象,可以使用delete来删除,或者将变量重新赋值让旧的对象脱离引用关系。我们来看一下主动清除和整理老内存的一段代码:

global.foo = "I am global object";
console.log(global.foo); // => "I am global object"
delete global.foo;
// 或者重新赋值
global.foo = undefined; // or null
console.log(global.foo); // => undefined

其他的变量主动释放都可以用这个方法,同时由于delete会干扰v8的优化,因此,采用赋空值的方式,比较稳妥。

闭包

因为作用域链的原因,变量只能从内向外查找,不能从外向内查找,因此,下边的代码会存在问题:

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

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

在js中实现外部作用域访问内部作用域的方法叫做闭包(closure),这个也是高阶函数的作用,让参数或者返回值是一个函数。我们来看看闭包的实现:

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

闭包是通过中间函数进行间接访问内部变量实现的一个功能,一旦变量引用这个中间函数,这个中间函数将不会释放,同时也会使原始的作用域不会得到释放,作用域中产生的内存占用也不会得到释放。除非不再有引用,才会逐步释放。

因此,可以利用闭包和垃圾回收的机制,来存储一些需要存活时间长一些的对象,并将其作为公共访问的数据区域来使用。但是,闭包和全局变量的使用还是要小心,由于无法及时回收内存,这会增加常驻内存的产生。

内存指标

一般而言,应用中,肯定是需要存在全局变量和闭包的,并且垃圾回收机制正常的话,也会将这部分内存进行计算的mark-sweep-compact,并释放。

但是,也会存在一些我们认为回收了,但是没有被回收的对象,这会导致内存不限制的增长。一旦增长到了v8的限制,就会造成内存溢出,进而导致进程退出。

查看内存使用情况

使用node的process.memoryUsage()、os模块的totalmem()、os模块的freemen()查看内存使用情况

查看进程的内存占用

使用process.memoryUsage()查看

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

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

除了rss外,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();


执行结果如下:

$ node outofmemory.js
Process: heapTotal 3.86 MB heapUsed 2.10 MB rss 11.16 MB
----------------------------------------------------------------
Process: heapTotal 357.88 MB heapUsed 353.95 MB rss 365.44 MB
----------------------------------------------------------------
Process: heapTotal 520.88 MB heapUsed 513.94 MB rss 526.30 MB
----------------------------------------------------------------
Process: heapTotal 679.91 MB heapUsed 673.86 MB rss 686.14 MB
----------------------------------------------------------------
Process: heapTotal 839.93 MB heapUsed 833.86 MB rss 846.16 MB
----------------------------------------------------------------
Process: heapTotal 999.94 MB heapUsed 993.86 MB rss 1006.93 MB
----------------------------------------------------------------
Process: heapTotal 1159.96 MB heapUsed 1153.86 MB rss 1166.95 MB
----------------------------------------------------------------
Process: heapTotal 1367.99 MB heapUsed 1361.86 MB rss 1375.00 MB
----------------------------------------------------------------
FATAL ERROR: CALL_AND_RETRY_2 Allocation failed - process out of memory

可以看到,每次调用useMem都导致了3个值得增长,最后内存溢出。

查看系统的内存占用

使用os.totalMem()和os.freeMem()来查看系统的内存使用。

$ node
> os.totalmem()
8589934592
> os.freemem()
4527833088
>

堆外内存

因为,堆内内存经过v8的分配是有大小限制的,因此,可以不通过v8分配内存,这样的内存被称为堆外内存。使用堆外内存将会引入buffer模块,这个我们将在下一章中重点讲解。

我们对之前的代码进行改造,将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;
};

重新执行:

$ node out_of_heap.js
Process: heapTotal 3.86 MB heapUsed 2.07 MB rss 11.12 MB
----------------------------------------------------------------
Process: heapTotal 5.85 MB heapUsed 1.94 MB rss 212.88 MB
----------------------------------------------------------------
Process: heapTotal 5.85 MB heapUsed 1.95 MB rss 412.89 MB
----------------------------------------------------------------
Process: heapTotal 5.85 MB heapUsed 1.95 MB rss 612.89 MB
----------------------------------------------------------------
Process: heapTotal 5.85 MB heapUsed 1.92 MB rss 812.89 MB
----------------------------------------------------------------
Process: heapTotal 5.85 MB heapUsed 1.92 MB rss 1012.89 MB
----------------------------------------------------------------
Process: heapTotal 5.85 MB heapUsed 1.84 MB rss 1212.91 MB
----------------------------------------------------------------
Process: heapTotal 5.85 MB heapUsed 1.84 MB rss 1412.91 MB
----------------------------------------------------------------
Process: heapTotal 5.85 MB heapUsed 1.84 MB rss 1612.91 MB
----------------------------------------------------------------
Process: heapTotal 5.85 MB heapUsed 1.84 MB rss 1812.91 MB
----------------------------------------------------------------
Process: heapTotal 5.85 MB heapUsed 1.84 MB rss 2012.91 MB
----------------------------------------------------------------
Process: heapTotal 5.85 MB heapUsed 1.84 MB rss 2212.91 MB
----------------------------------------------------------------
Process: heapTotal 5.85 MB heapUsed 1.84 MB rss 2412.91 MB
----------------------------------------------------------------
Process: heapTotal 5.85 MB heapUsed 1.85 MB rss 2612.91 MB
----------------------------------------------------------------
Process: heapTotal 5.85 MB heapUsed 1.85 MB rss 2812.91 MB
----------------------------------------------------------------
Process: heapTotal 5.85 MB heapUsed 1.85 MB rss 3012.91 MB
----------------------------------------------------------------

我们看到,15次循环都执行了,并且,heapTotal和heapUsed的内存的使用量一直没有改变,或者变化极小。唯一改变的是rss,并且rss已经远远超过的v8的限制。这就是因为使用了buffer对象的缘故,buffer对象不经过v8内存分配,因此,也不会有堆内存的大小限制。

内存泄漏

node对内存泄漏是否敏感,一旦线上应用有成千上万的流量,哪怕是一个字节的内存泄漏也会造成堆积,垃圾回收过程中,将会耗费更多时间进行对象扫描,应用响应缓慢,直到进程内存溢出,应用崩溃。在v8的垃圾回收机制下,在通常的代码编写中,很少会出现内存泄漏的情况,但是内存泄漏发生后,会很难排查,内存泄漏的实质就是应当回收的对象因为意外没有被回收,变成了常驻内存中的对象。

那么一般情况下,造成内存泄漏的主要原因有,滥用内存为缓存、队列消费不及时、作用域未释放等

慎将内存当做缓存

缓存十分节省资源,因为他的访问比IO效率要高,一旦命中缓存,就可以节省一次IO时间。但是,在使用node的过程中,很多程序员因为是前端转过来的,因此,很容滥用内存为缓存,这个虽然用法是一样的,但是本质上有区别,v8内存是通过垃圾回收进行处理的,没有过期策略,而真正的缓存是存在过期策略的。

我们来看下边的例子:

var cache = {};
var get = function (key) {
if (cache[key]) {
return cache[key];
} else {
// get from otherwise
}
};
var set = function (key, value) {
cache[key] = value;
};

因为,缓存中的对象是一个常驻内存对象,因此,大量使用会造成内存泄漏。如果必须这样使用,我们需要对内存型缓存的对象大小进行限制,同时,自己实现过期策略,防止内存的不限制增长。

我们来看书中的一个可能无意识造成的内存泄漏的场景:memoize,下面是著名的类库underscore对象memoize的实现:

_.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执行时间。这些被创建出来的对象,都会进入老内存,成为常驻对象,最终,有可能发生内存泄漏。

限制缓存策略

作者给了一个解决方案,他写了一个limitablemap的模块

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];
};
module.exports = LimitableMap;

将结果记录在数组中,一旦超过数量,就以先进先出的方式进行淘汰。如果需要更高效的缓存,可以参与LRU算法,或者看看这个文章https://github.com/isaacs/node-lru-cache

另外一个原因,是因为模块的加载,模块加载后成为了常驻对象,例如下边的代码

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

因此,每次调用时都会造成内存增长

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

这样设计的模块,请提供内存清理的api,来手动清理内存。

缓存的解决方案

进程间是无法共享内存的,因此,使用内存作为缓存不是一个好的解决方案。最好的解决方案是使用外部缓存,例如redis等。这些缓存可以将缓存的压力从内存转移到进程的外部,减少常驻内存的对象数量,让垃圾回收更有效率,同时,还可以实现进程间共享缓存,节约宝贵的资源。

关注队列状态

因为一般情况下,消费的速度要远远高于生产的速度,因此,不容易产生内存泄漏,不过一旦发生内存泄漏,将会造成内存堆积。

例如,日志写入数据库的这种情况,因为数据库写入速度低于日志的生产速度,造成了数据库写入请求的堆积,进而造成内存溢出。

解决这个问题是很难的,因此,应该通过监控来控制队列的长度,一旦产生堆积,应当通过监控系统报警,同时,设置合理的超时机制,一旦超时,就报错并预警。(这也是bagpipe为什么有超时模式和拒绝模式的原因)

内存泄漏排查

我们可以使用工具进行排查,我们看看如下工具:

工具 说明
v8-profiler 可以对v8堆内存抓取快照,并对cpu进行分析
node-heapdump 可以对v8堆内存抓取快照,用于事后分析
node-mtrace 使用gcc的mtrace工具来分析堆的使用
dtrace 在smartos上使用的内存分析工具
node-memwatch 采用wtfpl许可发布的内存分析工具

我们重点介绍node-heapdump和node-memwatch

node-heapdump

我们先安装

$ npm install heapdump

然后写代码

var heapdump = require('heapdump');
var leakArray = [];
var leak = function () {
    leakArray.push("leak" + Math.random());
};
http.createServer(function (req, res) {
    leak();
    res.writeHead(200, { 'Content-Type': 'text/plain' });
    res.end('Hello World\n');
}).listen(1337);
console.log('Server running at http://127.0.0.1:1337/')

然后,我们启动服务,并在一段时间后,发生指令$ kill -USR2 <pid>,让node-heapdump抓取堆内存快照

这份快照将会在文件目录下以heapdump-<sec>.<usec>.heapsnapshot的格式保存,这是一个较大的json文件,可以用chrome的开发者工具打开。


查看堆内存

node-memwatch

与node-heapdump使用类似,安装后,准备存在内存泄漏的代码,然后执行命令

var memwatch = require('memwatch');
memwatch.on('leak', function (info) {
    console.log('leak:');
    console.log(info);
});
memwatch.on('stats', function (stats) {
    console.log('stats:')
    console.log(stats);
});

var http = require('http');
var leakArray = [];
var leak = function () {
    leakArray.push("leak" + Math.random());
};
http.createServer(function (req, res) {
    leak();
    res.writeHead(200, { 'Content-Type': 'text/plain' });
    res.end('Hello World\n');
}).listen(1337);
console.log('Server running at http://127.0.0.1:1337/');

stats事件

在启动了node-memwatch后,每次进行去全堆垃圾回收时,都会触发stats事件。这个事件将会统计内存使用信息:

stats:
{ num_full_gc: 4, //第几次全堆垃圾回收
num_inc_gc: 23, // 第几次曾量垃圾回收
heap_compactions: 4, // 第几次对于老内存进行整理
usage_trend: 0, // 使用趋势
estimated_base: 7152944, // 预估基数
current_base: 7152944, // 当前基数
min: 6720776, // 最大
max: 7152944 } // 最小

leak事件

如果连续5次垃圾回收后,内存仍然没有被释放,着意味着将会发生内存泄漏,此时会发出一个leak事件。

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

堆内存比较

我们先来执行一段代码,并输出结果

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 < 10000; i++) {
    leak();
}
// Take the second snapshot and compute the diff
var diff = hd.end();
console.log(JSON.stringify(diff, null, 2));

//输出结果

$ node diff.js
{
    "before": {
        "nodes": 11719,
            "time": "2013-10-07T06:32:07.000Z",
                "size_bytes": 1493304,
                    "size": "1.42 mb"
    },
    "after": {
        "nodes": 31618,
            "time": "2013-10-07T06:32:07.000Z",
                "size_bytes": 2684864,
                    "size": "2.56 mb"
    },
    "change": {
        "size_bytes": 1191560,
            "size": "1.14 mb",
                "freed_nodes": 129,
                    "allocated_nodes": 20028,
                        "details": [
                            {
                                "what": "Array",
                                "size_bytes": 323720,
                                "size": "316.13 kb",
                                "+": 15,
                                "-": 65
                            },
                            {
                                "what": "Code",
                                "size_bytes": -10944,
                                "size": "-10.69 kb",
                                "+": 8,
                                "-": 28
                            },
                            {
                                "what": "String",
                                "size_bytes": 879424,
                                "size": "858.81 kb",
                                "+": 20001,
                                "-": 1
                            }
                        ]
    }
}

我们要关注change节点下的freed_nodes(释放节点数)和allocated_nodes(分配节点数)

大内存应用

使用流的方式操作大内存,也就是使用stream模块。这个模块继承了eventemitter,并且,node中大多数模块都有stream应用,例如fs的createReadStream()和createWriteStream(),process模块的stdin和stdout。因此,我们不要用fs.readFile()和fs.writeFile()直接读取大文件,而要使用fs的createReadStream()和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();
});

//或者

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

因为流使用了buffer作为读写的编码方式,因此,不受v8内存的限制。但是,要当心物理内存的限制。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,290评论 4 363
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,399评论 1 294
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,021评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,034评论 0 207
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,412评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,651评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,902评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,605评论 0 199
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,339评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,586评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,076评论 1 261
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,400评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,060评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,083评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,851评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,685评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,595评论 2 270