Photo by Mohammad Rahmani on Unsplash
導致記憶體洩漏的常見原因
不當的建立變數並使其不斷膨脹
定義全域變數、在 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)」
 

如果 root -> F 的參考消失,導致 F 變成「無法到達的物件」,那麼 F 與其子節點們就會被自動回收。
Stack & Heap
- 比較簡單類型的 Primitive Type 會被放在 stack 裡
 - 比較複雜類型的 Reference Type 則會把
資料存在 heap 中,再把資料在heap 的記憶體位址記錄到 stack 裡 

可以看到 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 即可開啟監控

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

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

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 問題時,我們會以這個欄位為判斷點。 | 

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

使用 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

等它跑完

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

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

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


其他可能狀況
引用的套件有 Memory Leaks 問題
也有可能找了老半天,發現是使用的套件有 Memory Leak 問題
參考資料
- [web] 記憶體問題 memory leak | PJCHENder 未整理筆記
 - 從你的 Node.js 專案裡找出 Memory leak,及早發現、及早治療! | 方格子
 - Load testing for engineering teams | Grafana k6
 - Trash talk: the Orinoco garbage collector · V8
 - 4 Types of Memory Leaks in JavaScript and How to Get Rid Of Them
 - 網站常見 Memory Leak: 循環參照、事件循環監聽、存取全域變數 | 前端三分鐘 | 一起用三分鐘分享技術與知識
 - JavaScript 記憶體洩漏(Memory Leak)問題 - G. T. Wang
 - 身為 JS 開發者,你應該要知道的記憶體管理機制. 如果你是寫 C/C++… | by 莫力全 Kyle Mo | Starbugs Weekly 星巴哥技術專欄 | Medium
 - Memory Leaks in JavaScript and how to avoid them. | by Eduard Hayrapetyan | Preezma Software Development Company | Medium
 
Donate KJ 贊助作者喝咖啡
如果這篇文章對你有幫助的話,可以透過下面支付方式贊助作者喝咖啡,如果有什麼建議或想說的話可以贊助並留言給我
If this article has been helpful to you, you can support the author by treating them to a coffee through the payment options below. If you have any suggestions or comments, feel free to sponsor and leave a message for me!
| 方式 Method | 贊助 Donate | 
| PayPal | https://paypal.me/kejyun | 
| 綠界 ECPay | https://p.ecpay.com.tw/AC218F1 | 
| 歐付寶 OPay | https://payment.opay.tw/Broadcaster/Donate/BD2BD896029F2155041C8C8FAED3A6F8 |