How Browsers Work · Part 8 — Memory & Garbage Collection
How browser memory is managed across V8 and Blink Oilpan: call frames, heaps, generational tracing GC, reachability, and practical leak diagnosis.
Phần 1–7 đã đi qua kiến trúc, render, và cách JavaScript chạy trên luồng chính. Phần này zoom vào bộ nhớ: giá trị/object sống ở đâu, các GC phối hợp quyết định liveness thế nào, và vì sao SPA có thể ngốn RAM dần dù bạn không bao giờ gọi malloc.
Renderer memory không chỉ có một V8 heap. JavaScript object/closure nằm trên heap V8; phần DOM/Blink C++ được quản lý bởi Oilpan (cppgc); ảnh decode, ArrayBuffer backing store, font và GPU resource còn có vùng native/external khác. V8 và Oilpan phối hợp trace graph JS ↔ DOM, nhưng DevTools có thể báo các category khác nhau. Tài liệu Oilpan của V8 mô tả unified JavaScript/C++ heap này.
Điểm chung: tracing GC chỉ thu hồi object khi nó chứng minh được object không còn reachable. Rò rỉ xảy ra khi app vô tình giữ reference, hoặc khi tài nguyên native cần cleanup tường minh vẫn chưa được release.
| Khái niệm | Tóm tắt một dòng |
|---|---|
| Call stack | Frame thực thi, return address và slot/register theo implementation |
| Managed heaps | V8 cho JS; Oilpan cho nhiều object Blink/DOM |
| Roots | Điểm bắt đầu GC dùng để quyết định reachability |
| Generational GC | Hầu hết object chết sớm → tối ưu cho allocation ngắn hạn |
1. Stack vs heap: giá trị sống ở đâu
“Stack vs heap” là mental model hữu ích, không phải contract của JavaScript. Engine có thể giữ value trong register, stack slot, context heap hoặc tối ưu bỏ allocation hoàn toàn.
Call stack là cấu trúc LIFO của frame thực thi. Mỗi lần function được gọi, engine tạo/activate frame; khi return, frame rời stack.
- Địa chỉ return, metadata và value/reference cần cho code đang chạy.
- Local chưa escape có thể ở stack/register hoặc bị optimizer loại bỏ.
- Local bị closure capture hay async/generator suspend có thể sống trong context trên heap sau khi call đã return.
Stack nhanh và tự động được thu hồi khi function return. Nó cũng nhỏ — đệ quy sâu có thể làm tràn.
Heap V8 giữ phần lớn object, array, function và context sống động. Khi bạn viết const user = { name: 'An' }, object thường nằm trên managed heap; chỗ giữ reference user tùy code tier và escape analysis.
Call stack / registers Managed heap (conceptual)
┌─────────────────────┐ ┌──────────────────────────┐
│ main() frame │ │ { name: 'An' } ←───────┼── user (ref)
│ user ─────────────┼─────────►│ │
│ count: 42 │ │ [1, 2, 3] ←───────┼── items (ref)
│ fetchData() frame │ │ │
│ items ────────────┼─────────►│ function() { ... } │
│ i: 0 │ └──────────────────────────┘
└─────────────────────┘
Điều cần nhớ không phải “primitive luôn ở stack”, mà là lifetime theo reachability: object/context còn đường từ root thì GC phải giữ.
2. Reachability và GC roots
V8 không quyết định liveness của managed JS object bằng reference counting (vốn mắc kẹt với cycle). Nó dùng tracing garbage collection: bắt đầu từ roots đã biết, duyệt reference, đánh dấu thứ reachable, và giải phóng phần còn lại.
GC roots phổ biến trong renderer trình duyệt:
| Root | Ví dụ |
|---|---|
| Global/environment | window, binding module, property trên globalThis |
| Execution state | Value/reference trong stack/register đang chạy; context async/generator/closure còn reachable |
| Engine internals | Reference ẩn từ code đã tối ưu, handle đăng ký với V8 API |
| DevTools / debugger | Scope bị pause và reference từ console có thể tạm giữ object sống |
Nếu object reachable từ bất kỳ root nào — trực tiếp hay qua chuỗi reference — nó ở lại. Nếu không, nó thành garbage và sẽ được thu gom.
let cache = [];
function loadUsers() {
const users = fetchFromApi(); // heap object
cache.push(users); // still reachable via global `cache`
return users;
}
function loadUsersOnce() {
const users = fetchFromApi();
return users; // if caller drops the reference, GC can free it
}
Phiên bản đầu giữ mọi mảng fetch trong cache mãi mãi trừ khi bạn xóa tường minh. Đó không phải bug GC — đó là reachability có chủ ý.
3. Thu gom rác theo thế hệ
V8 chia heap thành các thế hệ dựa trên quy luật quan sát được: hầu hết object chết sớm. Cấp phát và giải phóng object tạm ngắn hạn chiếm ưu thế trong workload thực tế.
┌─────────────────────────────────────────────────────────┐
│ V8 Heap │
├──────────────────────┬──────────────────────────────────┤
│ Young Generation │ Old Generation │
│ (New Space) │ (Old Space) │
│ │ │
│ ┌────────┐ ┌──────┐ │ Long-lived objects promoted │
│ │ From │ │ To │ │ here after surviving N collections│
│ │ space │ │space │ │ │
│ └────────┘ └──────┘ │ │
│ Scavenger (copying) │ Major Mark-Compact │
│ — fast, frequent │ — slower, less frequent │
└──────────────────────┴──────────────────────────────────┘
Thế hệ trẻ: Scavenger
Phần lớn object JS nhỏ mới cấp phát trong thế hệ trẻ (semi-space from/to); large object hoặc allocation site được pretenure có thể đi đường khác.
- Duyệt object reachable từ roots.
- Chép survivor sang to-space (đồng thời compact).
- Hoán đổi vai trò from/to; vứt from-space cũ một lần.
Scavenge nhanh vì thế hệ trẻ nhỏ và hầu hết object đã chết.
Full heap / thế hệ già: Mark-Compact
Object sống sót qua nhiều Scavenge được promote lên thế hệ già.
- Mark — duyệt từ roots, đánh dấu mọi object reachable.
- Sweep — giải phóng object không được đánh dấu.
- Compact — tùy chọn di chuyển object sống sót để giảm phân mảnh.
Major GC có concurrent/incremental phase nhưng vẫn còn stop-the-world work để đồng bộ/compact. Pause phụ thuộc live set, fragmentation, allocation pattern và thiết bị; đừng suy từ tổng heap size một mình.
4. GC incremental, concurrent, và lazy
Một major GC thuần stop-the-world sẽ gây pause lớn, nên V8 hiện đại trải/offload nhiều phần công việc:
| Kỹ thuật | Làm gì |
|---|---|
| Incremental marking | Chia mark thành slice nhỏ xen kẽ với JS |
| Concurrent marking | Mark trên luồng phụ trong khi JS chạy |
| Concurrent sweeping | Giải phóng bộ nhớ trên luồng nền |
| Lazy sweeping | Hoãn sweep đến khi thật sự cần bộ nhớ |
Các kỹ thuật này giảm pause nhưng không loại bỏ chi phí GC. Object ngắn hạn thường rất rẻ trong generational collector; vấn đề xuất hiện khi allocation rate vượt khả năng thu gom hoặc nhiều object sống sót/promote. Live graph lớn làm mark/compact tốn hơn. Tổng quan chính thức nằm ở bài Orinoco của V8.
Bạn không thể
free()thủ công trong JavaScript. Cần gạt duy nhất là ngừng giữ reference tới thứ không còn cần.
5. Rò rỉ bộ nhớ phổ biến trong web app
Memory leak trong JS nghĩa là object reachable tích lũy theo thời gian — thường vì thứ bạn quên vẫn reference chúng. Thủ phạm điển hình trong SPA:
| Mẫu rò rỉ | Vì sao |
|---|---|
| Forgotten timers | setInterval hoặc timer được reschedule giữ closure sống và tiếp tục chạy |
| Event listeners not removed | Handler closure capture state component; DOM hoặc emitter vẫn giữ handler |
| Detached DOM nodes | Node bị gỡ khỏi cây nhưng vẫn được reference trong JS — GC không thu được nó hay subtree |
| Growing global caches | Map, mảng, hoặc object trên window chỉ biết .set() / .push() mà không có eviction |
| Closures over large scopes | Callback nhỏ vô tình đóng over biến data khổng lồ |
| Observers not disconnected | ResizeObserver, IntersectionObserver, MutationObserver còn giữ callback/target theo lifecycle liên quan |
WeakMap và WeakRef cho cache
Khi cache key theo object (vd metadata theo DOM element), Map mạnh ngăn key bị thu gom. Dùng WeakMap — key giữ yếu và không ngăn GC object key.
const metadata = new WeakMap();
function attachMeta(el, info) {
metadata.set(el, info); // when `el` is GC'd, entry disappears
}
WeakRef và FinalizationRegistry là công cụ cuối cùng cho use case đặc biệt, không phải cache mặc định. Thời điểm GC không xác định, deref() có thể mất value bất kỳ lúc hợp lệ, và finalizer có thể chạy rất muộn hoặc không chạy trước khi process kết thúc. Không dùng chúng cho cleanup correctness-critical; cache bình thường nên có size/TTL/eviction rõ.
6. Chẩn đoán rò rỉ bằng DevTools
Panel Memory của Chrome DevTools là công cụ chính. Quy trình khi nghi rò rỉ:
- Heap snapshot — chụp graph object tại một thời điểm; lọc theo constructor.
- Comparison — chụp A, lặp hành động, cho/force GC khi test, chụp B, rồi xem constructor và retained size nào tăng.
- Allocation instrumentation on timeline / sampling — xem allocation site; spike allocation chưa phải leak nếu object được thu hồi.
- Detached nodes — tìm
Detachedtrong snapshot; mỗi cái là subtree DOM đã gỡ nhưng vẫn được reference trong JS.
Theo dõi Task Manager (Phần 1) để thấy xu hướng, nhưng đừng đòi memory về đúng baseline ngay: GC, decoded image, code cache và allocator giữ memory để tái sử dụng. Dấu hiệu mạnh hơn là retained set tăng lặp lại sau cùng một workflow và các kỳ GC.
7. Ví dụ cụ thể: rò rỉ listener khi unmount
Xét widget dashboard subscribe resize cửa sổ:
// ❌ LEAK — listener never removed; closure captures `state`
function mountWidget(container, state) {
const handler = () => {
container.textContent = JSON.stringify(state);
};
window.addEventListener('resize', handler);
// no cleanup — if mountWidget runs again, old handler + state stay alive
}
// ✅ FIX — return a cleanup function; call it on unmount
function mountWidget(container, state) {
const handler = () => {
container.textContent = JSON.stringify(state);
};
window.addEventListener('resize', handler);
return () => {
window.removeEventListener('resize', handler);
};
}
// Usage in a SPA component lifecycle
let cleanup;
function onMount(el, data) {
cleanup = mountWidget(el, data);
}
function onUnmount() {
cleanup?.();
cleanup = null;
}
Trong React, tương đương là useEffect có cleanup return. Trong vanilla JS, listener trên target sống lâu hơn component (window, document, event bus) cần cleanup; listener trên cả target và callback đã unreachable không tự tạo leak. AbortController giúp gom teardown theo lifecycle.
// Modern pattern with AbortController
function mountWidget(container, state) {
const controller = new AbortController();
const { signal } = controller;
const handler = () => {
container.textContent = JSON.stringify(state);
};
window.addEventListener('resize', handler, { signal });
return () => controller.abort(); // removes all listeners registered with this signal
}
Sau khi sửa, lặp lại heap snapshot comparison: detached node và số (closure) phải ổn định qua các chu kỳ mount/unmount.
Tóm tắt
- Stack/register/heap là implementation detail; mental model quan trọng là call frame và managed object graph theo reachability.
- GC giải phóng object unreachable duyệt từ roots — không phải “khi bạn nghĩ xong”.
- Generational GC: Scavenger xử lý young generation; major Mark-Compact xử lý full heap với incremental/concurrent work.
- Kỹ thuật incremental/concurrent giảm pause nhưng áp lực allocation vẫn tốn kém.
- Leak = reference quên: timer, listener, DOM detached, cache global, closure béo.
- Dùng WeakMap khi metadata có lifetime theo object key; tránh dựa vào WeakRef/finalizer cho correctness. Chẩn đoán bằng retained path, snapshot comparison và allocation profile.
Phần tiếp theo: Networking & HTTP caching — trình duyệt fetch tài nguyên, cache, và ưu tiên byte quan trọng trên đường truyền.