Home / Fundamentals / WeakMap vs WeakSet: tránh memory leak khi cache theo object
Fundamentals

WeakMap vs WeakSet: tránh memory leak khi cache theo object

So sánh Map/Set với WeakMap/WeakSet: weak reference, GC reachability và cách tránh memory leak khi cache theo object.

Bạn cache kết quả tính toán theo từng object bằng Map: cache.set(user, result). User đã logout từ lâu, mọi reference khác đã bỏ, nhưng heap snapshot trong Chrome DevTools vẫn thấy object nằm nguyên — vì chính Map đang giữ nó sống. Cache chạy càng lâu càng phình, đó là memory leak đúng nghĩa.

JavaScript có sẵn hai cấu trúc cho đúng tình huống này: WeakMapWeakSet — giữ dữ liệu theo object nhưng không giữ object sống. Bài này giải thích strong/weak reference, GC reachability, và khi nào nên hay chưa nên dùng bản weak.

1. Map cache và memory leak: object không bao giờ được dọn

Tình huống rất đời thường: bạn cần gắn thêm dữ liệu cho mỗi object mà không sửa chính object đó — session cache theo user, kết quả tính toán theo dataset, metadata theo DOM node. Map nhận key kiểu bất kỳ nên phản xạ đầu tiên là cache.set(user, data).

Vấn đề: Map giữ strong reference tới key. Chừng nào Map còn sống, mọi object từng làm key đều không thể bị dọn — kể cả khi phần còn lại của chương trình đã quên hẳn chúng.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const cache = new Map();

function makeUser(id, name) {
  return { id, name };
}

let alice = makeUser(1, "Alice");
let bob = makeUser(2, "Bob");
let carol = makeUser(3, "Carol");

cache.set(alice, "session-1");
cache.set(bob, "session-2");
cache.set(carol, "session-3");

// User logout: bỏ mọi reference bên ngoài
alice = null;
bob = null;
carol = null;

// Map vẫn tham chiếu cả 3 object → chúng không thể bị GC dọn
console.log("cache.size:", cache.size);
console.log("các key vẫn đọc được:", [...cache.keys()].map((u) => u.name));

Sau khi gán null cho cả ba biến, cache.size vẫn là 3 và bạn vẫn duyệt lại được cả ba object qua cache.keys(). Chương trình không còn ai cần tới ba user đó nữa, nhưng Map thì vẫn nắm chặt.

Đây mới là bằng chứng ở mức bộ nhớ. File weakmap-weakset/map-holds-key.js cấp phát 3 object lớn làm key của Map, bỏ mọi reference bên ngoài, rồi ép GC chạy bằng node --expose-gc:

1
Map: heapUsed 3MB -> 26MB, 3/3 object còn sống

heapUsed leo từ ~3 MB nền lên ~26 MB và không tụt lại sau khi GC chạy; cả 3 object vẫn còn sống. (Con số MB tùy máy và phiên bản V8 — đo trên máy mình, Node v22 — điều đáng nhìn là magnitude, không phải byte chính xác.) Vì sao Map lại chặn được GC như vậy? Đó là câu chuyện của mục tiếp theo.

2. Strong reference, weak reference và GC reachability

Garbage collector không xoá object theo lệnh của bạn. Nó dọn theo reachability: bắt đầu từ một tập GC rootsglobalThis, các biến đang nằm trên call stack, scope của module đang chạy — rồi đi theo mọi reference. Object nào còn một chuỗi reference nối từ root chạm tới thì sống; object nào không còn đường nào chạm tới từ root thì được phép dọn.

Hai hệ quả literal, không cần ẩn dụ:

  • obj = null chỉ bỏ một reference, nó không bao giờ “xoá” object. Object chỉ biến mất khi mọi đường từ root tới nó đã đứt.
  • weak reference là reference mà GC bỏ qua khi tính reachability. Một object chỉ còn được trỏ tới bằng weak reference vẫn được coi là dọn được.

Map giữ key bằng strong reference, nên ở mục 1 chuỗi GC root → Map → user vẫn nguyên: object không thể dọn. Đó chính là lý do heap không tụt.

2.1. Gán null không xoá object

1
2
3
4
5
6
7
8
9
10
const config = { theme: "dark", lang: "vi" };

let a = config; // reference thứ nhất
let b = config; // reference thứ hai — cùng trỏ một object

a = null; // bỏ MỘT reference, object không bị xoá

// object vẫn sống vì b còn trỏ tới nó
console.log("a:", a);
console.log("b vẫn đọc được:", b.theme);

a = null không đụng gì tới object; nó chỉ làm a thôi trỏ. b vẫn giữ một strong reference nên object còn nguyên và b.theme vẫn đọc ra "dark". Object chỉ dọn được khi cả a lẫn b (và mọi reference khác) đều buông.

View Mermaid diagram code
flowchart LR
    Root(["GC root (globalThis, call stack)"]) --> M["Map cache"]
    Root --> W["WeakMap cache"]
    M -->|"strong ref tới key"| K1["user object — không bao giờ được dọn"]
    W -.->|"weak ref tới key"| K2["user object — GC dọn được"]

    style Root fill:#0e2233,stroke:#4aa8ff,stroke-width:1.5px,color:#9ecbff
    style M fill:#2a1517,stroke:#ff6b6b,stroke-width:1.5px,color:#ffb3b3
    style K1 fill:#2a1517,stroke:#ff6b6b,stroke-width:1.5px,color:#ffb3b3
    style W fill:#1a2c08,stroke:#c7ff50,stroke-width:1.5px,color:#c7ff50
    style K2 fill:#1a2c08,stroke:#c7ff50,stroke-width:1.5px,color:#c7ff50

Đường liền là strong reference, đường chấm là weak reference — GC đi qua đường liền để giữ object, và làm ngơ đường chấm.

Strong reference không chỉ đến từ biến. Một closure giữ nguyên lexical environment của nó cũng là một chuỗi strong reference sống dai — mình mổ xẻ trong bài Lưu trữ biến và tối ưu bộ nhớ với Closure. Còn nếu bạn quan tâm một hướng tiết kiệm bộ nhớ khác — chia sẻ method qua prototype thay vì nhân bản trên từng instance — thì có bài Tránh lãng phí bộ nhớ với Prototype.

3. WeakMap: giữ dữ liệu theo object mà không giữ object sống

WeakMapMap nhưng key được giữ weak. Bạn vẫn tra dữ liệu theo object, nhưng sự có mặt của object trong WeakMap không ngăn nó bị dọn. Khi object bị GC thu hồi, entry tương ứng trong WeakMap cũng biến mất theo.

3.1. API: set, get, has, delete

1
2
3
4
5
6
7
8
9
10
11
12
const metadata = new WeakMap();
const user = { name: "Alice" };

metadata.set(user, { role: "admin", lastSeen: 1717761000000 });

console.log("has:", metadata.has(user));
console.log("get:", metadata.get(user));

metadata.delete(user);

console.log("sau delete, has:", metadata.has(user));
console.log("get sau delete:", metadata.get(user));

Bốn method quen thuộc — set, get, has, delete — hoạt động y như Map. Khác biệt nằm ở phần bạn không thấy: cơ chế giữ key.

Chạy lại đúng thí nghiệm bộ nhớ của mục 1, chỉ đổi Map thành WeakMap (weakmap-weakset/weakmap-releases-key.js, cũng cần node --expose-gc):

1
WeakMap: heapUsed 3MB -> 3MB, 0/3 object còn sống

Đây là payoff của mục 1. Cùng 3 object lớn, cùng thao tác bỏ reference, nhưng lần này sau khi GC chạy heapUsed trở lại mức nền ~3 MB và cả 3 object đều đã được dọn (0/3 còn sống). WeakMap giữ key weak nên không có gì chặn GC — cache tự rỗng khi key hết được dùng.

3.2. Key phải là object

Đánh đổi của weak reference: key phải là object (hoặc symbol, xem 3.4). Một primitive như string thì không có “identity” để giữ weak, nên bị từ chối thẳng.

1
2
3
4
5
6
7
8
9
10
11
12
const wm = new WeakMap();

try {
  wm.set("user-1", { role: "admin" }); // string KHÔNG phải object
} catch (err) {
  console.log(err.name + ": " + err.message);
}

// object thì hợp lệ
const key = { id: "user-1" };
wm.set(key, { role: "admin" });
console.log("object key OK:", wm.get(key).role);
1
2
TypeError: Invalid value used as weak map key
object key OK: admin

Message trên là wording của V8 (Chrome, Node); engine khác có thể in chữ khác, nên đừng khớp cứng chuỗi này trong logic — chỉ cần biết đó là một TypeError.

3.3. Không có .size, không iterate, không clear

WeakMap cố tình không cho bạn .size, không cho iterate, không có .clear(), không spread.

1
2
3
4
5
6
7
8
9
10
11
const wm = new WeakMap();
wm.set({ id: 1 }, "a");

console.log("wm.size:", wm.size);
console.log("có Symbol.iterator?", typeof wm[Symbol.iterator]);

try {
  [...wm]; // WeakMap không iterate được
} catch (err) {
  console.log("spread:", err.name + ": " + err.message);
}
1
2
3
wm.size: undefined
có Symbol.iterator? undefined
spread: TypeError: wm is not iterable

wm is not iterable là wording của V8 (Chrome, Node); engine khác có thể in chữ khác, nên đừng khớp cứng chuỗi này — chỉ cần biết đó là một TypeError.

Đây không phải thiếu sót mà là ràng buộc thiết kế. Nếu WeakMap cho bạn đếm .size hay liệt kê key, thì thời điểm GC dọn một entry sẽ trở nên quan sát được từ trong chương trình: cùng một đoạn code có thể in size khác nhau tùy GC vừa chạy hay chưa. Kết quả là hành vi chương trình phụ thuộc vào lịch GC — thứ mà spec cố tình giấu đi. Vì không thể liệt kê, WeakMap chỉ hợp khi bạn đã cầm sẵn object để tra. (Nếu bạn tò mò Symbol.iterator và giao thức iterable đứng sau for...of, mình có bài riêng: Cách duyệt dữ liệu linh hoạt với Iterables & Iterators.)

3.4. ES2023: symbol làm key

Từ ES2023, WeakMap chấp nhận thêm một loại key ngoài object: non-registered symbol. Symbol("id") và cả well-known symbol như Symbol.iterator đều hợp lệ. Nhưng symbol từ Symbol.for() — registered symbol — thì bị từ chối, vì nó chia sẻ toàn cục và tái tạo được, nên không đảm bảo tính duy nhất mà weak key cần.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const wm = new WeakMap();

// Non-registered symbol: hợp lệ (ES2023)
const id = Symbol("id");
wm.set(id, "dữ liệu theo symbol");
console.log("non-registered symbol:", wm.get(id));

// Well-known symbol cũng là non-registered → hợp lệ
wm.set(Symbol.iterator, "ok");
console.log("Symbol.iterator:", wm.get(Symbol.iterator));

// Registered symbol từ Symbol.for(): bị từ chối
try {
  wm.set(Symbol.for("app"), "x");
} catch (err) {
  console.log(err.name + ": " + err.message);
}
1
2
3
non-registered symbol: dữ liệu theo symbol
Symbol.iterator: ok
TypeError: Invalid value used as weak map key

Tính năng này cần môi trường mới (Chrome 108+, Firefox 109+, Safari 16.4+, Node 20+). Nếu bạn chưa nắm symbol là gì, phần ## 4. Symbols trong ES6 của bài Type Coercion và Metaprogramming giải thích kỹ.

4. WeakSet: đánh dấu object mà không sở hữu nó

WeakSet là bản weak của Set: nó lưu object (weak, không giữ sống) và chỉ trả lời đúng một câu hỏi — “object này có trong tập không?”. Không value, không .size, không iterate. Đúng công cụ để đánh dấu một object đã qua trạng thái nào đó.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const processed = new WeakSet();
const jobs = [{ id: "a" }, { id: "b" }, { id: "c" }];

function handle(job) {
  if (processed.has(job)) {
    console.log(`${job.id}: đã xử lý, bỏ qua`);
    return;
  }
  processed.add(job);
  console.log(`${job.id}: xử lý lần đầu`);
}

jobs.forEach(handle); // lượt 1
jobs.forEach(handle); // lượt 2 — cùng object → bỏ qua

// dedup: cùng một object chỉ nằm một lần trong WeakSet
const j = jobs[0];
processed.add(j);
console.log("thêm lại object cũ, has:", processed.has(j));
processed.delete(j);
console.log("sau delete, has:", processed.has(j));

Lượt hai, cả ba job đều đã nằm trong processed nên bị bỏ qua. add cùng một object hai lần không nhân đôi (dedup theo identity), và delete gỡ dấu. Vì key weak, khi một job bị dọn thì dấu của nó cũng tự biến mất — không cần dọn tay.

4.1. Brand check: object này có thật do mình tạo ra?

Một use case gọn của WeakSetbrand check: xác nhận object đúng là instance do constructor của bạn tạo ra, chứ không phải một literal cùng shape giả dạng.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const realInstances = new WeakSet();

class Account {
  constructor(owner) {
    this.owner = owner;
    realInstances.add(this);
  }
}

function isRealAccount(obj) {
  return realInstances.has(obj);
}

const real = new Account("Alice");
const fake = { owner: "Alice" }; // cùng shape nhưng không do constructor tạo

console.log("instance thật:", isRealAccount(real));
console.log("object giả dạng:", isRealAccount(fake));

fake có đúng field owner nhưng chưa từng đi qua constructor, nên không nằm trong realInstancesisRealAccount(fake) trả về false. Vì WeakSet giữ weak, dấu brand không giữ instance sống thêm một giây nào.

5. Use case thực tế

5.1. Memoization theo object

Cache kết quả của một hàm tính đắt, khoá theo chính object đầu vào. WeakMap cho bạn cache mà không lo leak: khi dataset không còn ai dùng, entry cache tự rơi.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const cache = new WeakMap();
let computeCount = 0;

function expensiveStats(dataset) {
  if (cache.has(dataset)) {
    return cache.get(dataset);
  }
  computeCount++;
  const total = dataset.rows.reduce((s, n) => s + n, 0);
  const result = { total, avg: total / dataset.rows.length };
  cache.set(dataset, result);
  return result;
}

const ds = { rows: [10, 20, 30] };
expensiveStats(ds); // compute
expensiveStats(ds); // cache hit
console.log("gọi 2 lần cùng object, computeCount:", computeCount);

const other = { rows: [1, 2, 3] };
expensiveStats(other); // object khác → compute lại
console.log("object khác, computeCount:", computeCount);

Hai lần gọi với cùng ds chỉ tính một lần (computeCount = 1); một object khác thì tính lại (computeCount = 2). Cache khóa theo identity của object, không theo giá trị.

5.2. Metadata cho DOM node

Cần gắn dữ liệu phụ cho một DOM node — số lần click, id tracking — mà không muốn nhét property lạ lên chính element? WeakMap giữ metadata bên ngoài, và khi node bị gỡ khỏi cây DOM và không còn ai giữ, cả metadata cũng được dọn.

1
2
3
4
5
6
7
8
9
10
const nodeMeta = new WeakMap();

const button = document.createElement("button");
button.textContent = "Mua ngay";

nodeMeta.set(button, { clicks: 0, trackingId: "cta-hero" });

nodeMeta.get(button).clicks++;
console.log("trackingId:", nodeMeta.get(button).trackingId);
console.log("clicks:", nodeMeta.get(button).clicks);

Đây là pattern bookkeeping theo element mà không đụng vào element — họ hàng gần với ý tưởng gom listener về một chỗ trong bài Tối ưu với Event Delegation.

5.3. Private data trước khi có #field

Trước khi class có #field, cách giấu state thật sự khỏi bên ngoài là để nó trong một WeakMap theo từng instance. State không nằm trên instance nên vô hình với Object.keysJSON.stringify.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const privates = new WeakMap();

class BankAccount {
  constructor(balance) {
    privates.set(this, { balance });
  }
  deposit(amount) {
    privates.get(this).balance += amount;
  }
  getBalance() {
    return privates.get(this).balance;
  }
}

const acc = new BankAccount(100);
acc.deposit(50);

console.log("balance qua API:", acc.getBalance());
console.log("Object.keys:", Object.keys(acc));
console.log("JSON:", JSON.stringify(acc));

balance đọc được qua API nhưng Object.keys(acc) ra []JSON.stringify(acc) ra {} — dữ liệu thật sự nằm ngoài instance. Ngày nay câu trả lời hiện đại cho private data là #field của class: gọn hơn, không cần WeakMap phụ, và cũng ẩn khỏi keys/JSON y hệt (mục 2 của bài Class trong TypeScript: private, abstract, implements in đúng bằng chứng []/{} này). Pattern WeakMap vẫn đáng biết khi bạn phải hỗ trợ code cũ hoặc muốn giữ private state cho object không phải instance của class mình.

6. Khi nào KHÔNG dùng WeakMap/WeakSet

Weak không phải lúc nào cũng đúng. Ba trường hợp nên quay về Map/Set hoặc một cơ chế khác:

  • Cần iterate, đếm .size, hay serialize. WeakMap/WeakSet không cho liệt kê, và cũng không đi qua JSON.stringify hay structuredClone — nội dung của chúng đơn giản không xuất hiện khi bạn clone/serialize object. Cần duyệt lại toàn bộ entry thì dùng Map/Set.
  • Key là primitive. Cache theo userId dạng string thì weak key không nhận. Dùng Map kèm một chính sách eviction chủ động — giới hạn số entry, TTL, hay LRU (các thư viện kiểu lru-cache làm sẵn việc này) — để cache không phình vô hạn.
  • Cần cleanup đúng thời điểm. Đóng connection, hủy subscription, giải phóng file handle — những việc này phải xảy ra đúng lúc, không phải “lúc nào đó khi GC rảnh”. GC không phải cơ chế cleanup đáng tin về thời gian; hãy dùng dispose() / lifecycle tường minh.

7. WeakRef và FinalizationRegistry: phương án cuối cùng

Hai API này (ES2021, Baseline widely available — Chrome 84+, Firefox 79+, Safari 14.1+, Node 14.6+) cho bạn nhìn thẳng vào bộ máy GC. MDN mở đầu bằng đúng một lời cảnh báo: tránh dùng nếu được, vì runtime semantics của chúng gần như không được đảm bảo. Chương trình đúng phải chạy y hệt kể cả khi việc collection không bao giờ xảy ra.

WeakRef giữ một reference weak tới object. deref() trả về object khi nó còn sống, hoặc undefined khi đã bị dọn:

1
2
3
4
5
6
7
8
9
10
11
// Cache ảnh lớn: giữ bằng WeakRef để GC được phép dọn khi thiếu bộ nhớ
function makeRef() {
  const cache = { url: "/avatar.png", blob: new Array(1_000_000).fill(0) };
  return new WeakRef(cache);
}

const ref = makeRef();
ref.deref(); // → object khi còn sống

// Sau khi mọi strong reference biến mất và GC chạy ở turn sau:
//   ref.deref() → undefined

File weakmap-weakset/weakref-deref.js chạy đúng logic này với node --expose-gc:

1
2
Ngay sau khi tạo, deref(): còn object
Sau khi yield event loop + gc(), deref(): undefined

Có một cái bẫy trong đó: object vừa được tạo WeakRef (hoặc vừa deref() ra) được spec giữ sống tới hết job hiện tại — bạn chỉ “thấy” nó bị dọn giữa các turn của event loop. Vì vậy thí nghiệm phải await một vòng event loop (setTimeout(0)) trước khi gọi gc(), nếu không deref() vẫn trả về object và bạn tưởng nó “không chịu chết”.

FinalizationRegistry cho đăng ký một callback chạy sau khi object bị thu hồi, kèm một held value để biết dọn cái gì:

1
2
3
4
5
6
7
8
9
10
11
12
13
// Callback dọn dẹp chạy ở một turn SAU khi object bị GC thu hồi — hoặc không bao giờ
const registry = new FinalizationRegistry((heldValue) => {
  pool.release(heldValue); // ví dụ: trả connection về pool
});

function open() {
  const conn = { id: "conn-42", buffer: new Array(1_000_000).fill(0) };
  registry.register(conn, "conn-42");
}

open();
// Khi conn không còn strong reference và GC chạy:
//   callback nhận heldValue "conn-42"

File weakmap-weakset/finalization-registry.js (chạy node --expose-gc, bơm vài turn để cho GC cơ hội) in ra:

1
2
Đã register conn-42, chờ GC dọn...
Cleanup callback chạy, dọn: conn-42

Nhưng đọc kỹ cảnh báo của spec: callback không bao giờ chạy đồng bộ, có thể chạy trễ rất lâu, và một implementation hợp lệ không bắt buộc phải gọi nó bao giờ — khi tab đóng, khi chính registry không còn reachable, callback có thể im lặng không chạy. Đừng đặt logic thiết yếu vào đó.

Cả hai demo trên chỉ chạy được vì có node --expose-gc. Trên trình duyệt bạn không ép GC được, nên các block này không có nút Run — bằng chứng collection buộc phải là output paste từ lần chạy --expose-gc thật, không thể “bấm Run để thấy”.

8. Map/Set vs WeakMap/WeakSet: chọn cái nào

Tiêu chíMap / SetWeakMap / WeakSet
Reference tới keystrong (giữ key sống)weak (không giữ key sống)
Kiểu keybất kỳ (kể cả primitive)object + non-registered symbol
.sizekhông
Iterate / forEachkhông
.clear()không
GC tự dọn entrykhông (phải xóa tay)có (khi key hết reachable)
Hợp chodữ liệu cần liệt kê, đếm, serializemetadata theo object, cache không leak

Câu hỏi quyết định gọn lại thành một: bạn có cần liệt kê lại các key không? Cần duyệt qua toàn bộ, đếm số lượng, hay serialize — dùng Map/Set và tự quản lý vòng đời entry. Chỉ cần tra dữ liệu theo object đang cầm sẵn trong tay, và muốn nó tự biến mất khi object hết dùng — đó đúng là lúc WeakMap/WeakSet tỏa sáng.

← Previous
Refactor code ES5 sang ES6+: destructuring, spread, Map/Set
Table of Contents
  1. 1. Map cache và memory leak: object không bao giờ được dọn
  2. 2. Strong reference, weak reference và GC reachability
    1. 2.1. Gán null không xoá object
  3. 3. WeakMap: giữ dữ liệu theo object mà không giữ object sống
    1. 3.1. API: set, get, has, delete
    2. 3.2. Key phải là object
    3. 3.3. Không có .size, không iterate, không clear
    4. 3.4. ES2023: symbol làm key
  4. 4. WeakSet: đánh dấu object mà không sở hữu nó
    1. 4.1. Brand check: object này có thật do mình tạo ra?
  5. 5. Use case thực tế
    1. 5.1. Memoization theo object
    2. 5.2. Metadata cho DOM node
    3. 5.3. Private data trước khi có #field
  6. 6. Khi nào KHÔNG dùng WeakMap/WeakSet
  7. 7. WeakRef và FinalizationRegistry: phương án cuối cùng
  8. 8. Map/Set vs WeakMap/WeakSet: chọn cái nào