JS内存泄漏排查方法

写在前面

JS的内存问题往往出现在单页应用(SPA)中,一般认为场景特点是:

  • 页面生命周期长(用户可能存留10分钟、半小时甚至2小时)

  • 交互功能多(页面偏功能,而不是展示)

  • 重JS应用(前端有复杂的数据状态、视图管理)

内存泄漏是一个累积的过程,只有页面生命周期略长的时候才算是个问题(所谓“刷新一下满血复活”)。频繁交互能够加快累积过程,偏展示的页面很难把这样的问题暴露出来。最后,JS逻辑相对复杂才有可能出现内存问题(“bug多是因为代码量大,我自己都hold不住”),如果只是简单的表单验证提交,还没什么机会影响内存

那么交互功能多和JS逻辑复杂的标准是什么?到哪种程度才比较危险?

实际上,稍微有点交互功能(比如局部刷新)的简单页面,稍不仔细就会留下内存隐患,暴露出来就叫内存问题

一.工具环境

工具:

  • Chrome Task Manager工具

  • Chrome DevTools Performance面板

  • Chrome DevTools Memory面板

环境:

  • 稳定,去掉网络等变化因素(用假数据)

  • 操作易重复,降低“累积”难度(简化操作步骤,比如短信验证之类的环节考虑去掉)

  • 无干扰,排除插件影响(开隐身模式)

也就是说(Mac下):

  1. Command + Shift + N进隐身模式

  2. Command + Alt + I打开DevTools

  3. 输入URL打开页面

然后就可以装模作样开始搞了

二.术语概念

先要具备基本的内存知识,了解DevTools提供的各项记录含义

Mark-and-sweep

JS相关的GC算法主要是引用计数(IE的BOM、DOM对象)和标记清除(主流做法),各有优劣:

  • 引用计数回收及时(引用数为0立即释放掉),但循环引用就永远无法释放

  • 标记清除不存在循环引用的问题(不可访问就回收掉),但回收不及时需要Stop-The-World

标记清除算法步骤如下:

  1. GC维护一个root列表,root通常是代码中持有引用的全局变量。JS中,window对象就是一例作为root的全局变量。window对象一直存在,所以GC认为它及其所有孩子一直存在(非垃圾)

  2. 所有root都会被检查并标记为活跃(非垃圾),其所有孩子也被递归检查。能通过root访问到的所有东西都不会被当做垃圾

  3. 所有没被标记为活跃的内存块都被当做垃圾,GC可以把它们释放掉归还给操作系统

现代GC技术对这个算法做了各种改进,但本质都一样:可访问的内存块被这样标记出来后,剩下的就是垃圾

Shallow Size & Retained Size

可以把内存看做由基本类型(如数字和字符串)与对象(关联数组)构成的图。形象一点,可以把内存表示为一个由多个互连的点组成的图,如下所示:

  3-->5->7
  ^      ^
 /|      |
1 |      6-->8
 \|     /^
  v    /
  2-->4

对象可以通过两种方式占用内存:

  • 直接通过对象自身占用

  • 通过持有对其它对象的引用隐式占用,这种方式会阻止这些对象被垃圾回收器(简称GC)自动处理

在DevTools的堆内存快照分析面板会看到Shallow SizeRetained Size分别表示对象通过这两种方式占用的内存大小

Shallow Size

对象自身占用内存的大小。通常,只有数组和字符串会有明显的Shallow Size。不过,字符串和外部数组的主存储一般位于renderer内存中,仅将一个小包装器对象置于JavaScript堆上

renderer内存是渲染页面进程的内存总和:原生内存 + 页面的JS堆内存 + 页面启动的所有专用worker的JS堆内存。尽管如此,即使一个小对象也可能通过阻止其他对象被自动垃圾回收进程处理的方式间接地占用大量内存

Retained Size

对象自身及依赖它的对象(从GC root无法再访问到的对象)被删掉后释放的内存大小

有很多内部GC root,其中大部分都不需要关注。从应用角度来看,GC root有以下几类:

  • Window全局对象(位于每个iframe中)。堆快照中有一个distance字段,表示从window出发的最短保留路径上的属性引用数量。

  • 文档DOM树,由可以通过遍历document访问的所有原生DOM节点组成。并不是所有的节点都有JS包装器,不过,如果有包装器,并且document处于活动状态,包装器也将处于活动状态

  • 有时,对象可能会被调试程序上下文和DevTools console保留(例如,在console求值计算后)。所以在创建堆快照调试时,要清除console并去掉断点

内存图从root开始,root可以是浏览器的window对象或Node.js模块的Global对象,我们无法控制root对象的垃圾回收方式

  3-->5->7   9-->10
  ^      ^
 /|      |
1 |      6-->8
 \|     /^
  v    /
  2-->4

其中,1是root(根节点),7和8是基本值(叶子节点),9和10将被GC掉(孤立节点),其余的都是对象(非根非叶子节点)

Object’s retaining tree

堆是一个由互连的对象组成的网络。在数学领域,这样的结构被称为“图”或内存图。图由通过边连接的节点组成,两者都以给定标签表示出来:

  • 节点(或对象)用构造函数(用来构建节点)的名称标记

  • 边用属性名标记

distance是指与GC root之间的距离。如果某类型的绝大多数对象的distance都相同,只有少数对象的距离偏大,就有必要仔细查查

Dominator

支配对象都由树结构组成,因为每个对象只有一个(直接)支配者,对象的支配者可能没有对其所支配的对象的直接引用,所以,支配者树不是图的生成树

在对象引用图中,所有指向对象B的路径都经过对象A,就认为A支配B。如果对象A是离对象B最近的支配对象,就认为A是B的直接支配者

下图中:

  1     1支配2
  |     2支配3 4 6
  v
  2
/   \
v   v
4   3   3支配5
|  /|
| / |
|/  |
v   v
6   5   5支配8; 6支配7
|   |
v   v
7   8

所以7的直接支配者是6,而7的支配者是1, 2, 6

V8的JS对象表示

primitive type

3种基本类型:

  • 数值

  • 布尔值

  • 字符串

它们无法引用其它值,所以总是叶子或终端节点

数值有两种存储方式:

  • 直接的31位整型值叫做小整型(SMI)

  • 堆对象,作为堆数值引用。堆数值用来存储不符合SMI格式的值(例如double型),或者一个值需要被装箱的时候,比如给它设置属性

字符串也有两种存储方式:

  • VM堆

  • renderer内存(外部),创建一个wrapper对象用来访问外部存储空间,例如,脚本源码和其它从Web接收到的内容都放在外部存储空间,而不是拷贝到VM堆

新JS对象的内存分配自专用JS堆(或VM堆),这些对象由V8的GC管理,因此,只要存在一个对它们的强引用,它们就会保持活跃

Native Object

原生对象是JS堆外的所有东西。与堆对象相比,原生对象的整个生命周期不由V8的GC管理,并且只能通过wrapper对象从JS访问

Cons String

拼接字符串(concatenated string)由存储并连接起来的成对字符串组成,只在需要时才把拼接字符串的内容连接起来,例如要取拼接字符串的子串时

例如,把ab拼接起来,得到字符串(a, b)表示连接结果,接着把d与这个结果拼接起来,就会得到另一个拼接字符串((a, b), d)

Array

数组是具有数值key的对象。在V8 VM中应用广泛,用来存储大量数据,用作字典的键值对集合也采用数组形式(存储)

典型JS对象对应两种数组类型,用来存储:

  • 命名属性

  • 数值元素

属性数量非常少的话,可以放在JS对象自身内部

Map

一种描述对象种类及其布局的对象,例如,map用来描述隐式对象层级结构实现快速属性访问

Object group

(对象组中)每个原生对象由互相持有引用的对象组成,例如,DOM子树上每个节点都有指向其父级、下一个孩子和下一个兄弟的关联,因此形成了一个连接图。原生对象不会表示在JS堆中,所以其大小为0。而会创建wrapper对象

每个wrapper对象都持有对相应原生对象的引用,用来将命令重定向到自身。这样,对象组会持有wrapper对象。但不会形成无法回收的循环,因为GC很聪明,谁的wrapper不再被引用了,就释放掉对应的对象组。但忘记释放wrapper的话,就将持有整个对象组和相关wrapper

三.工具用法

Task Manager

用来粗略地查看内存使用情况

入口在右上角三个点 -> 更多工具 -> 任务管理器,然后右键表头 -> 勾选JS使用的内存,主要关注两列:

  • 内存列表示原生内存。DOM节点存储在原生内存中,如果此值正在增大,则说明正在创建DOM节点

  • JS使用的内存列表示JS堆。此列包含两个值,需要关注的是实时值(括号中的数值)。实时数值表示页面上的可访问对象正在使用的内存量。如果该数值在增大,要么是正在创建新对象,要么是现有对象正在增长

Performance

用来观察内存变化趋势

入口在DevTools的Performance面板,然后勾选Memory,如果想看页面首次加载过程内存使用情况的话,Command + R刷新页面,会自动记录整个加载过程。想看某些操作前后的内存变化的话,操作前点“黑点”按钮开始记录,操作完毕点“红点”按钮结束记录

记录完毕后勾选中部的JS Heap,蓝色折线表示内存变化趋势,如果总体趋势不断上涨,没有大幅回落,就再通过手动GC来确认:再操作记录一遍,操作结束前或者过程中做几次手动GC(点“黑色垃圾桶”按钮),如果GC的时间点折线没有大幅回落,整体趋势还是不断上涨,就有可能存在内存泄漏

或者更粗暴的确认方式,开始记录 -> 重复操作50次 -> 看有没有自动GC引发的大幅下降,在使用的内存大小达到阈值时会自动GC,如果有泄漏的话,操作n次总会达到阈值,也可以用来确认内存泄漏问题是否已修复

P.S.还能看到document数量(可能针对iframe),节点数量、事件监听器数量、占用GPU内存的变化趋势,其中节点数量及事件监听器数量变化也有指导意义

Memory

这个面板有3个工具,分别是堆快照、内存分配情况和内存分配时间轴:

  • 堆快照(Take Heap Snapshot),用来具体分析各类型对象存活情况,包括实例数量、引用路径等等

  • 内存分配情况(Record Allocation Profile),用来查看分配给各函数的内存大小

  • 内存分配时间轴(Record Allocation Timeline),用来查看实时的内存分配及回收情况

其中内存分配时间轴和堆快照比较有用,时间轴用来定位内存泄漏操作,对快照用来具体分析问题

关于具体用法的更多介绍请查看解决内存问题

Record Allocation Timeline

点开时间轴,对页面进行各种交互操作,出现的蓝色柱子表示新内存分配,灰色的表示释放回收,如果时间轴上存在规律性的蓝色柱子,那就有很大可能存在内存泄漏

然后再反复操作观察,看是什么操作导致蓝色柱子残留,剥离出具体的某个操作

Take Heap Snapshot

堆快照用来进一步分析,找到泄漏的具体对象类型

到这里应该已经锁定可疑的操作了,通过不断重复该操作,观察堆快照各项的数量变化来定位泄漏对象类型

堆快照有4种查看模式:

  • Summary:摘要视图,展开并选中子项查看Object’s retaining tree(引用路径)

  • Comparison:对比视图,与其它快照对比,看增、删、Delta数量及内存大小

  • Containment:俯瞰视图,自顶向下看堆的情况,根节点包括window对象,GC root,原生对象等等

  • Dominators:支配树视图,新版Chrome好像去掉了,展示之前术语概念部分提到的支配树

其中最常用的是对比视图和摘要视图,对比视图可以把2次操作和1次操作的快照做diff,看Delta增量,找出哪类对象一直在增长。摘要视图用来分析这类可疑对象,看Distance,找出奇怪的长路径上,哪一环忘记断开了

看摘要视图有个小常识是新增的东西是黄底黑字,删除的是红底黑字,本来就有的是白底黑字,这一点很关键

关于对快照用法的更多图示,请查看如何记录堆快照

四.排查步骤

1.确认问题,找出可疑操作

先确认是否真的存在内存泄漏:

  1. 切换到Performance面板,开始记录(有必要从头记的话)

  2. 开始记录 -> 操作 -> 停止记录 -> 分析 -> 重复确认

  3. 确认存在内存泄漏的话,缩小范围,确定是什么交互操作引起的

也可以进一步通过Memory面板的内存分配时间轴来确认问题,Performance面板的优势是能看到DOM节点数和事件监听器的变化趋势,甚至在没有确定是内存问题拉低性能时,还可以通过Performance面板看网络响应速度、CPU使用率等因素

2.分析堆快照,找出可疑对象

锁定可疑的交互操作后,通过内存快照进一步深入:

  1. 切换到Memory面板,截快照1

  2. 做一次可疑的交互操作,截快照2

  3. 对比快照2和1,看数量Delta是否正常

  4. 再做一次可疑的交互操作,截快照3

  5. 对比3和2,看数量Delta是否正常,猜测Delta异常的对象数量变化趋势

  6. 做10次可疑的交互操作,截快照4

  7. 对比4和3,验证猜测,确定什么东西没有被按预期回收

3.定位问题,找到原因

锁定可疑对象后,再进一步定位问题:

  1. 该类型对象的Distance是否正常,大多数实例都是3级4级,个别到10级以上算异常

  2. 看路径深度10级以上(或者明显比其它同类型实例深)的实例,什么东西引用着它

4.释放引用,修复验证

到这里基本找到问题源头了,接下来解决问题:

  1. 想办法断开这个引用

  2. 梳理逻辑流程,看其它地方是否存在不会再用的引用,都释放掉

  3. 修改验证,没解决的话重新定位

当然,梳理逻辑流程在一开始就可以做,边用工具分析,边确认逻辑流程漏洞,双管齐下,最后验证可以看Performance面板的趋势折线或者Memory面板的时间轴

五.常见案例

这些场景可能存在内存泄漏隐患,当然,做好收尾工作就可以解决

1.隐式全局变量

function foo(arg) {
    bar = "this is a hidden global variable";
}

bar就被挂到window上了,如果bar指向一个巨大的对象,或者一个DOM节点,就会代码内存隐患

另一种不太明显的方式是构造函数被直接调用(没有通过new来调用):

function foo() {
    this.variable = "potential accidental global";
}

// Foo called on its own, this points to the global object (window)
// rather than being undefined.
foo();

或者匿名函数里的this,在非严格模式也指向global。可以通过lint检查或者开启严格模式来避免这些显而易见的问题

2.被忘记的timer或callback

var someResource = getData();
setInterval(function() {
    var node = document.getElementById('Node');
    if(node) {
        // Do stuff with node and someResource.
        node.innerHTML = JSON.stringify(someResource));
    }
}, 1000);

如果后续idNode的节点被移除了,定时器里的node变量仍然持有其引用,导致游离的DOM子树无法释放

回调函数的场景与timer类似:

var element = document.getElementById('button');

function onClick(event) {
    element.innerHtml = 'text';
}

element.addEventListener('click', onClick);
// Do stuff
element.removeEventListener('click', onClick);
element.parentNode.removeChild(element);
// Now when element goes out of scope,
// both element and onClick will be collected even in old browsers that don't
// handle cycles well.

移除节点之前应该先移除节点身上的事件监听器,因为IE6没处理DOM节点和JS之间的循环引用(因为BOM和DOM对象的GC策略都是引用计数),可能会出现内存泄漏,现代浏览器已经不需要这么做了,如果节点无法再被访问的话,监听器会被回收掉

3.游离DOM的引用

var elements = {
    button: document.getElementById('button'),
    image: document.getElementById('image'),
    text: document.getElementById('text')
};

function doStuff() {
    image.src = 'http://some.url/image';
    button.click();
    console.log(text.innerHTML);
    // Much more logic
}

function removeButton() {
    // The button is a direct child of body.
    document.body.removeChild(document.getElementById('button'));

    // At this point, we still have a reference to #button in the global
    // elements dictionary. In other words, the button element is still in
    // memory and cannot be collected by the GC.
}

经常会缓存DOM节点引用(性能考虑或代码简洁考虑),但移除节点的时候,应该同步释放缓存的引用,否则游离子树无法释放

另一个更隐蔽的场景是:

var select = document.querySelector;
var treeRef = select("#tree");
var leafRef = select("#leaf");
var body = select("body");

body.removeChild(treeRef);

//#tree can't be GC yet due to treeRef
treeRef = null;

//#tree can't be GC yet due to indirect
//reference from leafRef

leafRef = null;
//#NOW can be #tree GC

如下图:

treegc

treegc

游离子树上任意一个节点引用没有释放的话,整棵子树都无法释放,因为通过一个节点就能找到(访问)其它所有节点,都给标记上活跃,不会被清除

4.闭包

var theThing = null;
var replaceThing = function () {
  var originalThing = theThing;
  var unused = function () {
    if (originalThing)
      console.log("hi");
  };
  theThing = {
    longStr: new Array(1000000).join('*'),
    someMethod: function () {
      console.log(someMessage);
    }
  };
};
setInterval(replaceThing, 1000);

粘到console执行,再通过Performance面板趋势折线或者Memory面板时间轴看内存变化,能够发现非常规律的内存泄漏(折线稳步上升,每秒一根蓝色柱子笔直笔直的)

因为闭包的典型实现方式是每个函数对象都有一个指向字典对象的关联,这个字典对象表示它的词法作用域。如果定义在replaceThing里的函数都实际使用了originalThing,那就有必要保证让它们都取到同样的对象,即使originalThing被一遍遍地重新赋值,所以这些(定义在replaceThing里的)函数都共享相同的词法环境

但V8已经聪明到把不会被任何闭包用到的变量从词法环境中去掉了,所以如果把unused删掉(或者把unused里的originalThing访问去掉),就能解决内存泄漏

只要变量被任何一个闭包使用了,就会被添到词法环境中,被该作用域下所有闭包共享。这是闭包引发内存泄漏的关键

P.S.关于这个有意思的内存泄漏问题的详细信息,请查看An interesting kind of JavaScript memory leak

六.其它内存问题

除了内存泄漏,还有两种常见的内存问题:

  • 内存膨胀

  • 频繁GC

内存膨胀是说占用内存太多了,但没有明确的界限,不同设备性能不同,所以要以用户为中心。了解什么设备在用户群中深受欢迎,然后在这些设备上测试页面。如果体验很差,那么页面可能存在内存膨胀的问题

频繁GC很影响体验(页面暂停的感觉,因为Stop-The-World),可以通过Task Manager内存大小数值或者Performance趋势折线来看:

  • Task Manager中如果内存或JS使用的内存数值频繁上升下降,就表示频繁GC

  • 趋势折线中,如果JS堆大小或者节点数量频繁上升下降,表示存在频繁GC

可以通过优化存储结构(避免造大量的细粒度小对象)、缓存复用(比如用享元工厂来实现复用)等方式来解决频繁GC问题

参考资料

JS内存泄漏排查方法》上有3条评论

  1. 稳健哥

    请教一下对于chrome 的内存检测,是需要手动点下CG按钮来模拟浏览器的自动CG是吗,因为我在做performance 的录像快照后发现 ,node js heap listener 并没有被自动CG,只有点了CG按钮才回收…

    回复
    1. ayqy

      自动GC有阈值,内存占用达到阈值才会自动GC,这个阈值好像是300M。也就是说,占用内存多达300M,才会触发自动GC

      回复

发表评论

电子邮件地址不会被公开。 必填项已用*标注

*

code