JS内存泄漏排查方法(Chrome Profiles)

Google Chrome 浏览器 提供了非常强大的JS调试工具,Heap Profiling便是其中一个。Heap Profiling可以记录当前的堆内存(heap)快照,并生成对象的描述文件,该描述文件给出了当时JS运行所用到的所有对象,以及这些对 ...
一、概述 
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”标签。可以看到下图: 
图片1.jpg
可以看到,该面板可以监控CPU、CSS和内存,选中“Take Heap Snapshot”,点击“Start”按钮,就可以拍下当前JS的heap快照,如下图所示: 
图片2.jpg
右边视图列出了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不会)。把鼠标停留在某一个对象上,会显示出该对象的内部属性和当时的值。 
图片3.png
这个视图有助于我们辨别这是哪个对象。但该视图跟踪不了是被谁引用了。 

查看对象的引用关系 
点击其中一个对象,能看到对象的引用层级关系,如下图: 
图片4.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,可以看到下一级的引用: 
图片5.png
把这个树状图反过来看,可以看到,该对象(ID @70035)其中的一条引用链是这样的: 
GameListV _curV _gameListV 省略... \ | / \ | / _noticeWidget | _noticeC | _noticeV | _txtContent || Quark.Text @70035 
内存快照的对比通过快照对比的功能,可以知道程序在运行期间哪些对象变更了。 
刚才已经拍下了一个快照,接下来再拍一次,如下图: 
图片6.png
点击图中的黑色实心圆圈按钮,即可得到第二个内存快照: 
图片7.png
然后点击图中的“Snapshot 2”,视图才会切换到第二次拍的快照。 
图片8.png
点击图中的“Summary”,可弹出一个列表,选择“Comparison”选项,结果如下图: 
图片9.png
这个视图列出了当前视图与上一个视图的对象差异。列名字段解释:# New -- 新建了多少个对象# Deleted -- 回收了多少个对象# Delta -- 对象变化值,即新建的对象个数减去回收了的对象个数Size Delta -- 变化的内存大小(字节)注意Delta字段,尤其是值大于0的对象。下面以Quark.Tween为例子,展开该对象,可看到如下图所示: 
图片10.png
在“# New”列里,如果有“.”,则表示是新建的对象。 
在“# Deleted”列里,如果有“.”,则表示是回收了的对象。 
平时排查问题的时候,应该多拍几次快照进行对比,这样有利于找出其中的规律。 

三、内存泄漏的排查 
JS程序的内存溢出后,会使某一段函数体永远失效(取决于当时的JS代码运行到哪一个函数),通常表现为程序突然卡死或程序出现异常。 
这时我们就要对该JS程序进行内存泄漏的排查,找出哪些对象所占用的内存没有释放。这些对象通常都是开发者以为释放掉了,但事实上仍被某个闭包引用着,或者放在某个数组里面。 

观察者模式引起的内存泄漏 
有时我们需要在程序中加入观察者模式(Observer)来解藕一些模块,但如果使用不当,也会带来内存泄漏的问题。 
排查这类型的内存泄漏问题,主要重点关注被引用的对象类型是闭包(closure)和数组Array的对象。
下面以德州扑克游戏为例: 
图片11.png
图片12.png
测试人员发现德州扑克游戏存在内存溢出的问题,重现步骤:进入游戏--退出到分区--再进入游戏--再退出到分区,如此反复几次便出现游戏卡死的问题。 
排查的步骤如下: 
1.打开游戏; 
2.进入第一个分区(快速场5/10); 
3.进入后,拍下内存快照; 
4.退出到刚才的分区界面; 
5.再次进入同一个分区; 
6.进入后,再次拍下内存快照; 
7.重复步骤2到6,直到拍下5组内存快照; 
8.将每组的视图都转换到Comparison对比视图; 
9.进行内存对比分析。 
经过上面的步骤后,可以得到下图结果: 
图片13.png
先看最后一个快照,可以看到闭包(closure)+1,这是需要重点关注的部分。(string)、(system)和(compiled code)类型可以不管,因为提供的信息不多。 
图片14.png
接着点击倒数第二个快照,看到闭包(closure)类型也是+1。 
图片15.png
接着再看上一个快照,闭包还是+1。 
这说明每次进入游戏都会创建这个闭包函数,并且退出到分区的时候没有销毁。 
展开(closure),可以看到非常多的function对象: 
图片16.png
建新的闭包数量是49个,回收的闭包数量是48个,即是说这次操作有48个闭包正确释放了,有一个忘记释放了。每个新建和回收的function对象的ID都不一样,找不到任何的关联性,无法定位是哪一个闭包函数出了问题。 
接下来打开Object's retaining tree视图,查找引用里是否存在不断增大的数组。 
如下图,展开“Snapshot 5”每个function对象的引用: 
图片17.png
其中有个function对象的引用deleFunc存放在一个数组里,下标是4,数组的对象ID是@45599。 
继续查找“Snapshot 4”的function对象: 
图片18.png
发现这里有一个function的引用名称也是deleFunc,也存放在ID为@45599的数组里,下标是3。这个对象极有可能是没有释放掉的闭包。 
继续查看“Snapshot 3”里的function对象: 
图片19.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。看一下内存快照:

图片20.png
可以看到有两个ClassA对象,这与我们的本意不相符,我们释放了a,应该只存在一个ClassA对象b才对。 
图片21.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> 
再看看内存: 
图片22.png
可以看到只剩下一个ClassA对象b了,a已被释放掉了。 

四、结语 
JS的灵活性既是优点也是缺点,平时写代码时要注意内存泄漏的问题。当代码量非常庞大的时候,就不能仅靠复查代码来排查问题,必须要有一些监控对比工具来协助排查。 
之前排查内存泄漏问题的时候,总结出以下几种常见的情况: 
1.闭包上下文绑定后没有释放; 
2.观察者模式在添加通知后,没有及时清理掉; 
3.定时器的处理函数没有及时释放,没有调用clearInterval方法; 
4.视图层有些控件重复添加,没有移除。
### JavaScript闭包的概念 闭包是指一个函数能够记住并访问它的词法作用域,即使这个函数在其词法作用域之外被执行。这种特性允许内部函数访问外部函数的作用域中的变量,从而实现数据封装和持久化存储的效果[^3]。 例如: ```javascript function outerFunction() { let counter = 0; return function innerFunction() { counter++; console.log(counter); } } const closureExample = outerFunction(); closureExample(); // 输出 1 closureExample(); // 输出 2 ``` 上述代码展示了闭包的核心功能:`innerFunction` 记住了 `outerFunction` 中的 `counter` 变量,并能够在多次调用中保持其状态。 --- ### 如何避免由闭包引起的内存泄漏问题? #### 1. 避免不必要的全局变量引用 当闭包捕获了一个大对象或者长时间存在的对象时,可能会导致这些对象无法被垃圾回收机制释放。因此应尽量减少对外部大型对象的引用[^1]。 解决方案是在不需要该变量的时候将其显式设置为 `null` 或者解除对其的引用[^4]。 ```javascript function createClosure() { const largeObject = new Array(1e7).join('*'); // 创建一个很大的字符串 return function () { if (largeObject) { console.log('Large object still exists'); } else { console.log('Large object has been released'); } }; } let myClosure = createClosure(); myClosure(); // 显式释放 largeObject 的引用 createClosure().largeObject = null; myClosure(); ``` --- #### 2. 清理定时器或事件监听器 如果闭包被捕获在一个未清理的定时器或事件监听器中,则可能导致内存泄漏。因为定时器会持续持有对闭包及其依赖的对象的引用,阻止它们被销毁。 解决方法是确保在不再需要时清除所有的定时器和事件监听器。 ```javascript function setupInterval() { const data = { key: 'value' }; // 大型数据结构 setInterval(function () { console.log(data.key); // 定时器持有了 data 的引用 }, 1000); // 返回一个用于清理的方法 return function cleanup() { clearInterval(setupInterval.intervalId); }; } setupInterval.cleanup(); ``` --- #### 3. 使用弱引用(WeakMap/WeakSet) 对于某些场景,可以考虑使用 `WeakMap` 和 `WeakSet` 来代替普通的对象映射关系。这样可以让键值对中的键自动参与垃圾回收过程,而不会造成内存泄漏。 示例: ```javascript const weakMap = new WeakMap(); function addDataToElement(element, data) { weakMap.set(element, data); } addDataToElement(document.getElementById('example'), { info: 'data' }); console.log(weakMap.get(document.getElementById('example'))); // 获取绑定的数据 document.getElementById('example').remove(); // 移除 DOM 节点后,关联的数据也会被释放 ``` --- #### 4. 减少嵌套层次过深的闭包 过多的嵌套闭包可能增加复杂度,同时也增加了潜在的内存泄漏风险。可以通过重构代码来降低闭包的嵌套层数[^2]。 示例优化前: ```javascript function deepNestedClosure() { const a = {}; return function b() { const c = {}; return function d() { console.log(a, c); // 同时保留了 a 和 c 的引用 }; }; } ``` 优化后: ```javascript function shallowClosure() { const sharedState = {}; return function updateSharedState(value) { Object.assign(sharedState, value); console.log(sharedState); }; } ``` --- ### 工具支持 利用现代浏览器提供的开发工具可以帮助诊断内存泄漏问题。例如 Chrome DevTools 提供了 **Timeline** 和 **Profiles** 功能,通过记录堆分配情况找到周期性增长的内存区域。 具体操作如下: - 打开开发者工具 -> Memory Tab。 - 点击 Record 开始录制内存变化。 - 进行一系列测试操作后再停止录制分析结果。 --- ### 总结 合理设计程序逻辑、及时清理无用资源以及善用现代化调试手段都是有效防止闭包引发内存泄漏的关键措施。遵循最佳实践不仅可以提升应用性能还能增强用户体验。 ---
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值