Garbage collection trong JavaScript hoạt động thế nào

Garbage collection trong JavaScript là gì? Tìm hiểu cách bộ nhớ được dọn tự động, thuật toán mark and sweep và cách tránh rò rỉ bộ nhớ.

6 phút

Garbage collection trong JavaScript là cơ chế tự động giải phóng bộ nhớ. Nó dọn những đối tượng mà chương trình không còn dùng tới. Bạn không phải gọi lệnh giải phóng như trong ngôn ngữ C.

Nhưng điều đó không có nghĩa bạn khỏi cần quan tâm. Hiểu cách nó hoạt động giúp bạn tránh rò rỉ bộ nhớ. Lỗi này khiến trang web chậm dần theo thời gian.

Garbage collection trong JavaScript: bộ nhớ được cấp và thu hồi thế nào

Garbage collection trong JavaScript: bộ nhớ được cấp và thu hồi thế nào
Garbage collection trong JavaScript: bộ nhớ được cấp và thu hồi thế nào

Mỗi khi bạn tạo đối tượng, mảng hay hàm, engine JavaScript cấp một vùng nhớ trên heap. Giá trị nguyên thủy như số hay boolean thường nằm trong stack hoặc ngay trong biến. Vấn đề là biết lúc nào một vùng nhớ không còn cần nữa. Đó chính là việc của bộ thu gom rác.

Tiêu chí chính là khả năng tiếp cận (reachability). Một giá trị được coi là còn dùng nếu đi tới được nó từ các gốc (root). Gốc là biến toàn cục hay biến trong hàm đang chạy. Không đi tới được nữa thì giá trị đó là rác.

let nguoi = { ten: "An" };   // đối tượng đang được tham chiếu
nguoi = null;                 // không còn ai trỏ tới -> có thể bị dọn

Lưu ý rằng gán null không giải phóng bộ nhớ ngay lập tức. Nó chỉ bỏ tham chiếu, còn thời điểm dọn thật sự do engine quyết định.

Thuật toán mark and sweep

Thuật toán mark and sweep
Thuật toán mark and sweep

Các engine hiện đại như V8 dùng biến thể của thuật toán đánh dấu và quét. Quá trình gồm hai bước. Bước một, bộ thu gom bắt đầu từ các gốc.

Nó đi theo mọi tham chiếu và đánh dấu những đối tượng gặp được. Bước hai, nó quét toàn bộ heap và giải phóng đối tượng không được đánh dấu.

Cách này xử lý được cả tham chiếu vòng tròn. Giả sử hai đối tượng trỏ vào nhau nhưng không còn gì từ bên ngoài trỏ tới chúng. Thuật toán đếm tham chiếu đời cũ sẽ coi chúng còn dùng. Còn mark and sweep thấy cả hai không đi tới được từ gốc, nên dọn cả hai.

function tao() {
  const a = {};
  const b = { a };
  a.b = b;          // vòng tham chiếu
}
tao();              // sau khi hàm kết thúc, cả a và b đều là rác

Dọn theo thế hệ

Dọn theo thế hệ
Dọn theo thế hệ

V8 còn chia heap thành vùng đối tượng mới và vùng đối tượng cũ. Phần lớn đối tượng chết rất sớm, ví dụ biến tạm trong hàm. Vì thế vùng mới được dọn thường xuyên và nhanh.

Đối tượng sống sót qua vài lần dọn sẽ được chuyển sang vùng cũ, nơi chu kỳ dọn thưa hơn. Việc này giảm thời gian trang bị đứng hình khi dọn rác.

Những nguyên nhân gây rò rỉ bộ nhớ

Những nguyên nhân gây rò rỉ bộ nhớ
Những nguyên nhân gây rò rỉ bộ nhớ

Rò rỉ xảy ra khi bạn vô tình giữ tham chiếu tới thứ không còn cần. Bộ thu gom không thể đoán ý bạn, nên nó vẫn coi đó là đối tượng còn dùng. Bốn nguyên nhân thường gặp:

  • Biến toàn cục vô tình, ví dụ gán giá trị cho tên chưa khai báo.
  • Bộ hẹn giờ setInterval không bao giờ được xóa và vẫn giữ tham chiếu qua hàm callback.
  • Trình xử lý sự kiện gắn vào phần tử đã bị xóa khỏi trang nhưng chưa được gỡ.
  • Bộ nhớ đệm tự viết chỉ thêm mà không bao giờ bỏ bớt, nên lớn dần không giới hạn.
const cache = new Map();
function layDuLieu(id) {
  if (!cache.has(id)) cache.set(id, taiTuServer(id));
  return cache.get(id);       // cache lớn dần vĩnh viễn
}

Ví dụ trên là kiểu rò rỉ rất phổ biến. Cách sửa là giới hạn số phần tử. Hoặc dùng WeakMap khi khóa là đối tượng, để chúng vẫn có thể bị dọn. Nếu bạn đang quen dùng Map trong JavaScript, hãy phân biệt rõ Map giữ tham chiếu mạnh còn WeakMap thì không.

Cách kiểm tra rò rỉ bộ nhớ

Trong Chrome DevTools, mở thẻ Memory và chụp heap snapshot. Chụp trước và sau một thao tác lặp lại, rồi so sánh. Nếu số lượng đối tượng của một loại cứ tăng sau mỗi lượt, đó là ứng viên đáng ngờ.

Thẻ Performance cũng hiển thị biểu đồ bộ nhớ theo thời gian. Đường đi lên mãi không giảm là dấu hiệu rò rỉ.

Bạn cũng cần tránh tạo quá nhiều đối tượng tạm trong vòng lặp nóng. Ví dụ dùng các phương thức mảng như map lồng nhau trên dữ liệu lớn sẽ sinh nhiều mảng trung gian, làm bộ thu gom phải làm việc nhiều hơn.

Closure và bộ nhớ

Closure là nguồn gây hiểu lầm về bộ nhớ khá thường xuyên. Một hàm bên trong giữ tham chiếu tới phạm vi của hàm bao ngoài nếu nó dùng biến nào ở đó. Chi tiết còn phụ thuộc cách engine triển khai. Do đó dữ liệu lớn nằm trong phạm vi ấy có thể bị giữ lại lâu hơn bạn nghĩ.

function tao() {
  const lon = new Array(1_000_000).fill("x");
  return function () {
    return lon.length;     // closure giữ lon sống
  };
}
const f = tao();           // lon chưa thể bị dọn khi f còn được dùng

Cách xử lý đơn giản là chỉ giữ phần dữ liệu cần thiết. Ví dụ, lưu lon.length vào một biến nhỏ rồi trả về hàm dùng biến đó. Khi f không còn được tham chiếu, toàn bộ phạm vi mới được dọn.

Bộ thu gom rác chạy khi nào

Bạn không điều khiển được thời điểm chạy. Engine thường dọn khi vùng nhớ mới đầy hoặc khi tổng lượng bộ nhớ vượt một ngưỡng. Một số engine cố gắng dọn vào lúc trình duyệt rảnh để khỏi gây giật hình. Mặc dù vậy, các lần dọn lớn vẫn có thể làm trang tạm dừng vài chục mili giây.

Vì không đoán được thời điểm, bạn đừng dựa vào bộ thu gom rác để chạy mã dọn dẹp. Bạn nên tự đóng kết nối, hủy bộ hẹn giờ và gỡ trình xử lý sự kiện. Làm việc đó khi thành phần giao diện bị gỡ khỏi trang.

Tóm lại cách viết code thân thiện với bộ nhớ

Hãy giữ phạm vi biến nhỏ và khai báo bằng let, const trong khối cần dùng. Gỡ trình lắng nghe sự kiện khi không còn cần. Đặt giới hạn cho mọi bộ nhớ đệm tự viết. Với Node.js bạn có thể dùng cờ --inspect để mở DevTools và theo dõi heap giống như trên trình duyệt.

Khi cần tra cứu cấu trúc dữ liệu liên quan, bạn xem mảng trong JavaScript và optional chaining trong cùng chuyên mục.

Hãy đọc thêm phần giải thích về quản lý bộ nhớ trên MDN, rồi tự tạo một rò rỉ nhỏ bằng setInterval để quan sát trong DevTools. Thấy tận mắt sẽ dễ nhớ hơn đọc lý thuyết.

Chuyên mục