JS中由閉包引發記憶體洩露的深思
阿新 • • 發佈:2020-05-05
**目錄**
- 一個存在記憶體洩露的閉包例項
- 什麼是記憶體洩露
- JS的垃圾回收機制
- 什麼是閉包
- 什麼原因導致了記憶體洩露
- 參考
**1.一個存在記憶體洩露的閉包例項** ```js 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,閉包指的是: > 1. 從理論角度:所有的函式。因為它們都在建立的時候就將上層上下文的資料儲存起來了。哪怕是簡單的全域性變數也是如此,因為函式中訪問全域性變數就相當於是在訪問自由變數,這個時候使用最外層的作用域。 > > 2. 從實踐角度:以下函式才算是閉包: > > - 即使建立它的上下文已經銷燬,它仍然存在(比如,內部函式從父函式中返回) > > - 在程式碼中引用了自由變數
**(4)什麼原因導致了記憶體洩露** 我們這裡主要以實踐角度來理解我們所討論的閉包。 這裡需要弄明白一個問題:為什麼建立閉包函式的函式的上下文已經被銷燬了(常規理解就是函式呼叫棧釋放,函式內的臨時變數被回收等),閉包函式依舊可以讀取建立它的函式的內部變數? 從結果倒推,唯一能解釋這一點的就是:雖然建立閉包函式的函式的上下文已經被銷燬了,但被閉包函式所引用的變數沒有被回收。那具體是如何實現的呢? 為了深入理解這個問題,這裡就需要簡單的談一下函式中的作用域鏈: ``` 當前函式的作用域鏈[[Scope]] = VO / AO + 父級函式的作用域鏈[[Scope]] ``` 補充說明:VO 和 AO 分別表示變數物件和活動物件,而變數物件可以理解為儲存了當前上下文資料(變數、函式宣告、函式引數)的一個物件,而活動物件是特殊的變數物件,簡單理解就是函式的變數物件我們一般稱之為活動物件,而在全域性上下文裡,全域性物件自身就是變數物件。[點選檢視詳細解釋](https://www.cnblogs.com/TomXu/archive/2012/01/16/2309728.html) 在JS內部實現中,每個函式都會有一個 [[Scope]] 屬性,表示當前函式的可以訪問的作用域鏈。其實質上就是一個物件陣列,包含了函式能夠訪問到的所有識別符號(變數、函式等),用以查詢函式所使用的到的識別符號。而陣列中從左到右的物件依次對應了由內到外的其他函式(或全域性)的活動(變數)物件。另外,在 ECMAScript 中,同一個父上下文中建立的閉包是共用一個 [[Scope]] 屬性的。換句話說,同一個函式內部的所有閉包共用這個函式的 [[Scope]] 屬性。 對於閉包函式來說,為了實現其所引用的變數不會被回收,會保留它的作用域鏈(即 [[Scope]] 屬性),不會被垃圾回收器回收。
那麼上面的示例中,閉包函式 unused 與 someMethod 的作用域鏈如下圖所示(函式和物件名加了數字字尾,用以區分replaceThing 函式多次呼叫而產生的同名函式與物件) (1)replaceThing 函式第一次呼叫: ![第一次呼叫的作用域鏈](https://img2020.cnblogs.com/blog/898684/202005/898684-20200504234210289-1141040354.png) 如上圖,在 replaceThing 函式第一次呼叫完,通過全域性變數 theThing,可以訪問到閉包函式 someMehtod1,因此其作用域鏈也會被保留,即 replaceThing1.[[Scope]] 將被保留,所以閉包函式 unused1就算沒有被使用,也不會被回收。(全域性變數直到程式執行結束前都不會被回收)
(2)replaceThing 函式第二次呼叫: ![第二次呼叫的作用域鏈](https://img2020.cnblogs.com/blog/898684/202005/898684-20200504234254235-1102499678.png) 如上圖,在 replaceThing 函式第二次呼叫完,通過全域性變數 theThing,可以訪問到閉包函式 someMehtod2,因此其作用域鏈也會被保留,即 replaceThing2.[[Scope]] 將被保留,所以閉包函式 unused2 與物件 originalThing2 也將被保留,不會被回收。由於 originalThing2 可以訪問到閉包函式 someMehtod1,因此之前第一次被保留的作用域鏈仍將繼續被保留。 當 replaceThing 函式繼續重複呼叫時,相當於上圖中虛線框中的內容不斷重複,而且相互之間類似形成一個連結串列,通過 全域性變數 theThing 可以順著連結串列到查詢到第一次呼叫產生的物件 [Object1],這也就導致了垃圾回收器無法回收每次產生的新物件(裡面包含一個大陣列和一個閉包),造成嚴重的記憶體洩漏。
**2.參考** [記憶體管理- JavaScript | MDN](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Memory_Management) [Chrome 瀏覽器垃圾回收機制與記憶體洩漏分析](https://juejin.im/post/5db2beb8e51d455b450a64b4#heading-8) [4類 JavaScript 記憶體洩漏及如何避免](https://jinlong.github.io/2016/05/01/4-Types-of-Memory-Leaks-in-JavaScript-and-How-to-Get-Rid-Of-Them/) [深入理解JavaScript系列(12):變數物件(Variable Object)](https://www.cnblogs.com/TomXu/archive/2012/01/16/2309728.html) [深入理解JavaScript系列(14):作用域鏈(Scope Chain)](http://www.cnblogs.com/TomXu/archive/2012/01/18/2312463.html) [深入理解JavaScript系列(16):閉包(Closures)](https://www.cnblogs.com/TomXu/archive/2012/01/31/233025
**1.一個存在記憶體洩露的閉包例項** ```js 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,閉包指的是: > 1. 從理論角度:所有的函式。因為它們都在建立的時候就將上層上下文的資料儲存起來了。哪怕是簡單的全域性變數也是如此,因為函式中訪問全域性變數就相當於是在訪問自由變數,這個時候使用最外層的作用域。 > > 2. 從實踐角度:以下函式才算是閉包: > > - 即使建立它的上下文已經銷燬,它仍然存在(比如,內部函式從父函式中返回) > > - 在程式碼中引用了自由變數
**(4)什麼原因導致了記憶體洩露** 我們這裡主要以實踐角度來理解我們所討論的閉包。 這裡需要弄明白一個問題:為什麼建立閉包函式的函式的上下文已經被銷燬了(常規理解就是函式呼叫棧釋放,函式內的臨時變數被回收等),閉包函式依舊可以讀取建立它的函式的內部變數? 從結果倒推,唯一能解釋這一點的就是:雖然建立閉包函式的函式的上下文已經被銷燬了,但被閉包函式所引用的變數沒有被回收。那具體是如何實現的呢? 為了深入理解這個問題,這裡就需要簡單的談一下函式中的作用域鏈: ``` 當前函式的作用域鏈[[Scope]] = VO / AO + 父級函式的作用域鏈[[Scope]] ``` 補充說明:VO 和 AO 分別表示變數物件和活動物件,而變數物件可以理解為儲存了當前上下文資料(變數、函式宣告、函式引數)的一個物件,而活動物件是特殊的變數物件,簡單理解就是函式的變數物件我們一般稱之為活動物件,而在全域性上下文裡,全域性物件自身就是變數物件。[點選檢視詳細解釋](https://www.cnblogs.com/TomXu/archive/2012/01/16/2309728.html) 在JS內部實現中,每個函式都會有一個 [[Scope]] 屬性,表示當前函式的可以訪問的作用域鏈。其實質上就是一個物件陣列,包含了函式能夠訪問到的所有識別符號(變數、函式等),用以查詢函式所使用的到的識別符號。而陣列中從左到右的物件依次對應了由內到外的其他函式(或全域性)的活動(變數)物件。另外,在 ECMAScript 中,同一個父上下文中建立的閉包是共用一個 [[Scope]] 屬性的。換句話說,同一個函式內部的所有閉包共用這個函式的 [[Scope]] 屬性。 對於閉包函式來說,為了實現其所引用的變數不會被回收,會保留它的作用域鏈(即 [[Scope]] 屬性),不會被垃圾回收器回收。
那麼上面的示例中,閉包函式 unused 與 someMethod 的作用域鏈如下圖所示(函式和物件名加了數字字尾,用以區分replaceThing 函式多次呼叫而產生的同名函式與物件) (1)replaceThing 函式第一次呼叫: ![第一次呼叫的作用域鏈](https://img2020.cnblogs.com/blog/898684/202005/898684-20200504234210289-1141040354.png) 如上圖,在 replaceThing 函式第一次呼叫完,通過全域性變數 theThing,可以訪問到閉包函式 someMehtod1,因此其作用域鏈也會被保留,即 replaceThing1.[[Scope]] 將被保留,所以閉包函式 unused1就算沒有被使用,也不會被回收。(全域性變數直到程式執行結束前都不會被回收)
(2)replaceThing 函式第二次呼叫: ![第二次呼叫的作用域鏈](https://img2020.cnblogs.com/blog/898684/202005/898684-20200504234254235-1102499678.png) 如上圖,在 replaceThing 函式第二次呼叫完,通過全域性變數 theThing,可以訪問到閉包函式 someMehtod2,因此其作用域鏈也會被保留,即 replaceThing2.[[Scope]] 將被保留,所以閉包函式 unused2 與物件 originalThing2 也將被保留,不會被回收。由於 originalThing2 可以訪問到閉包函式 someMehtod1,因此之前第一次被保留的作用域鏈仍將繼續被保留。 當 replaceThing 函式繼續重複呼叫時,相當於上圖中虛線框中的內容不斷重複,而且相互之間類似形成一個連結串列,通過 全域性變數 theThing 可以順著連結串列到查詢到第一次呼叫產生的物件 [Object1],這也就導致了垃圾回收器無法回收每次產生的新物件(裡面包含一個大陣列和一個閉包),造成嚴重的記憶體洩漏。
**2.參考** [記憶體管理- JavaScript | MDN](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Memory_Management) [Chrome 瀏覽器垃圾回收機制與記憶體洩漏分析](https://juejin.im/post/5db2beb8e51d455b450a64b4#heading-8) [4類 JavaScript 記憶體洩漏及如何避免](https://jinlong.github.io/2016/05/01/4-Types-of-Memory-Leaks-in-JavaScript-and-How-to-Get-Rid-Of-Them/) [深入理解JavaScript系列(12):變數物件(Variable Object)](https://www.cnblogs.com/TomXu/archive/2012/01/16/2309728.html) [深入理解JavaScript系列(14):作用域鏈(Scope Chain)](http://www.cnblogs.com/TomXu/archive/2012/01/18/2312463.html) [深入理解JavaScript系列(16):閉包(Closures)](https://www.cnblogs.com/TomXu/archive/2012/01/31/233025