一种有趣的JavaScript内存泄漏[译]

原文链接:http://info.meteor.com/blog/an-interesting-kind-of-javascript-memory-leak

最近,Avi 和 David 在 Meteor 的实时HTML模板渲染系统中跟踪了一个惊人的 JavaScript 内存泄漏。修复将在 0.6.5 版本发布(现在处于 QA 最后阶段)。

我在网上搜索关于 javascript 闭包内存泄漏的变化,网上并未提及相关事情,似乎在 JavaScript 主题下,这是一个鲜为人知的话题。(大多数你找到的谈论的是老版本IE的差劲的垃圾收集算法,但这个问题影响甚至我目前的Chrome安装!)我后来发现了一个很棒的博客,由一个V8开发人员发布在此主题上,但似乎大多数 JavaScript 使用者还不知道他们必须注意这一点。

JavaScript是一门神秘的函数式编程语言,而且他的函数就是闭包:函数对象有权访问定义在它们封闭范围内的变量,甚至在此范围已经结束的时候。一旦函数运行结束,闭包所捕获的局部变量就会被垃圾回收,所有定义在闭包范围内的函数都会自我进行垃圾回收。

现在,考虑一下这段代码:

1
2
3
4
5
6
7
8
var theThing = null;
var replaceThing = function () {
var originalThing = theThing;
theThing = {
longStr: new Array(1000000).join('*')
};
};
setInterval(replaceThing, 1000);

每一秒,我么都会执行 replaceThing 函数。他用包含新分配的巨型字符串的新的对象来替换 theThing ,把 theThing 的原始值保存在了局部变量 originalThing 中。在它返回后, theThing 的旧值会被垃圾回收,包括它里面的长字符串,因为没有任何东西一直指向它。所以,这段代码用到的内存大体上是恒定的:它会一直分配大字符串,但是每一次他都会删除先前的大字符串。

但是如果我们有一个比 replaceThing 更长寿的闭包会怎么样?

1
2
3
4
5
6
7
8
9
10
11
var theThing = null;
var replaceThing = function () {
var originalThing = theThing;
theThing = {
longStr: new Array(1000000).join('*'),
someMethod: function () {
console.log(someMessage);
}
};
};
setInterval(replaceThing, 1000);

someMethod 理论上可以引用 originalThing ,所以只要 someMethod 存活着, originalThing 就应当被保留吗?这将导致无限的内存增长,因为每个版本的 theThing 都会抓住指向上一个版本的指针。

幸运的是,现代的 JavaScript 实现(包括 Chrome 和 Node 中目前的 V8 )足够聪明,能够注意到 originalThing 在闭包 someMethod 中未被实际应用,所以它没有被放到 someMethod 的词法环境中,所以当 replaceThing 结束时,是可以对之前的 theThing 进行垃圾回收的。

(但是,等等,你可能会问!如果有人早先运行 console.log = eval ,那么看起来无害的 console.log(someMessage) 实际上是正 eval 一些引用了 originalThing 的代码?嗯, JavaScript 标准已经领先你一步了。如果你像这样偷偷摸摸地使用 eval(通过除了调用 eval 以外的任何方式),它被称为“间接 eval ”,实际上是不能访问词汇环境的!另一方面,如果 someMethod 确实包含了对使用了这个名称的 eval 的直接调用,它可能实际上访问了 originalThing ,JavaScript 环境禁止让 originalThing 一直在词法环境之外,这将导致泄露。)

好,太棒了,JavaScript保护我们免受内存泄漏之扰,对吧?好,让我们试一下另一个版本,结合了前两个例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var theThing = null;
var replaceThing = function () {
var originalThing = theThing;
var unused = function () {
if (originalThing)
console.log("hi");
};
theThing = {
longStr: new Array(1000000).join('*'),
someMethod: function () {
console.log(someMessage);
}
};
};
setInterval(replaceThing, 1000);

在谷歌开发者工具中打开 ‘Timeline’ 标签,切换到 ‘Memory’ 视图,然后点击 ‘Record’。

memory_leak

看起来就像我们每秒使用一个额外的兆字节!甚至点击垃圾桶图标强制手动垃圾回收也没有帮助。所以就好像我们泄漏了 longStr

但是这不是和以前一样的情况吗? originalThing 仅在 replaceThing 的主体中引用,在 unused 中。 replaceThing 一结束, unused 本身(我们甚至不会运行!)就会被清理…从 replaceThing 中唯一逃脱的就是第二个闭包 someMethod 。而 someMethod 根本没有引用 originalString

因此,即使任何代码都无法再次引用 originalThing ,它也从来没有被垃圾收集!为什么?是这样的,实现闭包的典型方式是每个函数对象都有一个链接,到代表它的词法作用域的字典式对象。如果 replaceThing 中定义的两个函数实际上都使用了 originalThing ,重要的是它们都获得了相同的对象,即使 originalThing 被重复分配,两个函数也共享相同的词法环境。现在,Chrome 的 V8 JavaScript引擎显然足够聪明,可以将变量放在词法环境之外,如果它们没有被任何闭包使用:这就是为什么第一个例子不泄漏。

但是一旦某个变量被任何闭包使用,它最终会出现在该作用域内所有闭包共享的词法环境中。这可能导致内存泄漏。

你可以想象一个更聪明的,可以避免这个问题的词汇环境的实现。每个闭包都可以有一本词典,里面包含着闭包实际读写的变量;这个词典中的值本身可能会是可以在多个闭包的词法环境间共享的可变单元。基于我对ECMAScript第5版标准的随意阅读,这是合法的:它对词法环境的描述将它们描述为“纯粹的规范机制,其不需要等同于任何 ECMAScript 实现的特定的伪现象”。也就是说,这个标准实际上并没有包含“垃圾”这个词,只是说了一次“内存”。(这里有一篇 关于这个问题的最佳实现的文章,虽然不是 JavaScript 主题下的。其他一些语言实现确实有效,例如Go。)

修复这种形式的内存泄漏,一旦你注意到它,是直接的,如 the fix to the Meteor bug 所示。只需添加 originalThing = nullreplaceThing 的结尾。这样,即使名称 originalThing 仍在 someMethod的词法环境中,也不会有大的旧值的链接。

所以总结一下:如果你有一个巨大的对象在被一些闭包使用,但不是你需要持续使用的任何闭包,只要确保局部变量不再指向它,一旦你用完它了。不幸的是,这些错误可能相当微妙;如果 JavaScript 引擎不要求你必须考虑这些,它将更好。

热评文章