Giao diện
Bộ nhớ & GC
JavaScript tự quản lý bộ nhớ: tạo object thì V8 cấp chỗ, object không dùng nữa thì garbage collector (GC) tự dọn. Nhưng "không dùng nữa" theo nghĩa của GC khác với nghĩa của người viết code, và đó là nguồn gốc của memory leak trong Node.js: object không ai dùng tới nhưng vẫn còn được tham chiếu, nên GC không dọn được. Trang này giải thích GC của V8 hoạt động thế nào, giới hạn bộ nhớ của process, các kiểu leak hay gặp ở backend, và cách tìm leak.
Cách GC quyết định cái gì là rác
V8 không đếm số tham chiếu tới từng object. Nó bắt đầu từ các root (biến toàn cục, biến cấp module, các biến trên stack của hàm đang chạy), đi theo mọi tham chiếu để đánh dấu những object còn tới được (mark), rồi coi mọi object không được đánh dấu là rác và giải phóng chúng (sweep). Một object chỉ được dọn khi không còn đường nào từ root tới nó. Hai object trỏ vòng vào nhau mà không có đường từ root vẫn được dọn bình thường.
Hệ quả quan trọng: GC không biết code còn định dùng một object hay không. Một user đã nằm trong cache toàn cục thì GC giữ nó mãi, dù cả năm không ai đọc lại. Bấm vào mũi tên để cắt tham chiếu rồi chạy GC:
rootđi tới được từ root: còn sốngGC giải phóng
Bấm vào mũi tên để cắt một tham chiếu (giống gán null, xoá khỏi Map, gỡ listener, hay request chạy xong), rồi bấm Chạy GC.
Heap theo thế hệ
V8 dựa vào một quan sát gọi là generational hypothesis: phần lớn object chết trẻ. Object tạo ra trong lúc xử lý một request (body đã parse, object kết quả, chuỗi tạm) thường thành rác ngay khi request xong. Vì vậy heap được chia làm hai:
| Young generation | Old generation | |
|---|---|---|
| Chứa gì | Object mới tạo | Object đã sống qua vài lần GC của young generation |
| Kích thước | Nhỏ, vài MB tới vài chục MB | Phần lớn heap |
| GC | Scavenge (minor GC): chép object còn sống sang vùng khác, bỏ cả vùng cũ | Mark-sweep-compact (major GC): đánh dấu, dọn, dồn bộ nhớ |
| Chi phí | Tỉ lệ với số object còn sống, nên rất rẻ khi hầu hết đã chết | Tỉ lệ với kích thước heap còn sống |
Trên lab, tạo 3 triệu object sống ngắn chỉ cần 36 lần minor GC, tổng 14 ms, lần lâu nhất khoảng 5 ms. Giữ lại 3 triệu object trong một mảng (heap 514 MB) thì mỗi lần minor GC tốn hơn nhiều, vì phải chép object còn sống, và một lần full GC trên heap đó mất khoảng 150 ms. V8 làm phần lớn việc đánh dấu song song và xen kẽ với code JavaScript (incremental, concurrent marking), nhưng vẫn có những đoạn dừng main thread. GC pause dài cũng chặn event loop như code đồng bộ chạy lâu (Chặn event loop).
Bài học thực tế: object sống ngắn rất rẻ, object sống lâu thì đắt. Một heap lớn đầy dữ liệu sống lâu (cache khổng lồ trong process) làm mỗi lần major GC lâu hơn, và p99 của service tăng theo.
Giới hạn bộ nhớ
Heap của V8 có giới hạn. Vượt quá thì process chết với FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory, không có cách bắt lỗi trong code. Trên lab, giới hạn mặc định trên máy 16 GB là khoảng 4,5 GB.
--max-old-space-size=<MiB>đặt kích thước tối đa của old generation. Tài liệu Node gợi ý trên máy 2 GiB nên đặt khoảng 1536 để chừa chỗ cho phần khác và tránh swap. Giới hạn heap thật lớn hơn con số này một chút vì còn young generation: đặt 512 thìheap_size_limitlà khoảng 738 MB.- Bộ nhớ của process lớn hơn heap. RSS gồm cả code, stack, và bộ nhớ ngoài heap như
Buffer. Một service đọc ghi nhiều Buffer có thể dùng RSS gấp đôi heap. - Trong container, nếu RSS vượt giới hạn bộ nhớ của container thì kernel giết process (exit code 137) trước cả khi V8 kịp báo lỗi. Vì vậy nên đặt
--max-old-space-sizethấp hơn giới hạn container một khoảng, ví dụ 70–75%, để V8 tự GC mạnh hơn khi gần chạm ngưỡng thay vì bị giết đột ngột.process.constrainedMemory()trả về giới hạn bộ nhớ mà hệ điều hành áp cho process (cgroup), hữu ích để kiểm tra lúc khởi động.
Các kiểu leak hay gặp
| Kiểu | Ví dụ | Cách sửa |
|---|---|---|
| Cache không giới hạn | const cache = new Map() ở cấp module, mỗi request thêm một key mới | Giới hạn số phần tử (LRU, thư viện lru-cache), đặt TTL, hoặc dùng Redis |
| Event listener không gỡ | Mỗi request bus.on('order', handler) mà không off | Gỡ listener khi xong (off, once, AbortSignal trong events.on) |
| Closure giữ dữ liệu lớn | Listener hay timer sống lâu, cùng scope với một Buffer lớn | Tách dữ liệu lớn khỏi scope của closure sống lâu (Closure) |
| Timer không xoá | setInterval tạo trong mỗi request, không clearInterval | Xoá timer khi xong; với timer chạy nền thì unref() |
| Mảng hay Map toàn cục chỉ thêm | Lưu lịch sử, log, kết quả vào một mảng để "debug" | Giới hạn kích thước, ghi ra ngoài |
| Promise không bao giờ xong | Hàng nghìn request chờ một promise không bao giờ resolve | Luôn có timeout (Promise & async/await) |
Trên lab, một cache Map nhận 200.000 key khác nhau làm heap tăng 65 MB và không bao giờ giảm lại, còn cache giới hạn 1.000 key nhận cùng lượng key đó thì heap gần như không đổi. 5.000 request mỗi request gắn một listener giữ mảng 8 KB mà không gỡ thì heap tăng 41 MB. Node có cảnh báo sớm cho trường hợp này: khi một event có hơn 10 listener, nó in MaxListenersExceededWarning: Possible EventEmitter memory leak detected. Đừng tắt cảnh báo này bằng setMaxListeners(0), vì nó gần như luôn chỉ ra một leak thật.
Một cache LRU đơn giản dùng chính thứ tự chèn của Map:
js
const MAX = 1000
const cache = new Map()
function remember(key, value) {
cache.delete(key) // đưa key lên cuối (mới dùng nhất)
cache.set(key, value)
if (cache.size > MAX) cache.delete(cache.keys().next().value) // xoá key cũ nhất
}Cache trong process còn có một nhược điểm khác: mỗi instance có bản riêng, không chia sẻ được và mất khi restart (Cache).
WeakMap, WeakRef
WeakMap giữ key yếu: nếu object làm key không còn tham chiếu nào khác, GC vẫn dọn được nó, và mục tương ứng trong WeakMap biến mất theo. Trên lab, hai object 8 MB không còn biến nào trỏ tới, một cái làm key của Map, một cái làm key của WeakMap. Sau GC, chỉ object trong WeakMap được giải phóng, còn Map vẫn giữ object kia.
WeakMap hợp để gắn thêm dữ liệu vào một object mà mình không sở hữu, ví dụ gắn metadata vào object req của framework, mà không kéo dài đời sống của nó. Key phải là object, và WeakMap không duyệt được.
WeakRef và FinalizationRegistry cho tham chiếu yếu tới một object và một callback chạy sau khi object bị dọn. Tài liệu khuyên tránh dùng chúng nếu có thể, vì không biết trước GC chạy lúc nào. Đừng dùng chúng làm cơ chế dọn tài nguyên chính (đóng file, đóng connection); việc đó phải làm tường minh.
Tìm leak
1. Xác nhận là leak. Theo dõi heap đã dùng (process.memoryUsage().heapUsed) theo thời gian, sau mỗi lần GC. Bộ nhớ tăng rồi giảm lại theo tải là bình thường. Bộ nhớ chỉ tăng, kể cả sau khi tải giảm, và mức thấp nhất sau mỗi lần GC cứ cao dần lên, thì mới là leak.
2. Chụp heap snapshot và so sánh. Heap snapshot ghi lại mọi object trong heap và ai đang giữ chúng. Cách hiệu quả là chụp hai hay ba snapshot cách nhau một khoảng có tải, rồi so sánh trong Chrome DevTools (chế độ Comparison): loại object nào tăng số lượng giữa các lần chụp, và xem retainer (chuỗi tham chiếu từ root giữ nó lại) để biết ai đang giữ. Có ba cách chụp:
- Gắn Chrome DevTools qua
node --inspect, tab Memory. - Gọi
v8.writeHeapSnapshot()trong code, hoặc chạy với--heapsnapshot-signal=SIGUSR2rồi gửi signal. --heapsnapshot-near-heap-limit=1tự chụp khi heap gần chạm giới hạn, để có bằng chứng trước khi process chết.
Chụp snapshot có giá. Tài liệu Node nói việc tạo snapshot chạy đồng bộ, chặn event loop, và cần lượng bộ nhớ khoảng gấp đôi heap lúc chụp. Trên lab, chụp heap 84 MB làm process dừng hẳn khoảng 5 giây, file snapshot 158 MB. Trên production, chỉ chụp ở một instance đã được rút khỏi load balancer, và để ý giới hạn bộ nhớ của container.
3. Đọc theo kích thước giữ lại. Trong snapshot, cột retained size cho biết bao nhiêu bộ nhớ sẽ được giải phóng nếu object đó bị dọn. Một Map có retained size hàng trăm MB là ứng viên số một.
Công cụ như clinic.js heapprofiler hay --heap-prof (lấy mẫu cấp phát theo thời gian) giúp thấy chỗ nào cấp phát nhiều, bổ sung cho snapshot (Hiệu năng & profiling).
Ghi nhớ
- GC dọn object không còn đường tham chiếu từ root; object vẫn được tham chiếu thì không bao giờ bị dọn, dù không ai dùng.
- Heap chia young (scavenge, rẻ vì phần lớn object chết trẻ) và old (mark-sweep-compact, đắt theo kích thước heap còn sống). Heap lớn đầy dữ liệu sống lâu làm GC pause dài hơn.
- Vượt giới hạn heap thì process chết; trong container đặt
--max-old-space-sizethấp hơn giới hạn container để tránh bị kernel giết. - Leak hay gặp: cache không giới hạn, listener không gỡ (cảnh báo
MaxListenersExceededWarning), closure giữ dữ liệu lớn, timer không xoá. - Tìm leak bằng cách so sánh heap snapshot và xem retainer; chụp snapshot chặn process và cần gấp đôi bộ nhớ heap.
Nguồn tham khảo
Đã đối chiếu (đọc nguồn ngày 2026-10-26):
| Nguồn | Dùng cho phần |
|---|---|
| V8 blog, Trash talk: the Orinoco garbage collector | Generational hypothesis: phần lớn object chết trẻ; young và old generation |
Node.js docs, --max-old-space-size | Đặt kích thước tối đa của old generation; gần giới hạn thì V8 GC nhiều hơn; máy 2 GiB nên đặt khoảng 1536 |
Node.js docs, v8.writeHeapSnapshot | Chụp snapshot chạy đồng bộ, chặn event loop, cần bộ nhớ khoảng gấp đôi heap |
Node.js docs, process.constrainedMemory() | Bộ nhớ được phép theo giới hạn của hệ điều hành; trả 0 nếu không có |
Kiểm bằng lab: giới hạn heap, OOM, số lần và thời gian GC theo loại, leak của cache và listener, MaxListenersExceededWarning, Map so với WeakMap, thời gian và kích thước heap snapshot. Chưa kiểm: giới hạn heap mặc định khi chạy trong container có giới hạn bộ nhớ, và exit code 137 khi container bị OOM (Docker Desktop chưa bật lúc viết trang); mức 70–75% là kinh nghiệm vận hành; incremental và concurrent marking của Orinoco (từ kiến thức chung).
Số đo chi tiết
Kết quả chạy lab/bo-nho-gc/gc.mjs (MacBook M2 16 GB, Node 24.14)
| Phép thử | Kết quả |
|---|---|
heap_size_limit mặc định | 4.496 MB |
--max-old-space-size=512 | heap_size_limit 738 MB |
| Vượt heap 64 MB | FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory, process chết bởi SIGABRT |
| 3.000.000 object sống ngắn | 36 lần minor GC, tổng 14 ms, lâu nhất 4,7 ms |
| 3.000.000 object giữ lại (heap 514 MB) | 6 lần minor GC tổng 65 ms (lâu nhất 18 ms), 2 lần incremental tổng 41 ms |
global.gc() (full GC) trên heap đó | 153 ms |
Cache Map, 200.000 key | heap tăng 65 MB |
| Cache giới hạn 1.000 key, cùng 200.000 key | heap tăng 0 MB |
| 5.000 listener giữ mảng 8 KB, không gỡ | heap tăng 41 MB, MaxListenersExceededWarning |
Object 8 MB làm key của Map / WeakMap, không còn tham chiếu khác | Map giữ lại / WeakMap để GC dọn |
v8.writeHeapSnapshot() với heap 84 MB | dừng process khoảng 4,9 giây (timer 10 ms chạy 0 lần), file 158 MB |
Code đã dùng để chạy thử
Chạy bằng npm run lab -- bo-nho-gc, chỉ cần Node.js, khoảng 15 giây. Script ghi một file heap snapshot khoảng 160 MB vào thư mục tạm rồi xoá. Xem Lab.
lab/bo-nho-gc/gc.mjs
js
// Trang nodejs/bo-nho-gc. Chạy với --expose-gc (run.mjs đặt sẵn).
// 1. Giới hạn heap mặc định và khi đặt --max-old-space-size
// 2. Thời gian GC theo loại (scavenge cho object sống ngắn, mark-sweep-compact cho object sống lâu), đo bằng PerformanceObserver
// 3. Leak: cache Map không giới hạn so với cache có giới hạn; event listener không gỡ
// 4. Map giữ key sống, WeakMap thì không
// 5. Chụp heap snapshot dừng process bao lâu
import v8 from 'node:v8'
import { PerformanceObserver } from 'node:perf_hooks'
import { EventEmitter } from 'node:events'
import { spawnSync } from 'node:child_process'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { rmSync, statSync } from 'node:fs'
const mb = (b) => Math.round(b / 1e6).toLocaleString('vi-VN') + ' MB'
const ms = (v) => v.toFixed(v < 10 ? 2 : 0).replace('.', ',') + ' ms'
const log = (label, v) => console.log(`${label.padEnd(58)} ${v}`)
const heap = () => process.memoryUsage().heapUsed
console.log('\n=== 1. Giới hạn heap ===')
log('heap_size_limit mặc định trên máy này', mb(v8.getHeapStatistics().heap_size_limit))
const limited = spawnSync(process.execPath, ['--max-old-space-size=512', '-e', "console.log(require('v8').getHeapStatistics().heap_size_limit)"], { encoding: 'utf8' })
log('với --max-old-space-size=512', mb(Number(limited.stdout)))
const oom = spawnSync(process.execPath, ['--max-old-space-size=64', '-e', 'const a = []; for (;;) a.push(new Array(1e5).fill(Math.random()))'], { encoding: 'utf8' })
log('vượt heap 64 MB', `exit code ${oom.status}, signal ${oom.signal}; ${(oom.stderr.match(/FATAL ERROR: [^\n]*/) || [''])[0]}`)
console.log('\n=== 2. GC theo loại ===')
const gcs = []
const obs = new PerformanceObserver((list) => gcs.push(...list.getEntries()))
obs.observe({ entryTypes: ['gc'] })
const KIND = { 1: 'minor (scavenge)', 2: 'major (mark-sweep-compact)', 4: 'incremental', 8: 'weakcb' }
const summarize = async (label) => {
await new Promise((r) => setTimeout(r, 50))
const by = {}
for (const e of gcs) {
const k = KIND[e.detail?.kind ?? e.kind] ?? `kind ${e.detail?.kind}`
by[k] ??= { n: 0, total: 0, max: 0 }
by[k].n++; by[k].total += e.duration; by[k].max = Math.max(by[k].max, e.duration)
}
console.log(label)
for (const [k, v] of Object.entries(by)) console.log(` ${k.padEnd(30)} ${String(v.n).padStart(5)} lần, tổng ${ms(v.total).padStart(9)}, lâu nhất ${ms(v.max)}`)
gcs.length = 0
}
// Object sống ngắn: tạo rồi bỏ ngay, như object của mỗi request
let t = performance.now()
for (let i = 0; i < 3_000_000; i++) { const o = { id: i, name: 'user' + i, tags: [i, i + 1] }; if (o.id === -1) console.log(o) }
await summarize(`3.000.000 object sống ngắn, ${ms(performance.now() - t)}:`)
// Object sống lâu: giữ lại trong một mảng, như cache
t = performance.now()
const keep = []
for (let i = 0; i < 3_000_000; i++) keep.push({ id: i, name: 'user' + i, tags: [i, i + 1] })
await summarize(`3.000.000 object giữ lại trong mảng (heap ${mb(heap())}), ${ms(performance.now() - t)}:`)
t = performance.now(); global.gc(); log('global.gc() một lần (full GC) khi heap đang giữ 3 triệu object', ms(performance.now() - t))
keep.length = 0
global.gc(); gcs.length = 0
obs.disconnect()
console.log('\n=== 3. Leak hay gặp ===')
global.gc(); let h0 = heap()
const cache = new Map()
for (let i = 0; i < 200_000; i++) cache.set(`user:${i}`, { id: i, profile: 'x'.repeat(200) })
global.gc(); log('Cache Map không giới hạn, 200.000 key khác nhau', `heap tăng ${mb(heap() - h0)}, và không bao giờ giảm`)
cache.clear(); global.gc(); h0 = heap()
const MAX = 1000
const lru = new Map()
for (let i = 0; i < 200_000; i++) {
lru.set(`user:${i}`, { id: i, profile: 'x'.repeat(200) })
if (lru.size > MAX) lru.delete(lru.keys().next().value) // Map giữ thứ tự chèn: key đầu tiên là key cũ nhất
}
global.gc(); log(`Cache giới hạn ${MAX} key, cùng 200.000 key`, `heap tăng ${mb(heap() - h0)}`)
lru.clear()
const bus = new EventEmitter()
const warnings = []
process.on('warning', (w) => warnings.push(w.name))
global.gc(); h0 = heap()
// Mỗi "request" gắn listener rồi quên gỡ. Dùng mảng số thay vì 'x'.repeat(), vì V8 có thể lưu chuỗi repeat rất gọn.
for (let i = 0; i < 5000; i++) { const big = new Array(1000).fill(i); bus.on('order', () => big.length) }
await new Promise((r) => setTimeout(r, 10))
global.gc(); log('5.000 request gắn listener vào emitter dùng chung, không gỡ', `heap tăng ${mb(heap() - h0)}, listenerCount = ${bus.listenerCount('order')}, cảnh báo: ${[...new Set(warnings)].join(', ') || 'không'}`)
bus.removeAllListeners(); global.gc()
console.log('\n=== 4. Map và WeakMap ===')
const reg = new FinalizationRegistry((name) => collected.push(name))
const collected = []
const strong = new Map(), weak = new WeakMap()
;(() => {
const a = { name: 'object A', data: new Array(1e6).fill(1) }, b = { name: 'object B', data: new Array(1e6).fill(1) }
strong.set(a, 'metadata'); weak.set(b, 'metadata')
reg.register(a, 'A (key của Map)'); reg.register(b, 'B (key của WeakMap)')
})() // a và b không còn biến nào trỏ tới
for (let i = 0; i < 5; i++) { global.gc(); await new Promise((r) => setTimeout(r, 20)) }
log('Sau GC, object đã được giải phóng', collected.join(', ') || 'không có')
log('Map vẫn giữ key', `strong.size = ${strong.size}`)
console.log('\n=== 5. Heap snapshot ===')
const big = Array.from({ length: 1_000_000 }, (_, i) => ({ id: i, s: 'v' + i }))
const file = join(tmpdir(), `heap-${process.pid}.heapsnapshot`)
const before = process.memoryUsage().rss
let peak = before
t = performance.now()
let ticks = 0
const iv = setInterval(() => ticks++, 10)
v8.writeHeapSnapshot(file)
const dt = performance.now() - t
clearInterval(iv)
peak = Math.max(peak, process.memoryUsage().rss)
log(`writeHeapSnapshot với heap ${mb(heap())}`, `process dừng hẳn ${ms(dt)} (timer 10 ms chạy ${ticks} lần), file ${mb(statSync(file).size)}, RSS ${mb(before)} → ${mb(peak)}`)
rmSync(file)
if (big.length < 0) console.log(big)