04.16 如何有效避免JS內存洩漏

背景

其實在寫這篇文章之前,我也想了很久,因為網上對這塊的東西已經很多了,但有些讀起來還是不容易讓人理解,而且JS 中的內存管理, 我的感覺就像 JS 中的一門副科, 我們平時不會太重視, 但是一旦出問題又很棘手. 所以可以通過平時多瞭解一些 JS 中內存管理問題, 在寫代碼中通過一些習慣, 避免內存洩露的問題。

內容概要

內存的生命週期JS的內存回收常見的內存洩露案例

內存生命週期

不管什麼程序語言,內存生命週期基本是一致的:

分配你所需要的內存使用分配到的內存(讀, 寫)不需要時將其釋放/歸還

在 C語言中, 有專門的內存管理接口, 像malloc() 和 free(). 而在 JS 中, 沒有專門的內存管理接口, 所有的內存管理都是"自動"的. JS 在創建變量時, 自動分配內存, 並在不使用的時候, 自動釋放. 這種"自動"的內存回收, 造成了很多 JS 開發並不關心內存回收, 實際上, 這是錯誤的.

JS 中的內存回收

引用

垃圾回收算法主要依賴於引用的概念. 在內存管理的環境中, 一個對象如果有訪問另一個對象的權限(隱式或者顯式), 叫做一個對象引用另一個對象. 例如: 一個Javascript對象具有對它原型的引用(隱式引用)和對它屬性的引用(顯式引用).

引用計數垃圾收集

這是最簡單的垃圾收集算法.此算法把“對象是否不再需要”簡化定義為“對象有沒有其他對象引用到它”. 如果沒有引用指向該對象(零引用), 對象將被垃圾回收機制回收. 示例:

let arr = [1, 2, 3, 4];
arr = null; // [1,2,3,4]這時沒有被引用, 會被自動回收

限制: 循環引用

在下面的例子中, 兩個對象對象被創建並互相引用, 就造成了循環引用. 它們被調用之後不會離開函數作用域, 所以它們已經沒有用了, 可以被回收了. 然而, 引用計數算法考慮到它們互相都有至少一次引用, 所以它們不會被回收.

function f() {
var o1 = {};
var o2 = {};
o1.p = o2; // o1 引用 o2
o2.p = o1; // o2 引用 o1. 這裡會形成一個循環引用
}
f();

實際例子:

var div;
window.onload = function(){
div = document.getElementById("myDivElement");
div.circularReference = div;
div.lotsOfData = new Array(10000).join("*");
};

在上面的例子裡, myDivElement 這個 DOM 元素裡的 circularReference 屬性引用了 myDivElement, 造成了循環引用. IE 6, 7 使用引用計數方式對 DOM 對象進行垃圾回收. 該方式常常造成對象被循環引用時內存發生洩漏. 現代瀏覽器通過使用標記-清除內存回收算法, 來解決這一問題.

標記-清除算法

這個算法把“對象是否不再需要”簡化定義為“對象是否可以獲得”.

這個算法假定設置一個叫做根root的對象(在Javascript裡,根是全局對象). 垃圾回收器將從根開始, 找到所有從根開始引用的對象, 然後找這些對象引用的對象, 從根開始,垃圾回收器將找到所有可以獲得的對象和所有不能獲得的對象.

從2012年起, 所有現代瀏覽器都使用了標記-清除內存回收算法。所有對JavaScript垃圾回收算法的改進都是基於標記-清除算法的改進.

自動 GC 的問題

儘管自動 GC 很方便, 但是我們不知道GC 什麼時候會進行. 這意味著如果我們在使用過程中使用了大量的內存, 而 GC 沒有運行的情況下, 或者 GC 無法回收這些內存的情況下, 程序就有可能假死, 這個就需要我們在程序中手動做一些操作來觸發內存回收.

什麼是內存洩露?

本質上講, 內存洩露就是不再被需要的內存, 由於某種原因, 無法被釋放.

常見的內存洩露案例

1. 全局變量

function foo(arg) {
bar = "some text";
}

在 JS 中處理未被聲明的變量, 上述範例中的 bar時, 會把bar, 定義到全局對象中, 在瀏覽器中就是 window 上. 在頁面中的全局變量, 只有當頁面被關閉後才會被銷燬. 所以這種寫法就會造成內存洩露, 當然在這個例子中洩露的只是一個簡單的字符串, 但是在實際的代碼中, 往往情況會更加糟糕.

另外一種意外創建全局變量的情況.

function foo() {
this.var1 = "potential accidental global";
}
// Foo 被調用時, this 指向全局變量(window)
foo();

在這種情況下調用foo, this被指向了全局變量window, 意外的創建了全局變量.

我們談到了一些意外情況下定義的全局變量, 代碼中也有一些我們明確定義的全局變量. 如果使用這些全局變量用來暫存大量的數據, 記得在使用後, 對其重新賦值為 null.

2. 未銷燬的定時器和回調函數

在很多庫中, 如果使用了觀察著模式, 都會提供回調方法, 來調用一些回調函數. 要記得回收這些回調函數. 舉一個 setInterval的例子.

var serverData = loadData();
setInterval(function() {
var renderer = document.getElementById('renderer');
if(renderer) {
renderer.innerHTML = JSON.stringify(serverData);
}
}, 5000); // 每 5 秒調用一次

如果後續 renderer 元素被移除, 整個定時器實際上沒有任何作用. 但如果你沒有回收定時器, 整個定時器依然有效, 不但定時器無法被內存回收, 定時器函數中的依賴也無法回收. 在這個案例中的 serverData 也無法被回收.

3. 閉包

在 JS 開發中, 我們會經常用到閉包, 一個內部函數, 有權訪問包含其的外部函數中的變量. 下面這種情況下, 閉包也會造成內存洩露.

var theThing = null;
var replaceThing = function () {
var originalThing = theThing;
var unused = function () {
if (originalThing) // 對於 'originalThing'的引用
console.log("hi");
};
theThing = {
longStr: new Array(1000000).join('*'),
someMethod: function () {
console.log("message");
}
};
};
setInterval(replaceThing, 1000);

這段代碼, 每次調用replaceThing時, theThing 獲得了包含一個巨大的數組和一個對於新閉包someMethod的對象. 同時 unused 是一個引用了originalThing的閉包.

這個範例的關鍵在於, 閉包之間是共享作用域的, 儘管unused可能一直沒有被調用, 但是someMethod 可能會被調用, 就會導致內存無法對其進行回收. 當這段代碼被反覆執行時, 內存會持續增長.

該問題的更多描述可見Meteor團隊的這篇文章.

4. DOM 引用

很多時候, 我們對 Dom 的操作, 會把 Dom 的引用保存在一個數組或者 Map 中.

var elements = {
image: document.getElementById('image')
};
function doStuff() {
elements.image.src = 'http://example.com/image_name.png';
}
function removeImage() {
document.body.removeChild(document.getElementById('image'));
// 這個時候我們對於 #image 仍然有一個引用, Image 元素, 仍然無法被內存回收.
}

上述案例中, 即使我們對 image 元素進行了移除, 但是仍然有對 image 元素的引用, 依然無法對其進行內存回收.

另外需要注意的一個點是, 對於一個 Dom 樹的葉子節點的引用. 舉個例子: 如果我們引用了一個表格中的td元素, 一旦在 Dom 中刪除了整個表格, 我們直觀的覺得內存回收應該回收除了被引用的 td外的其他元素. 但是事實上, 這個td 元素是整個表格的一個子元素, 並保留對於其父元素的引用. 這就會導致對於整個表格, 都無法進行內存回收. 所以我們要小心處理對於 Dom 元素的引用.

小結

我們平時在寫代碼的時候,可能很少去操作內存管理方面的事情,但我們要有內存管理方面的意思,特別是上面我提出的幾種可能導致內存洩漏的情況,寫代碼的時候要謹慎。