in node.js gc 内存溢出 垃圾回收 ~ read.

[译]Node.js垃圾回收与内存泄露的排查

原文地址:https://blog.codeship.com/understanding-garbage-collection-in-node-js/

阅读时间: 8 分钟

尽管总有一些Node.js(通常)性能方面的负面报道,这并不是说Node.js比其他技术更容易出现问题。我们需要清楚知道Node.js是怎么玩的。

虽然这项技术的学习曲线相当平坦,但是其内部的实现是相当复杂的,而且你必须要了解怎么规避一些陷阱。 如果一旦出现错误你需要知道怎么快速解决问题。

这篇文章我会提到Node.js的内存管理是怎么回事,怎么能追踪内存泄露问题。和其他平台语言不同(如php), Node.js程序是一种长期运行的线程。虽然这有很多用武之地,比如一次数据库链接之后所有的请求均可复用,但是这也会产生很多问题。

首先我们来搞清楚一些Node.js的基本问题。

Node.js是通过V8引擎控制C++程序的Javascript

Google的V8引擎最开始适用于Chrome浏览器的,但是它也可以独立运行,Node.js能抱大腿就是因为它能独立运行。

V8的编译器编译js成本机代码然后运行,运行过程中V8负责按需要回收,释放内存。所以我们在说内存管理实际上是说V8的内存管理机制。

V8的内存设计

一个程序的运行可以用一些内存分配来表示。这些内存分配我们叫常驻内存,V8的内存结构设计和JVM很相似,它将内存分为如下几部分:

  • 代码块 - 正在执行的实际代码
  • 栈 - 包含所有值类型(原始的类型像整型布尔型)在堆上引用对象的指针,和程序控制流的指针。
  • 堆 - 用于存储引用类型(如对象、字符串和闭包)的内存段。

V8 memory scheme

在Node.js中使用process.memoryUsage() 可以很轻松的查出目前的内存使用状况。这个方法会返回一个包含如下属性的对象:

  • 常驻内存大小
  • 堆的总大小
  • 实际使用的堆的大小

我们用这个方法记录下一段时间内V8引擎实际怎么处理内存的,我们用个图表展示下:

Node.js在一段时间内的内存使用情况

从图得知堆的占用非常不稳定但是都是围绕一个中位数附近变化,分配和释放堆内存的机制称为垃圾回收。

理解垃圾回收 gc

所有的我程序都要使用内存,但需要一个机制来分配或者释放内存,在C和C++中由malloc()free()函数完成,例子如下:

char * buffer;  
buffer = (char*) malloc (42);

// Do something with buffer

free (buffer);  

程序员需要想办法去释放不再使用的内存,如果程序占用内存长时间不释放,那么堆的大小就会猛增可利用内存会枯竭最终导致程序崩溃,我们称以上过程为内存泄露。

上文提到了在Node.js里面javascript会被V8引擎编译成本机代码,生成的本机数据结构和你原始的代码表示层没有太大关系,因为都是V8引擎在处理这些结构。

这就意味着我们不能灵活的在javascript层面产生或者销毁内存。V8使用一种称为垃圾回收的机制来解决这个问题。

这种机制原理十分简单就是当一个语句在任何地方都不被引用的时候,我们就假定它是可被回收的。然而检索和维护这个是非常复杂的逻辑,比如会有一个引用和间接引用的关系,这是一个很复杂的图结构。

一个堆的图示,只有当没有别的更多引用时红圈才能被回收

垃圾回收是一个很费资源的操作因为它会中断程序执行,自然就会影响gc的性能,V8提供了两套不同的gc方案来解决这个问题:

  • Scavenge : 非常快但是不彻底
  • Mark-Sweep: 相对来说比较慢但是能释放所有不再被引用的内存

一个包含V8中垃圾收集的深入信息的优秀博客文章,请点击这里

通过分析由process.memoryUsage()函数产生的数据,很容易分辨出两种垃圾回收的不同,锯齿形的图是Scavenge, 波动大的是Mark-Sweep。

通过一个原生模块node-gc-profiler 我们可以得到更多这两种gc是怎么耍的信息,这个模块监听了由V8暴露给javascript的gc事件。返回的对象表示垃圾回收的类型和持续时间。

再来张图表示下吧:

不同gc的持续时间和触发频率

从图得知 Scavenge Compact(密集清除算法)比Mark-Sweep(标记清除算法)有更高的触发频率。由于应用程序的复杂性不同,其持续时间将有所不同。有趣的是有时候Mark-Sweep 有时也有更频繁,更短时间的表现,我不能确定是不是因为我程序的原因。

悲剧发生了

如果gc都那么牛逼了为什么你还要担心呢?实际上你还是会是不是看到你的日志里出现内存泄露字样。 内存泄露的异常 利用我们之前使用的图表也能看到内存飙升! 内存泄露

gc尽最大努力去释放内存但是在其运行之后还是有内存飙升情况发生,这是一个明显的内存泄露信号。

这些现象是我们研究异常检测很好的开端,我们在做排查之前先来试着制造一个内存泄露的程序。

写个内存泄露程序

有些内存泄露是很明显的比如把访客ip存到一个全局变量中。但是有一些内存泄露比如著名的沃尔玛内存泄露就是有一个存在于Node.js核心代码中的非常小的语句,花了好几周时间才追踪到。 核心代码里面的错误我就不复述了,我们来看下一个非常不容易排查的自己写的javascript代码块,这些代码来自 Meteor’s blog

乍一看这个貌似没问题,我们认为theThing会在调用replaceThing()方法时候重写。问题在于someMethod有一个闭包作为上下文,这说明在unused()方法内部有一个someMethod()方法存在而且unused()方法永远不会被调用到,这样会阻止gc回收这个originalThing。 这些代码很具有欺骗性。这也许不是你代码里面的bug但是它会导致内存泄露并且很难排查。

有没有什么好的方法去堆里面看看目前的内存状况,我们当然可以 666!V8引擎提供了打印当前堆的方法,并且你可以很轻松的在javascript里卖弄使用。

/**
 * Simple userland heapdump generator using v8-profiler
 * Usage: require('[path_to]/HeapDump').init('datadir')
 *
 * @module HeapDump
 * @type {exports}
 */

var fs = require('fs');  
var profiler = require('v8-profiler');  
var _datadir = null;  
var nextMBThreshold = 0;


/**
 * Init and scheule heap dump runs
 *
 * @param datadir Folder to save the data to
 */
module.exports.init = function (datadir) {  
    _datadir = datadir;
    setInterval(tickHeapDump, 500);
};

/**
 * Schedule a heapdump by the end of next tick
 */
function tickHeapDump() {  
    setImmediate(function () {
        heapDump();
    });
}

/**
 * Creates a heap dump if the currently memory threshold is exceeded
 */
function heapDump() {  
    var memMB = process.memoryUsage().rss / 1048576;

    console.log(memMB + '>' + nextMBThreshold);

    if (memMB > nextMBThreshold) {
        console.log('Current memory usage: %j', process.memoryUsage());
        nextMBThreshold += 50;
        var snap = profiler.takeSnapshot('profile');
        saveHeapSnapshot(snap, _datadir);
    }
}

/**
 * Saves a given snapshot
 *
 * @param snapshot Snapshot object
 * @param datadir Location to save to
 */
function saveHeapSnapshot(snapshot, datadir) {  
    var buffer = '';
    var stamp = Date.now();
    snapshot.serialize(
        function iterator(data, length) {
            buffer += data;
        }, function complete() {

            var name = stamp + '.heapsnapshot';
            fs.writeFile(datadir + '/' + name , buffer, function () {
                console.log('Heap snapshot written to ' + name);
            });
        }
    );
}

这个简单的模块提供的堆的打印文件能观察到内存飙升,当然有更复杂的方式来探测异常,但是杀鸡焉用牛刀?

如果出现了内存泄露你应该中断程序并且会的到一个有标志文件文件名的文件,你应该密切监控这些模块并加入报警功能。这些相同的打印堆的功能在chrome的Chrome developer中也有提供,名字是V8-profiler 一个堆打印快照很难让你看出问题所以Chrome提供了多个快照比对功能。通过比较两次快照我们发现了一些变量所占内存在不断增长。如下图所示: 问题来了,一个叫longStr的变量包含了很多由星号组成的字符串,而且被originalThing引用,还被各种各样的方法引用,好吧,这个是个超级长的引用网,闭包上下文阻止了longStr变量被回收。

虽然这个结果是显而易见的但是其他问题处理过程也类似:

1.创建不同时间点的堆快照,正常运行和失败时都需要
2. 比较堆快照看看是那个变量只涨不消

总结

正如我们所见gc是个非常复杂的过程,即使有效的代码有可以导致内存泄露。通过使用V8提供的便捷方法加上Chrome developer tools 有可能帮我们排查到内存泄露。如果将这些功能集成到你的程序里,在问题发生时就能帮我们及时排查。

但是还是有问题啊?怎么修复这个问题呢? 答案很简单-- 加一个theThing=null;在这个方法的尾部,就会释放掉内存。

另外 多谢@paladin-t 对的翻译的指正。