DEV Community

Memory — Garbage Collection

Garbage collection: reachability và vì sao memory leak xảy ra dù có GC

JavaScript tự quản lý bộ nhớ: garbage collector tự thu hồi object không còn dùng tới. Nhưng "không còn dùng" được định nghĩa bằng reachability — một object được giữ lại chừng nào còn với tới được từ một gốc (global, stack hiện tại) qua chuỗi tham chiếu. Memory leak trong JS không phải do quên free, mà do vô tình giữ tham chiếu tới thứ đáng lẽ phải bỏ: một listener không gỡ, một timer không clear, một cache không giới hạn. GC không cứu được những trường hợp đó vì theo định nghĩa, object vẫn còn reachable.

Cơ chế hoạt động

GC dùng thuật toán mark-and-sweep: bắt đầu từ các gốc (root), đánh dấu mọi object với tới được, rồi quét và thu hồi những object không được đánh dấu. V8 chia heap thành thế hệ: object mới ở "young generation" được thu gom thường xuyên và nhanh (đa số object chết trẻ); object sống sót được thăng lên "old generation", thu gom ít hơn nhưng tốn hơn.

let user = { name: 'an', orders: [...] }
user = null // object cũ giờ không còn root nào với tới -> đủ điều kiện thu hồi
Enter fullscreen mode Exit fullscreen mode

Điểm cốt lõi: object đủ điều kiện thu hồi ngay khi không còn đường tham chiếu từ root tới nó. Còn một đường — dù là đường ta quên mất — là nó còn sống.

Vấn đề gặp trong production

Failure mode: listener và timer không dọn. Đây là nguồn leak phổ biến nhất trong app chạy lâu (SPA, server giữ kết nối). Đăng ký listener/interval mà không gỡ khi không cần giữ sống cả closure và mọi thứ nó tham chiếu:

function mountWidget(el, data) {
  const handler = () => render(el, data) // closure giữ el và data
  window.addEventListener('resize', handler)
  // BUG: không gỡ khi widget unmount -> handler, el, data sống mãi
}
Enter fullscreen mode Exit fullscreen mode

Mỗi lần mount/unmount widget lại thêm một listener không bao giờ gỡ; data (có thể nặng) không bao giờ được thu hồi. Heap phình dần qua mỗi vòng điều hướng tới khi tab/process OOM. Sửa bằng cách luôn gỡ trong bước dọn dẹp:

function mountWidget(el, data) {
  const handler = () => render(el, data)
  window.addEventListener('resize', handler)
  return () => window.removeEventListener('resize', handler) // cleanup
}
Enter fullscreen mode Exit fullscreen mode

setInterval không clearInterval cũng cùng một lớp lỗi — callback và mọi thứ nó closure sống mãi.

Failure mode: cache không giới hạn. Một Map dùng làm cache, key đa dạng vô hạn (user id, query), không bao giờ xóa entry — heap tăng tuyến tính theo lưu lượng. Map giữ key vĩnh viễn; muốn entry tự biến mất khi key (object) không còn ai dùng thì dùng WeakMap/WeakSet, chúng giữ tham chiếu yếu nên không ngăn GC thu hồi key. Với key là primitive hoặc cần kiểm soát kích thước, phải thêm chính sách evict (LRU/TTL).

Cách debug và monitor

Triệu chứng leak: bộ nhớ process tăng đều, không phẳng lại sau khi traffic ổn định, và cuối cùng OOM hoặc GC chạy liên tục làm chậm. Công cụ chính là heap snapshot (DevTools cho browser, --inspect/heapdump cho Node): chụp hai snapshot cách nhau một quãng thao tác lặp lại, dùng chế độ so sánh để tìm loại object tăng số lượng đều — đó là thứ đang leak. Tìm object "Detached" (DOM đã gỡ nhưng còn bị JS giữ) và các closure có retained size lớn. Ở server, theo dõi RSS/heap used theo thời gian; đường đi lên dốc đều giữa các lần GC là dấu hiệu leak. Khi nghi listener, đếm số listener đang đăng ký — con số chỉ tăng không giảm là bằng chứng.

Tradeoff

Cache đánh đổi RAM lấy tốc độ: giữ kết quả tính sẵn để khỏi tính lại, nhanh hơn nhưng tốn bộ nhớ và là nguồn leak nếu không giới hạn. GC tự động giải phóng lập trình viên khỏi quản lý bộ nhớ thủ công nhưng không miễn nhiễm leak — nó chỉ thu hồi cái không reachable, nên trách nhiệm là không giữ tham chiếu thừa. Quy tắc thực tế: mọi listener/timer/subscription phải có bước gỡ tương ứng; mọi cache phải có trần hoặc TTL hoặc dùng WeakMap; và khi tối ưu bằng cache, đo cả mức tăng bộ nhớ chứ không chỉ tốc độ.

Câu hỏi phỏng vấn

GC hoạt động thế nào, và vì sao vẫn có memory leak trong một ngôn ngữ có GC?

GC dùng mark-and-sweep: từ các root (global, stack hiện tại) nó đánh dấu mọi object reachable qua chuỗi tham chiếu, rồi thu hồi phần còn lại; V8 còn chia thế hệ young/old để thu gom hiệu quả vì đa số object chết trẻ. Vẫn có leak vì GC định nghĩa "còn dùng" bằng reachability, không phải "thực sự còn cần": nếu code vô tình giữ một đường tham chiếu tới object đáng lẽ phải bỏ — listener không gỡ, setInterval không clear, cache Map không giới hạn — object vẫn reachable nên GC không thu hồi, và heap phình dần tới OOM. Điểm ăn điểm: mọi subscription/timer cần bước cleanup, cache cần trần/TTL hoặc dùng WeakMap (tham chiếu yếu, không chặn GC), và phát hiện bằng cách so sánh heap snapshot để tìm loại object tăng đều.

Hands-on

Dựng một component/widget thật được mount và unmount nhiều lần, đăng ký một resize listener (hoặc setInterval) closure lên một mảng dữ liệu lớn nhưng cố tình không gỡ khi unmount. Lặp vòng mount/unmount nhiều lần, chụp heap snapshot trước và sau trong DevTools, dùng chế độ so sánh để thấy số lượng listener và mảng dữ liệu tăng đều — xác nhận chúng không được thu hồi. Thêm bước cleanup (removeEventListener/clearInterval) và chạy lại để thấy heap phẳng lại. Cuối cùng làm tương tự với một cache Map không giới hạn so với WeakMap để quan sát khác biệt trong hành vi thu hồi.

Top comments (0)