識別並避免 Js 記憶體漏失,跟低階缺陷say goodbye,讓老總對你刮目相看

2022-01-04 11:00:02

目錄

記憶體漏失

常見的記憶體漏失型別

1、意外的全域性變數

2、被遺忘的定時器或回撥函數

3、脫離DOM的參照

4、閉包

擴充套件

垃圾回收機制

參照計數法

標記清除法(常用)


記憶體漏失

對於持續執行的服務程序(daemon),必須及時釋放不再用到的記憶體。否則,記憶體佔用越來越高,輕則影響系統效能,重則導致程序崩潰。 對於不再用到的記憶體,沒有及時釋放,就叫做記憶體漏失(memory leak)。

常見的記憶體漏失型別

1、意外的全域性變數

在一個區域性作用域中,未定義的變數會在全域性物件建立一個新變數

function fn() {
    msg = "這是一個意外的全域性變數";
}

函數 fn 內部忘記使用 var ,實際上 js 會把 msg 掛載到全域性物件上,意外建立一個全域性變數。

類似

function fn() {
    window.msg = "這是一個顯式定義的全域性變數";
}

另一種意外的全域性變數可能由 this 建立

function fn() {
    this.msg = "該this指向window,建立了一個全域性變數";
}

// fn 呼叫自己時,this 指向了全域性物件(window),而不是 undefined
fn();

如何避免

在 JavaScript 檔案頭部加上 "use strict" ,使用嚴格模式避免意外的全域性變數,此時上例中的this指向undefined。如果必須使用全域性變數儲存大量資料時,確保用完以後把它設定為 null 或者重新定義

<script>
    "use strict"
    //  以下的所有程式碼都處於嚴格模式
</script>

2、被遺忘的定時器或回撥函數

定時器setInterval、setTimeout程式碼很常見

let data = getData();
setInterval(() => {
    let el = document.getElementById('El');
    if(el) {
        // 處理 el 和 data
        el.innerHTML = JSON.stringify(data);
    }
}, 1000);

以上例子中,在 el 或者資料不再需要時(如節點移除),定時器仍然指向這些資料。所以就算 el 節點被移除後,setInterval 仍舊存活且垃圾回收器沒辦法回收,它的依賴自然也沒辦法被回收,除非終止定時器,如

let data = getData();
let timerId = setInterval(() => {
    let el = document.getElementById('El');
    if(el) {
        // 處理 el 和 data
        el.innerHTML = JSON.stringify(data);
    }
}, 1000);

// 終止定時器,使得它的依賴(el、data)可被回收
clearInterval(timerId)

// setTimeout使用clearTimeout()

補充

let btn = document.getElementById('button');
function onClick(event) {
    btn.innerHTML = 'text';
}
btn.addEventListener('click', onClick);

對於監聽繫結,一旦它們不再需要(或者關聯的物件變成不可達),明確地移除它們非常重要。老的 IE 6 是無法處理迴圈參照的。因為老版本的 IE 是無法檢測 DOM 節點與 JavaScript 程式碼之間的迴圈參照,會導致記憶體漏失。

// 明確移除監聽器
btn.removeEventListener('click', onClick);

但是,現代的瀏覽器(包括 IE 和 Microsoft Edge)使用了更先進的垃圾回收演演算法(標記清除),已經可以正確檢測和處理迴圈參照了。即回收節點記憶體時,不必非要呼叫 removeEventListener 了。

3、脫離DOM的參照

如果把DOM 存成字典(JSON 鍵值對)或者陣列,此時,同樣的 DOM 元素存在兩個參照:一個在 DOM 樹中,另一個在字典中,那麼將來需要把兩個參照都清除

// 建立一個elements,依賴#button與#image
let elements = {
    button: document.getElementById('button'),
    image: document.getElementById('image'),
};
function doStuff() {
    let image = document.getElementById('image');
    image.src = 'http://some.url/image';
    let button = document.getElementById('button');
    button.click();
    // ...
}
function removeButton() {
    // 按鈕是 body 的後代元素
    document.body.removeChild(document.getElementById('button'));
    // 此時,仍舊存在一個全域性的#button的參照elements字典。button元素仍舊在記憶體中,不能被GC回收。
}

// 顯式移除參照,elements = null,使依賴的DOM可被GC回收

如果程式碼中儲存了表格某一個 <td> 的參照。將來決定刪除整個表格的時候,直覺認為 GC 會回收除了已儲存的 <td> 以外的其它節點。實際情況並非如此:此 <td> 是表格的子節點,子元素與父元素是參照關係。由於程式碼保留了 <td> 的參照,導致整個表格仍待在記憶體中。所以儲存 DOM 元素參照的時候,要小心謹慎。 

4、閉包

閉包的關鍵是內部函數可以存取父級作用域的變數

let theThing = null;
let replaceThing = function () {
  let originalThing = theThing;
  let 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 迫使它保留在記憶體中(防止被回收)。

如何避免

在 replaceThing 的最後新增 originalThing = null 。 

擴充套件

垃圾回收機制

對垃圾回收機制來說,核心思想就是如何判斷記憶體已經不再使用,常用垃圾回收演演算法有下面兩種:

  • 參照計數
  • 標記清除(常用)

參照計數法

參照計數演演算法定義「記憶體不再使用」的標準很簡單,就是看一個物件是否有指向它的參照,如果沒有其他物件指向它了,說明該物件已經不再需要了,如上我們將一些變數賦值為null。

參照計數有一個致命的問題,那就是迴圈參照。如果兩個物件相互參照,儘管他們已不再使用,但是垃圾回收器不會進行回收,最終可能會導致記憶體漏失

function cycle() {
    let obj1 = {};
    let obj2 = {};
    // 相互參照
    obj1.a = obj2;
    obj2.a = obj1; 
    // 即使未使用到這兩個物件,兩者也都不會被GC回收
    return "迴圈參照!"
}

cycle();

cycle函數執行完成之後,物件obj1和obj2實際上已經不再需要了,但根據參照計數的原則,他們之間的相互參照依然存在,因此這部分記憶體不會被回收,所以現代瀏覽器不再使用這個演演算法,但是IE依舊使用。

標記清除法(常用)

標記清除演演算法將「不再使用的物件」定義為「無法到達的物件」。即從根部(在JS中就是全域性物件)出發定時掃描記憶體中的物件,凡是能從根部到達的物件,保留。那些從根部出發無法觸及到的物件被標記為不再使用,稍後進行回收。無法觸及的物件包含了沒有參照的物件這個概念,但反之未必成立。

對於主流瀏覽器來說,只需要切斷需要回收的物件與根部的聯絡,就可以正確被垃圾回收處理。最常見的記憶體洩露一般都與DOM元素繫結有關

email.message = document.createElement(「div」);
displayList.appendChild(email.message);

// 稍後從displayList中清除DOM元素
displayList.removeAllChildren();

上面程式碼中,div元素已經從DOM樹中清除,但是該div元素還繫結在email物件的message,所以如果email物件存在,那麼該div元素就會一直儲存在記憶體中。

徹底清除 email = null 或 email.message = null。