内存泄漏
概述
不再用到的内存,没有及时释放,就叫做内存泄漏 (Memory Leak)。
JS 中常见的内存泄漏有:
- 意外的全局变量
- 创建以及删除 DOM
- 循环引用
内存泄漏的识别方法
找出周期性增长的内存
怎样可以观察到内存泄漏呢?经验法则是,如果连续五次垃圾回收之后,内存占用一次比一次大,就有内存泄漏。这就要求实时查看内存占用。Chrome 浏览器查看内存占用:
具体如何查看内存占用请阅读 Chrome DevTools 官方教程:Chrome DevTools: 解决内存问题,如果内存占用基本平稳,接近水平,就说明不存在内存泄漏。反之,就是内存泄漏了。
保存两个快照
切换到 Chrome Dev Tools 的 Profiles
标签,刷新页面,等页面刷新完成之后,点击 Take Heap Snapshot 保存快照作为基准。而后再次点击 The Button 按钮,等数秒以后,保存第二个快照。
筛选菜单选择 Summary,右侧选择 Objects allocated between Snapshot 1 and Snapshot 2,或者筛选菜单选择 Comparison ,然后可以看到一个对比列表。
此例很容易找到内存泄漏,看下 (string) 的 Size Delta Constructor,8MB,58 个新对象。新对象被分配,但是没有释放,占用了 8MB。
如果展开 (string) Constructor,会看到许多单独的内存分配。选择某一个单独的分配,下面的 retainers 会吸引我们的注意。
我们已选择的分配是数组的一部分,数组关联到 window 对象的 x 变量。这里展示了从巨大对象到无法回收的 root(window)的完整路径。我们已经找到了潜在的泄漏以及它的出处。
我们的例子还算简单,只泄漏了少量的 DOM 节点,利用以上提到的快照很容易发现。对于更大型的网站,Chrome 还提供了 Record Heap Allocations 功能。
Record heap allocations 找内存泄漏
回到 Chrome Dev Tools 的 profiles 标签,点击 Record Heap Allocations。工具运行的时候,注意顶部的蓝条,代表了内存分配,每一秒有大量的内存分配。运行几秒以后停止。
上图中可以看到工具的杀手锏:选择某一条时间线,可以看到这个时间段的内存分配情况。尽可能选择接近峰值的时间线,下面的列表仅显示了三种 constructor:其一是泄漏最严重的(string),下一个是关联的 DOM 分配,最后一个是 Text constructor(DOM 叶子节点包含的文本)。
从列表中选择一个 HTMLDivElement constructor,然后选择 Allocation stack。
现在知道元素被分配到哪里了吧(grow -> createSomeNodes),仔细观察一下图中的时间线,发现 HTMLDivElement constructor 调用了许多次,意味着内存一直被占用,无法被 GC 回收,我们知道了这些对象被分配的确切位置(createSomeNodes)。回到代码本身,探讨下如何修复内存泄漏吧。
另一个有用的特性
在 heap allocations 的结果区域,选择 Allocation。
这个视图呈现了内存分配相关的功能列表,我们立刻看到了 grow 和 createSomeNodes。当选择 grow 时,看看相关的 object constructor,清楚地看到 (string), HTMLDivElement 和 Text 泄漏了。
结合以上提到的工具,可以轻松找到内存泄漏。
意外的全局变量
先看以下例子,可以本地建一个 html
文件运行一下。
<body>
<button id="btn">Click</button>
</body>
<script>
var happy; // global
var poop = function () {
for (var i = 0; i < 100000; i++) {
happy += ' a ';
}
};
document.getElementById('btn').addEventListener('click', poop);
</script>
当我们不断点击 button
的时候通过 Performance
面板可以分析出以下内存占用图:
可以看出发生了内存泄漏,如果修改一下代码,将全局变量换成局部变量:
<body>
<button id="btn">Click</button>
</body>
<script>
var poop = function () {
var happy; // local
for (var i = 0; i < 100000; i++) {
happy += ' a ';
}
};
document.getElementById('btn').addEventListener('click', poop);
</script>
再看一下内存占用图,内存得到了及时的回收:
尤其当全局变量用于临时存储和处理大量信息时,需要多加小心。如果必须使用全局变量存储大量数据时,确保用完以后把它设置为 null
或者重新定义。与全局变量相关的增加内存消耗的一个主因是缓存。
缓存数据是为了重用,缓存必须有一个大小上限才有用。高内存消耗导致缓存突破上限,因为缓存内容无法被回收。
创建以及删除 DOM
有一组很经典的情况就是游离状的 DOM 无法被回收。以下的代码, node
已经被删除了,那么 node
中的子元素是否可以被回收?
let node = document.getElementById('node');
for (let i = 0; i < 2000; i++) {
let div = document.createElement('div');
node.appendChild(div);
}
document.body.removeChild(node);
答案是 NO
,因为 node
的引用还存在着,虽然在 DOM 中被删除了,但在 window
中引用还在,这个时候 node 的子元素就会以游离状态的 DOM 存在,而且无法被回收,如下图所示:
解决方案就是 node = null
,手动清空引用,消除游离状态的 DOM。
即使 setInterval
像下面这样写,其实也没发生内存泄漏:
setInterval(function () {
var happy;
for (var i = 0; i < 100000; i++) {
happy += ' a ';
}
}, 1000);
setInterval
只是放大了内存泄漏,但并不是罪魁祸首。
循环引用
对于观察者的例子,一旦它们不再需要(或者关联的对象变成不可达),明确地移除它们非常重要。老的 IE 6 是无法处理循环引用的。如今,即使没有明确移除它们,一旦观察者对象变成不可达,大部分浏览器是可以回收观察者处理函数的。
观察者代码示例:
var element = document.getElementById('button');
function onClick(event) {
element.innerHTML = 'text';
}
element.addEventListener('click', onClick);
老版本的 IE 是无法检测 DOM 节点与 JavaScript 代码之间的循环引用,会导致内存泄漏。如今,现代的浏览器(包括 IE 和 Microsoft Edge)使用了更先进的垃圾回收算法,已经可以正确检测和处理循环引用了。换言之,回收节点内存时,不必非要调用 removeEventListener 了。
在 Chrome 中查看一下内存情况,发现其实并没有发生泄漏:
WeakSet/WeakMap
前面说过,及时清除引用非常重要。但是,你不可能记得那么多,有时候一疏忽就忘了,所以才有那么多内存泄漏。
最好能有一种方法,在新建引用的时候就声明,哪些引用必须手动清除,哪些引用可以忽略不计,当其他引用消失以后,垃圾回收机制就可以释放内存。这样就能大大减轻程序员的负担,你只要清除主要引用就可以了。
ES6 考虑到了这一点,推出了两种新的数据结构:WeakSet 和 WeakMap。它们对于值的引用都是不计入垃圾回收机制的(其实就是对 key 或者 value 的引用计数不再增加,相当于 WeakMap 中这次引用计数是不算数的),所以名字里面才会有一个"Weak",表示这是弱引用。
下面以 WeakMap 为例,看看它是怎么解决内存泄漏的。
const wm = new WeakMap();
const element = document.getElementById('example');
wm.set(element, 'some information');
wm.get(element); // "some information"
上面代码中,先新建一个 WeakMap 实例。然后,将一个 DOM 节点作为键名存入该实例,并将一些附加信息作为键值,一起存放在 WeakMap 里面。这时,WeakMap 里面对 element
的引用就是弱引用,不会被计入垃圾回收机制。
也就是说,DOM 节点对象的引用计数是 1,而不是 2。这时,一旦消除对该节点的引用,它占用的内存就会被垃圾回收机制释放。 WeakMap 保存的这个键值对,也会自动消失。
基本上,如果你要往对象上添加数据,又不想干扰垃圾回收机制,就可以使用 WeakMap。只要外部的引用消失(比如 element
的引用被解除),WeakMap 内部的引用,就会自动被垃圾回收清除('some information'
的空间就会被回收)。