How Browsers Work · Part 17 — Memory Deep: Heap Snapshots, Leaks & Detached DOM
Điều tra rò rỉ bộ nhớ bằng heap snapshot, Comparison và retainer path; hiểu detached DOM, Oilpan/V8 GC, weak references và giới hạn của phép đo memory.
Phần 8 giới thiệu garbage collection (generational, mark-sweep). Phần này biến lý thuyết thành kỹ năng điều tra: làm sao tìm ra cái gì đang giữ bộ nhớ không cho giải phóng. Rò rỉ trong JS hiếm khi là “quên free” — nó là vô tình giữ một tham chiếu khiến GC không thể thu hồi.
1. Định nghĩa rò rỉ: reachable nhưng vô dụng
GC thu hồi mọi thứ không reachable từ root (Phần 8). Một object bị “rò rỉ” khi nó vẫn reachable (có đường tham chiếu từ root) nhưng chương trình không còn cần. GC không thể đoán ý định của bạn — nó chỉ thấy tham chiếu.
root (window, stack, ...)
│
▼
cache ──► entry ──► hugeObject ← bạn quên xoá entry → không bao giờ GC
Bốn nguồn rò rỉ kinh điển: biến toàn cục vô tình, listener/timer chưa gỡ, detached DOM bị JS giữ, và closure giữ tham chiếu sống lâu hơn cần thiết.
2. Heap snapshot & các view chính
DevTools → Memory → Heap snapshot chụp graph object JS/DOM còn reachable sau khi DevTools chạy GC. Snapshot không đại diện toàn bộ RAM của renderer/GPU. Bốn chế độ xem chính:
| View | Trả lời câu hỏi |
|---|---|
| Summary | Có những loại (constructor) gì, bao nhiêu instance, chiếm bao nhiêu? |
| Comparison | Giữa hai snapshot, cái gì mới sinh ra mà chưa chết? |
| Containment | Cây sở hữu từ root — duyệt thủ công |
| Statistics | Tỉ lệ tương đối của code, string, array, typed array và system object |
Hai cột quan trọng:
- Shallow size: bộ nhớ object tự nó chiếm.
- Retained size: ước lượng phần heap có thể được giải phóng nếu object này và các object chỉ reachable qua nó biến mất. Retained size lớn là điểm bắt đầu điều tra, chưa tự chứng minh object đó là leak.
3. Retainer path — vì sao object còn sống
Chọn một object, DevTools hiện Retainers (ai đang giữ nó). Lần ngược chuỗi này tới root chính là retainer path — bằng chứng trực tiếp cho việc object còn reachable; việc đó có phải leak hay không vẫn phụ thuộc ý định ứng dụng.
Detached HTMLDivElement
◄── retained by array "cache" (đối tượng A)
◄── retained by window.appState
◄── root (window) ← đây là dây neo cần cắt
Quy trình ba snapshot để tìm object tiếp tục sống sau khi ứng dụng trở về cùng trạng thái logic:
- Chụp snapshot 1 (baseline).
- Lặp thao tác N lần (mở/đóng modal), trở về trạng thái đầu, chụp snapshot 2.
- Lặp thêm N lần, trở về cùng trạng thái, chụp snapshot 3.
- So sánh 3 với 2: constructor/retainer nào tiếp tục tăng theo mỗi batch là nghi phạm. Cache có chủ đích cũng tăng, nên luôn đọc retainer path trước khi kết luận.
4. Detached DOM — một nguồn rò rỉ phổ biến
Khi bạn gỡ một node khỏi cây DOM nhưng JS vẫn giữ tham chiếu, node đó (và toàn bộ cây con) thành detached — không hiển thị nhưng vẫn tốn bộ nhớ.
// ❌ nếu biến này sống lâu, node đã remove vẫn bị giữ
let cached = document.querySelector('#list');
cached?.remove();
// 'cached' (và cây con) vẫn reachable qua biến JS
// ✅ giải phóng tham chiếu khi không cần
cached = null;
Trong heap snapshot, lọc “Detached” hoặc dùng profile Detached elements, rồi đọc Retainers. Một listener nằm trên chính node detached không tự tạo leak nếu cả cycle đã unreachable; leak thường xuất hiện khi root sống lâu (window, store, timer, cache…) giữ handler/node/closure.
5. Listener & timer — gỡ đúng vòng đời
// ❌ component huỷ nhưng listener/timer vẫn chạy → giữ closure + node
function mount(el) {
const onResize = () => layout(el);
window.addEventListener('resize', onResize);
const id = setInterval(() => poll(el), 1000);
// không có đường gỡ → rò rỉ khi el bị xoá
}
// ✅ luôn có teardown đối xứng
function mount(el) {
const ctrl = new AbortController();
window.addEventListener('resize', () => layout(el), { signal: ctrl.signal });
const id = setInterval(() => poll(el), 1000);
return () => { ctrl.abort(); clearInterval(id); }; // gọi khi unmount
}
AbortController + { signal } gỡ nhiều listener bằng một lệnh abort() — gọn và khó quên hơn removeEventListener từng cái.
6. Oilpan, WeakRef & đo bộ nhớ
Oilpan là GC của Blink cho các đối tượng C++ phía trình duyệt (DOM node…). Nó hợp tác với GC của V8 (cho JS) — nhưng một tham chiếu JS tới DOM node vẫn giữ node sống xuyên qua ranh giới này, nên detached DOM mới rò rỉ được.
Công cụ JS để gắn dữ liệu mà không cố ý giữ key sống:
// Lựa chọn mặc định cho metadata theo vòng đời object
const metadata = new WeakMap();
function attachMetadata(node) {
metadata.set(node, { selected: true });
}
// WeakRef/FinalizationRegistry: chỉ cho cache/quan sát best-effort
const cache = new Map();
const registry = new FinalizationRegistry((key) => cache.delete(key));
let target = { large: new ArrayBuffer(10_000_000) };
const ref = new WeakRef(target);
registry.register(target, 'key-123');
target = null;
const maybeTarget = ref.deref(); // object hoặc undefined; chỉ mạnh trong scope này
Dùng
WeakMap/WeakSetcho metadata gắn theo object (key không bị giữ sống). TránhWeakRef/FinalizationRegistrycho logic chính — thời điểm GC không xác định.
Đo ước lượng memory của browsing context group hỗ trợ API (cần secure, cross-origin isolated context — Phần 20):
if (
crossOriginIsolated &&
typeof performance.measureUserAgentSpecificMemory === 'function'
) {
const { bytes } = await performance.measureUserAgentSpecificMemory();
}
API này không có ở mọi browser và kết quả có nhiễu/implementation detail; dùng xu hướng qua nhiều mẫu, không coi một lần đo là “RAM chính xác của tab”.
7. Tóm tắt
- Rò rỉ JS = object vẫn reachable nhưng không còn cần; GC chỉ thấy tham chiếu, không thấy ý định.
- Heap snapshot có Summary/Comparison/Containment/Statistics; retained size gợi ý vùng điều tra, còn retainer path mới chỉ ra dây neo.
- Quy trình ba snapshot so sánh batch 3 với 2; tăng lặp lại là nghi phạm, không tự động là leak.
- Detached DOM là nguồn leak phổ biến; listener-cycle chỉ leak khi còn đường từ một root sống lâu.
- Gỡ listener/timer theo vòng đời, ưu tiên
AbortController+{ signal }. - Oilpan GC cho object Blink hợp tác với V8; ưu tiên
WeakMap/WeakSet, xemWeakRef/finalizer là best-effort, và coi memory API là phép ước lượng.
Phần tiếp theo: Accessibility tree internals — trình duyệt dẫn xuất cây a11y từ DOM thế nào, name/role được tính ra sao, và screen reader truy vấn qua API nền tảng như thế nào.