JS中的内存管理
# 简介
像 C 语言这样的高级语言一般都有底层的内存管理接口,比如 malloc()和 free().另一方面,JavaScript 创建变量(对象,字符串等)时分配内存,并且在不再使用它们时“自动”释放. 后一个过程称为垃圾回收.这个“自动”是混乱的根源,并让 JavaScript(和其他高级语言)开发者感觉他们可以不关心内存管理. 这是错误的,所以可以通过平时多了解一些 JS 中内存管理问题, 在写代码中通过一些习惯, 避免内存泄露的问题.
# 内存生命周期
不管什么程序语言,内存生命周期基本是一致的:
- 分配你所需要的内存
- 使用分配到的内存(读、写)
- 不需要时将其释放\归还
所有语言第二部分都是明确的.第一和第三部分在底层语言中是明确的,但在像 JavaScript 这些高级语言中,大部分都是隐含的.
# JavaScript 的内存分配
# 值的初始化
为了不让程序员费心分配内存,JavaScript 在定义变量时就完成了内存分配.
var n = 123; // 给数值变量分配内存
var s = "azerty"; // 给字符串分配内存
var o = {
a: 1,
b: null,
}; // 给对象及其包含的值分配内存
// 给数组及其包含的值分配内存(就像对象一样)
var a = [1, null, "abra"];
function f(a) {
return a + 2;
} // 给函数(可调用的对象)分配内存
// 函数表达式也能分配一个对象
someElement.addEventListener(
"click",
function () {
someElement.style.backgroundColor = "blue";
},
false
);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 通过函数调用分配内存
有些函数调用结果是分配对象内存:
var d = new Date(); // 分配一个 Date 对象
var e = document.createElement("div"); // 分配一个 DOM 元素
2
3
有些方法分配新变量或者新对象:
var s = "azerty";
var s2 = s.substr(0, 3); // s2 是一个新的字符串
// 因为字符串是不变量,
// JavaScript 可能决定不分配内存,
// 只是存储了 [0-3] 的范围.
var a = ["ouais ouais", "nan nan"];
var a2 = ["generation", "nan nan"];
var a3 = a.concat(a2);
// 新数组有四个元素,是 a 连接 a2 的结果
2
3
4
5
6
7
8
9
10
# 使用值
的过程实际上是对分配内存进行读取与写入的操作.读取与写入可能是写入一个变量或者一个对象的属性值,甚至传递函数的参数.
# 当内存不再需要使用时释放
大多数内存管理的问题都在这个阶段.在这里最艰难的任务是找到“所分配的内存确实已经不再需要了”.它往往要求开发人员来确定在程序中哪一块内存不再需要并且释放它.
# 垃圾回收
如上所述自动寻找是否一些内存“不再需要”的问题是无法判定的.因此,垃圾回收实现只能有限制的解决一般问题.
# 引用
垃圾回收算法主要依赖于引用的概念.在内存管理的环境中,一个对象如果有访问另一个对象的权限(隐式或者显式),叫做一个对象引用另一个对象.例如,一个 Javascript 对象具有对它原型的引用(隐式引用)和对它属性的引用(显式引用). 在这里,“对象”的概念不仅特指 JavaScript 对象,还包括函数作用域(或者全局词法作用域).
# 引用计数垃圾收集
在下面的例子中, 此算法把“对象是否不再需要”简化定义为“对象有没有其他对象引用到它”.如果没有引用指向该对象(零引用),对象将被垃圾回收机制回收.
var o = {
a: {
b: 2,
},
};
// 两个对象被创建,一个作为另一个的属性被引用,另一个被分配给变量o
// 很显然,没有一个可以被垃圾收集
var o2 = o; // o2变量是第二个对“这个对象”的引用
o = 1; // 现在,“这个对象”的原始引用o被o2替换了
var oa = o2.a; // 引用“这个对象”的a属性
// 现在,“这个对象”有两个引用了,一个是o2,一个是oa
o2 = "yo"; // 最初的对象现在已经是零引用了
// 他可以被垃圾回收了
// 然而它的属性a的对象还在被oa引用,所以还不能回收
oa = null; // a属性的那个对象现在也是零引用了
// 它可以被垃圾回收了
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 限制:循环引用
该算法有个限制:无法处理循环引用.在下面的例子中,两个对象被创建,并互相引用,形成了一个循环.它们被调用之后会离开函数作用域,所以它们已经没有用了,可以被回收了.然而,引用计数算法考虑到它们互相都有至少一次引用,所以它们不会被回收.
function f() {
var o = {};
var o2 = {};
o.a = o2; // o 引用 o2
o2.a = o; // o2 引用 o
return "azerty";
}
f();
2
3
4
5
6
7
8
9
10
实际例子: IE 6, 7 使用引用计数方式对 DOM 对象进行垃圾回收.该方式常常造成对象被循环引用时内存发生泄漏:
var div;
window.onload = function () {
div = document.getElementById("myDivElement");
div.circularReference = div;
div.lotsOfData = new Array(10000).join("*");
};
2
3
4
5
6
在上面的例子里,myDivElement 这个 DOM 元素里的 circularReference 属性引用了 myDivElement,造成了循环引用.如果该属性没有显示移除或者设为 null,引用计数式垃圾收集器将总是且至少有一个引用,并将一直保持在内存里的 DOM 元素,即使其从 DOM 树中删去了.如果这个 DOM 元素拥有大量的数据 (如上的 lotsOfData 属性),而这个数据占用的内存将永远不会被释放.
# 标记-清除算法
这个算法把“对象是否不再需要”简化定义为“对象是否可以获得”.
这个算法假定设置一个叫做根(root)的对象(在 Javascript 里,根是全局对象).垃圾回收器将定期从根开始,找所有从根开始引用的对象,然后找这些对象引用的对象……从根开始,垃圾回收器将找到所有可以获得的对象和收集所有不能获得的对象.
这个算法比前一个要好,因为“有零引用的对象”总是不可获得的,但是相反却不一定,参考“循环引用”.
从 2012 年起,所有现代浏览器都使用了标记-清除垃圾回收算法.所有对 JavaScript 垃圾回收算法的改进都是基于标记-清除算法的改进,并没有改进标记-清除算法本身和它对“对象是否不再需要”的简化定义.
# 自动 GC 的问题
尽管自动 GC 很方便, 但是我们不知道 GC 什么时候会进行. 这意味着如果我们在使用过程中使用了大量的内存, 而 GC 没有运行的情况下, 或者 GC 无法回收这些内存的情况下, 程序就有可能假死, 这个就需要我们在程序中手动做一些操作来触发内存回收.
# 什么是内存泄露?
本质上讲, 内存泄露就是不再被需要的内存, 由于某种原因, 无法被释放.
# 常见的内存泄露案例
# 全局变量
function foo(arg) {
bar = "some text";
}
2
3
在 JS 中处理未被声明的变量, 上述范例中的 bar 时, 会把 bar, 定义到全局对象中, 在浏览器中就是 window 上. 在页面中的全局变量, 只有当页面被关闭后才会被销毁.所以这种写法就会造成内存泄露, 当然在这个例子中泄露的只是一个简单的字符串, 但是在实际的代码中, 往往情况会更加糟糕.
另外一种意外创建全局变量的情况.
function foo() {
this.var1 = "potential accidental global";
}
// Foo 被调用时, this 指向全局变量(window)
foo();
2
3
4
5
在这种情况下调用 foo, this 被指向了全局变量 window, 意外的创建了全局变量.
我们谈到了一些意外情况下定义的全局变量, 代码中也有一些我们明确定义的全局变量. 如果使用这些全局变量用来暂存大量的数据, 记得在使用后, 对其重新赋值为 null.
# 未销毁的定时器和回调函数
在很多库中, 如果使用了观察着模式, 都会提供回调方法, 来调用一些回调函数. 要记得回收这些回调函数. 举一个 setInterval 的例子.
var serverData = loadData();
setInterval(function () {
var renderer = document.getElementById("renderer");
if (renderer) {
renderer.innerHTML = JSON.stringify(serverData);
}
}, 5000); // 每 5 秒调用一次
2
3
4
5
6
7
如果后续 renderer 元素被移除, 整个定时器实际上没有任何作用. 但如果你没有回收定时器, 整个定时器依然有效, 不但定时器无法被内存回收, 定时器函数中的依赖也无法回收. 在这个案例中的 serverData 也无法被回收.
# 闭包
在 JS 开发中, 我们会经常用到闭包, 一个内部函数, 有权访问包含其的外部函数中的变量. 下面这种情况下, 闭包也会造成内存泄露.
var theThing = null;
var replaceThing = function () {
var originalThing = theThing;
var unused = function () {
if (originalThing)
// 对于 'originalThing'的引用
console.log("hi");
};
theThing = {
longStr: new Array(1000000).join("*"),
someMethod: function () {
console.log("message");
},
};
};
setInterval(replaceThing, 1000);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
这段代码, 每次调用 replaceThing 时, theThing 获得了包含一个巨大的数组和一个对于新闭包 someMethod 的对象. 同时 unused 是一个引用了 originalThing 的闭包.
这个范例的关键在于, 闭包之间是共享作用域的, 尽管 unused 可能一直没有被调用, 但是 someMethod 可能会被调用, 就会导致内存无法对其进行回收. 当这段代码被反复执行时, 内存会持续增长.
该问题的更多描述阅读这篇文章 (opens new window).
# DOM 引用
很多时候, 我们对 Dom 的操作, 会把 Dom 的引用保存在一个数组或者 Map 中.
var elements = {
image: document.getElementById("image"),
};
function doStuff() {
elements.image.src = "http://example.com/image_name.png";
}
function removeImage() {
document.body.removeChild(document.getElementById("image"));
// 这个时候我们对于 #image 仍然有一个引用, Image 元素, 仍然无法被内存回收.
}
2
3
4
5
6
7
8
9
10
上述案例中, 即使我们对于 image 元素进行了移除, 但是仍然有对 image 元素的引用, 依然无法对齐进行内存回收.
另外需要注意的一个点是, 对于一个 Dom 树的叶子节点的引用. 举个例子: 如果我们引用了一个表格中的 td 元素, 一旦在 Dom 中删除了整个表格, 我们直观的觉得内存回收应该回收除了被引用的 td 外的其他元素. 但是事实上, 这个 td 元素是整个表格的一个子元素, 并保留对于其父元素的引用. 这就会导致对于整个表格, 都无法进行内存回收. 所以我们要小心处理对于 Dom 元素的引用.
# 结论
ES6 中引入 WeakSet
和 WeakMap
两个新的概念, 来解决引用造成的内存回收问题. WeakSet
和 WeakMap
对于值的引用可以忽略不计, 他们对于值的引用是弱引用,内存回收机制, 不会考虑这种引用. 当其他引用被消除后, 引用就会从内存中被释放.
JS 这类高级语言,隐藏了内存管理功能。但无论开发人员是否注意,内存管理都在那,所有编程语言最终要与操作系统打交道,在内存大小固定的硬件上工作。不幸的是,即使不考虑垃圾回收对性能的影响,2017 年最新的垃圾回收算法,也无法智能回收所有极端的情况。
唯有程序员自己才知道何时进行垃圾回收,而 JS 由于没有暴露显示内存管理接口,导致触发垃圾回收的代码看起来像“垃圾”,或者优化垃圾回收的代码段看起来不优雅、甚至不可读。
所以在 JS 这类高级语言中,有必要掌握基础内存分配原理,在对内存敏感的场景,比如 nodejs 代码做严格检查与优化。谨慎使用 dom 操作、主动删除没有业务意义的变量、避免提前优化、过度优化,在保证代码可读性的前提下,利用性能监控工具,通过调用栈定位问题代码。
即便在 JS 中, 我们很少去直接去做内存管理. 但是我们在写代码的时候, 也要有内存管理的意识, 谨慎的处理可能会造成内存泄露的场景.
同时对于如何利用 chrome 调试工具, 分析内存泄露的方法和技巧.