js閉包測試

weixin_34262482發表於2013-10-01

本文的誕生,源自近期打算做的一個關於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系列一視同仁,掛上“垃圾”的名號。客觀地去看待,去評價,正是一個技術人員應該具備的最基本的準則和素養。