記憶體洩漏(memory leaks)

Node.js 記憶體洩漏(memory leaks)

導致記憶體洩漏的常見原因

不當的建立變數並使其不斷膨脹

定義全域變數、在 module 或 closure 中定義變數,但不斷塞入內容而沒有主動清除(設成 null)。

全域陣列物件保留 Closure 沒有用的變數

每次收到請求,都會使 requests 的資料量成長,而 request 又存在於 global scope,屬於可從根探詢到的程式碼,因此它不會被清除

const http = require('http');
// 全域陣列物件
const requests = [];

http.createServer((request, response) => {
    // 將請求儲存到 Global
    requests.push(request);
    response.writeHead(200, {'Content-Type': 'text/plain'});
    response.end('Hello Memory Leaks');
}).listen(3000);

setInterval 保留外部變數導致變數無法釋放


const object = {
   a: new Array(1000),
   b: new Array(2000)
};
setInterval(() => {
   // setInterval
   console.log(object.a)
}, 1000);

未使用的 unused function 一直使用 originalThing,所以資料不會被回收

// 全域變數
var theThing = null;

var replaceThing = function () {
  // 區域變數取的全域變數
  var originalThing = theThing;
  var unused = function () {
    // 未使用的 function 一直使用 originalThing,所以資料不會被樂素回收
    if (originalThing) {
      console.log("hi");
    }
  };

  // 全域變數建立一個很大的陣列資料
  theThing = {
    longStr: new Array(1000000).join('*'),
    someMethod: function () {
      console.log(someMessage);
    }
  };
};

setInterval(replaceThing, 1000);

未使用的 a 一直使用外部的參數,所以傳入的資料不會被回收

function parentFunction(paramA) {
  var a = paramA;
  function childFunction() {
    return a + 2;
  }
  return childFunction();
}

每次呼叫時都將 Hello 存到 potentiallyHugeArray,而這個變數是外部 scope 的變數,所以無法在呼叫完函式時清除

function outer() {
    const potentiallyHugeArray = [];
    return function inner() {
        // 每次呼叫時都將 Hello 存到 potentiallyHugeArray,而這個變數是外部 scope 的變數,所以無法在呼叫完函式時清除
        potentiallyHugeArray.push('Hello');
        console.log('Hello');
    };
};
// contains definition of the function inner
const sayHello = outer();
function repeat(fn, num) {
    for (let i = 0; i < num; i++){
        fn();
    }
}
// each sayHello call pushes another 'Hello' to the potentiallyHugeArray
repeat(sayHello, 10);
// now imagine repeat(sayHello, 100000)

忘記解除 event listener 的註冊

Event Listener 中的 event handler 函式會被移除的時間包含:

  • 使用 removeEventListener
  • DOM 元素被移除後

DOM Element 保存成 JavaScript 變數,移除 DOM 但還是有 JavaScript 變數存著

將 DOM Element 保存成 JavaScript 變數後,即使使用 removeChild 移除了該 DOM Element 只要這個 JavaScript 變數還存在,就可以參照到該 DOM 元素,使得該 DOM Element 沒辦法被 GC。

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);
    // Much more logic
}

function removeButton() {
    // 直接移除元素
    document.body.removeChild(document.getElementById('button'));
    // 讚全域變數還有參照這個按鈕的變數,所以這個元素還是會保留在記憶體不會被清除

    // At this point, we still have a reference to #button in the global
    // elements dictionary. In other words, the button element is still in
    // memory and cannot be collected by the GC.

}

Timer 使用時沒有設定 id 來做後續的 clear Timer 或是 callback

  • setTimeout
  • setInterval
function setCallback() {    
const data = {   
   counter: 0,    
   hugeString: new Array(100000).join('x')   
 };
return function cb() {
  // data 物件現在是 callback 的一部分了,不可以被隨意清除
  // data object is now part of the callback's scope
  data.counter++;
  console.log(data.counter);
}
}

// 設定完後要怎麼去停止它?
setInterval(setCallback(), 1000);

上面程式碼可以改寫成以下,避免 Memory Leak

function setCallback() {
    // 拆解變數將 counter 獨立
    let counter = 0;
    // 在 setCallback 回傳 return 資料後會被移除
    const hugeString = new Array(100000).join('x');

    return function cb() {
        // 只有 counter 是 callback 的 scope 變數
        counter++;
        console.log(counter);
    }
}
// 儲存 Interval Timer ID
const timerId = setInterval(setCallback(), 1000); // saving the interval ID
// 清除 Timer
clearInterval(timerId);

循環式參照

function myFunction(element) {
  this.elementReference = element;
  // 這裡會形成循環式參照
  // DOM-->JS-->DOM
  element.expandoProperty = this;
}
function Leak() {
  // 造成記憶體的洩漏
  new myFunction(document.getElementById("myDiv"));
}
function referenceCycle() {
    var OriginalObject = {};
    var OtherObject = {};
    // OriginalObject 參考 OtherObject
    OriginalObject.a = OtherObject;
    // OtherObject 參考 OriginalObject
    OtherObject.a = OriginalObject;
    return 'reference cycle';
}

referenceCycle();

hoisting 的特性產生全域變數

JavaScript 有些寫法也會產生不預期的全域變數

// 假設以下 function 都是 global 的
// author 會被 hoist 成一個全域變數
function ironman() {
    author = "Kyle Mo";
}
// 這種寫法在 non strict mode 下也會變成全域變數
function hello_it_home() {
    this.author = "Kyle Mo";
}
// 這種情況下就算是 strict mode author 也會變成全域變數
const hello_ironman = () => {
    this.author = "Kyle Mo";
}

Capturing objects from an iframe

忘記關閉 worker

Garbage collection 垃圾回收機制流程

瀏覽器偵測到某個物件不在被使用時,會執行垃圾回收(garbage collection)的機制,以此釋放記憶體空間。

  • 定期從根物件 (root,在瀏覽器中是 window,node 則是 global) 開始往下探詢每一個子節點
  • 並清除沒有被探詢到、或是沒有被探詢物件參考的物件,也就是所謂「無法到達的物件 (unreachable objects)」

Memory Leak

如果 root -> F 的參考消失,導致 F 變成「無法到達的物件」,那麼 F 與其子節點們就會被自動回收。

Stack & Heap

  • 比較簡單類型的 Primitive Type 會被放在 stack 裡
  • 比較複雜類型的 Reference Type 則會把資料存在 heap 中,再把資料在 heap 的記憶體位址記錄到 stack 裡

Memory Leak

可以看到 Object 類型的數據實際上是存在 Heap 裡,Stack 中存的只是物件在 Heap 中的記憶體位置而已

變數 four = three 這段 code 實際上是把 Three 指向的物件在 Heap 中的記憶體位置 指派給 Four 變數,所以它們實際上指向的是同一個物件

Stack & Heap Garbage collection 回收機制

如果是物件的話,Stack 中存的是 Heap 空間的 address

所以就算 Stack 被回收,存在 Heap 空間的數據依然存在,這時就需要靠 GC 來判斷 Heap 空間中哪些資料是用不到且需要被回收的

正確避免 Memory Leak

移除元素前先移除 Event Listener

避免元素還有其他變數在使用,所以移除前,相關的綁定使用的變數要先移除

  • 移除 Event Listener
  • 移除元素
var element = document.getElementById('button');

function onClick(event) {
    element.innerHtml = 'text';
}

element.addEventListener('click', onClick);
// 移除 Event Listener
element.removeEventListener('click', onClick);
// 移除元素
element.parentNode.removeChild(element);

解決 Memory Leaks 問題

使用將變數設為 null

手動告訴機器這個物件沒有使用了

var myVar = "Hello";
// Hello
console.log(myVar);
myVar = null;
// null
console.log(myVar);

利用開發者工具中的快照

簡單用法就是使用一陣子之後重新抓一次快照,觀察記憶體有沒有上升太多

事件加入 console.log 觀察

事件中的 listener 可以放個 console.log('避免重複監聽') 來暴力觀察

使用 Chrome 的 DevTool 監控

Chrome 提供的 DevTool 除了能監測運行在瀏覽器中的程式外,也能監測運行在本地端的 Node.js 程式,能使用 Devtool 監測 Heap 的使用狀況。

1. 在要執行的程式加入 --inspect 標記啟動監控

node --inspect test.js
Debugger listening on ws://127.0.0.1:9229/6b6d9bbb-1db2-4f46-a399-b9e8f4a4bfa3

2. 開啟 Chrome Inspect 監控

打開 Chrome 網址輸入 chrome://inspect,在畫面下點選 inspect 即可開啟監控

Memory Leak

在開啟的視窗就可以看到記憶體使用狀況

Memory Leak

3. 記錄當前 Memory 使用狀況

切換到 Memory 頁籤,在下方看到目前運行中的 VM instance,按下 Take snapshot,就會幫我們分析此刻的 heap 使用狀況,並將結果儲存在左側邊欄。

Memory Leak

4. 查詢 Memory 使用狀況

點選左側欄 Snapshot 就可以看到執行的 node 程式記憶體使用狀況,在上方頁籤可以看到有這幾個項目

類型 說明
Constructor 此物件的建構子 (DevTool 幫我們進行的分類)
Distance 從根 (root) 開始探訪的深度。
Shallow Size 物件本身佔用的記憶體量(bytes),通常 shallow size 很大的都是 String 或是裝著 Primitive Type Data 的陣列,如果是物件裡存著 reference 則不會被算到 shallow size 裡面。
Retained Size 物件本身佔用的記憶體空間,加上依賴此物件的所有資料所佔用的記憶體量(bytes)。可以表示,你刪除這個物件後,他總共會釋放的記憶體量,因此在查找 memory leak 問題時,我們會以這個欄位為判斷點。

Memory Leak

先將物件以 Retained Size 排序,沒意外的話佔用最多的應該是 (compiled code),因為我們引用了一些套件,這些物件也會被存在 Heap 中。

Memory Leak

使用 K6 進行壓力測試

壓力測試程式

// request.js
import http from "k6/http";
import { sleep } from "k6";

export default function() {
  http.get("http://localhost:3000");
  sleep(1);
}

1. 執行壓力測試

從終端機啟動壓力測試,使用下面的參數

參數 說明
duration 執行測試的時間
vus 同時測試的虛擬使用者數量 (virtual users)
k6 run --duration 2m --vus 100 request.js

Memory Leak

等它跑完

Memory Leak

2. 觀察 Chrome DevTool 變化

重新 Take shapshot,如果你發現 heap 不斷增長且沒有回到正常數字的話,那八成是抓到兇手了!

Memory Leak

觀察兩個 snapshot 之間的變化,若看不太出來的話,可能單次 leak 的記憶體量很少,可以試著發送更多請求數看看,看能不看看出個什麼明顯的變化

3. 找 Memory Leaks 兇手

壓測前

Memory Leak

壓測後

找到發現 (closure) / () / context / requests 使用了太多的記憶體,可以看到是剛剛程式中 requests 這個變數,那就可試著從這裡把問題修正了

Memory Leak

Memory Leak

其他可能狀況

引用的套件有 Memory Leaks 問題

也有可能找了老半天,發現是使用的套件有 Memory Leak 問題

參考資料