JS 内存泄漏排查方法 Chrome Profiles

发布于 2021-11-28 10:20:04 字数 10458 浏览 1368 评论 0

一、概述

Google Chrome 浏览器提供了非常强大的JS调试工具,Heap Profiling 便是其中一个。Heap Profiling 可以记录当前的堆内存(heap)快照,并生成对象的描述文件,该描述文件给出了当时 JS 运行所用到的所有对象,以及这些对象所占用的内存大小、引用的层级关系等等。这些描述文件为内存泄漏的排查提供了非常有用的信息。

注意:本文里的所有例子均基于 Google Chrome 浏览器。

什么是 heap

JS 运行的时候,会有栈内存(stack)和堆内存(heap),当我们用 new 实例化一个类的时候,这个 new 出来的对象就保存在 heap 里面,而这个对象的引用则存储在 stack 里。程序通过 stack 里的引用找到这个对象。例如 var a = [1,2,3];,a 是存储在stack里的引用,heap 里存储着内容为 [1,2,3] 的 Array 对象。

二、Heap Profiling

打开工具

打开 Chrome 浏览器(版本 25.0.1364.152 m),打开要监视的网站(这里以游戏大厅为例),按下 F12 调出调试工具,点击 Profiles 标签。可以看到下图:

img.png

可以看到,该面板可以监控 CPU、CSS 和内存,选中 Take Heap Snapshot,点击 Start 按钮,就可以拍下当前 JS 的 heap 快照,如下图所示:

img.png

右边视图列出了 heap 里的对象列表。由于游戏大厅使用了 Quark 游戏库,所以这里可以清楚地看到 Quark.XXX 之类的类名称(即 Function 对象的引用名称)。

注意:每次拍快照前,都会先自动执行一次 GC,所以在视图里的对象都是可及的。

视图解释

列字段解释:

  • Constructor — 类名 Distance — 估计是对象到根的引用层级距离
  • Objects Count — 给出了当前有多少个该类的对象
  • Shallow Size — 对象所占内存(不包含内部引用的其它对象所占的内存)(单位:字节)
  • Retained Size — 对象所占总内存(包含内部引用的其它对象所占的内存)(单位:字节)

下面解释一下部分类名称所代表的意思:

  • (compiled code) — 未知,估计是程序代码区
  • (closure) — 闭包(array) — 未知
  • Object — JS对象类型(system) — 未知
  • (string) — 字符串类型,有时对象里添加了新属性,属性的名称也会出现在这里
  • Array — JS数组类型cls — 游戏大厅特有的继承类
  • Window — JS的window对象
  • Quark.DisplayObjectContainer — Quark引擎的显示容器类
  • Quark.ImageContainer — Quark引擎的图片类
  • Quark.Text — Quark引擎的文本类
  • Quark.ToggleButton — Quark引擎的开关按钮类

对于 cls 这个类名,是由于游戏大厅的继承机制里会使用 cls 这个引用名称,指向新建的继承类,所以凡是使用了该继承机制的类实例化出来的对象,都放在这里。例如程序中有一个类 ClassA,继承了 Quark.Text,则 new 出来的对象是放在cls里,不是放在 Quark.Text 里。

查看对象内容

点击类名左边的三角形,可以看到所有该类的对象。对象后面的 @70035 表示的是该对象的 ID(有人会错认为是内存地址,GC 执行后,内存地址是会变的,但对象 ID 不会)。把鼠标停留在某一个对象上,会显示出该对象的内部属性和当时的值。

img.png

这个视图有助于我们辨别这是哪个对象。但该视图跟踪不了是被谁引用了。

查看对象的引用关系

点击其中一个对象,能看到对象的引用层级关系,如下图:

img.png

Object’s retaining tree视图显示出了该对象被哪些对象引用了,以及这个引用的名称。图中的这个对象被5个对象引用了,分别是:

  1. 一个cls对象的 _txtContent 变量;
  2. 一个闭包函数的context变量;
  3. 同一个闭包函数的self变量;
  4. 一个数组对象的0位置;
  5. 一个Quark.Tween对象的target变量。

看到context和self这两个引用,可以知道这个Quark.Text对象使用了JS常用的上下文绑定机制,被一个闭包里的变量引用着,相当于该Quark.Text对象多了两个引用,这种情况比较容易出现内存泄漏,如果闭包函数不释放,这个Quark.Text对象也释放不了。

展开 _textContent,可以看到下一级的引用:

img.png

把这个树状图反过来看,可以看到,该对象(ID @70035)其中的一条引用链是这样的:

GameListV       _curV       _gameListV    省略...
                  \         |        /
                    \       |       /
                  _noticeWidget
                           |
                     _noticeC
                           |
                     _noticeV
                           |
                  _txtContent
                           ||
             Quark.Text @70035

内存快照的对比通过快照对比的功能,可以知道程序在运行期间哪些对象变更了。

刚才已经拍下了一个快照,接下来再拍一次,如下图:

img.png

点击图中的黑色实心圆圈按钮,即可得到第二个内存快照:

img.png

然后点击图中的 Snapshot 2,视图才会切换到第二次拍的快照。

img.png

点击图中的 Summary,可弹出一个列表,选择 Comparison 选项,结果如下图:

img.png

这个视图列出了当前视图与上一个视图的对象差异。列名字段解释:

  • New — 新建了多少个对象
  • Deleted — 回收了多少个对象
  • Delta — 对象变化值,即新建的对象个数减去回收了的对象个数
  • Size Delta — 变化的内存大小(字节)

注意 Delta 字段,尤其是值大于0的对象。下面以 Quark.Tween 为例子,展开该对象,可看到如下图所示:

img.png

在 # New 列里,如果有 “.”,则表示是新建的对象。

在 # Deleted 列里,如果有 “.”,则表示是回收了的对象。

平时排查问题的时候,应该多拍几次快照进行对比,这样有利于找出其中的规律。

三、内存泄漏的排查

JS程序的内存溢出后,会使某一段函数体永远失效(取决于当时的JS代码运行到哪一个函数),通常表现为程序突然卡死或程序出现异常。

这时我们就要对该JS程序进行内存泄漏的排查,找出哪些对象所占用的内存没有释放。这些对象通常都是开发者以为释放掉了,但事实上仍被某个闭包引用着,或者放在某个数组里面。

观察者模式引起的内存泄漏

有时我们需要在程序中加入观察者模式(Observer)来解藕一些模块,但如果使用不当,也会带来内存泄漏的问题。

排查这类型的内存泄漏问题,主要重点关注被引用的对象类型是闭包(closure)和数组Array的对象。

下面以德州扑克游戏为例:

img.png

img.png

测试人员发现德州扑克游戏存在内存溢出的问题,重现步骤:进入游戏–退出到分区–再进入游戏–再退出到分区,如此反复几次便出现游戏卡死的问题。

排查的步骤如下:

  1. 打开游戏;
  2. 进入第一个分区(快速场 5/10);
  3. 进入后,拍下内存快照;
  4. 退出到刚才的分区界面;
  5. 再次进入同一个分区;
  6. 进入后,再次拍下内存快照;
  7. 重复步骤2到6,直到拍下5组内存快照;
  8. 将每组的视图都转换到Comparison对比视图;
  9. 进行内存对比分析。

经过上面的步骤后,可以得到下图结果:

img.png

先看最后一个快照,可以看到闭包(closure)+1,这是需要重点关注的部分。(string)、(system)和(compiled code)类型可以不管,因为提供的信息不多。

img.png

接着点击倒数第二个快照,看到闭包(closure)类型也是+1。

img.png

接着再看上一个快照,闭包还是 +1。

这说明每次进入游戏都会创建这个闭包函数,并且退出到分区的时候没有销毁。

展开(closure),可以看到非常多的function对象:

img.png

建新的闭包数量是49个,回收的闭包数量是48个,即是说这次操作有48个闭包正确释放了,有一个忘记释放了。每个新建和回收的function对象的ID都不一样,找不到任何的关联性,无法定位是哪一个闭包函数出了问题。

接下来打开 Object’s retaining tree 视图,查找引用里是否存在不断增大的数组。

如下图,展开 Snapshot 5 每个 function 对象的引用:

img.png

其中有个 function 对象的引用 deleFunc 存放在一个数组里,下标是 4,数组的对象 ID 是 @45599。

继续查找 Snapshot 4 的 function 对象:

img.png

发现这里有一个 function 的引用名称也是 deleFunc,也存放在 ID 为 @45599 的数组里,下标是 3。这个对象极有可能是没有释放掉的闭包。

继续查看 Snapshot 3 里的 function 对象:

img.png

从图中可以看到同一个 function 对象,下标是 2。那么这里一定存在内存泄漏问题。

数组下面有一个引用名称 login_success,在程序里搜索一下该关键字,终于定位到有问题的代码。因为进入游戏的时候注册了 login_success 通知:

ob.addListener("login_success", _onLoginSuc);

但退出到分区的时候,没有移除该通知,下次进入游戏的时候,又再注册了一次,所以造成 function 不断增加。改成退出到分区的时候移除该通知:

ob.removeListener("login_success", _onLoginSuc);

这样就成功解决这个内存泄漏的问题了。

德州扑克这种问题多数见于观察者设计模式中,使用一个全局数组存储所有注册的通知,如果忘记移除通知,则该数组会不断增大,最终造成内存溢出。

上下文绑定引起的内存泄漏

很多时候我们会用到上下文绑定函数 bind(也有些人写成 delegate),无论是自己实现的 bind 方法还是 JS 原生的 bind 方法,都会有内存泄漏的隐患。

下面举一个简单的例子:

<script type="text/javascript">
var ClassA = function(name){
  this.name = name;
  this.func = null;
};

var a = new ClassA("a");
var b = new ClassA("b");

b.func = bind(function(){
  console.log("I am " + this.name);
}, a);

b.func();  //输出 I am a

a = null;  //释放a
//b = null;  //释放b

//模拟上下文绑定
function bind(func, self){
  return function(){
    return func.apply(self);
  };
}; 
</script>

上面的代码中,bind 通过闭包来保存上下文 self,使得事件 b.func 里的 this 指向的是 a,而不是 b。

首先我们把 b = null; 注释掉,只释放 a。看一下内存快照:

img.png

可以看到有两个 ClassA 对象,这与我们的本意不相符,我们释放了 a,应该只存在一个 ClassA 对象 b 才对。

img.png

从上面两个图可以看出这两个对象中,一个是 b,另一个并不是a,因为a这个引用已经置空了。第二个 ClassA 对象是 bind 里的闭包的上下文 self,self 与 a 引用同一个对象。虽然 a 释放了,但由于 b 没有释放,或者 b.func 没有释放,使得闭包里的self也一直存在。要释放 self,可以执行 b=null 或者 b.func=null。

把代码改成:

<script type="text/javascript">
var ClassA = function(name){
  this.name = name;
  this.func = null;
};

var a = new ClassA("a");
var b = new ClassA("b");

b.func = bind(function(){
  console.log("I am " + this.name);
}, a);

b.func();  //输出 I am a
a = null;  //释放a

b.func = null;  //释放self

//模拟上下文绑定
function bind(func, self){
  return function(){
    return func.apply(self);
  };
};
</script>

再看看内存:

img.png

可以看到只剩下一个 ClassA 对象 b 了,a 已被释放掉了。

四、结语

JS 的灵活性既是优点也是缺点,平时写代码时要注意内存泄漏的问题。当代码量非常庞大的时候,就不能仅靠复查代码来排查问题,必须要有一些监控对比工具来协助排查。

之前排查内存泄漏问题的时候,总结出以下几种常见的情况:

  1. 闭包上下文绑定后没有释放;
  2. 观察者模式在添加通知后,没有及时清理掉;
  3. 定时器的处理函数没有及时释放,没有调用 clearInterval 方法;
  4. 视图层有些控件重复添加,没有移除。

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据

关于作者

晚风撩人

暂无简介

0 文章
0 评论
24509 人气
更多

推荐作者

lorenzathorton8

文章 0 评论 0

Zero

文章 0 评论 0

萧瑟寒风

文章 0 评论 0

mylayout

文章 0 评论 0

tkewei

文章 0 评论 0

17818769742

文章 0 评论 0

    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文