IT序号网

JavaScript中V8引擎内存问题

itxm 2021年05月25日 编程语言 429 0

简介

V8 是谷歌开发的高性能 JavaScript 引擎,该引擎使用 C++ 开发。目前主要应用在 Google Chrome 浏览器和 node.js 当中。

V8 自带的高性能垃圾回收机制,使开发者能够专注于程序开发中,极大的提高开发者的编程效率。但是方便之余,也会出现一些对新手来说比较棘手的问题:进程内存暴涨,cpu 飙升,性能很差等。这个时候,了解 V8 的内存结构和垃圾回收机制、知道如何进行性能调优就很有必要。本文主要讲述 V8 的内存管理和垃圾回收,后面会用示例代码结合 Chrome 的开发者工具进行分析;最后介绍了阿里的 node.js 应用服务解决方案 alinode。

V8 内存构成

一个 V8 进程的内存通常由以下几个块构成:

  1. 新生代内存区(new space)大多数的对象都会被分配在这里,这个区域很小但是垃圾回收比较频繁;
  2. 老生代内存区(old space)
    属于老生代,这里只保存原始数据对象,这些对象没有指向其他对象的指针;
  3. 大对象区(large object space)这里存放体积超越其他区大小的对象,每个对象有自己的内存,垃圾回收其不会移动大对象区;
  4. 代码区(code space)
    代码对象,会被分配在这里。唯一拥有执行权限的内存;
  5. map 区(map space)
    存放 Cell 和 Map,每个区域都是存放相同大小的元素,结构简单。

内存构成可以用下图来表示:

JavaScript中V8引擎内存问题

 

其中带斜纹的是对应的内存块中未使用的内存空间。new space 通常很小(1~8M),它被分成了两部分,一部分叫做 inactive new space,一部分是激活状态,为啥会有激活和未激活之分的原因,下面会提到。old space 偏大,可能达几百兆。

V8 内存生命周期

假设代码中有一个对象 jerry ,这个对象从创建到被销毁,刚好走完了整个生命周期,通常会是这样一个过程:

  1. 这个对象被分配到了 new space;
  2. 随着程序的运行,new space 塞满了,gc 开始清理 new space 里的死对象,jerry 因为还处于活跃状态,所以没被清理出去;
  3. gc 清理了两遍 new space,发现 jerry 依然还活跃着,就把 jerry 移动到了 old space;
  4. 随着程序的运行,old space 也塞满了,gc 开始清理 old space,这时候发现 jerry 已经没有被引用了,就把 jerry 给清理出去了。

第二步里,清理 new space 的过程叫做 Scavenge,这个过程采用了空间换时间的做法,用到了上面图中的 inactive new space,过程如下:

  1. 当活跃区满了之后,交换活跃区和非活跃区,交换后活跃区变空了;
  2. 将非活跃区的两次清理都没清理出去的对象移动到 old space;
  3. 将还没清理够两次的但是活跃状态的对象移动到活跃区。

第四步里,清理 old space 的过程叫做 Mark-sweep ,这块占用内存很大,所以没有使用 Scavenge,这个回收过程包含了若干次标记过程和清理过程:

  1. 标记从根(root)可达的对象为黑色;
  2. 遍历黑色对象的邻接对象,直到所有对象都标记为黑色;
  3. 循环标记若干次;
  4. 清理掉非黑色的对象。

简单来说,Mark-sweep 就是把从根节点无法获取到的对象清理掉了。

使用 Chrome 调优前端代码

注:本文截图里的 Chrome 版本为 Version 64.0.3282.140 (Official Build) (64-bit)

1. 查看内容构成

在控制台获取当前页面的堆内存快照(heap snapshot):

JavaScript中V8引擎内存问题

 

为了便于观看,先在 console 里声明一个类并创建它的一些对象:

class Jane { 
} 
 
class Tom { 
  constructor () {  this.jane = new Jane() 
  } 
} 
 
Array(1000000)  .fill('')   .map(() => new Tom())

获取成功后,可以看到一个表格:

JavaScript中V8引擎内存问题

 

JavaScript中V8引擎内存问题

 

介绍一下几个关键的列:

  1. Constructor:对象的类名;
  2. Distance:对象到根的引用层级;
  3. Objects Count:对象的数量;
  4. Shallow Size: 对象本身占用的内存,不包括引用的对象所占内存;
  5. Retained Size: 对象所占总内存,包含引用的其他对象所占内存;
  6. Retainers:对象的引用层级关系。

shallow size 和 retained size 的区别可以用红框里的 Tom 和 Jane 更直观的展示:Tom 的 shallow 占了 32M,retained 占用了 56M,这是因为 retained 包括了引用的指针对应的内存大小,即 tom.jane 所占用的内存;所以 Tom 的 retained 总和比 shallow 多出来的 24M 正好跟 Jane 占用的 24M 相同。retained size 可以理解为当回收掉该对象时可以释放的内存大小,在内存调优中具有重要参考意义。

2. 查看对象的引用关系

这里使用一个稍复杂的代码来展示:

class B {} 
 
class A { 
  constructor () { 
    this.b = new B() 
  } 
} 
 
class BList { 
  constructor () { 
    this.values = [] 
  } 
  push (b) { 
    this.values.push(b) 
  } 
} 
 
const aArray = Array(1000000).fill('').map(() => new A()) 
const bList = new BList() 
aArray.forEach(a => { bList.push(a.b) })
 

heap snapshot 如下图所示:

JavaScript中V8引擎内存问题

 

红框中展示了该 B 实例被应用的三个位置,后面的 @?? 可以视为内存的地址,同样的地址意味着同一个对象。可以展开左边的箭头查看,这三个直接引用的地方分别是:

  1. Blist.values 对应的指针;
  2. A.b 对应的指针;
  3. Blist.values 指向的数组的指针。

可以观察到,A 的 retained size 现在和 shallow size 一样了,因为 A 的实例在 aArray 中被引用了;B 的两个 size 也一样了,因为在 A 中和 bList 中都有引用,销毁其本身并不会释放相应的内存。

3. 调试内存泄露

如果你的网页在放久了的情况下内存越来越大甚至 tab 页崩溃,那就要考虑是否内存泄露了。通过 Chrome 的任务管理器可以看到 JavaScript 所占用的内存:

JavaScript中V8引擎内存问题

 

通过 Performance 里的 record 也可以直观地看到内存的增长(需要勾上 Memory 选项):

JavaScript中V8引擎内存问题

 

用一个示例代码,结合 heap snapshot 来说明如何排查内存泄露:

const a = {} 
 
setInterval(() => { 
  a[Date.now()] = new ArrayBuffer(1000000) 
}, 100)

这段代码粘贴在控制台后,在控制台的 Memory 页面,隔 10s 取一个 heap snapshot:

JavaScript中V8引擎内存问题

 

选中第二个和第三个,在选取观察类型的下拉菜单里选择「Comparison」,然后再选择右面的下拉菜单,选择上一个 snapshot:

JavaScript中V8引擎内存问题

 

JavaScript中V8引擎内存问题

 

这个时候后列表中的内容是当前的 snapshot 针对上一个的增加的部分,可以看到图中的 snapshot 14 比 snapshot 13 多出来的部分,跟 snapshot 13 比 snapshot 12 多出来的部分都有 ArrayBuffer,那么就可以确定 ArrayBuffer 导致了内存泄露。这个时候可以结合上面一节的「查看对象引用关系」来定位到类或者代码。

使用 alinode 调优 node.js 进程

alinode 是阿里云出品的 node.js 应用服务解决方案,是一套基于社区 Node 改进的运行时环境和服务平台。使用 alinode 来调优 node.js 进程更加直观,便捷,而且具备系统监控、日志服务、nodejs 进程监控、报警等功能,非常强大。

使用 alinode 要经过以下步骤:

  1. 注册阿里云账号;
  2. 开通 alinode 服务;
  3. 创建 alinode 应用;
  4. 在自己服务器上安装 alinode 和 agenthub,配置好自己应用的 id 和 secret;
  5. 启动自己的 node 进程。

下图是 alinode 某应用的一个实例的控制台:

JavaScript中V8引擎内存问题

 

图中圈出来的是常用的几个指标:

  1. 进程存活时间线:线断了意味着可能进程重启了或者机器网络故障;
  2. CPU;
  3. 内存;
  4. GC 时间占比:一分钟内 gc 占用时间的百分比;一般认为小于 5% 属于正常状态,比例很大的话意味着 CPU 需要耗费很多时间在 gc 上,导致进程性能严重下降。这通常对应以下三种情况:
    1. 进程负载太高,需要增加服务节点;
    2. 进程内存泄露,导致不停的在 gc;
    3. 代码需要优化;
  5. CPU Profile:火焰图的形式来统计分析;
  6. 堆快照:可以通过点击「对快照」来生成 heap snapshot,可以下载下来通过 Chrome 来分析,也可以使用 alinode 自带的分析工具:
JavaScript中V8引擎内存问题

 

此外还有一个专门针对内存和 gc 的工具:GC Trace,这个是用来观察 gc 的过程,从而更直观的观察进程 gc 的步骤:

JavaScript中V8引擎内存问题

 

在这里,你可以直观的看到堆大小的变化,可以看到每一次的 GC 是 Scavenge 还是 Mark-Sweep,可以看到每一次 gc 内存堆各类型的大小变化,有了这个强大的分析工具,你可以写出性能更高、更加稳定、响应速度更快的 node.js 代码。

作者:Tom Wan
链接:https://zhuanlan.zhihu.com/p/33816534
来源:知乎
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。


评论关闭
IT序号网

微信公众号号:IT虾米 (左侧二维码扫一扫)欢迎添加!