美文网首页
2019-05-30

2019-05-30

作者: 平仄_pingze | 来源:发表于2019-05-30 17:30 被阅读0次

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'
}]

相关文章

网友评论

      本文标题:2019-05-30

      本文链接:https://www.haomeiwen.com/subject/quovtctx.html