記憶體洩漏(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)」
如果 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