剑客
关注科技互联网

实用小指南——教你如何在 Node.js 中发现 JavaScript 内存漏洞

介绍

几月前,我不得不调式 Node.js程序中的 内存泄漏,对此找到了很多此类的文章,但细读了一些后,还是不知所措。

这篇文章的初衷是 定位Node.js中内存泄漏的 一个简单的教程。我将介绍一简单的方法入门,(以我来看)这应该是任何内存泄漏调试的起点。对于一些情况,这方法或许并不详尽。我将附上一些你想要了解的 资源 链接。

最小化理论

JavaScript是一个垃圾回收的语言。因此,所有Node.js进程使用的内存将由V8 JavaScript引擎自动分配、释放。

V8如何知道何时释放内存?V8维护着一个的图,这个图始于根节点,记录 程序中所有变量 。JavaScript有4种数据类型:Boolean, String, Number及Object。前3个是简单类型,仅由分配的变量保存(如string文本)。Object及其其它任何数据在JavaScript中皆为Object(如数组是Object),能记录其它对象的引用。

实用小指南——教你如何在 Node.js 中发现 JavaScript 内存漏洞

V8会周期性的遍历内存图,试图确认不再被根节点抵达的一组数据。假如无法从根节点抵达,V8会认为此数据不再被使用,随即释放内存。这个过程称为垃圾回收。

何时发生内存泄漏?

当不再需要的数据依然可从根节点抵达时, JavaScript中 就出现了 内存泄漏。V8将认为此类数据依然在使用当中,并且不会进行垃圾回收。为了调试内存泄漏,我们需要定位被错误保留的数据的位置,确保V8能够清理。

值得注意的是垃圾回收并不会时刻进行,通常当V8认为时机合适时,才会进行触发垃圾回收。如周期性的触发,或可用内存很紧张时会特殊性调用。Node.js的每个进程的内存使用有限,所以V8必须谨慎使用。

实用小指南——教你如何在 Node.js 中发现 JavaScript 内存漏洞

另一特殊性调用的情况,是造成性能急速下降的地方。

试想有一个app有诸多内存泄漏, 不久,Node进程会用尽内存,由此V8会触发特殊性内存回收。但自大多数数据依然可从根节点访问,只有非常少的数据会被清理。

不久,Node进程将再次用尽内存,触发另一次垃圾回收。在你察觉前,app会不断的进行垃圾回收,只是尝试让进程运行。尽管V8花了大多精力在垃圾回收,但只有非常少的资源留给实际的程序。

步骤 1、重现并确认问题

正如我前面所指出的,JavaScript的V8引擎使用了一套复杂的逻辑来觉id那个什么时候垃圾收集应该运行。明白了这个,就会知道尽管我们可以看到用于一个Node现成的内存在持续地增涨,我们还是不能确定自己是否目击了一次内存泄露, 直到我们知晓了垃圾收集已经运行起来,让不再被使用的内存可以被清理出来。

值得庆幸的是,Node允许我们手动触发垃圾回收,而这是在尝试确认一个内存泄露问题时我们应该要做的第一件事情。这件事情可以借助在运行 Node 时带上 –expose-gc 标识(例如 node –expose-gc index.js)来完成。一旦node以那个模式运行,你就可以用编程的方式通过从你的程序调用 global.gc() 来在任何时刻触发一次垃圾回收。

你也可以借助于调用 process.memoryUsage().heapUsed 来检测进程使用的内存数量。

通过手动触发垃圾回收并检测堆的使用情况,你就能够判别出自己是否实际地观察到了程序中的一次内存泄露。

示例程序

我已经创建了一个简单的内存泄露程序,你可以看看这儿: https://github.com/akras14/memory-leak-example

你可以把它clone下来,然后运行 node –expose-gc index.js 将它跑起来。

"use strict";
require('heapdump');
 
var leakyData = [];
var nonLeakyData = [];
 
class SimpleClass {
  constructor(text){
    this.text = text;
  }
}
 
function cleanUpData(dataStore, randomObject){
  var objectIndex = dataStore.indexOf(randomObject);
  dataStore.splice(objectIndex, 1);
}
 
function getAndStoreRandomData(){
  var randomData = Math.random().toString();
  var randomObject = new SimpleClass(randomData);
 
  leakyData.push(randomObject);
  nonLeakyData.push(randomObject);
 
  // cleanUpData(leakyData, randomObject); //<-- Forgot to clean up
  cleanUpData(nonLeakyData, randomObject);
}
 
function generateHeapDumpAndStats(){
  //1. Force garbage collection every time this function is called
  try {
    global.gc();
  } catch (e) {
    console.log("You must run program with 'node --expose-gc index.js' or 'npm start'");
    process.exit();
  }
 
  //2. Output Heap stats
  var heapUsed = process.memoryUsage().heapUsed;
  console.log("Program is using " + heapUsed + " bytes of Heap.")
 
  //3. Get Heap dump
  process.kill(process.pid, 'SIGUSR2');
}
 
//Kick off the program
setInterval(getAndStoreRandomData, 5); //Add random data every 5 milliseconds
setInterval(generateHeapDumpAndStats, 2000); //Do garbage collection and heap dump every 2 seconds

这个程序将会:

  1. 每过5毫秒生成一个随机的对象并将其存储到两个数组中,一个叫做 leakyData 而另外一个叫做 nonLeakyData 。每过5毫秒我们将清理掉 nonLeakyData 数组, 而我们将会“忘记”清理 leakyData 数组。

  2. 每过两秒程序就会输出内存的使用数量 (并发生堆的转储,而我们会在下一节对此进行更详细的描述)。

如果你使用 node –expose-gc index.js (或者是 npm start)来运行这个程序, 它就会开始输出内存的统计信息。我们让它跑一两分钟然后使用 Ctr + c 快捷键杀掉它(进程)。

你会看到内存使用在快速地上涨,尽管每两分钟我们都是触发了垃圾回收的,就在我们获得这份统计数据之前:

//1. Force garbage collection every time this function is called
try {
  global.gc();
} catch (e) {
  console.log("You must run program with 'node --expose-gc index.js' or 'npm start'");
  process.exit();
}
 
//2. Output Heap stats
var heapUsed = process.memoryUsage().heapUsed;
console.log("Program is using " + heapUsed + " bytes of Heap.")

With the stats output looking something like the following:

Program is using 3783656 bytes of Heap.
Program is using 3919520 bytes of Heap.
Program is using 3849976 bytes of Heap.
Program is using 3881480 bytes of Heap.
Program is using 3907608 bytes of Heap.
Program is using 3941752 bytes of Heap.
Program is using 3968136 bytes of Heap.
Program is using 3994504 bytes of Heap.
Program is using 4032400 bytes of Heap.
Program is using 4058464 bytes of Heap.
Program is using 4084656 bytes of Heap.
Program is using 4111128 bytes of Heap.
Program is using 4137336 bytes of Heap.
Program is using 4181240 bytes of Heap.
Program is using 4207304 bytes of Heap.

如果你以图表展现数据,那么内存的增长态势会表现得更加明显。

实用小指南——教你如何在 Node.js 中发现 JavaScript 内存漏洞

注意:如果你比较好奇我是如何做到以图表展现数据得,请继续都下去。如果不感兴趣的话请跳到  下一节

我是将输出的统计数据保存到了一个 JSON 文件中,然后用几行Python代码读入它并以图表展示出来。为避免混乱,我已经将其保存造一个独立的分支中,而你可以在这儿check出来: https://github.com/akras14/memory-leak-example/tree/plot

相关的部分内容如下:

var fs = require('fs');
var stats = [];
 
//--- skip ---
 
var heapUsed = process.memoryUsage().heapUsed;
stats.push(heapUsed);
 
//--- skip ---
 
//On ctrl+c save the stats and exit
process.on('SIGINT', function(){
  var data = JSON.stringify(stats);
  fs.writeFile("stats.json", data, function(err) {
    if(err) {
      console.log(err);
    } else {
      console.log("/nSaved stats to stats.json");
    }
    process.exit();
  });
});

还有:

#!/usr/bin/env python
 
import matplotlib.pyplot as plt
import json
 
statsFile = open('stats.json', 'r')
heapSizes = json.load(statsFile)
 
print('Plotting %s' % ', '.join(map(str, heapSizes)))
 
plt.plot(heapSizes)
plt.ylabel('Heap Size')
plt.show()

你可以check出 plot 分支,然后跟往常一样运行程序。一旦你运行完 plot.py ,就会有图表生成出来。你会需要在机器上安装好  Matplotlib 库,才能让程序跑起来。

或者你也可以在Excel中以图表展现出来。

步骤 2、做至少3次堆的转储

好了,我们已经重现了问题,接下来该如何呢? 现在我们需要搞清楚问题出在哪儿,然后解决它。

你也许已经注意到在我上面的示例中如下的几行代码:

require('heapdump');
// ---skip---
 
//3. Get Heap dump
process.kill(process.pid, 'SIGUSR2');
 
// ---skip---

我是使用了一个 node-heapdump 模块,你可以在这儿找到: https://github.com/bnoordhuis/node-heapdump

为了能使用 node-heapdump, 你只需要这样做:

  1. 安装它。

  2. 在程序的顶部引入它

  3. 在类Unix的平台上调用 kill -USR2 {{pid}}

如果你从前没有遇到过 kill 这部分的话,其实它是 Unix 中的一个命令,你可以用它来(在其它东西中) 发送自定义信号 (也就是用户信号(User Signal))给任何正在运行的进程。Node-heapdump 被配置为当它收到一个用户信号二,也就是 -USR2, 后面带上进程id,就 要做一次进程的堆转储

在我的示例程序中,通过运行 process.kill(process.pid, ‘SIGUSR2’); ,我对 kill -USR2 {{pid}} 命令进行了自动化,这里  process.kill 是针对 kill 命令的一个封装,SIGUSR2 是 -USR2 的Node表示方式, 而 process.pid 会获取到当前 Node 进程的 id。我会在每次垃圾回收之后运行这个命令来获得一个干净的堆转储。

我想 process.kill(process.pid, ‘SIGUSR2’); 是不会在 Windows 上面运行的, 不过你还是可以运行 heapdump.writeSnapshot() 来实现同样的事情。

如果第一时间 就使用 heapdump.writeSnapshot() 的话, 这个示例也许会稍微简单一点,不过我想提一提的就是,你还是可以在类 Unix 平台上使用 kill -USR2 {{pid}} 信号来触发一次堆转储,而这可能会拍上用场。

下一节会讲到我们如何使用生成的堆转储来堆内存泄露进行隔离。

步骤3、定位问题

在第二步中,我们做了堆转储,但是我们将至少需要3块,你不久就会明白为什么要这样。

你一旦有了堆转储。马上去谷歌浏览器,打开浏览器开发者工具(windows系统快捷键是F12,Mac上是Commands+Options+i)。

一旦在开发者工具里导航到“Profiles”的标签,在屏幕的底部选择“ 加载 ”按键,导航到你导入的第一块对转储并且选中它。对转储将会加载进浏览器里,如下图所示:

实用小指南——教你如何在 Node.js 中发现 JavaScript 内存漏洞

继续把另外2块堆转储 加载 到view视图中。例如,你可以使用你导入的最后2块堆转储。最重要的事情是,堆转储必须依照顺序来加载。你的文件夹导航大概如下图所示:

实用小指南——教你如何在 Node.js 中发现 JavaScript 内存漏洞

你可以从图中获取的信息是,堆继续随着时间的推移而增长。

3个堆转储方法

堆转储一旦加载好,你将会在文件夹导航栏看见许多子视图,并且它们很容易丢失。但是,我发现有一个视图特别有用。

点击你导入的最后一个堆转储,它将会马上呈现“ 概要 ”视图给你。到左边的概要导航栏下拉,你可以看见另一个全部的下拉菜单。点击它并且选择“对象被分配在你第一块堆转储与第二和最后一块堆转储之间”,如下图所示:

实用小指南——教你如何在 Node.js 中发现 JavaScript 内存漏洞

它将会展示我们在第一块与最后一块堆转储所分配的所有对象。事实是,这些对象依然存在于你的堆中,引起关注和值得研究,由于它们已经被垃圾回收器回收。

事实很令人吃惊,但是并不是靠着直觉来查找,并且容易被忽略。

忽略 括号里任何事物,至少要做的如下(字符串)

完成示例程序的概述步骤后,我以如下视图作为总结。

注意到阴影大小代表对象本身,而剩下的数量代表对象与所有子对象。

实用小指南——教你如何在 Node.js 中发现 JavaScript 内存漏洞

似乎还有5个条目保存在我的快照里(数组)、(编译的代码)、(字符串)、(系统)以及简单类。

它们看起来只有简单类似曾相识,由于它来自如下示例程序中的代码。

ar randomObject = new SimpleClass(randomData);

它可能是诱人的开始,通过(数组)或者(字符串)。在概要视图中所有对象由它们的的构造函数名称来分组。对于数组或者字符串来说,那些构造函数内嵌到JavaScript引擎里。当你的程序非常确定:通过构造函数创建来传递一些数据,你也会在那里获取一些脏数据,使得我们更难找出内存泄露的根源。

这就是为什么我们一开始跳过那些步骤,看看这样你是否能发现可疑的内存,比如在示例程序的简单类构造函数里。

在简单类的构造函数点击下拉菜单,从结果列表中选择任意被创建的对象,将会在窗口下部填充剩下的路径(请看上图)。从那里很容跟踪我们数据中的内存泄露部分。

如果在你的app中你不够幸运,像我在app里遇到问题一样,你应该查找内部构造方法(比如字符串),从那里尝试找出内存泄露的来源。在这种情况下,关键是要尝试其他组别的值,经常出现在一些内部构造函数里,尝试使用提示指向一个内存泄漏的可疑之处。

比如,在示例程序中,你可以观察到许多字符串,看起来像是随机数转换成字符串的。假如你检测他们的原始路径,Chrome浏览器开发者工具将会指出内存泄露的数组。

第四步.确认问题已解决

确认并解决了一个可疑的内存泄漏之后,你就应该在你的堆使用中看到很大的不同。

如果我们在示例应用程序中取消下面这一行的注解:

cleanUpData(leakyData, randomObject); //<-- Forgot to clean up

然后像第一步描述的那样重新运行应用,你将会观察到下面这样的输出:

Program is using 3756664 bytes of Heap.
Program is using 3862504 bytes of Heap.
Program is using 3763208 bytes of Heap.
Program is using 3763400 bytes of Heap.
Program is using 3763424 bytes of Heap.
Program is using 3763448 bytes of Heap.
Program is using 3763472 bytes of Heap.
Program is using 3763496 bytes of Heap.
Program is using 3763784 bytes of Heap.
Program is using 3763808 bytes of Heap.
Program is using 3763832 bytes of Heap.
Program is using 3758368 bytes of Heap.
Program is using 3758368 bytes of Heap.
Program is using 3758368 bytes of Heap.
Program is using 3758368 bytes of Heap.

如果我们绘制数据图表,就会得到类似于这张图:

实用小指南——教你如何在 Node.js 中发现 JavaScript 内存漏洞

万岁!!内存泄漏消失了。

注意在内存使用初始时的尖峰时刻仍然存在,当程序等到稳定时就会正常。注意那个尖峰时刻,在你分析的时候不要把它当做内存泄漏

其他相关资源

谷歌浏览器开发者工具中的内存分析

在这篇文章中,你读到的大多数东西可以从上面的视频中得到。这篇文章存在的唯一理由是因为我在这两周的学习中为了发现(我所相信的)关键点,我不得不观看3次视频,我希望让这个发现过程对其他人来说比较容易些。

我强烈建议你看这个视频来 补充 这个帖子。

另一个有用的工具——memwatch-next

这是另一个我认为值得一提的工具。你从 这里 可以阅读更多推荐它的理由(篇幅不长,值得阅读)。

或直接去github库: https://github.com/marcominetti/node-memwatch

不用点击下载,你也可以通过这样的方式安装:npm install memwatch-next

然后通过两个事件使用它:

var memwatch = require('memwatch-next');
memwatch.on('leak', function(info) { /*Log memory leak info, runs when memory leak is detected */ });
memwatch.on('stats', function(stats) { /*Log memory stats, runs when V8 does Garbage Collection*/ });
 
//It can also do this...
var hd = new memwatch.HeapDiff();
// Do something that might leak memory
var diff = hd.end();
console.log(diff);

最后的后台日志将会输出像下面这样的东西,为你显示什么类型的对象在内存中增长了。

{
  "before": { "nodes": 11625, "size_bytes": 1869904, "size": "1.78 mb" },
  "after":  { "nodes": 21435, "size_bytes": 2119136, "size": "2.02 mb" },
  "change": { "size_bytes": 249232, "size": "243.39 kb", "freed_nodes": 197,
    "allocated_nodes": 10007,
    "details": [
      { "what": "String",
        "size_bytes": -2120,  "size": "-2.07 kb",  "+": 3,    "-": 62
      },
      { "what": "Array",
        "size_bytes": 66687,  "size": "65.13 kb",  "+": 4,    "-": 78
      },
      { "what": "LeakingClass",
        "size_bytes": 239952, "size": "234.33 kb", "+": 9998, "-": 0
      }
    ]
  }
}

很酷.

来自developer.chrome.com的JavaScript内存分析。

https://developer.chrome.com/devtools/docs/javascript-memory-profiling

强烈推荐必读。它涵盖了所有比我接触到的还要多的主题,并且更加详细更加准确。

不要忽视 Addy Osmani在下面谈到的,他提到了一些调试的提示和资源。

你可以从 这里 获取到幻灯片,从 这里 获取到示例代码

总结

  1. 在尝试重现并验证一个内存泄漏问题时手动触发垃圾回收。你可以在运行 Node 时带上 –expose-gc 标记,并且在你的程序里面嗲用 global.gc() 。

  2. 做至少3次堆的转储(Heap Dump),要使用 https://github.com/bnoordhuis/node-heapdump

  3. 使用3次堆的转储方法来对内存泄露问题进行隔离

  4. 确认内存泄露现象已经消失

  5. 取得效果

分享到:更多 ()

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址