一、概述
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
”标签。可以看到下图:
<ignore_js_op>
可以看到,该面板可以监控
CPU
、
CSS
和内存,选中“
Take Heap Snapshot
”,点击“
Start
”按钮,就可以拍下当前
JS
的
heap
快照,如下图所示:
<ignore_js_op>
右边视图列出了
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
不会)。把鼠标停留在某一个对象上,会显示出该对象的内部属性和当时的值。
<ignore_js_op>
这个视图有助于我们辨别这是哪个对象。但该视图跟踪不了是被谁引用了。
查看对象的引用关系
点击其中一个对象,能看到对象的引用层级关系,如下图:
<ignore_js_op>
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
,可以看到下一级的引用:
<ignore_js_op>
把这个树状图反过来看,可以看到,该对象
(ID @70035)
其中的一条引用链是这样的:
GameListV _curV _gameListV
省略
...
\ | /
\ | /
_noticeWidget
|
_noticeC
|
_noticeV
|
_txtContent
||
Quark.Text @70035
内存快照的对比通过快照对比的功能,可以知道程序在运行期间哪些对象变更了。
刚才已经拍下了一个快照,接下来再拍一次,如下图:
<ignore_js_op>
点击图中的黑色实心圆圈按钮,即可得到第二个内存快照:
<ignore_js_op>
然后点击图中的“
Snapshot 2
”,视图才会切换到第二次拍的快照。
<ignore_js_op>
点击图中的“
Summary
”,可弹出一个列表,选择“
Comparison
”选项,结果如下图:
<ignore_js_op>
这个视图列出了当前视图与上一个视图的对象差异。列名字段解释:# New --
新建了多少个对象# Deleted --
回收了多少个对象# Delta --
对象变化值,即新建的对象个数减去回收了的对象个数Size Delta --
变化的内存大小
(
字节
)注意
Delta
字段,尤其是值大于
0
的对象。下面以
Quark.Tween
为例子,展开该对象,可看到如下图所示:
<ignore_js_op>
在“
# New
”列里,如果有“
.
”,则表示是新建的对象。
在“
# Deleted
”列里,如果有“
.
”,则表示是回收了的对象。
平时排查问题的时候,应该多拍几次快照进行对比,这样有利于找出其中的规律。
三、内存泄漏的排查
JS
程序的内存溢出后,会使某一段函数体永远失效(取决于当时的
JS
代码运行到哪一个函数),通常表现为程序突然卡死或程序出现异常。
这时我们就要对该
JS
程序进行内存泄漏的排查,找出哪些对象所占用的内存没有释放。这些对象通常都是开发者以为释放掉了
,但事实上仍被某个闭包引用着,或者放在某个数组里面。
观察者模式引起的内存泄漏
有时我们需要在程序中加入观察者模式(
Observer
)来解藕一些模块,但如果使用不当,也会带来内存泄漏的问题。
排查这类型的内存泄漏问题,主要重点关注被引用的对象类型是闭包(
closure
)和数组
Array
的对象。
下面以德州扑克游戏为例:
<ignore_js_op>
<ignore_js_op>
测试人员发现德州扑克游戏存在内存溢出的问题,重现步骤:进入游戏
--
退出到分区
--
再进入游戏
--
再退出到分区,如此反复几次便出现游戏卡死的问题。
排查的步骤如下:
1.打开游戏;
2.进入第一个分区(快速场
5/10
);
3.进入后,拍下内存快照;
4.退出到刚才的分区界面;
5.再次进入同一个分区;
6.进入后,再次拍下内存快照;
7.重复步骤
2
到
6
,直到拍下
5
组内存快照;
8.将每组的视图都转换到
Comparison
对比视图;
9.进行内存对比分析。
经过上面的步骤后,可以得到下图结果:
<ignore_js_op>
先看最后一个快照,可以看到闭包(
closure
)
+1
,这是需要重点关注的部分。(
string
)、(
system
)和(
compiled code
)类型可以不管,因为提供的信息不多。
<ignore_js_op>
接着点击倒数第二个快照,看到闭包(
closure
)类型也是
+1
。
<ignore_js_op>
接着再看上一个快照,闭包还是
+1
。
这说明每次进入游戏都会创建这个闭包函数,并且退出到分区的时候没有销毁。
展开(
closure
),可以看到非常多的
function
对象:
<ignore_js_op>
建新的闭包数量是
49
个,回收的闭包数量是
48
个,即是说这次操作有
48
个闭包正确释放了,有一个忘记释放了。每个新建和回收的
function
对象的
ID
都不一样,找不到任何的关联性,无法定位是哪一个闭包函数出了问题。
接下来打开
Object's retaining tree
视图,查找引用里是否存在不断增大的数组。
如下图,展开“
Snapshot 5
”每个
function
对象的引用:
<ignore_js_op>
其中有个
function
对象的引用
deleFunc
存放在一个数组里,下标是
4
,数组的对象
ID
是
@45599
。
继续查找“
Snapshot 4
”的
function
对象:
<ignore_js_op>
发现这里有一个
function
的引用名称也是
deleFunc
,也存放在
ID
为
@45599
的数组里,下标是
3
。这个对象极有可能是没有释放掉的闭包。
继续查看“
Snapshot 3
”里的
function
对象:
<ignore_js_op>
从图中可以看到同一个
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 。看一下内存快照:
<ignore_js_op>
可以看到有两个
ClassA
对象,这与我们的本意不相符,我们释放了
a
,应该只存在一个
ClassA
对象
b
才对。
<ignore_js_op>
从上面两个图可以看出这两个对象中,一个是
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>
再看看内存:
<ignore_js_op>
可以看到只剩下一个
ClassA
对象
b
了,
a
已被释放掉了。
四、结语
JS
的灵活性既是优点也是缺点,平时写代码时要注意内存泄漏的问题。当代码量非常庞大的时候,就不能仅靠复查代码来排查问题,必须要有一些监控对比工具来协助排查。
之前排查内存泄漏问题的时候,总结出以下几种常见的情况:
1.闭包上下文绑定后没有释放;
2.
观察者模式在添加通知后,没有及时清理掉;
3.
定时器的处理函数没有及时释放,没有调用
clearInterval
方法;
4.
视图层有些控件重复添加,没有移除。
|