2019-05-30

1. V8内存管理和相关问题

Node.js基于V8引擎,其内存管理就是V8的内存管理。

V8内置了自动垃圾回收(GC)。

V8由Google开发,使用C++编写,最早在Chrome中使用。相对于其他JavaScript引擎将代码装换成字节码或解释执行,V8将代码变异成原生机器码,并且使用了如内联缓存等方法来提高性能。JavaScript程序在V8引擎下运行速度媲美二进制程序。

autoauto- 1. V8内存管理和相关问题 (原)auto - 1.1. V8内存设计auto - 1.1.1. 内存分区auto - 1.1.2. 内存生命周期auto - 1.2. V8垃圾回收auto - 1.2.1. 标记清除法auto - 1.2.2. 垃圾回收算法auto - 1.3. Node.js如何检视内存和GCauto - 1.3.1. 测试auto - 1.3.1.1. external内存和GC测试auto - 1.3.1.2. heap内存和GC测试auto - 1.3.2. 更多auto - 1.3.2.1. 总结auto - 1.4. 常见的内存泄漏案例auto - 1.4.1. 全局变量auto - 1.4.2. 闭包auto - 1.4.3. 消费者速度小于生产者auto - 1.5. 如何发现和定位内存问题auto - 1.5.1. memwatch-nextauto - 1.5.2. heapdumpauto - 1.5.3. 使用PM2做 Memory Threshold Auto Reload 处理autoauto

1.1. V8内存设计

1.1.1. 内存分区

V8中,内存分为几个部分:

  • 新生代区 new space
    大多数的对象都会被分配在这里,这个区域很小但是垃圾回收比较频繁。

  • 老生代区 old space
    属于老生代,这里只保存原始数据对象,这些对象没有指向其他对象的指针。

  • 大对象区 large object space
    这里存放体积超越其他区大小的对象,每个对象有自己的内存,垃圾回收其不会移动大对象区。

  • 代码区 code space
    代码对象,会被分配在这里。唯一拥有执行权限的内存。

  • map区 map space
    存放 Cell 和 Map,每个区域都是存放相同大小的元素,结构简单。

1.1.2. 内存生命周期

一个对象A创建后,被分配到新生代区。

新生代区满后,V8进行Scavenge操作,清除需要回收的。如果对象A还有效,则保留。

如果对象A再次被清理(或者满足其他条件),则晋升到老生代区。

老生代区满后,V8进行Mark Sweep操作,将这时需要回收的对象A清除。

1.2. V8垃圾回收

1.2.1. 标记清除法

当变量进入环境(例如,在函数中声明一个变量)时,就将这个变量标记为“进入环境”。从逻辑上讲,永远不能释放进入环境的变量所占用的内存,因为只要执行流进入相应的环境,就可能会用到它们。而当变量离开环境时,则将其标记为“离开环境”。

与之对应的还有引用计数法,但会因循环引用导致内存泄漏,所以很少见到。

1.2.2. 垃圾回收算法

由于新生代和老生代存放了不同性质的内存对象,其清除方式也不同。

简单来说,新生代使用Scavenge算法。分成From和To两个区,将需要回收的对象留在From,其他移到To,然后交换From和To。垃圾回收将To空间内存全部释放。

老生代使用Mark Sweep算法,直接标记需要被回收的对象,在垃圾回收时释放相应地址空间。

此外,还有Mark Compact算法,将存活和需要回收的对象放在地址区域的两边,以避免回收后内存不连续的问题。

1.3. Node.js如何检视内存和GC

Node.js提供了一些API来帮助开发者检视程序的内存使用状况和GC情况。

process.memoryUsage()

会返回一个内存使用信息对象,单位为字节Byte。类似:

Object {rss: 25358336, heapTotal: 8232960, heapUsed: 5488248, external: 8608}
  • rss 驻留集大小, 即程序分配的物理内存大小,包括堆、栈、代码段
  • heapTotal V8堆总大小
  • heapTotal V8堆使用量大小
  • external V8绑定到Javascript的C++对象的内存大小

对象,字符串,闭包等存于堆内存。 变量存于栈内存。 实际的JavaScript源代码存于代码段内存。

1.3.1. 测试

下面的测试,执行时都给node添加启动参数--trace-gc--expose-gc
前者可以打印出GC操作log,后者允许在代码中控制GC。

1.3.1.1. external内存和GC测试

尝试用fs.readFileSync('/path/')读取一个100M左右的文件。发现rss和external增加了100M左右。
heapUsed则只增加了一点。看来直接读取文件返回的是一个C++对象的引用。

即使没有保存fs.readFileSync()返回的对象,rss和external还是增大了。且即使等待,这部分内存也不会被回收。

在代码中调用global.gc()主动进行GC回收。
回收后,增加的100M左右rss被释放。

1.3.1.2. heap内存和GC测试

如果使用fs.readFileSync('/path/', 'utf-8'),返回的将是一个字符串对象。会占用heap内存。
反复执行10次并保留每次的引用,发现程序错误:

FATAL ERROR: CALL_AND_RETRY_LAST Allocation failed - JavaScript heap out of memory

这体现了V8的堆内存大小是有限制的。这个限制可以修改。
老生代用node --max-old-space-size=xxxx(单位MB)修改。
新生代用node --max-new-space-size=xxxx(单位MB)修改。

1.3.2. 更多

  • node --v8-options print v8 command line options
  • node --v8-pool-size=num set v8's thread pool size
  • node --prof-process process v8 profiler output generated using --prof
  • node --track-heap-objects track heap object allocations for heap snapshots
  • os.totalmem() 系统总内存
  • os.freemem() 系统空闲内存

1.3.2.1. 总结

通过测试,可以发现GC的一些表面规则:

  • 部分函数会创建C++对象并返回其引用,而不是JS对象。因此占用external而非heap。
  • global.x 不会被回收。const x,如果后面没有使用x,则会很快被回收。

1.4. 常见的内存泄漏案例

1.4.1. 全局变量

全局变量global.xxx不会被GC回收。

未声明变量会隐式产生全局变量:

function foo() {
  // 即 global.a = 1;
  a = 1;
}

使用tslint等工具规范代码可以避免此种问题。

1.4.2. 闭包

闭包就是能够读取其他函数内部变量的函数。

闭包作用域会保留其中涉及的引用,会导致对象无法被回收。

要注意的一个知识点是:每当在同一个父作用域下创建闭包作用域的时候,这个作用域是被共享的。

看一个经典问题(曾经是web框架meteor的著名bug):

let theThing = null;
const replaceThing = function () {
  const originalThing = theThing;
  function unused() {
    if (originalThing) {}
  }
  theThing = {
    longStr: new Array(1000000).join('*'),
    someMethod: () => {}
  };
};
setInterval(() => {
  replaceThing();
  console.log(process.memoryUsage().heapTotal)
}, 1000);

运行这段代码,会发现rss、heapTotal、heapUsed不断增长。

这是因为在replaceThing()的词法作用域中,声明了originalThing,而闭包函数unused()使用了originalThing;theThing.someMethod()虽然是空函数,但由于上面提及的知识点,其闭包作用域也包含了originalThing,而theThing定义在文件作用域,无法回收。

这些就导致了每次重声明的originalThing都无法回收,就会有大量longStr积累在堆中。

如果需要解决这个问题,可以在replaceThing()的最后加originalThing = null;

这个问题出现的关键在于,变量间产生了循环使用,且一个在闭包作用域中,导致其每次定义后,都无法释放。

也可以改成下面这样,效果一样:

let theThing = null;
const replaceThing = function () {
  const originalThing = {
    theThing,
    longStr: new Array(1000000).join('*'),
  };
  function unused() {
    if (originalThing) {}
  }
  theThing = ()=> {}
};
setInterval(() => {
  replaceThing();
  console.log(process.memoryUsage().heapTotal)
}, 1000);

1.4.3. 消费者速度小于生产者

常见于使用消息队列或大量IO操作时。由于作为生产者时,消费者一方不能及时处理任务,导致任务数据在生产者内存缓存中大量积存,最终导致内存溢出。

1.5. 如何发现和定位内存问题

1.5.1. memwatch-next

memwatch-next是一个能发现内存泄漏问题,并给出简单问题分析的工具。

使用如下:

// 使用方式1:监听内存泄漏
// 5个连续GC周期下,
memwatch.on('leak', function (info) { 
  console.warn("MEMLEAK", info);
});

// 使用方式2:生成一段时间的内存和对象变化报告
const hd = new memwatch.HeapDiff();
setTimeout(() => {
  const diff = hd.end();
  console.log(JSON.stringify(diff, null, "  "));
}, 1000 * 10);

在上面那个闭包引起内存泄漏的代码中使用,可以发现部分报告输出如下:

{
  "change": {
    "details": [
      {
        "what": "Closure",
        "size_bytes": 6624,
        "size": "6.47 kb",
        "+": 96,
        "-": 4
      },
      {
        "what": "String",
        "size_bytes": 93003520,
        "size": "88.7 mb",
        "+": 205,
        "-": 22
      }
    ]
  }
}

由此,可以推测是大量String对象造成内存占用,可能和闭包有关。

1.5.2. heapdump

heapdump是一个用于导出V8 Heap Snapshot的工具。导出数据可以导入到Chrome浏览器查看。

和memwatch结合使用:

memwatch.on('leak', function (info) { 
  console.warn("MEMLEAK", info);
  heapdump.writeSnapshot('' + Date.now() + '.heapsnapshot');
});

等到leak事件触发后,便会导出一个.heapsnapshot文件。从 [Chrome开发者工具]-[memory]-[Profiles]-[Heap snapshot] 中,Load这个文件。

然后就可以看到报告内容。
可以按Shallow Size排序,查看是何种对象占用了大量内存。(如果内存泄漏时缓慢增长的,则可以等待足够长时间后再导出报告)

对上面的闭包例子做报告,可以发现占用最多的是string,有多个大体积的“***...*”字符串。

1.5.3. 使用PM2做 Memory Threshold Auto Reload 处理

有时内存泄漏的问题隐藏地很深,短时间内难以定位和解决。这时要优先保证服务正常运行不收内存
问题的影响,就可以利用pm2管理工具的内存限制重启特性。

具体方式是在配置文件中增加max_memory_restart属性:

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

推荐阅读更多精彩内容