JavaScript 記憶體洩露的4種方式及如何避免
本文將探索常見的客戶端 JavaScript 記憶體洩露,以及如何使用 Chrome 開發工具發現問題。
簡介
記憶體洩露是每個開發者最終都要面對的問題,它是許多問題的根源:反應遲緩,崩潰,高延遲,以及其他應用問題。
什麼是記憶體洩露?
本質上,記憶體洩露可以定義為:應用程式不再需要佔用記憶體的時候,由於某些原因,記憶體沒有被作業系統或可用記憶體池回收。程式語言管理記憶體的方式各不相同。只有開發者最清楚哪些記憶體不需要了,作業系統可以回收。一些程式語言提供了語言特性,可以幫助開發者做此類事情。另一些則寄希望於開發者對記憶體是否需要清晰明瞭。
JavaScript 記憶體管理
JavaScript 是一種垃圾回收語言。垃圾回收語言通過週期性地檢查先前分配的記憶體是否可達,幫助開發者管理記憶體。換言之,垃圾回收語言減輕了“記憶體仍可用”及“記憶體仍可達”的問題。兩者的區別是微妙而重要的:僅有開發者瞭解哪些記憶體在將來仍會使用,而不可達記憶體通過演算法確定和標記,適時被作業系統回收。
JavaScript 記憶體洩露
垃圾回收語言的記憶體洩露主因是不需要的引用。理解它之前,還需瞭解垃圾回收語言如何辨別記憶體的可達與不可達。
Mark-and-sweep
大部分垃圾回收語言用的演算法稱之為 Mark-and-sweep 。演算法由以下幾步組成:
- 垃圾回收器建立了一個“roots”列表。Roots 通常是程式碼中全域性變數的引用。JavaScript 中,“window” 物件是一個全域性變數,被當作 root 。window 物件總是存在,因此垃圾回收器可以檢查它和它的所有子物件是否存在(即不是垃圾);
- 所有的 roots 被檢查和標記為啟用(即不是垃圾)。所有的子物件也被遞迴地檢查。從 root 開始的所有物件如果是可達的,它就不被當作垃圾。
- 所有未被標記的記憶體會被當做垃圾,收集器現在可以釋放記憶體,歸還給作業系統了。
現代的垃圾回收器改良了演算法,但是本質是相同的:可達記憶體被標記,其餘的被當作垃圾回收。
不需要的引用是指開發者明知記憶體引用不再需要,卻由於某些原因,它仍被留在啟用的 root 樹中。在 JavaScript 中,不需要的引用是保留在程式碼中的變數,它不再需要,卻指向一塊本該被釋放的記憶體。有些人認為這是開發者的錯誤。
為了理解 JavaScript 中最常見的記憶體洩露,我們需要了解哪種方式的引用容易被遺忘。
三種型別的常見 JavaScript 記憶體洩露
1:意外的全域性變數
JavaScript 處理未定義變數的方式比較寬鬆:未定義的變數會在全域性物件建立一個新變數。在瀏覽器中,全域性物件是 window
。
function foo(arg) { bar = "this is a hidden global variable"; }
真相是:
function foo(arg) { window.bar = "this is an explicit global variable"; }
函式 foo
內部忘記使用 var
,意外建立了一個全域性變數。此例洩露了一個簡單的字串,無傷大雅,但是有更糟的情況。
另一種意外的全域性變數可能由 this
建立:
function foo() { this.variable = "potential accidental global"; } // Foo 呼叫自己,this 指向了全域性物件(window) // 而不是 undefined foo();
在 JavaScript 檔案頭部加上
'use strict'
,可以避免此類錯誤發生。啟用嚴格模式解析 JavaScript ,避免意外的全域性變數。
全域性變數注意事項
儘管我們討論了一些意外的全域性變數,但是仍有一些明確的全域性變數產生的垃圾。它們被定義為不可回收(除非定義為空或重新分配)。尤其當全域性變數用於臨時儲存和處理大量資訊時,需要多加小心。如果必須使用全域性變數儲存大量資料時,確保用完以後把它設定為 null 或者重新定義。與全域性變數相關的增加記憶體消耗的一個主因是快取。快取資料是為了重用,快取必須有一個大小上限才有用。高記憶體消耗導致快取突破上限,因為快取內容無法被回收。
2:被遺忘的計時器或回撥函式
在 JavaScript 中使用 setInterval
非常平常。一段常見的程式碼:
var someResource = getData(); setInterval(function() { var node = document.getElementById('Node'); if(node) { // 處理 node 和 someResource node.innerHTML = JSON.stringify(someResource)); } }, 1000);
此例說明了什麼:與節點或資料關聯的計時器不再需要,node
物件可以刪除,整個回撥函式也不需要了。可是,計時器回撥函式仍然沒被回收(計時器停止才會被回收)。同時,someResource
如果儲存了大量的資料,也是無法被回收的。
對於觀察者的例子,一旦它們不再需要(或者關聯的物件變成不可達),明確地移除它們非常重要。老的 IE 6 是無法處理迴圈引用的。如今,即使沒有明確移除它們,一旦觀察者物件變成不可達,大部分瀏覽器是可以回收觀察者處理函式的。
觀察者程式碼示例:
var element = document.getElementById('button'); function onClick(event) { element.innerHTML = 'text'; } element.addEventListener('click', onClick);
物件觀察者和迴圈引用注意事項
老版本的 IE 是無法檢測 DOM 節點與 JavaScript 程式碼之間的迴圈引用,會導致記憶體洩露。如今,現代的瀏覽器(包括 IE 和 Microsoft Edge)使用了更先進的垃圾回收演算法,已經可以正確檢測和處理迴圈引用了。換言之,回收節點記憶體時,不必非要呼叫 removeEventListener
了。
3:脫離 DOM 的引用
有時,儲存 DOM 節點內部資料結構很有用。假如你想快速更新表格的幾行內容,把每一行 DOM 存成字典(JSON 鍵值對)或者陣列很有意義。此時,同樣的 DOM 元素存在兩個引用:一個在 DOM 樹中,另一個在字典中。將來你決定刪除這些行時,需要把兩個引用都清除。
var elements = { button: document.getElementById('button'), image: document.getElementById('image'), text: document.getElementById('text') }; function doStuff() { image.src = 'http://some.url/image'; button.click(); console.log(text.innerHTML); // 更多邏輯 } function removeButton() { // 按鈕是 body 的後代元素 document.body.removeChild(document.getElementById('button')); // 此時,仍舊存在一個全域性的 #button 的引用 // elements 字典。button 元素仍舊在記憶體中,不能被 GC 回收。 }
此外還要考慮 DOM 樹內部或子節點的引用問題。假如你的 JavaScript 程式碼中儲存了表格某一個 <td>
的引用。將來決定刪除整個表格的時候,直覺認為 GC 會回收除了已儲存的 <td>
以外的其它節點。實際情況並非如此:此<td>
是表格的子節點,子元素與父元素是引用關係。由於程式碼保留了 <td>
的引用,導致整個表格仍待在記憶體中。儲存 DOM 元素引用的時候,要小心謹慎。
4:閉包
閉包是 JavaScript 開發的一個關鍵方面:匿名函式可以訪問父級作用域的變數。
程式碼示例:
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);
程式碼片段做了一件事情:每次呼叫 replaceThing
,theThing
得到一個包含一個大陣列和一個新閉包(someMethod
)的新物件。同時,變數 unused
是一個引用 originalThing
的閉包(先前的 replaceThing
又呼叫了 theThing
)。思緒混亂了嗎?最重要的事情是,閉包的作用域一旦建立,它們有同樣的父級作用域,作用域是共享的。someMethod
可以通過 theThing
使用,someMethod
與 unused
分享閉包作用域,儘管 unused
從未使用,它引用的 originalThing
迫使它保留在記憶體中(防止被回收)。當這段程式碼反覆執行,就會看到記憶體佔用不斷上升,垃圾回收器(GC)並無法降低記憶體佔用。本質上,閉包的連結串列已經建立,每一個閉包作用域攜帶一個指向大陣列的間接的引用,造成嚴重的記憶體洩露。
Meteor 的博文 解釋瞭如何修復此種問題。在
replaceThing
的最後新增originalThing = null
。
Chrome 記憶體剖析工具概覽
Chrome 提供了一套很棒的檢測 JavaScript 記憶體佔用的工具。與記憶體相關的兩個重要的工具:timeline
和 profiles
。
Timeline
timeline 可以檢測程式碼中不需要的記憶體。在此截圖中,我們可以看到潛在的洩露物件穩定的增長,資料採集快結束時,記憶體佔用明顯高於採集初期,Node(節點)的總量也很高。種種跡象表明,程式碼中存在 DOM 節點洩露的情況。
Profiles
Profiles 是你可以花費大量時間關注的工具,它可以儲存快照,對比 JavaScript 程式碼記憶體使用的不同快照,也可以記錄時間分配。每一次結果包含不同型別的列表,與記憶體洩露相關的有 summary(概要) 列表和 comparison(對照) 列表。
summary(概要) 列表展示了不同型別物件的分配及合計大小:shallow size(特定型別的所有物件的總大小),retained size(shallow size 加上其它與此關聯的物件大小)。它還提供了一個概念,一個物件與關聯的 GC root 的距離。
對比不同的快照的 comparison list 可以發現記憶體洩露。
例項:使用 Chrome 發現記憶體洩露
實質上有兩種型別的洩露:週期性的記憶體增長導致的洩露,以及偶現的記憶體洩露。顯而易見,週期性的記憶體洩露很容易發現;偶現的洩露比較棘手,一般容易被忽視,偶爾發生一次可能被認為是優化問題,週期性發生的則被認為是必須解決的 bug。
以 Chrome 文件中的程式碼為例:
var x = []; function createSomeNodes() { var div, i = 100, frag = document.createDocumentFragment(); for (;i > 0; i--) { div = document.createElement("div"); div.appendChild(document.createTextNode(i + " - "+ new Date().toTimeString())); frag.appendChild(div); } document.getElementById("nodes").appendChild(frag); } function grow() { x.push(new Array(1000000).join('x')); createSomeNodes(); setTimeout(grow,1000); }
當 grow
執行的時候,開始建立 div 節點並插入到 DOM 中,並且給全域性變數分配一個巨大的陣列。通過以上提到的工具可以檢測到記憶體穩定上升。
找出週期性增長的記憶體
timeline 標籤擅長做這些。在 Chrome 中開啟例子,開啟 Dev Tools ,切換到 timeline,勾選 memory 並點選記錄按鈕,然後點選頁面上的 The Button
按鈕。過一陣停止記錄看結果:
兩種跡象顯示出現了記憶體洩露,圖中的 Nodes(綠線)和 JS heap(藍線)。Nodes 穩定增長,並未下降,這是個顯著的訊號。
JS heap 的記憶體佔用也是穩定增長。由於垃圾收集器的影響,並不那麼容易發現。圖中顯示記憶體佔用忽漲忽跌,實際上每一次下跌之後,JS heap 的大小都比原先大了。換言之,儘管垃圾收集器不斷的收集記憶體,記憶體還是週期性的洩露了。
確定存在記憶體洩露之後,我們找找根源所在。
儲存兩個快照
切換到 Chrome Dev Tools 的 profiles 標籤,重新整理頁面,等頁面重新整理完成之後,點選 Take Heap Snapshot 儲存快照作為基準。而後再次點選 The Button
按鈕,等數秒以後,儲存第二個快照。
篩選選單選擇 Summary,右側選擇 Objects allocated between Snapshot 1 and Snapshot 2,或者篩選選單選擇 Comparison ,然後可以看到一個對比列表。
此例很容易找到記憶體洩露,看下 (string)
的 Size Delta
Constructor,8MB,58個新物件。新物件被分配,但是沒有釋放,佔用了8MB。
如果展開 (string)
Constructor,會看到許多單獨的記憶體分配。選擇某一個單獨的分配,下面的 retainers 會吸引我們的注意。
我們已選擇的分配是陣列的一部分,陣列關聯到 window
物件的 x
變數。這裡展示了從巨大物件到無法回收的 root(window
)的完整路徑。我們已經找到了潛在的洩露以及它的出處。
我們的例子還算簡單,只洩露了少量的 DOM 節點,利用以上提到的快照很容易發現。對於更大型的網站,Chrome 還提供了 Record Heap Allocations 功能。
Record heap allocations 找記憶體洩露
回到 Chrome Dev Tools 的 profiles 標籤,點選 Record Heap Allocations。工具執行的時候,注意頂部的藍條,代表了記憶體分配,每一秒有大量的記憶體分配。執行幾秒以後停止。
上圖中可以看到工具的殺手鐗:選擇某一條時間線,可以看到這個時間段的記憶體分配情況。儘可能選擇接近峰值的時間線,下面的列表僅顯示了三種 constructor:其一是洩露最嚴重的(string)
,下一個是關聯的 DOM 分配,最後一個是 Text
constructor(DOM 葉子節點包含的文字)。
從列表中選擇一個 HTMLDivElement
constructor,然後選擇 Allocation stack
。
現在知道元素被分配到哪裡了吧(grow
-> createSomeNodes
),仔細觀察一下圖中的時間線,發現 HTMLDivElement
constructor 呼叫了許多次,意味著記憶體一直被佔用,無法被 GC 回收,我們知道了這些物件被分配的確切位置(createSomeNodes
)。回到程式碼本身,探討下如何修復記憶體洩露吧。
另一個有用的特性
在 heap allocations 的結果區域,選擇 Allocation。
這個檢視呈現了記憶體分配相關的功能列表,我們立刻看到了 grow
和 createSomeNodes
。當選擇 grow
時,看看相關的 object constructor,清楚地看到 (string)
, HTMLDivElement
和 Text
洩露了。
結合以上提到的工具,可以輕鬆找到記憶體洩露。
相關文章
- 如何避免JavaScript中的記憶體洩漏?JavaScript記憶體
- 【譯】JavaScript的記憶體管理和 4 種處理記憶體洩漏的方法JavaScript記憶體
- JAVA記憶體洩露的原因及解決Java記憶體洩露
- 【譯】JavaScript的工作原理:記憶體管理和4種常見的記憶體洩漏JavaScript記憶體
- SHBrowseForFolder 記憶體洩露記憶體洩露
- [譯] JavaScript如何工作:垃圾回收機制 + 常見的4種記憶體洩漏JavaScript記憶體
- 記憶體溢位和記憶體洩露記憶體溢位記憶體洩露
- java中如何檢視記憶體洩露Java記憶體洩露
- Lowmemorykiller記憶體洩露分析記憶體洩露
- Java面試題:細數ThreadLocal大坑,記憶體洩露本可避免Java面試題thread記憶體洩露
- 避免使用不當pthread_create函式造成記憶體洩露thread函式記憶體洩露
- win10驅動記憶體洩露如何解決_win10記憶體洩露處理方法Win10記憶體洩露
- ArkTS 的記憶體快照與記憶體洩露除錯記憶體洩露除錯
- [翻譯]查詢Windows記憶體洩露的幾種方法Windows記憶體洩露
- 轉載 ]查詢Windows記憶體洩露的幾種方法Windows記憶體洩露
- JavaScript 工作原理之三-記憶體管理及如何處理 4 類常見的記憶體洩漏問題(譯)JavaScript記憶體
- [譯]Kotlin是如何幫助你避免記憶體洩漏的?Kotlin記憶體
- 使用 mtrace 分析 “記憶體洩露”記憶體洩露
- 實戰Go記憶體洩露Go記憶體洩露
- Android 記憶體洩露詳解Android記憶體洩露
- 如何檢查Javascript中的記憶體洩漏JavaScript記憶體
- 可能會導致.NET記憶體洩露的8種行為記憶體洩露
- android Handler導致的記憶體洩露Android記憶體洩露
- 記憶體洩漏-原因、避免和定位記憶體
- Linux記憶體洩露案例分析和記憶體管理分享Linux記憶體洩露
- nodejs爬蟲記憶體洩露排查NodeJS爬蟲記憶體洩露
- Pprof定位Go程式記憶體洩露Go記憶體洩露
- 簡單的記憶體“洩露”和“溢位”記憶體
- Python實現記憶體洩露排查的示例Python記憶體洩露
- 在 ArkTS 中,如何有效地進行記憶體管理和避免記憶體洩漏?記憶體
- 避免PHP-FPM記憶體洩漏導致記憶體耗盡PHP記憶體
- netty 堆外記憶體洩露排查盛宴Netty記憶體洩露
- 乾貨分享:淺談記憶體洩露記憶體洩露
- 解決git記憶體洩露問題Git記憶體洩露
- Spring Boot heapdump洩露記憶體分析方法Spring Boot記憶體
- 線上記憶體洩露定位--memleak工具記憶體洩露
- JavaScript之記憶體溢位和記憶體洩漏JavaScript記憶體溢位
- JavaScript之記憶體洩漏【四】JavaScript記憶體
- C語言記憶體洩露很嚴重,如何應對?C語言記憶體洩露