記憶體分析與記憶體洩漏定位

王下邀月熊發表於2017-11-03

記憶體分析與記憶體洩漏定位是筆者現代 Web 開發工程化實踐之除錯技巧的一部分,主要介紹 Web 開發中需要了解的記憶體分析與記憶體洩露定位手段,本部分涉及的參考資料統一宣告在Web 開發介面除錯資料索引

無論是分散式計算系統、服務端應用程式還是 iOS、Android 原生應用都會存在記憶體洩漏問題,Web 應用自然也不可避免地存在著類似的問題。雖然因為網頁往往都是即用即走,較少地存在某個網頁長期執行的問題,即使存在記憶體洩漏可能表現地也不明顯;但是在某些資料展示型的,需要長期執行的頁面上,如果不及時解決記憶體洩漏可能會導致網頁佔據過大地記憶體,不僅影響頁面效能,還可能導致整個系統的崩潰。前端每週清單推薦過的 How JavaScript works 就是非常不錯地介紹 JavaScript 執行機制的系列文章,其也對記憶體管理與記憶體洩漏有過分析,本文部分圖片與示例程式碼即來自此係列。

類似於 C 這樣的語言提供了 malloc()free() 這樣的底層記憶體管理原子操作,開發者需要顯式手動地進行記憶體的申請與釋放;而 Java 這樣的語言則是提供了自動化的記憶體回收機制,筆者在垃圾回收演算法與 JVM 垃圾回收器綜述一文中有過介紹。JavaScript 也是採用的自動化記憶體回收機制,無論是 Object、String 等都是由垃圾回收程式自動回收處理。自動化記憶體回收並不意味著我們就可以忽略記憶體管理的相關操作,反而可能會導致更不易發現的記憶體洩漏出現。

記憶體分配與回收

筆者在 JavaScript Event Loop 機制詳解與 Vue.js 中實踐應用一文中介紹過 JavaScript 的記憶體模型,其主要也是由堆、棧、佇列三方面組成:

其中佇列指的是訊息佇列、棧就是函式執行棧,其基本結構如下所示:

JavaScript 棧模型
JavaScript 棧模型

而主要的使用者建立的物件就存放在堆中,這也是我們記憶體分析與記憶體洩漏定位所需要關注的主要的區域。所謂記憶體,從硬體的角度來看,就是無數觸發器的組合;每個觸發器能夠存放 1 bit 位的資料,不同的觸發器由唯一的識別符號定位,開發者可以根據該識別符號讀寫該觸發器。抽象來看,我們可以將記憶體當做位元陣列,而資料就是在記憶體中順序排布:

1*W7L7JN5q4p7w2E7HbBYS3g
1*W7L7JN5q4p7w2E7HbBYS3g

JavaScript 中開發者並不需要手動地為物件申請記憶體,只需要宣告變數,JavaScript Runtime 即可以自動地分配記憶體:

var n = 374; // allocates memory for a number
var s = 'sessionstack'; // allocates memory for a string 
var o = {
  a: 1,
  b: null
}; // allocates memory for an object and its contained values
var a = [1, null, 'str'];  // (like object) allocates memory for the
                           // array and its contained values
function f(a) {
  return a + 3;
} // allocates a function (which is a callable object)
// function expressions also allocate an object
someElement.addEventListener('click', function() {
  someElement.style.backgroundColor = 'blue';
}, false);複製程式碼

某個物件的記憶體生命週期分為了記憶體分配、記憶體使用與記憶體回收這三個步驟,當某個物件不再被需要時,它就應該被清除回收;所謂的垃圾回收器,Garbage Collector 即是負責追蹤記憶體分配情況、判斷某個被分配的記憶體是否有用,並且自動回收無用的記憶體。大部分的垃圾回收器是根據引用(Reference)來判斷某個物件是否存活,所謂的引用即是某個物件是否依賴於其他物件,如果存在依賴關係即存在引用;譬如某個 JavaScript 物件引用了它的原型物件。最簡單的垃圾回收演算法即是引用計數(Reference Counting),即清除所有零引用的物件:

var o1 = {
  o2: {
    x: 1
  }
};
// 2 objects are created. 
// 'o2' is referenced by 'o1' object as one of its properties.
// None can be garbage-collected

var o3 = o1; // the 'o3' variable is the second thing that 
            // has a reference to the object pointed by 'o1'. 

o1 = 1;      // now, the object that was originally in 'o1' has a         
            // single reference, embodied by the 'o3' variable

var o4 = o3.o2; // reference to 'o2' property of the object.
                // This object has now 2 references: one as
                // a property. 
                // The other as the 'o4' variable

o3 = '374'; // The object that was originally in 'o1' has now zero
            // references to it. 
            // It can be garbage-collected.
            // However, what was its 'o2' property is still
            // referenced by the 'o4' variable, so it cannot be
            // freed.

o4 = null; // what was the 'o2' property of the object originally in
           // 'o1' has zero references to it. 
           // It can be garbage collected.複製程式碼

不過這種演算法往往受制於迴圈引用問題,即兩個無用的物件相互引用:

function f() {
  var o1 = {};
  var o2 = {};
  o1.p = o2; // o1 references o2
  o2.p = o1; // o2 references o1. This creates a cycle.
}

f();複製程式碼

稍為複雜的演算法即是所謂的標記-清除(Mark-Sweep)演算法,其根據某個物件是否可達來判斷某個物件是否可用。標記-清除演算法會從某個根元素開始,譬如 window 物件開始,沿著引用樹向下遍歷,標記所有可達的物件為可用,並且清除其他未被標記的物件。

2012 年之後,幾乎所有的主流瀏覽器都實踐了基於標記-清除演算法的垃圾回收器,並且各自也進行有針對性地優化。

記憶體洩漏

所謂的記憶體洩漏,即是指某個物件被無意間新增了某條引用,導致雖然實際上並不需要了,但還是能一直被遍歷可達,以致其記憶體始終無法回收。本部分我們簡要討論下 JavaScript 中常見的記憶體洩漏情境與處理方法。在新版本的 Chrome 中我們可以使用 Performance Monitor 來動態監測網頁效能的變化:

上圖中各項指標的含義為:

  • CPU usage - 當前站點的 CPU 使用量;
  • JS heap size - 應用的記憶體佔用量;
  • DOM Nodes - 記憶體中 DOM 節點數目;
  • JS event listeners- 當前頁面上註冊的 JavaScript 時間監聽器數目;
  • Documents - 當前頁面中使用的樣式或者指令碼檔案數目;
  • Frames - 當前頁面上的 Frames 數目,包括 iframe 與 workers;
  • Layouts / sec - 每秒的 DOM 重佈局數目;
  • Style recalcs / sec - 瀏覽器需要重新計算樣式的頻次;

當發現某個時間點可能存在記憶體洩漏時,我們可以使用 Memory 標籤頁將此時的堆分配情況列印下來:

Memory Snapshot Take heap snapshot
Memory Snapshot Take heap snapshot

Memory Snapshot 結果
Memory Snapshot 結果

全域性變數

JavaScript 會將所有的為宣告的變數當做全域性變數進行處理,即將其掛載到 global 物件上;瀏覽器中這裡的 global 物件就是 window:

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

// 等價於

function foo(arg) {
    window.bar = "some text";
}複製程式碼

另一種常見的建立全域性變數的方式就是誤用 this 指標:

function foo() {
    this.var1 = "potential accidental global";
}
// Foo called on its own, this points to the global object (window)
// rather than being undefined.
foo();複製程式碼

一旦某個變數被掛載到了 window 物件,就意味著它永遠是可達的。為了避免這種情況,我們應該儘可能地新增 use strict 或者進行模組化編碼(參考 JavaScript 模組演化簡史)。我們也可以擴充套件類似於下文的掃描函式,來檢測出 window 物件的非原生屬性,並加以判斷:

function scan(o) {
  Object.keys(o).forEach(function(key) {
    var val = o[key];

    // Stop if object was created in another window
    if (
      typeof val !== "string" &&
      typeof val !== "number" &&
      typeof val !== "boolean" &&
      !(val instanceof Object)
    ) {
      debugger;
      console.log(key);
    }

    // Traverse the nested object hierarchy
  });
}複製程式碼

定時器與閉包

我們經常會使用 setInterval 來執行定時任務,很多的框架也提供了基於回撥的非同步執行機制;這可能會導致回撥中宣告瞭對於某個變數的依賴,譬如:

var serverData = loadData();
setInterval(function() {
    var renderer = document.getElementById('renderer');
    if(renderer) {
        renderer.innerHTML = JSON.stringify(serverData);
    }
}, 5000); //This will be executed every ~5 seconds.複製程式碼

定時器保有對於 serverData 變數的引用,如果我們不手動清除定時器話,那麼該變數也就會一直可達,不被回收。而這裡的 serverData 也是閉包形式被引入到 setInterval 的回撥作用域中;閉包也是常見的可能導致記憶體洩漏的元凶之一:

var theThing = null;
var replaceThing = function () {
  var originalThing = theThing;
  var unused = function () {
    if (originalThing) // a reference to 'originalThing'
      console.log("hi");
  };
  theThing = {
    longStr: new Array(1000000).join('*'),
    someMethod: function () {
      console.log("message");
    }
  };
};
setInterval(replaceThing, 1000);複製程式碼

上述程式碼中 replaceThing 會定期執行,並且建立大的陣列與 someMethod 閉包賦值給 theThing。someMethod 作用域是與 unused 共享的,unused 又有一個指向 originalThing 的引用。儘管 unused 並未被實際使用,theThing 的 someMethod 方法卻有可能會被外部使用,也就導致了 unused 始終處於可達狀態。unused 又會反向依賴於 theThing,最終導致大陣列始終無法被清除。

DOM 引用與監聽器

有時候我們可能會將 DOM 元素存放到資料結構中,譬如當我們需要頻繁更新某個資料列表時,可能會將用到的資料列表存放在 JavaScript 陣列中;這也就導致了每個 DOM 元素存在了兩個引用,分別在 DOM 樹與 JavaScript 陣列中:

var elements = {
    button: document.getElementById('button'),
    image: document.getElementById('image')
};
function doStuff() {
    elements.image.src = 'http://example.com/image_name.png';
}
function removeImage() {
    // The image is a direct child of the body element.
    document.body.removeChild(document.getElementById('image'));
    // At this point, we still have a reference to #button in the
    //global elements object. In other words, the button element is
    //still in memory and cannot be collected by the GC.
}複製程式碼

此時我們就需要將 DOM 樹與 JavaScript 陣列中的引用皆刪除,才能真實地清除該物件。類似的,在老版本的瀏覽器中,如果我們清除某個 DOM 元素,我們需要首先移除其監聽器,否則瀏覽器並不會自動地幫我們清除該監聽器,或者回收該監聽器引用的物件:

var element = document.getElementById('launch-button');
var counter = 0;
function onClick(event) {
   counter++;
   element.innerHtml = 'text ' + counter;
}
element.addEventListener('click', onClick);
// Do stuff
element.removeEventListener('click', onClick);
element.parentNode.removeChild(element);
// Now when element goes out of scope,
// both element and onClick will be collected even in old browsers // that don't handle cycles well.複製程式碼

現代瀏覽器使用的現代垃圾回收器則會幫我們自動地檢測這種迴圈依賴,並且予以清除;jQuery 等第三方庫也會在清除元素之前首先移除其監聽事件。

iframe

iframe 是常見的介面共享方式,不過如果我們在父介面或者子介面中新增了對於父介面某物件的引用,譬如:

// 子頁面內
window.top.innerObject = someInsideObject
window.top.document.addEventLister(‘click’, function() { … });

// 外部頁面
 innerObject = iframeEl.contentWindow.someInsideObject複製程式碼

就有可能導致 iframe 解除安裝(移除元素)之後仍然有部分物件保留下來,我們可以在移除 iframe 之前執行強制的頁面過載:

<a href="#">Remove</a>
<iframe src="url" />​

$('a').click(function(){
    $('iframe')[0].contentWindow.location.reload();
    // 線上環境實測重置 src 效果會更好
    // $('iframe')[0].src = "javascript:false";
    setTimeout(function(){
       $('iframe').remove();
    }, 1000);
});​複製程式碼

或者手動地執行頁面清除操作:

window.onbeforeunload = function(){
    $(document).unbind().die();    //remove listeners on document
    $(document).find('*').unbind().die(); //remove listeners on all nodes
    //clean up cookies
    /remove items from localStorage
}複製程式碼

Web Worker

現代瀏覽器中我們經常使用 Web Worker 來執行後臺任務,不過有時候如果我們過於頻繁且不加容錯地在主執行緒與工作執行緒之間傳遞資料,可能會導致記憶體洩漏:

function send() {
 setInterval(function() { 
    const data = {
     array1: get100Arrays(),
     array2: get500Arrays()
    };

    let json = JSON.stringify( data );
    let arbfr = str2ab (json);
    worker.postMessage(arbfr, [arbfr]);
  }, 10);
}


function str2ab(str) {
   var buf = new ArrayBuffer(str.length*2); // 2 bytes for each char
   var bufView = new Uint16Array(buf);
   for (var i=0, strLen=str.length; i<strLen; i++) {
     bufView[i] = str.charCodeAt(i);
   }
   return buf;
 }複製程式碼

在實際的程式碼中我們應該檢測 Transferable Objects 是否正常工作:

let ab = new ArrayBuffer(1);

try {
   worker.postMessage(ab, [ab]);

   if (ab.byteLength) {
      console.log('TRANSFERABLE OBJECTS are not supported in your browser!');
   } 
   else {
     console.log('USING TRANSFERABLE OBJECTS');
   }
} 
catch(e) {
  console.log('TRANSFERABLE OBJECTS are not supported in your browser!');
}複製程式碼

相關文章