Bài 2 · Nâng cao · 24 phút
Bộ nhớ & garbage collector
Biên soạn bởi Nguyễn Anh Tuấn
V8 quản lý bộ nhớ thế nào: stack & heap, garbage collector thế hệ (generational GC), memory leak hay gặp trong Node.js và cách soi bằng heap snapshot.
Bài Bản chất: event loop & libuv trả lời câu hỏi khi nào code chạy; bài này trả lời nốt: dữ liệu nằm ở đâu, và ai dọn khi ta xài xong. V8 chia bộ nhớ làm hai vùng: stack - nhỏ, ngăn nắp, xếp theo từng lời gọi hàm, hàm return là tự giải phóng; và heap - rộng rãi, chứa mọi thứ to và sống lâu tuỳ ý: object, mảng, hàm, chuỗi. Primitive nhỏ (số, boolean…) nằm gọn trên stack hoặc ngay bên trong object chứa nó; còn object luôn ở heap - biến chỉ cầm tham chiếu (địa chỉ) trỏ tới đó, vì vậy gán b = a là copy tham chiếu chứ không copy object, như mèo con đã thấy ở bài Mảng & object của khoá Cơ bản. Heap không hề trừu tượng - Node gắn sẵn đồng hồ đo: process.memoryUsage().heapUsed là số byte heap đang dùng thật. Cấp phát 1 triệu object và nhìn kim nhảy:
node heapused.mjs - số liệu trên máy chạy thử, máy của mèo con sẽ khác
const mb = (x) => Math.round(x / 1024 / 1024) + " MB";
const truoc = process.memoryUsage().heapUsed;
const hoSo = [];
for (let i = 0; i < 1_000_000; i++) hoSo.push({ id: i }); // 1 triệu object lên heap
const sau = process.memoryUsage().heapUsed;
console.log("heapUsed trước:", mb(truoc));
console.log("heapUsed sau: ", mb(sau)); Kết quả khi chạy
heapUsed trước: 4 MB heapUsed sau: 59 MB
- ▸Stack: nhỏ, tự dọn theo lời gọi hàm. Heap: rộng, chứa object/mảng/chuỗi - phải có người dọn.
- ▸Biến object chỉ giữ THAM CHIẾU trỏ vào heap - gán biến là copy tham chiếu, không copy dữ liệu.
- ▸Mỗi tiến trình Node một heap (mỗi Worker là isolate có heap riêng); heapUsed là đồng hồ đo của nó.
Trong C, lập trình viên tự free() từng vùng nhớ. JavaScript thuê hẳn người dọn: garbage collector (GC). Luật làm việc chỉ một câu: xuất phát từ các root - biến toàn cục, biến trên stack của những hàm đang chạy, closure còn sống - lần theo mọi tham chiếu; thứ gì không với tới được («khả năng với tới» - reachability) là rác, thu hồi. GC không đếm tham chiếu: hai object trỏ vòng vào nhau mà cả cụm đứt khỏi root vẫn bị thu gọn gàng. Thí nghiệm được luôn: cờ --expose-gc mở hàm global.gc() để ép GC chạy ngay - đo trước, bỏ tham chiếu, đo sau:
node --expose-gc reachability.mjs - số liệu trên máy chạy thử, máy của mèo con sẽ khác
const mb = (x) => Math.round(x / 1024 / 1024) + " MB";
let bangGia = new Array(5_000_000).fill(123); // mảng to ~40 MB trên heap
global.gc(); // ép GC chạy (nhờ cờ --expose-gc)
console.log("đang giữ tham chiếu:", mb(process.memoryUsage().heapUsed));
bangGia = null; // bỏ tham chiếu - mảng hết với tới được
global.gc();
console.log("bỏ tham chiếu + GC: ", mb(process.memoryUsage().heapUsed)); Kết quả khi chạy
đang giữ tham chiếu: 42 MB bỏ tham chiếu + GC: 4 MB
- ▸Root = biến toàn cục + stack các hàm đang chạy + closure còn sống; rác = thứ không với tới được từ root.
- ▸GC theo reachability, KHÔNG đếm tham chiếu - vòng tròn tham chiếu không làm nó bó tay; global.gc() chỉ là cái cân thí nghiệm (xem FAQ).
- ▸Hệ quả đảo ngược: thứ gì CÒN với tới được thì GC tuyệt đối không đụng - kể cả khi bạn đã quên nó.
Quét cả heap mỗi lần dọn thì quá đắt. V8 dựa vào một quan sát thống kê - «giả thuyết thế hệ» (generational hypothesis): đa số object chết trẻ. Biến tạm, kết quả trung gian, object của một request… sinh ra rồi bị bỏ rơi gần như lập tức. Vậy nên heap chia theo tuổi: new space - vùng nhỏ đón object mới, do Scavenger dọn (minor GC: rất nhanh, rất thường xuyên); và old space - vùng lớn cho object sống dai, do Mark-Sweep / Mark-Compact dọn (major GC: thưa hơn, nặng hơn - Mark-Compact còn dồn object lại cho liền mạch). Object nào sống sót vài lần minor GC được coi là "thọ" và được promote (chuyển) sang old space - từ đó Scavenger khỏi quét lại nó mãi. Mèo con tự bấm thử: cấp phát vài object, bỏ tham chiếu vài cái, chạy Minor GC hai lần rồi xem ai bị thu, ai được lên old space:
Ô đậm = còn tham chiếu · ô mờ gạch = hết với tới được (chờ GC) · age = số lần minor GC đã sống sót. Bấm một ô đang sống để chọn nạn nhân cho "Bỏ tham chiếu" - không chọn thì Mèo rút thăm.
Trung thực: mô hình đã rút gọn
- ▸Giả thuyết thế hệ: đa số object chết trẻ → dồn sức quét vùng trẻ - vùng nhỏ, quét thường xuyên.
- ▸Minor GC (Scavenger) dọn new space - nhanh, liên tục; major GC (Mark-Sweep/Mark-Compact) dọn old space - thưa, nặng hơn.
- ▸Sống sót vài lần minor GC → promote sang old space; object chết ở old space phải đợi major GC mới được thu.
Có GC rồi sao Node server vẫn phình? Vì memory leak trong ngôn ngữ có GC không phải "quên free" - mà là giữ tham chiếu ngoài ý muốn: object vẫn với tới được từ root, nên với GC nó là đồ đang dùng, không phải rác. Thủ phạm số một trong server - cache toàn cục chỉ nạp mà không bao giờ xả:
node cache-leak.mjs - heap tăng đều theo số request; số MB trên máy chạy thử, máy của mèo con sẽ khác
const cache = new Map(); // cache toàn cục - chỉ set, không bao giờ xoá
function xuLy(requestId) {
const ketQua = new Array(5_000).fill(requestId); // dữ liệu của một request
cache.set(requestId, ketQua); // "cache lại cho nhanh"
return ketQua;
}
const mb = () => Math.round(process.memoryUsage().heapUsed / 1024 / 1024);
for (let dot = 1; dot <= 3; dot++) {
for (let i = 0; i < 1000; i++) xuLy(`req-${dot}-${i}`);
console.log(`sau ${dot * 1000} request: heapUsed ≈ ${mb()} MB`);
} Kết quả khi chạy
sau 1000 request: heapUsed ≈ 43 MB sau 2000 request: heapUsed ≈ 81 MB sau 3000 request: heapUsed ≈ 119 MB
Thủ phạm thứ hai: gắn listener vào EventEmitter sống lâu (bus sự kiện, socket server…) mà không bao giờ gỡ - mỗi listener là một closure, và closure giữ sống mọi thứ nó nhìn thấy. Node còn đánh hơi giúp ta kiểu leak này:
node listener-leak.mjs - warning có thật của Node; số PID (node:31946) mỗi lần chạy sẽ khác
import { EventEmitter } from "node:events";
const bus = new EventEmitter();
function moKetNoi(tenMeo) {
// mỗi kết nối gắn một listener - kết nối đóng rồi vẫn KHÔNG gỡ ra
bus.on("tin-moi", (tin) => console.log(`gửi cho ${tenMeo}: ${tin}`));
}
for (let i = 1; i <= 11; i++) moKetNoi(`mèo ${i}`);
console.log("listener đang gắn:", bus.listenerCount("tin-moi")); Kết quả khi chạy
listener đang gắn: 11 (node:31946) MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 11 tin-moi listeners added to [EventEmitter]. MaxListeners is 10. Use emitter.setMaxListeners() to increase limit (Use `node --trace-warnings ...` to show where the warning was created)
Hai thủ phạm còn lại hay đi chung một cửa: timer quên clear và closure giữ data lớn. Một setInterval vẽ biểu đồ mỗi giây từ mảng lichSuGia to đùng: chừng nào chưa clearInterval, interval còn sống - closure của nó còn sống - cả mảng còn nguyên trên heap, dù người dùng đã đóng màn hình từ lâu. Với GC, chuỗi tham chiếu đó vẫn nối về root: chưa phải rác.
- ▸Leak trong JS = giữ tham chiếu ngoài ý muốn - còn với tới được thì GC coi là đồ đang dùng.
- ▸Bộ tứ quen mặt: cache Map toàn cục không giới hạn · listener không gỡ · timer không clear · closure ôm data lớn.
- ▸MaxListenersExceededWarning không phải lỗi vặt để tắt đi - nó là máy báo khói cho leak listener.
Nghi server leak thì đo trước, đoán sau. Tầng rẻ nhất: in dấu chân bộ nhớ định kỳ - một setInterval mỗi 10 giây log process.memoryUsage() như Bước 1 (thêm .unref() để chính timer này không giữ tiến trình sống). Nếu heapUsed leo thang đều, không chịu hạ sau các đợt major GC - có leak. Tầng thứ hai trả lời "leo thang vì cái gì": heap snapshot - chụp ảnh cả heap rồi so hai tấm. Node mở cổng debug, Chrome DevTools làm máy ảnh:
heap snapshot - cần Chrome/Edge trên máy của mèo con, không chạy trong terminal thuần được
node --inspect server.mjs # mở cổng debug ở 127.0.0.1:9229
# Chrome/Edge → gõ chrome://inspect → bấm "inspect" cạnh tiến trình Node
# tab Memory → Take heap snapshot: MỘT tấm lúc bình thường, MỘT tấm sau khi nghi leak
# chế độ Comparison: loại object nào TĂNG bất thường? cột Retainers: AI đang giữ nó sống? Cuối cùng, công cụ phòng leak từ thiết kế: cần "treo dữ liệu kèm một object" thì dùng WeakMap - nó giữ tham chiếu yếu, không giữ sống key: object hết tham chiếu mạnh là GC dọn cả object lẫn mục đi kèm (em họ WeakRef làm vậy cho một tham chiếu lẻ). Chi tiết so với Map: mục Câu hỏi thường gặp bên dưới.
- ▸Đo theo tầng: memoryUsage() định kỳ phát hiện CÓ leak; heap snapshot + Comparison chỉ ra leak Ở ĐÂU.
- ▸Trong snapshot, cột Retainers trả lời đúng câu hỏi của GC: ai đang giữ tham chiếu tới object này?
- ▸WeakMap/WeakRef giữ tham chiếu yếu - công cụ "cache kèm object" không nuôi leak.
Tiếp theo: pha tạo execution context
Câu hỏi thường gặp
Không. Hàm này mặc định còn không tồn tại - phải mở bằng cờ --expose-gc, vì V8 muốn tự quyết thời điểm dọn (nó nhìn được áp lực cấp phát, kích thước heap… tốt hơn ta). Gọi GC bằng tay thường chỉ làm chậm chương trình. Trong bài này ta dùng nó đúng một việc: làm thí nghiệm đo đạc cho dễ quan sát.
Chưa chắc. GC "lười" có chủ đích: nó chạy khi cần (sắp hết chỗ, rảnh rỗi…), không phải ngay khi object hết tham chiếu. Ngoài ra heapTotal đã xin của hệ điều hành thì thường giữ lại dùng tiếp. Chỉ đáng lo khi heapUsed tăng ĐỀU qua nhiều lần major GC mà không bao giờ hạ - đó mới là dấu vân tay của memory leak.
Map giữ tham chiếu MẠNH tới key: chừng nào mục còn trong Map, object key còn sống - quên xoá là leak. WeakMap giữ tham chiếu YẾU: nó không giữ sống key; khi object hết mọi tham chiếu mạnh bên ngoài, GC dọn object và mục trong WeakMap tự biến mất. Đổi lại, WeakMap không duyệt được, không có .size - nó sinh ra để "treo dữ liệu kèm object", không phải để làm danh sách.
Không. Mỗi Worker là một isolate V8 riêng - heap riêng, GC riêng; dữ liệu gửi qua postMessage là bản sao. Vì thế leak trong worker chỉ phình tiến trình theo phần heap của nó, và một worker bận GC không làm khựng JS của luồng chính (trừ vùng SharedArrayBuffer dùng chung).
Tick những điều em tự tin làm được. Càng lên cao, em càng hiểu sâu.
Trả lời vài câu để chắc rằng em đã nắm bài.
Chạy let a = { x: 1 }; let b = a; b = null; - object { x: 1 } có bị GC thu không?
- 1
Cân thử heap
Mèo con viết script in
heapUsed(làm tròn MB) trước và sau khi tạo 2 triệu object dạng{ id, ten }trong một mảng.Hoàn thành khi: Số sau lớn hơn rõ rệt; ghi lại mức chênh trên máy mình (mỗi máy mỗi khác - quan trọng là thấy heap "ăn" theo số object).
- 2
Tự tay ép GC
Chạy lại thí nghiệm Bước 2 với cờ
--expose-gc, nhưng thay mảng số bằng mảng 100.000 chuỗi dài (vd lặp"meo".repeat(100)).Hoàn thành khi: Sau khi gán
nullvà gọiglobal.gc(),heapUsedtụt về gần mức ban đầu - chuỗi cũng là dân heap. - 3
Gây leak rồi chữa
Chạy demo cache
Mapở Bước 4, rồi sửa: giới hạn 100 mục - vượt thì xoá mục cũ nhất (Map giữ thứ tự chèn; key cũ nhất làcache.keys().next().value).Hoàn thành khi: Bản chưa sửa:
heapUsedtăng đều qua 3 đợt. Bản đã sửa: heap đi ngang dù xử lý bao nhiêu request. - 4
Gỡ listener tử tế
Tái tạo
MaxListenersExceededWarningở Bước 4, rồi sửa: lưu từng hàm listener và gọibus.off("tin-moi", hàm)khi "kết nối đóng".Hoàn thành khi: Warning biến mất;
bus.listenerCount("tin-moi")quay về 0 sau khi mọi kết nối đóng. - 5
Chụp heap snapshot đầu tiên
Thêm một
setIntervalrỗng vào script cache-leak để tiến trình sống, chạynode --inspect, mởchrome://inspect, chụp snapshot trước và sau khi thêm 1.000 mục, bật chế độ Comparison.Hoàn thành khi: Thấy số mảng mới sinh cỡ ~1.000 giữa hai tấm; lần theo cột Retainers dẫn ngược về
Mapcache. - 6
Vẽ vòng đời một object
Không nhìn bài, mèo con vẽ sơ đồ: cấp phát → new space → sống sót minor GC → promote → old space → major GC. Rồi đối chiếu bằng mô phỏng Bước 3.
Hoàn thành khi: Sơ đồ có đủ 2 vùng, 2 loại GC, và điều kiện promote (sống sót đủ số lần minor GC) ghi đúng chỗ mũi tên.