首頁
學習紀錄
遊戲心得影視Life書單案件檔案
Side Projects委託作品與二創互動實驗場
Kurau
百百 BLOG
首頁
學習紀錄
遊戲心得影視Life書單案件檔案
Side Projects委託作品與二創互動實驗場
Kurau

Kurau Blog

「隨心而寫,真真假假,都是我」

一個記錄生活、輸出興趣的個人空間。
遊戲、影視、閱讀、學習……每一段體驗都值得留下文字。

頁面導覽

  • 學習紀錄
  • 遊戲心得
  • 影視Life
  • 書單
  • 委託作品與二創
  • Kurau
  • 合作邀請

找到我

歡迎來 Discord 找我聊天!

“曾經發生的事不可能忘記,只是暫時想不起來而已。”-《神隱少女》

© 2026 Kurau All rights reserved

軟體工程

Memory Leak (記憶體管理)

By Kurau·2024-02-05·Updated 2026-05-09·7 分鐘閱讀

Memory Leak(JS 記憶體管理)

TL;DR
JS 有 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。
主要參考
  • 身為 JS 開發者,你應該要知道的記憶體管理機制(Kyle Mo)
  • 從你的 Node.js 專案裡找出 Memory leak,及早發現、及早治療!(Vocus)

什麼是 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:

  1. Mark:從 root 走遍所有 reachable 物件,標記
  2. 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 });
});
javascript

每個 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)
javascript

解法:用完明確設 null:

function createHandler() {
  let hugeData = new Array(1000000).fill('x');
  // ... 用 hugeData
  hugeData = null;   // ⭐ 解除 closure 引用,GC 可清

  return function() { /* 不再用 hugeData */ };
}
javascript

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);
}, []);
javascript

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);
}, []);
javascript

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物件 + 它所有依賴的,真正釋放會回收的量
Deltasnapshot 之間的變化
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
bash
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
bash

或更輕量的 autocannon(Node.js 寫的):

npx autocannon -c 100 -d 120 http://localhost:3000/api/data
bash

壓測 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 越來越大
javascript

解法:幸好有 __loadWithEntities setter,可以手動初始化:

import { Entity } from 'draft-js';
import { Map } from 'immutable';

// 每次 SSR 後手動清除
Entity.__loadWithEntities(Map());
javascript

兇手 2:CharacterMetadata.js 的 global pool

// 連 setter 都沒有,只能 patch source code
let pool = OrderedMap();
javascript

解法:fork draft.js + 自己加清除方法。

教訓

套件也會 leak
「我用的套件都 10K stars,應該沒問題」 — 錯。

draft.js 這樣的 Facebook 開源套件,在 SSR 環境下仍會 leak,因為設計者預期它在「短暫的瀏覽器 session」內用,沒考慮「server 長期運行」場景。

自己 SSR 用什麼套件,都該確認 server-friendly。


防止 Leak 的編碼習慣

個人 checklist
  1. 所有 useEffect 都有 cleanup(timer / listener / subscription)
  2. global 變數都有清除機制(LRU cache 限大小、定期清空)
  3. Closure 內大物件用完設 null
  4. Server-side 程式碼避免 mutable global state(尤其 SSR)
  5. CI 加上 memory test(壓測 + heap monitor 自動 fail)

現代工具(2026)

工具用途
Chrome DevTools Memory基本款,人人會用
Node.js --inspectBackend debug 同套工具
Clinic.js Doctor自動診斷 Node.js 效能 / leak 問題
0xFlame graph,看 CPU 熱點
heapdump package程式內主動 dump heap
node --heapsnapshot-signal=SIGUSR2signal 觸發 dump,production 友善
Sentry / DatadogAPM 看 production memory 趨勢
# Clinic.js
npx clinic doctor -- node app.js
# 跑完自動產生 HTML 報告
bash

Clinic.js 是現代 Node.js 性能診斷標配,自動分析 event loop / GC / memory 三大維度。


延伸閱讀

  • V8 GC 詳解(官方)
  • Chrome heap snapshot 文件
  • Node.js diagnostics 官方指南

目錄

    ◆ 相關文章

    • 四種渲染模式

      2026-05-09
    • 物件導向設計(OOD Object-Oriented Design)

      2026-05-09
    • 物件導向分析(OOA Object-Oriented Analysis)

      2026-05-09
    • 物件導向程式設計基本原則 - SOLID

      2026-05-09
    ← 上一篇iframe 嵌入下一篇 →Human套件教學

    ◆ 關於作者

    Kurau

    個人寫作 / 創作的 SoT,記錄遊戲、影視、學習與生活。

    更多 Kurau 的文章