本文的誕生,源自近期打算做的一個關於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引擎分別進行了測試,參與測試的有:
~IE8自帶的JScript.dll
~IE9自帶的Chakra
~Opera 11.60自帶的Carakan
~Chrome 16.0.912.63自帶的V8(3.6.6.11)
~Firefox 9.0.1自帶的SpiderMonkey
測試的基本方案是,使用類似以下的代碼:
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結構的情況有以下幾種:
1.進入一個函數。
2.進入一段eval代碼。
3.使用with語句。
4.使用catch語句。
因此以下將針對這4種情況,進行多用例的測試。
測試過程級結果
基本測試
使用代碼
function outer() { var largeObject = LargeObject.fromSize('100MB'); return function() { debugger; }; } var inner = outer();
測試結果
~JScript.dll – 不回收,內存無下降趨勢。
~Chakra – 回收,內存會恢復到outer函數執行前的狀態。
~Carakan – 不回收,內存無下降趨勢。
~V8 – 回收,訪問largeObject拋出ReferenceError。
~SpiderMonkey – 回收,訪問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();
測試結果
~JScript.dll – 不回收,內存無下降趨勢。
~Chakra – 回收anotherLargeObject,內存會回到outer調用前並增加100MB左右。
~Carakan – 不回收,內存無下降趨勢。
~V8 – 回收,訪問largeObject能得到正確的值,訪問anotherLargeObject拋出ReferenceError。
~SpiderMonkey – 回收,訪問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();
測試結果
~JScript.dll – 不回收,內存無下降趨勢。
~Chakra – 不回收,內存無下降趨勢。
~Carakan – 不回收,內存無下降趨勢。
~V8 – 不回收,訪問largeObject可得到正確的值。
~SpiderMonkey – 不回收,訪問largeObject可得到正確的值。
結論
如果返回的inner函數中有使用eval函數,則不LexicalEnvironment中的任何變量進行解除綁定的操作,保留所有變量的綁定,以避免產生不可預期的結果。
間接調用eval
使用代碼
function outer() { var largeObject = LargeObject.fromSize('100MB'); return function() { window.eval(''); debugger; }; } var inner = outer(); inner();
測試結果
~JScript.dll – 不回收,內存無下降趨勢。
~Chakra – 回收,內存會恢復到outer函數執行前的狀態。
~Carakan – 不回收,內存無下降趨勢。
~V8 – 回收,訪問largeObject拋出ReferenceError。
~SpiderMonkey – 回收,訪問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();
測試結果
~JScript.dll – 不回收,內存無下降趨勢。
~Chakra – 不回收,內存無下降趨勢。
~Carakan – 不回收,內存無下降趨勢。
~V8 – 不回收,訪問largeObject可得到正確的值。
~SpiderMonkey – 不回收,訪問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();
測試結果
~JScript.dll – 不回收,內存無下降趨勢。
~Chakra – 回收largeObject,但不回收scope.o,內存恢復至outer函數被調用前並增加100MB左右(無法得知scope是否被回收)。
~Carakan – 不回收,內存無下降趨勢。
~V8 – 不回收,訪問largeObject和scope以及o均可得到正確的值。
~SpiderMonkey – 回收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();
測試結果
~JScript.dll – 不回收,內存無下降趨勢。
~Chakra – 回收largeObject和ex,內存會恢復到outer函數被調用前的狀態。
~Carakan – 不回收,內存無下降趨勢。
~V8 – 僅回收largeObject,訪問largeObject拋出ReferenceError,但仍可訪問到ex。
~SpiderMonkey – 僅回收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();
測試結果
~JScript.dll – 不回收,內存無下降趨勢。
~Chakra – 回收,內存會恢復到outer函數被調用前的狀態。
~Carakan – 不回收,內存無下降趨勢。
~V8 – 回收,內存會恢復到outer函數被調用前的狀態。
~SpiderMonkey – 回收,內存會恢復到outer函數被調用前的狀態。
結論
嵌套函數中有與外層函數同名的變量或參數時,不會影響到外層函數中該變量的回收優化。即javascript引擎會排除FormalParameterList和所有VariableDeclaration表達式中的Identifier,再掃描所有Identifier來分析變量的可回收性。
總體結論
首先一個較為明確的結論是,以下內容會影響到閉包內變量的回收:
~嵌套的函數中是否有使用該變量。
~嵌套的函數中是否有直接調用eval。
~是否使用了with表達式。
Chakra、V8和SpiderMonkey將受以上因素的影響,表現出不盡相同又較為相似的回收策略,而JScript.dll和Carakan則完全沒有這方面的優化,會完整保留整個LexicalEnvironment中的所有變量綁定,造成一定的內存消耗。
由於對閉包內變量有回收優化策略的Chakra、V8和SpiderMonkey引擎的行為較為相似,因此可以總結如下,當返回一個函數fn時:
1.如果fn的[[Scope]]是ObjectEnvironment(with表達式生成ObjectEnvironment,函數和catch表達式生成DeclarativeEnvironment),則:
A.如果是V8引擎,則退出全過程。
B.如果是SpiderMonkey,則處理該ObjectEnvironment的外層LexicalEnvironment。
2.獲取當前LexicalEnvironment下的所有類型為Function的對象,對於每一個Function對象,分析其FunctionBody:
A.如果FunctionBody中含有直接調用eval,則退出全過程。
B.否則得到所有的Identifier。
C.對於每一個Identifier,設其為name,根據查找變量引用的規則,從LexicalEnvironment中找出名稱為name的綁定binding。
D.對binding添加notSwap屬性,其值為true。
3.檢查當前LexicalEnvironment中的每一個變量綁定,如果該綁定有notSwap屬性且值為true,則:
A.如果是V8引擎,刪除該綁定。
B.如果是SpiderMonkey,將該綁定的值設為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系列一視同仁,掛上“垃圾”的名號。客觀地去看待,去評價,正是一個技術人員應該具備的最基本的准則和素養。