Memory và GC của V8 — heap, rò rỉ, và OOM trong container

Dev PHP ít khi phải nghĩ về rò rỉ bộ nhớ: mỗi request xong là mọi thứ bị dọn. Node là một process sống nhiều ngày — một Map quên xoá sẽ lớn dần tới khi process chết. Bài này đi qua cách V8 chia heap, hai loại GC, cách phân biệt "heap cao" với "rò rỉ", và phần hay bị hỏi nhất khi phỏng vấn Senior: Node chạy trong container 256MB thì chết kiểu gì — lab cho thấy có hai kiểu chết rất khác nhau, một kiểu không để lại dòng log nào.

Thuật ngữ dùng trong bài
heap (vùng nhớ V8 cấp cho object JS) · GC — garbage collector (bộ dọn rác: tìm object không còn ai tham chiếu và giải phóng) · generational GC (chia heap theo "tuổi" object) · new space / young generation (vùng cho object mới) · old space (vùng cho object sống lâu) · Scavenge (GC nhỏ cho new space, copy object còn sống sang nửa kia) · Mark-Compact (GC lớn cho toàn heap: đánh dấu, quét, dồn chặt) · promote (đưa object sống sót lên old space) · stop-the-world pause (GC dừng main thread) · RSS (resident set size — RAM thật process đang chiếm) · retainer (object đang giữ tham chiếu tới object khác — lý do nó chưa bị dọn) · OOMKilled (kernel giết process vì vượt giới hạn RAM của cgroup).
Kiểm chứng: output thật trên Node 22.23.1 (V8 12.4) ở host và node:22-bookworm-slim (Node 22.23.3) trong container tạm có -m 256m/-m 1g. Phần thuật toán GC (Orinoco, tên các tầng) dựa trên blog chính thức của V8 (v8.dev) — chỉ phần nào thấy được qua --trace-gc mới được coi là đã verify.

1. Bài toán gốc và bối cảnh ra đời

V8 được thiết kế năm 2008 cho tab trình duyệt: heap nhỏ, trang sống vài phút, điều quan trọng nhất là không giật (animation 60fps = mỗi khung hình 16ms). Từ đó V8 đặt cược vào giả thuyết thế hệ (generational hypothesis): hầu hết object chết rất trẻ. Dọn vùng object trẻ thật thường xuyên và thật rẻ; vùng object già dọn ít hơn và làm dần (incremental, concurrent) để tránh pause dài.

Node mang V8 lên server — một môi trường khác hẳn: process sống nhiều ngày, heap có thể vài GB, và "cache trong RAM" là cám dỗ thường trực. Giả thuyết thế hệ vẫn đúng với request/response (object theo request chết ngay), nhưng mọi thứ gắn vào biến sống lâu (module-level, singleton) thì sống mãi. PHP-FPM không có vấn đề này vì cả heap bị vứt sau mỗi request.

Tối ưu cho: nhiều object nhỏ sống ngắn, pause ngắn. Không tối ưu cho: heap rất lớn (hàng chục GB), dữ liệu sống lâu khổng lồ trong RAM (cache lớn nên nằm ở Redis), kiểm soát chính xác thời điểm giải phóng.

2. Mô hình tư duy cốt lõi

  1. Object còn sống = còn đường đi từ root (biến global, module, stack, closure đang được giữ, handle đang mở). Rò rỉ trong JS không phải "quên free" mà là vô tình giữ một đường đi tới dữ liệu không dùng nữa.
  2. Heap chia theo tuổi: new space nhỏ dọn rất thường (Scavenge, vài ms); object sống sót 2 lần lên old space, dọn hiếm hơn nhưng tốn hơn (Mark-Compact).
GC theo thế hệ của V8 Object mới sinh ra trong from-space của new space. Tới lần Scavenge, phần lớn đã chết và biến mất; một object còn sống được copy sang to-space. Sống sót lần nữa thì được promote lên old space, nơi Mark-Compact thỉnh thoảng chạy. New space (young) from-space to-space Scavenge: copy object còn sống, bỏ phần còn lại (~1–15ms) Old space Mark-Compact: toàn heap, hiếm, tốn hơn (lab: 170ms)
Hình 1 — Chấm xám: object sống ngắn (biến tạm trong request) — chết ở lần Scavenge đầu, không tốn gì để dọn. Chấm xanh: object còn được tham chiếu — copy sang to-space, sống thêm lần nữa thì lên old space. Rò rỉ = object lẽ ra phải xám nhưng vẫn bị giữ nên thành xanh và tích dần ở old space.

3. Internals và quyết định thiết kế

3.1 Nhìn GC chạy thật bằng --trace-gc

// 50-trace-gc.js — nhiều object sống ngắn + 10% sống lâu
const keep = [];
for (let i = 0; i < 3_000_000; i++) {
  const tmp = { i, s: 'x' + i };                      // chết ngay -> Scavenge dọn
  if (i % 10 === 0) keep.push(tmp);                   // 10% sống lâu -> bị promote lên old space
}
$ node --trace-gc 50-trace-gc.js
32 ms: Scavenge 4.4 (5.1) -> 3.7 (6.1) MB, pooled: 0 MB, 0.95 / 0.00 ms  (average mu = 1.000, ...) allocation failure;
35 ms: Scavenge 4.4 (6.1) -> 3.8 (6.4) MB, pooled: 0 MB, 1.63 / 0.00 ms  ...
...
1517 ms: Mark-Compact 21.5 (46.5) -> 15.5 (48.9) MB, pooled: 0 MB, 170.02 / 0.00 ms
         (+ 35.2 ms in 0 steps since start of marking, ..., walltime since start of marking 229 ms)
         (average mu = 0.865, current mu = 0.865) finalize incremental marking via stack guard; GC in old space requested
...
2911 ms: Scavenge 44.5 (62.1) -> 31.2 (63.3) MB, pooled: 0 MB, 15.08 / 0.00 ms  ...
# tổng: 31 lần Scavenge, 1 lần Mark-Compact

Cách đọc một dòng: 21.5 (46.5) -> 15.5 (48.9) MB = heap dùng (heap đã cấp) trước → sau; 170.02 ms là pause trên main thread; mu (mutator utilization) là tỉ lệ thời gian JS được chạy (không bị GC chiếm) — 0.865 nghĩa là GC ăn ~13,5%. "incremental marking … walltime 229 ms" cho thấy phần đánh dấu đã được làm dần trước đó, pause cuối chỉ là bước chốt. Đánh đổi: Scavenge copy object sống — rẻ khi ít object sống, đắt khi nhiều; vì vậy new space giữ nhỏ (vài MB đến vài chục MB).

3.2 heap limit: tự tính theo RAM và theo cgroup

$ node -e "... v8.getHeapStatistics().heap_size_limit ..."
host 7.8GB RAM: heap_size_limit = 2096MB
--max-old-space-size=512: heap_size_limit = 560MB          ← = old space 512 + new space

$ docker run --rm -m 256m node:22-bookworm-slim node -e "..."
docker -m 256m: heap_size_limit = 259MB | constrainedMemory = 256MB | node v22.23.3
$ docker run --rm -m 1g node:22-bookworm-slim node -e "..."
docker -m 1g: heap_size_limit = 524MB | constrainedMemory = 1024MB | node v22.23.3

Node 22 có đọc giới hạn cgroup (process.constrainedMemory()) và heap limit thay đổi theo đó. Nhưng để ý dòng 256m: heap limit 259MB ≥ giới hạn container 256MB — heap còn chưa chạm trần thì RSS (heap + Buffer + code + stack) đã vượt container. Hệ quả ở lab 5.2. Công thức chính xác Node/V8 dùng để ra các con số này chưa verify; chỉ tin số đo.

3.3 Heap không phải toàn bộ bộ nhớ

Trường process.memoryUsage()Là gìBị heap limit chặn?
heapUsed / heapTotalObject JS trong heap V8✅
externalBộ nhớ C++ gắn với object JS❌
arrayBuffersBuffer, ArrayBuffer (nằm trong external)❌
rssTất cả: heap + external + code + stack thread + thư viện native❌ — nhưng cgroup chặn

Lab ở bài 5: đọc file 300MB → rss=345MB arrayBuffers=300MB heapUsed=5MB. --max-old-space-size không hề biết tới 300MB đó.

4. Đảm bảo và giới hạn

5. Failure modes

5.1 Rò rỉ kiểu phổ biến nhất: cache không giới hạn

// 51-leak.js — cache toàn cục, key là requestId -> không bao giờ xoá
const cache = new Map();
let i = 0;
setInterval(() => {
  for (let k = 0; k < 20_000; k++) cache.set(`req-${i++}`, { body: 'x'.repeat(200), at: Date.now() });
}, 10);
$ node --max-old-space-size=64 51-leak.js
<--- Last few GCs --->
[211767:0x29960000]     2332 ms: Mark-Compact 59.0 (88.9) -> 57.8 (94.4) MB, pooled: 0 MB, 882.71 / 0.00 ms  ...
[211767:0x29960000]     3080 ms: Mark-Compact (reduce) 65.2 (94.6) -> 63.7 (66.6) MB, pooled: 0 MB, 111.25 / 0.00 ms  ...
<--- JS stacktrace --->
FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory
timeout: the monitored command dumped core
exit=134

Để ý dòng Mark-Compact 882ms dọn được có 1,2MB: trước khi chết, process dành phần lớn thời gian chạy GC mà không thu hồi được gì ("GC thrashing" — GC chạy liên tục mà vô ích). Người dùng thấy latency tăng vọt vài phút trước khi process chết — đây là dấu hiệu cảnh báo sớm.

5.2 Chết trong container: hai kiểu, một kiểu không có log

$ docker run --rm -m 256m --memory-swap 256m -v $PWD:/lab -w /lab node:22-bookworm-slim node 51-leak.js
entries=580000 heapUsed=200MB rss=259MB
entries=680000 heapUsed=229MB rss=289MB
exit=137                                                    ← OOMKilled: không có dòng FATAL nào

$ docker run ... node --max-old-space-size=1024 51-leak.js
entries=540000 heapUsed=189MB rss=251MB
exit=137                                                    ← đặt heap lớn hơn container: y hệt, chết im lặng

$ docker run ... node --max-old-space-size=160 51-leak.js
entries=460000 heapUsed=151MB rss=212MB
FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory
exit=139                                                    ← V8 tự dừng, có log rõ ràng
Heap limit cao hơn hay thấp hơn giới hạn container Hai cột bộ nhớ dâng lên. Cột trái: heap limit đặt cao hơn giới hạn container, RSS chạm vạch container trước, kernel giết process, exit 137, không có log. Cột phải: heap limit thấp hơn container, heap chạm vạch heap limit trước, V8 in FATAL ERROR, có log. heap limit ≥ container container 256MB heap limit 259MB+ SIGKILL · exit 137 · không log heap limit < container container 256MB heap limit 160MB FATAL ERROR + stack · có log
Hình 2 — Chạm vạch nào trước quyết định kiểu chết. Chạm giới hạn container trước: kernel SIGKILL, không kịp in gì, không thể chụp heap snapshot. Chạm heap limit trước: V8 in lý do và (nếu bật) ghi heap snapshot để điều tra.

Hệ quả: mất mọi request đang chạy (cả hai kiểu), nhưng kiểu 137 để lại đúng một dấu vết là OOMKilled: true trong kubectl describe pod — không stack, không snapshot. Exit 139 (thay vì 134 như ở host) khi Node là PID 1 trong container: đã thấy thật, nguyên nhân chưa verify.

5.3 Listener leak

// 52-listener-leak.js — mỗi request gắn listener vào 1 emitter sống lâu mà không gỡ
const redis = new EventEmitter();
for (let req = 0; req < 11; req++) redis.on('reconnect', () => { /* closure giữ req, res... */ });
$ node 52-listener-leak.js
(node:215269) MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 11 reconnect listeners
added to [EventEmitter]. MaxListeners is 10. Use emitter.setMaxListeners() to increase limit

Mỗi listener là một closure giữ cả req/res của request đã xong. Warning này là tín hiệu thật — đừng "chữa" bằng setMaxListeners(0).

5.4 Phân biệt "heap cao" với "rò rỉ"

// 53-gc-check.js — ép GC rồi xem heapUsed có về lại không
$ node --expose-gc 53-gc-check.js normal; node --expose-gc 53-gc-check.js leak
normal vòng 1: trước GC 27MB -> sau GC 3MB
normal vòng 2: trước GC 25MB -> sau GC 4MB
normal vòng 3: trước GC 31MB -> sau GC 4MB
leak   vòng 1: trước GC 27MB -> sau GC 24MB
leak   vòng 2: trước GC 46MB -> sau GC 45MB
leak   vòng 3: trước GC 72MB -> sau GC 65MB

Heap răng cưa (lên rồi về) là bình thường. Rò rỉ = đáy của răng cưa (heap sau GC) tăng dần theo thời gian. Trên dashboard, theo dõi heapUsed sau major GC hoặc giá trị thấp nhất mỗi giờ — không phải giá trị tức thời.

5.5 Tìm thủ phạm: heap snapshot

$ node --max-old-space-size=64 --heapsnapshot-near-heap-limit=1 51-leak.js
Wrote snapshot to .../snap/Heap.20260930.105900.261547.0.001.heapsnapshot
FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory
$ ls -la snap/
-rw------- 1 root root 133190239 Sep 30 10:59 Heap.20260930.105900.261547.0.001.heapsnapshot    ← 133MB cho heap 64MB

$ node 55-snap-summary.js snap/*.heapsnapshot       # gom self_size theo constructor, giống tab Summary của DevTools
  32.4MB    1060573 obj  (concatenated string)
   9.7MB     540684 obj  (string)
   7.6MB        926 obj  (array)
   6.8MB     177325 obj  Object
   2.7MB     177006 obj  (number)

Summary cho biết cái gì chiếm chỗ (177k Object + hàng triệu chuỗi req-…). Bước tiếp theo trong Chrome DevTools → Memory → mở snapshot → chọn một Object → khung Retainers cho biết ai giữ: chuỗi retainer sẽ dẫn tới Map trong biến cache của closure module. Kỹ thuật chuẩn: chụp 3 snapshot cách nhau vài phút, dùng chế độ "Comparison" để xem object nào chỉ tăng không giảm. (Thao tác trên DevTools không chạy trong lab này — chỉ verify được file snapshot và bản tóm tắt.)

Bẫy khi chụp snapshot ở production: file snapshot to gấp ~2 lần heap (133MB cho 64MB) và quá trình chụp dừng main thread suốt lúc ghi, cần thêm RAM. Chụp trên pod sắp OOM có thể chính là thứ đẩy nó qua giới hạn. Chụp trên một pod đã rút khỏi load balancer.

6. Vận hành trong production

MetricLấy từCảnh báo khi
heapUsed / heap_size_limitv8.getHeapStatistics()> 80% kéo dài
Đáy heap sau GC (xu hướng)heapUsed sau major GCTăng đều qua nhiều giờ = rò
RSS so với container limitprocess.memoryUsage().rss, cAdvisor> 85%
external / arrayBuffersprocess.memoryUsage()Tăng khi heap đứng yên = Buffer/stream
GC pausePerformanceObserver entry type gcMajor GC > 100ms thường xuyên
Số restart / OOMKilledKubernetesBất kỳ

Ngưỡng là ví dụ minh hoạ. Đo GC pause không cần --trace-gc:

// 56-gc-observer.js
const { PerformanceObserver, constants } = require('node:perf_hooks');
new PerformanceObserver((list) => {
  for (const e of list.getEntries()) { /* e.detail.kind: NODE_PERFORMANCE_GC_MINOR / _MAJOR / _INCREMENTAL; e.duration: ms */ }
}).observe({ entryTypes: ['gc'] });
$ node 56-gc-observer.js       # cùng tải như 50-trace-gc.js
minor (Scavenge)        31 lần | tổng 130ms | lâu nhất 17ms
incremental              1 lần | tổng 0ms | lâu nhất 0ms
major (Mark-Compact)     1 lần | tổng 14ms | lâu nhất 14ms

Hai lần chạy khác trên máy lab (load ~13) cho Scavenge lâu nhất 983ms và 1258ms — cùng code, cùng dữ liệu. Nguyên nhân chưa verify (nghi thread GC bị tranh CPU/bị kernel tạm dừng). Bài học vận hành: pod bị CPU throttling (hết quota CPU của cgroup) sẽ có GC pause dài bất thường; đặt CPU limit quá sát làm latency đuôi (p99) tệ đi dù code không đổi.

Cấu hình khuyến nghị cho container (ví dụ minh hoạ, cần đo lại theo app):

7. So sánh và ma trận quyết định

TrụcV8 (Node)PHP (Zend)GoJVM (G1/ZGC)
Cơ chếGenerational: Scavenge + Mark-Compact, incremental/concurrentReference counting + dọn vòng; heap vứt sau mỗi requestConcurrent mark-sweep, không generationalGenerational, nhiều GC chọn được
Rò rỉ giữa requestCó (process sống lâu)Gần như không (FPM)CóCó
PauseVài ms – vài trăm msKhông đáng kểRất thấp (dưới 1ms là mục tiêu)ZGC: rất thấp; G1: chỉnh được
Núm chỉnhÍt (heap size, semi-space)memory_limitGOGC, GOMEMLIMITRất nhiều
Công cụ tìm rò rỉHeap snapshot (DevTools)Ít khi cầnpprofRất mạnh (JFR, MAT)

(Cột Go/JVM/PHP theo tài liệu chính thức từng hệ, không dựng lab trong bài này.) Node hợp khi dữ liệu theo request nhỏ và sống ngắn, state lớn nằm ngoài process (Redis/DB). Tránh giữ cache lớn hay dữ liệu hàng GB trong heap Node — pause dài, snapshot khó, restart mất sạch.

8. Góc nhìn tech lead: tác động tổ chức

9. Anti-patterns và bài học thực tế

  1. Cache Map không giới hạn (5.1) — dùng LRU có max + TTL, hoặc Redis.
  2. Map keyed theo requestId/sessionId không xoá khi xong — "tạm thời" thành vĩnh viễn khi có một nhánh lỗi quên delete.
  3. Gắn listener mỗi request vào emitter sống lâu (5.3).
  4. setInterval/setTimeout không clear — closure giữ mọi thứ nó nhìn thấy.
  5. --max-old-space-size lớn hơn memory limit container — chết im lặng (5.2).
  6. Gọi global.gc() trong code production — pause toàn heap mỗi lần gọi, che mất triệu chứng rò rỉ.
  7. "Chữa" MaxListenersExceededWarning bằng setMaxListeners(0).
  8. Đọc file/response lớn vào Buffer — không bị heap limit chặn nhưng bị container chặn (bài 5).

10. Câu hỏi phụ interviewer hay hỏi tiếp

Memory leak trong ngôn ngữ có GC là gì?
Dữ liệu không còn dùng nhưng vẫn còn đường tham chiếu từ root (global, closure, listener, timer, cache) nên GC không dọn được.
Làm sao phát hiện?
Đáy heap sau GC tăng dần theo thời gian; xác nhận bằng 2–3 heap snapshot so sánh; tìm retainer chain.
Heap limit mặc định bao nhiêu?
Không cố định: lab đo được 2096MB trên host 7.8GB, 524MB trong container 1GB, 259MB trong container 256MB. Luôn đặt tường minh trong container.
Container báo OOMKilled nhưng log không có gì — vì sao?
RSS chạm giới hạn cgroup trước khi heap chạm heap limit; kernel SIGKILL nên V8 không kịp in. Chữa: heap limit ~70–75% memory limit.
Buffer có tính vào heap không?
Không — nằm ở external/arrayBuffers. Đọc 300MB: heapUsed 5MB, rss 345MB.
Scavenge và Mark-Compact khác nhau thế nào?
Scavenge: chỉ new space, copy object sống sang nửa kia, rất thường, vài ms. Mark-Compact: toàn heap, đánh dấu từ root rồi quét và dồn, hiếm, tốn hơn — phần lớn làm dần song song, pause cuối vẫn có thể hàng trăm ms.
WeakMap giúp gì?
Key là object và bị giữ yếu: khi object key không còn ai tham chiếu, entry tự biến mất. Hợp cho metadata gắn vào object; không thay được cache có TTL (không lặp được, không biết kích thước).

Phụ lục: lab đã chạy

Môi trường: Node v22.23.1 (V8 12.4.254.21), Linux 6.8, 4 vCPU / 7.8GB RAM dùng chung. Lab container: node:22-bookworm-slim (Node 22.23.3), container tạm tmp-node-oom xoá ngay sau khi chạy.

# heap limit
node -e "console.log(require('v8').getHeapStatistics().heap_size_limit/2**20)"
docker run --rm -m 256m node:22-bookworm-slim node -e "console.log(require('v8').getHeapStatistics().heap_size_limit/2**20, process.constrainedMemory()/2**20)"

# 3 kiểu chết trong container 256MB
for f in "" "--max-old-space-size=1024" "--max-old-space-size=160"; do
  docker run --name tmp-node-oom --rm -m 256m --memory-swap 256m -v $PWD:/lab -w /lab node:22-bookworm-slim node $f 51-leak.js; echo "exit=$?"
done

# GC
node --trace-gc 50-trace-gc.js
node --expose-gc 53-gc-check.js normal; node --expose-gc 53-gc-check.js leak
node --max-old-space-size=64 --heapsnapshot-near-heap-limit=1 51-leak.js
node --max-old-space-size=4096 55-snap-summary.js Heap.*.heapsnapshot
// 55-snap-summary.js — tóm tắt heap snapshot theo constructor
const s = JSON.parse(require('fs').readFileSync(process.argv[2]));
const f = s.snapshot.meta.node_fields, types = s.snapshot.meta.node_types[0], F = f.length;
const iT = f.indexOf('type'), iN = f.indexOf('name'), iS = f.indexOf('self_size');
const agg = {};
for (let i = 0; i < s.nodes.length; i += F) {
  const type = types[s.nodes[i + iT]];
  const name = type === 'object' || type === 'closure' ? s.strings[s.nodes[i + iN]] : `(${type})`;
  (agg[name] ||= { n: 0, size: 0 }); agg[name].n++; agg[name].size += s.nodes[i + iS];
}
Object.entries(agg).sort((a, b) => b[1].size - a[1].size).slice(0, 5)
  .forEach(([k, v]) => console.log(`${(v.size / 2 ** 20).toFixed(1).padStart(6)}MB  ${String(v.n).padStart(8)} obj  ${k}`));

// 53-gc-check.js
const leak = process.argv[2] === 'leak'; const store = [];
const mb = () => (process.memoryUsage().heapUsed / 2 ** 20).toFixed(0) + 'MB';
for (let round = 1; round <= 3; round++) {
  let tmp = Array.from({ length: 300_000 }, (_, i) => ({ i, s: 'x' + i }));
  if (leak) store.push(tmp);
  const before = mb(); tmp = null; global.gc();
  console.log(`${leak ? 'leak  ' : 'normal'} vòng ${round}: trước GC ${before} -> sau GC ${mb()}`);
}

Chưa verify: công thức tính heap limit theo RAM/cgroup; exit 139 thay vì 134 khi là PID 1; thao tác Retainers trên DevTools; ảnh hưởng của --max-semi-space-size (lab thử 0/16/64MB cho 31/32/28 lần Scavenge — không khác biệt rõ với tải nhỏ này, máy lại nhiễu); nguyên nhân các lần Scavenge lâu bất thường.