关于闭包及变量回收问题
本文的诞生,源自近期打算做的一个关于javascript中的闭包的专题,由于需要解析闭包对垃圾回收的影响,特此针对不同的javascript引擎,做了相关的测试。
为了能从本文中得到需要的知识,看本文前,请明确自己知道闭包的概念,并对垃圾回收的常用算法有一定的了解。
假设有如下的代码:
function outer() { var largeObject = LargeObject.fromSize('100MB'); return function() { console.log('inner'); };}var inner = outer();
在这一段代码中,outer
函数和inner
函数间会形成一个闭包,致使inner
函数能够访问到largeObject
,但是显然inner
并没有访问largeObject
,那么在闭包中的largeObject
对象是否能被回收呢?
如果引入更复杂的情况:
function outer() { var largeObject = LargeObject.fromSize('100MB'); var anotherLargeObject = LargeObject.fromSize('100MB'); return function() { largeObject.work(); console.log('inner'); };}var inner = outer();
首先一个显然的概念是largeObject
肯定不能被回收,因为inner
确实地需要使用它。但是anotherLargeObject
又能不能被回收呢?它将跟随largeObject
一起始终存在,还是和largeObject
分离,独立地被回收呢?
带着这个疑问,对现有的几款现代javascript引擎分别进行了测试,参与测试的有:
测试的基本方案是,使用类似以下的代码:
function outer() { var largeObject = LargeObject.fromSize('100MB'); return function() { debugger; };}var inner = outer();
通过各浏览器的开发者工具(Developer Tools、Firebug、Dragonfly等),在断点处停止javascript的执行,并通过控制台或本地变量查看功能检查largeObject
的值,如果其值存在,则认为GC并没有回收该对象。
对于部分浏览器(特别是IE),考虑到对脚本执行有2种模式(执行模式和调试模式,IE通过开发者工具的Script面板中的“Start Debugging”按钮切换),在调试模式下才会命中断点,但是调试模式下可能存在不同的引擎优化方案,因此采用内存比对的方式进行测试。即打开资源浏览器,在var inner = outer();
一行后强制执行一次垃圾回收(IE使用window.CollectGarbage()
;Opera使用window.opera.collect();
),查看内存的变化。如果内存始终有100MB的占用,没有明显的下降现象,则认为GC并没有回收该对象。
对于用例的设计,由于从ECMAScript标准中可以得知,所有的变量访问是通过一个LexicalEnvironment对象进行的,因此目标在于在不同的LexicalEnvironment结构下进行测试。从标准中,搜索LexicalEnvironment不难得出能够改变LexicalEnvironment结构的情况有以下几种:
eval
代码。with
语句。catch
语句。因此以下将针对这4种情况,进行多用例的测试。
function outer() { var largeObject = LargeObject.fromSize('100MB'); return function() { debugger; };}var inner = outer();inner();
outer
函数执行前的状态。largeObject
抛出ReferenceError。largeObject
得到undefined
。当一个函数outer
返回另一个函数inner
时,Chakra、V8和SpiderMonkey会对outer
中声明,但inner
中不使用的变量进行回收,其中V8直接将变量从LexicalEnvironment上解除绑定,而SpiderMonkey仅仅将变量的值设为undefined
,并不解除绑定。
function outer() { var largeObject = LargeObject.fromSize('100MB'); var anotherLargeObject = LargeObject.fromSize('100MB'); return function() { largeObject; debugger; };}var inner = outer();inner();
anotherLargeObject
,内存会回到outer
调用前并增加100MB左右。largeObject
能得到正确的值,访问anotherLargeObject
抛出ReferenceError。largeObject
能得到正确的值,访问anotherLargeObject
得到undefined
。当一个LexicalEnvironment上存在多个变量绑定时,Chakra、V8和SpiderMonkey会针对不同的变量判断是否有被使用,该判断方法是扫描返回的函数inner
的源码来实现的,随后会将没有被inner
使用的变量从LexicalEnvironment中解除绑定(同样的,SpiderMonkey不解除绑定,仅赋值为undefined
),而剩下的变量继续保留。
eval
的影响function outer() { var largeObject = LargeObject.fromSize('100MB'); return function() { eval(''); debugger; };}var inner = outer();inner();
largeObject
可得到正确的值。largeObject
可得到正确的值。如果返回的inner
函数中有使用eval
函数,则不LexicalEnvironment中的任何变量进行解除绑定的操作,保留所有变量的绑定,以避免产生不可预期的结果。
eval
function outer() { var largeObject = LargeObject.fromSize('100MB'); return function() { window.eval(''); debugger; };}var inner = outer();inner();
outer
函数执行前的状态。largeObject
抛出ReferenceError。largeObject
得到undefined
。由于ECMAScript规定间接调用eval
时,代码将在全局作用域下执行,是无法访问到largeObject
变量的。因此对于间接调用eval
的情况,各javascript引擎将按标准的方式进行处理,无视该间接调用eval
的存在。
同样的,对于new Function('return largeObject;')
这种情形,由于标准规定new Function
创建的函数的[[Scope]]
是全局的LexicalEnvironment,因此也无法访问到largeObject
,所有引擎都参照间接调用eval
的方式,选择无视Function
构造函数的调用。
function outer() { var largeObject = LargeObject.fromSize('100MB'); function help() { largeObject; // eval(''); } return function() { debugger; };}var inner = outer();inner();
largeObject
可得到正确的值。largeObject
可得到正确的值。不仅仅是被返回的inner
函数,如果在outer
函数中定义的嵌套的help
函数中使用了largeObject
变量(或直接调用eval
),也同样会造成largeObject
变量无法回收。因此javascript引擎扫描的不仅仅是inner
函数的源码,同样扫描了其他所有嵌套函数的源码,以判断是否可以解除某个特定变量的绑定。
with
表达式function outer() { var largeObject = LargeObject.fromSize('100MB'); var scope = { o: LargeObject.fromSize('100MB') }; with (scope) { return function() { debugger; }; }}var inner = outer();inner();
largeObject
,但不回收scope.o
,内存恢复至outer
函数被调用前并增加100MB左右(无法得知scope
是否被回收)。largeObject
和scope
以及o
均可得到正确的值。largeObject
和scope
,访问该2个变量均得到undefined
,不回收o
,可得到正确的值。当有with
表达式时,V8将会放弃所有变量的回收,保留LexicalEnvironment中所有变量的绑定。而SpiderMonkey则会保留由with
表达式生成的新的LexicalEnvironment中的所有变量的绑定,而对于outer
函数生成的LexicalEnvironment,按标准的方式进行处理,尽可能解除其中的变量绑定。
catch
表达式function outer() { var largeObject = LargeObject.fromSize('100MB'); try { throw { o: LargeObject.fromSize('100MB'); } } catch (ex) { return function() { debugger; }; }}var inner = outer();inner();
largeObject
和ex
,内存会恢复到outer
函数被调用前的状态。largeObject
,访问largeObject
抛出ReferenceError,但仍可访问到ex
。largeObject
,访问largeObject
得到undefined
,但仍可访问到ex
。catch
表达式虽然会增加一个LexicalEnvironment,但对闭包内变量的绑定解除算法几乎没有影响,这源于catch
生成的LexicalEnvironment仅仅是追加了被catch的Error对象一个绑定,是可控的(相对的with
则不可控),因此对变量回收的影响也可以控制和优化。但对于新生成并添加了Error对象的LexicalEnvironment,V8和SpiderMonkey均不会进一步优化回收,而Chakra则会对该LexicalEnvironment进行处理,如果其中的Error对象可以回收,则会解除其绑定。
function outer() { var largeObject = LargeObject.fromSize('100MB'); return function(largeObject /* 或在函数体内声明 */) { // var largeObject; };}var inner = outer();inner();
outer
函数被调用前的状态。outer
函数被调用前的状态。outer
函数被调用前的状态。嵌套函数中有与外层函数同名的变量或参数时,不会影响到外层函数中该变量的回收优化。即javascript引擎会排除FormalParameterList和所有VariableDeclaration表达式中的Identifier,再扫描所有Identifier来分析变量的可回收性。
首先一个较为明确的结论是,以下内容会影响到闭包内变量的回收:
eval
。with
表达式。Chakra、V8和SpiderMonkey将受以上因素的影响,表现出不尽相同又较为相似的回收策略,而JScript.dll和Carakan则完全没有这方面的优化,会完整保留整个LexicalEnvironment中的所有变量绑定,造成一定的内存消耗。
由于对闭包内变量有回收优化策略的Chakra、V8和SpiderMonkey引擎的行为较为相似,因此可以总结如下,当返回一个函数fn
时:
如果fn
的[[Scope]]
是ObjectEnvironment(with
表达式生成ObjectEnvironment,函数和catch
表达式生成DeclarativeEnvironment),则:
获取当前LexicalEnvironment下的所有类型为Function的对象,对于每一个Function对象,分析其FunctionBody:
name
,根据查找变量引用的规则,从LexicalEnvironment中找出名称为name
的绑定binding
。binding
添加notSwap
属性,其值为true
。检查当前LexicalEnvironment中的每一个变量绑定,如果该绑定有notSwap
属性且值为true
,则:
undefined
,将删除notSwap
属性。对于Chakra引擎,暂无法得知是按V8的模式还是按SpiderMonkey的模式进行。
从以上测试及结论来看,V8确实是一个优秀的javascript引擎,在这一方面的优化相当到位。而SpiderMonkey则采取一种更为友好的方式,不直接删除变量的绑定,而是将值赋为undefined
,也许是SpiderMonkey团队考虑到有一些极端特殊的情况,依旧有可能导致使用到该变量,因此保证至少不会抛出ReferenceError打断代码的执行。而IE9的Chakra相比IE8的JScript.dll进步非常大,细节上的处理也很优秀。Opera的Carakan在这一方面则相对落后,完全没有对闭包内的变量回收进行优化,选择了最为稳妥但略显浪费的方式。
此外,所有带有优化策略的浏览器,都在内在开销和速度之间选择了一个平衡点,这也正是为什么“多个嵌套函数”这一测试用例中,虽然inner
没有再使用largeObject
对象,甚至在inner
中的断点处,连help
函数对象也已经解除绑定,却没有解除largeObject
的绑定。基于这种现象,可以推测各引擎均只选择检查一层的关联性,即不去处理inner -> help -> largeObject
这样深度的引用关系,只找inner -> largeObject
和help -> largeObject
并做一个合集来处理,以提高效率。也许这种方式依旧存在内存开销的浪费,但同时CPU资源也是非常贵重的,如何掌握这之间的平衡,便是javascript引擎的选择。
此外,根据部分开发者的测试,Chakra甚至有资格被称为现有最快速的javascript引擎,微软也一直在努力,而开发者更不应该一味地谩骂和嘲笑IE。
我们可以嘲笑IE6的落后,可以看不到低版本的IE曾经为互联网的发展做过的贡献,可以在这些历史产品已经没落的今天无情地给予打击,却最最不应该将整个IE系列一视同仁,挂上“垃圾”的名号。客观地去看待,去评价,正是一个技术人员应该具备的最基本的准则和素养。