Memory Leak (記憶體管理)
Memory Leak(JS 記憶體管理)
TL;DRJS 有 GC 不代表沒 memory leak。GC 只清「unreachable」物件,但 global / closure / timer / unremoved listener 會讓物件「看起來還在用」永遠不被回收。診斷流程:Chrome DevTools Memory tab → Heap Snapshot 兩次對比 → 找 retained size 異常成長的物件 → 找 source code。Node.js 用 --inspect flag 接 Chrome DevTools。
主要參考
什麼是 Memory Leak
程式運行過程中,不再使用的記憶體沒被釋放,持續占空間。
正常流程:用完 → unreachable → GC 清掉 → 記憶體釋放
Leak 流程:用完 → ==reachable(被某處 hold 住)== → GC 不清 → 累積爆炸
最終結果:程式越跑越慢、最後 crash(Node.js 超過 heap 上限直接死)。
JS 的 GC 怎麼運作
┌──────────┐
│ ROOT │ Browser: window
│ (root) │ Node.js: global
└────┬─────┘
│
┌────┴────┐
↓ ↓
Object A Object B
│ │
↓ ↓
Object C Object D
│
↓
Object E
→ 從 root 走到的 = ==reachable== = 不能清
→ 走不到的 = ==unreachable== = 清掉
GC 演算法叫 Mark-and-Sweep:
- Mark:從 root 走遍所有 reachable 物件,標記
- Sweep:把沒標記的清掉
現代 JS engine 還有 generational GC:新物件放 young gen,常被清;活下來的搬到 old gen,清得少。V8 v8 後又有 Orinoco 等優化,但核心原理不變。
Memory Leak 的 4 大兇手
1. Global scope 累積
最常見、也最坑:
// ❌ 經典範例
const requests = [];
app.post('/api', (req, res) => {
requests.push({ body: req.body, timestamp: Date.now() });
res.json({ ok: true });
});
每個 request 都往 requests push,但 requests 在 global,GC 永遠不會清 → 跑久了爆掉。
2. Closure 誤用
// ❌ closure 把 huge data hold 住
function createHandler() {
const hugeData = new Array(1000000).fill('x'); // 1M 字串
return function() {
console.log('handler called');
// ⚠️ hugeData 沒用到,但 closure scope 包含它 → 不會被清
};
}
const handler = createHandler();
// hugeData 無法被回收(handler 還活著就保住 closure scope)
解法:用完明確設 null:
function createHandler() {
let hugeData = new Array(1000000).fill('x');
// ... 用 hugeData
hugeData = null; // ⭐ 解除 closure 引用,GC 可清
return function() { /* 不再用 hugeData */ };
}
3. Timer / Interval 沒清
// ❌ component unmount 後 interval 還在跑
function MyComponent() {
useEffect(() => {
setInterval(() => {
// 即使 component unmount 了,這個 callback 仍持續跑
// closure 內所有變數都不會被回收
}, 1000);
// ⚠️ 沒 return cleanup
}, []);
}
// ✅ 必加 cleanup
useEffect(() => {
const id = setInterval(() => { /* ... */ }, 1000);
return () => clearInterval(id);
}, []);
4. Event Listener 沒移除
// ❌ window listener 沒拆
useEffect(() => {
const handler = () => { /* 用了一堆 component state */ };
window.addEventListener('scroll', handler);
// ⚠️ 沒 return cleanup
}, []);
// ✅ 必加 cleanup
useEffect(() => {
const handler = () => { /* ... */ };
window.addEventListener('scroll', handler);
return () => window.removeEventListener('scroll', handler);
}, []);
DOM listener 沒拆會讓整個 component scope 永遠 hold,因為 listener function 是 closure 引用了 component state。
診斷流程(Chrome DevTools)
步驟 1:確認懷疑(看 Performance Monitor)
F12 → Performance Monitor(底部 drawer)
看 ==JS heap size== 隨時間的變化:
- 正常:鋸齒狀(GC 後降下來)
- Leak:==階梯狀向上,不下降==
步驟 2:Heap Snapshot 兩次對比
1. 開 ==DevTools → Memory tab==
2. ==Take snapshot==(基準點)
3. 跑會 leak 的操作多次
4. ==Take snapshot==(對比點)
5. 切換到 Snapshot 2 → ==Comparison view==(下拉選單)
6. 排序 ==Delta==(物件數量變化)
重點欄位:
| 欄位 | 意思 |
|---|---|
| Constructor | 物件 type 或 DevTools 分類 |
| Distance | 從 root 走幾步到達(越深越深層) |
| Shallow Size | 物件本身占用 bytes(string / primitive array 較大) |
| Retained Size | 物件 + 它所有依賴的,真正釋放會回收的量 |
| Delta | snapshot 之間的變化 |
Retained Size 是關鍵找 leak 時 排序 Retained Size,看哪個物件「如果刪掉會釋放最多」。通常那就是 leak 的兇手。
步驟 3:找出可疑 object 的 source
點開 Retained Size 大的物件
↓
看「Retainers」panel(誰 hold 住它?)
↓
回溯到 source code 找原因
DevTools 通常會直接告訴你變數名,例如 requests 這種 global array。
Node.js 怎麼接 DevTools
# 啟動時加 --inspect
node --inspect app.js
# 或啟動腳本內
node --inspect=0.0.0.0:9229 app.js
console:
Debugger listening on ws://127.0.0.1:9229/...
接著:
1. Chrome 開新分頁:chrome://inspect
2. ==Remote Target== 區塊看到你的 app.js
3. 點 ==inspect==
4. DevTools 打開,跟瀏覽器版完全一樣的功能
5. 切到 Memory tab → 同樣流程
Production 不要用 --inspect--inspect 開 debugger port,exposed 出去等於 server 控制權。只在 local debug。Production 用 --inspect-brk 也禁止,真要 debug 用 Clinic.js 等專業工具。
用 K6 / autocannon 模擬壓力測試
有時 leak 量小,要打很多 request 才看得出來:
# K6
brew install k6 # 或從 https://k6.io/ 下載
cat > test.js <<'EOF'
import http from 'k6/http';
export default function () {
http.post('http://localhost:3000/api/data', JSON.stringify({ test: 'x' }), {
headers: { 'Content-Type': 'application/json' },
});
}
EOF
k6 run --duration 2m --vus 100 test.js
或更輕量的 autocannon(Node.js 寫的):
npx autocannon -c 100 -d 120 http://localhost:3000/api/data
壓測 2 分鐘後 take snapshot,跟壓測前對比,leak 通常很明顯。
真實案例:draft.js 的 SSR Leak
公司產品 Memory Leak 的真實故事上線後跑一段時間就慢到無法忍受,甚至超過 Node.js heap 上限被 kill。經過 Heap Snapshot 對比,發現 Map / HashArrayMapNode 成長異常,來自 immutable.js。而專案裡用到 immutable 的只有 Facebook 的 draft.js(rich text editor)。
兇手 1:DraftEntity.js 的 global instance
// draft.js 內部 — 沒導出 setter 也沒清除機制
let instance = OrderedMap();
function __add(entity) {
instance = instance.set(...); // ⭐ 只增不減
}
// 在 SSR 階段每個 request 都會跑 → instance 越來越大
解法:幸好有 __loadWithEntities setter,可以手動初始化:
import { Entity } from 'draft-js';
import { Map } from 'immutable';
// 每次 SSR 後手動清除
Entity.__loadWithEntities(Map());
兇手 2:CharacterMetadata.js 的 global pool
// 連 setter 都沒有,只能 patch source code
let pool = OrderedMap();
解法:fork draft.js + 自己加清除方法。
教訓
套件也會 leak「我用的套件都 10K stars,應該沒問題」 — 錯。draft.js 這樣的 Facebook 開源套件,在 SSR 環境下仍會 leak,因為設計者預期它在「短暫的瀏覽器 session」內用,沒考慮「server 長期運行」場景。
自己 SSR 用什麼套件,都該確認 server-friendly。
防止 Leak 的編碼習慣
個人 checklist
- 所有 useEffect 都有 cleanup(timer / listener / subscription)
- global 變數都有清除機制(LRU cache 限大小、定期清空)
- Closure 內大物件用完設 null
- Server-side 程式碼避免 mutable global state(尤其 SSR)
- CI 加上 memory test(壓測 + heap monitor 自動 fail)
現代工具(2026)
| 工具 | 用途 |
|---|---|
| Chrome DevTools Memory | 基本款,人人會用 |
Node.js --inspect | Backend debug 同套工具 |
| Clinic.js Doctor | 自動診斷 Node.js 效能 / leak 問題 |
| 0x | Flame graph,看 CPU 熱點 |
| heapdump package | 程式內主動 dump heap |
| node --heapsnapshot-signal=SIGUSR2 | signal 觸發 dump,production 友善 |
| Sentry / Datadog | APM 看 production memory 趨勢 |
# Clinic.js
npx clinic doctor -- node app.js
# 跑完自動產生 HTML 報告
Clinic.js 是現代 Node.js 性能診斷標配,自動分析 event loop / GC / memory 三大維度。