原文:http://point.davidglasser.net/2013/06/27/surprising-javascript-memory-leak.html
本周我在Meter的同事追蹤到了一個奇怪的Javascript內存泄漏。我找遍了互聯網,嘗試了各種關鍵字:javascript closure memory leak,無果。所以,這可能是一個未知的問題。(你們所找到的都是講老版本的IE的垃圾回收算法的問題,但是我碰到的這個問題甚至影響到我當前裝的最新Chrome瀏覽器。)
Update:Vyacheslav Egorov向我指出他曾經寫過的一篇同樣主題的文章,這篇文章有更詳細的樣例,更好的配圖,加上一些V8內部機制的知識,很棒的一篇文章。甚至,還有一篇論文專門來講這個問題的最佳實現方式,但是這篇文章並不是用Javascipt來說明的。
考慮如下代碼:
var run = function () { var str = new Array(1000000).join('*'); var doSomethingWithStr = function () { if (str === 'something') console.log("str was something"); }; doSomethingWithStr(); }; setInterval(run, 1000);
每一秒鍾執行一次run函數,它會申請一個巨大的字符串,創建一個閉包來使用它,調用閉包,然后返回。閉包返回之后,它會被垃圾回收,因為沒有什么引用str對象了,所以str也被回收。 但是如果我們在run里面持續調用閉包呢?
var run = function () { var str = new Array(1000000).join('*'); var logIt = function () { console.log('interval'); }; setInterval(logIt, 100); }; setInterval(run, 1000);
每一秒鍾run申請一個巨大的字符串,並且在內部調用logIt 10次,logIt持續運行,而str在它的詞法作用域內,所以這可能構成內存泄漏。 幸運的是,JavaScript的引擎(至少在最新的Chrome)足夠聰明,它能檢測到logIt並沒有使用到str,所以str不會被放到logIt的詞法作用域環境,所以呢每次run結束后,str被GC是沒有問題的。 太棒了,JavaScript能幫助我們防止內存泄漏,對嗎?讓我們來點更復雜的,把前兩個例子合並起來。
var run = function () { var str = new Array(1000000).join('*'); var doSomethingWithStr = function () { if (str === 'something') console.log("str was something"); }; doSomethingWithStr(); var logIt = function () { console.log('interval'); } setInterval(logIt, 100); }; setInterval(run, 1000);
打開Chrome開發者工具,切換到Memory View視圖,然后點record。
看起來每秒鍾都會增加1M的內存使用,甚至點擊垃圾回收按鈕來強直GC也不起作用,所以看起來str泄漏了。 不過這個看起來不是和之前的一樣的情形嗎,str只被run和doSomethingWithStr引用了。一旦run運行結束,doSomeThingWithStr會被回收。run里唯一一個引起泄漏的便是第二個閉包:logIt,並且logIt根本沒指向str。 所以,盡管沒有任何代碼指向str,它也沒有被GC,為什么呢?
好吧,閉包的典型實現是每一個函數對象有一個類似字典的對象來代表它的詞法環境。如果定義在run里的函數確實使用了str,即使str被一次又一次的賦值,它也能保證兩者引用的是相同的對象。如果變量沒有被任何閉包使用的話,V8引擎足夠聰明來讓變量在函數的詞法環境之外,所以第一個示例沒有發生內存泄漏。 但是一旦一個變量被任一個閉包使用了,它會在所有的閉包詞法環境結束之后才被釋放,這會導致內存泄漏。
到這里,你能想到一個更智能的詞法環境的實現來避免這個問題。每一個閉包用一個字典記錄它真正使用的變量,字典里的變量應當是一個可變單元,能被多個閉包共享詞法環境。根據我閱讀ECMA V5文檔,這個方法是的合法的。ECMA文檔指出詞法環境是純粹的規范機制,而不需要與EcmaScript的實現保持一致。
我們在Meter碰到的問題看起來是這樣:我們只是希望用一個新對象取代一個對象,並且原來的對象被釋放。
var theThing = null; var replaceThing = function () { var originalThing = theThing; // 定義一個引用originalThing的閉包但是沒有真正調用的 // 但是因為閉包的存在,originalThing會在詞法環境中 //所有定義在replaceThing的閉包都能訪問。 //你把這個函數去掉,就沒有泄漏了。 var unused = function () { if (originalThing) console.log("hi"); }; theThing = { longStr: new Array(1000000).join('*'), // originalThing理論上會被這個someMethod使用 //雖然很明顯這個函數沒有使用它。 //但是因為originalThing是詞法作用域的一部分,someMethod會保持對originalThing的引用。 //盡管我們每次調用都會把theThing覆蓋但是原先的值永遠不會被清除。 someMethod: function () {} }; // 如果你在這里加上 `originalThing = null` 就不會泄漏了 }; setInterval(replaceThing, 1000);
很顯然someMethod是個閉包,repalceThing執行結束后,因為theThing還引用着someMethod,originalThing就不會被釋放。
一旦了解了這些,解決這個問題就很簡單了。總結一下:如果你有一個大的對象被一些閉包使用,但是不是每一個閉包使用。只要保證使用完后本地變量不再指向它。不幸的是,這些Bug很難察覺。如果Javascript能讓你不用考慮就好了。