JS中由閉包引發記憶體泄露的深思
目錄
- 一個存在記憶體泄露的閉包實例
- 什麼是記憶體泄露
- JS的垃圾回收機制
- 什麼是閉包
- 什麼原因導致了記憶體泄露
- 參考
1.一個存在記憶體泄露的閉包實例
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);
上面程式碼片段做了一件事情:每隔1秒後調用 replaceThing 函數,全局變數 theThing 得到一個包含一個大數組和一個新閉包(someMethod)的新對象。同時,變數 unused 是一個引用 originalThing 的閉包。
初看之下,感覺應該不存在什麼記憶體泄露問題。replaceThing 函數在每次調用完之後,應該就會釋放或銷毀 originalThing 和 unused 變數,畢竟這兩個變數只在函數內部聲明使用了,不能夠在 replaceThing 函數外面被使用。而留在記憶體中的就只剩每次新分配給全局變數 theThing 的新對象。
但實際上面的直觀感受是錯誤,因為沒有真正理解到閉包的實現原理。為了弄清楚上面的程式碼為什麼存在記憶體泄露,我們首先需要弄清楚幾個概念與原理:什麼是記憶體泄露?JS的垃圾回收機制?什麼是閉包?
(1)什麼是記憶體泄露
應用程式不再用到的記憶體,由於某些原因,沒有及時釋放,就叫做記憶體泄漏。
(2)JS的垃圾回收機制
不同的程式語言管理記憶體的方式各不相同。一些高級程式語言的解釋器或運行時嵌入了「垃圾回收器」,通過演算法可自動的進行記憶體的分配與釋放管理(比如 JavaScript、Java、C# 等)。另一些則寄希望於開發者自己手動地進行記憶體的分配與釋放管理(比如 C/C++ 等)。
而JavaScript 是通過垃圾回收器來進行記憶體管理,其實現是基於標記-清除演算法。而這個演算法把「對象是否不再需要」簡化定義為「對象是否可以獲得」。其假定設置一個叫做根(root)的對象(在Javascript里,根是全局對象)。在標記過程,垃圾回收器將定期從根開始,找所有從根開始引用的對象,然後找這些對象引用的對象……從根開始,垃圾回收器將找到所有可以獲得的對象和收集所有不能獲得的對象。標記完成後就進行清除過程。(可達記憶體被標記,其餘的被當作垃圾回收。)
(3)什麼是閉包。
開發人員經常錯誤將閉包簡化理解成從父上下文中返回內部函數,或則簡單歸納為能夠讀取其他函數內部變數的函數。
實際上,根據 ECMAScript,閉包指的是:
從理論角度:所有的函數。因為它們都在創建的時候就將上層上下文的數據保存起來了。哪怕是簡單的全局變數也是如此,因為函數中訪問全局變數就相當於是在訪問自由變數,這個時候使用最外層的作用域。
從實踐角度:以下函數才算是閉包:
即使創建它的上下文已經銷毀,它仍然存在(比如,內部函數從父函數中返回)
在程式碼中引用了自由變數
(4)什麼原因導致了記憶體泄露
我們這裡主要以實踐角度來理解我們所討論的閉包。
這裡需要弄明白一個問題:為什麼創建閉包函數的函數的上下文已經被銷毀了(常規理解就是函數調用棧釋放,函數內的臨時變數被回收等),閉包函數依舊可以讀取創建它的函數的內部變數?
從結果倒推,唯一能解釋這一點的就是:雖然創建閉包函數的函數的上下文已經被銷毀了,但被閉包函數所引用的變數沒有被回收。那具體是如何實現的呢?
為了深入理解這個問題,這裡就需要簡單的談一下函數中的作用域鏈:
當前函數的作用域鏈[[Scope]] = VO / AO + 父級函數的作用域鏈[[Scope]]
補充說明:VO 和 AO 分別表示變數對象和活動對象,而變數對象可以理解為保存了當前上下文數據(變數、函數聲明、函數參數)的一個對象,而活動對象是特殊的變數對象,簡單理解就是函數的變數對象我們一般稱之為活動對象,而在全局上下文里,全局對象自身就是變數對象。點擊查看詳細解釋
在JS內部實現中,每個函數都會有一個 [[Scope]] 屬性,表示當前函數的可以訪問的作用域鏈。其實質上就是一個對象數組,包含了函數能夠訪問到的所有標識符(變數、函數等),用以查找函數所使用的到的標識符。而數組中從左到右的對象依次對應了由內到外的其他函數(或全局)的活動(變數)對象。另外,在 ECMAScript 中,同一個父上下文中創建的閉包是共用一個 [[Scope]] 屬性的。換句話說,同一個函數內部的所有閉包共用這個函數的 [[Scope]] 屬性。
對於閉包函數來說,為了實現其所引用的變數不會被回收,會保留它的作用域鏈(即 [[Scope]] 屬性),不會被垃圾回收器回收。
那麼上面的示例中,閉包函數 unused 與 someMethod 的作用域鏈如下圖所示(函數和對象名加了數字後綴,用以區分replaceThing 函數多次調用而產生的同名函數與對象)
(1)replaceThing 函數第一次調用:
如上圖,在 replaceThing 函數第一次調用完,通過全局變數 theThing,可以訪問到閉包函數 someMehtod1,因此其作用域鏈也會被保留,即 replaceThing1.[[Scope]] 將被保留,所以閉包函數 unused1就算沒有被使用,也不會被回收。(全局變數直到程式運行結束前都不會被回收)
(2)replaceThing 函數第二次調用:
如上圖,在 replaceThing 函數第二次調用完,通過全局變數 theThing,可以訪問到閉包函數 someMehtod2,因此其作用域鏈也會被保留,即 replaceThing2.[[Scope]] 將被保留,所以閉包函數 unused2 與對象 originalThing2 也將被保留,不會被回收。由於 originalThing2 可以訪問到閉包函數 someMehtod1,因此之前第一次被保留的作用域鏈仍將繼續被保留。
當 replaceThing 函數繼續重複調用時,相當於上圖中虛線框中的內容不斷重複,而且相互之間類似形成一個鏈表,通過 全局變數 theThing 可以順著鏈表到查找到第一次調用產生的對象 [Object1],這也就導致了垃圾回收器無法回收每次產生的新對象(裡面包含一個大數組和一個閉包),造成嚴重的記憶體泄漏。
2.參考
深入理解JavaScript系列(12):變數對象(Variable Object)
深入理解JavaScript系列(14):作用域鏈(Scope Chain)
深入理解JavaScript系列(16):閉包(Closures)