WeakMap، WeakSet و WeakRef و ارتباطشون با Garbage Collection

تا حالا شده یه اپلیکیشن جاوااسکریپتی بنویسی که اولش خیلی روون کار می‌کنه، ولی بعد از چند ساعت باز بودن، مرورگر شروع کنه به کند شدن و رم بخوره؟ احتمالاً یه جایی داشتی بی‌خبر به یه عالمه آبجکت قدیمی چنگ می‌زدی و نمی‌ذاشتی حافظه آزاد بشه. به این می‌گن Memory Leak.

جاوااسکریپت یه Garbage Collector (به اختصار GC) داره که حافظه‌ی آبجکت‌های بی‌مصرف رو خودش پاک می‌کنه. ولی GC هم فال‌گیر نیست! فقط چیزایی رو پاک می‌کنه که دیگه کسی بهشون دسترسی نداره. حالا WeakMap، WeakSet و WeakRef ابزارهایی‌ان که بهت اجازه می‌دن به آبجکت‌ها «یه جورایی» اشاره کنی، بدون اینکه جلوی پاک شدنشون رو بگیری. توی این مقاله قدم‌به‌قدم می‌ریم سراغشون.

اول یه یادآوری: GC چطوری فکر می‌کنه؟

کل ماجرای مدیریت حافظه تو جاوااسکریپت سه مرحله داره: تخصیص (Allocate)، استفاده (Use) و آزاد کردن (Release). مرحله‌ی اول و آخر رو خود موتور جاوااسکریپت پشت صحنه انجام می‌ده. یعنی وقتی یه آبجکت یا آرایه یا تابع می‌سازی، خودش براش حافظه می‌گیره، و وقتی دیگه لازمش نداشتی، GC حافظه‌اش رو پس می‌گیره.

ولی GC از کجا بفهمه «دیگه لازمش نداریم»؟ دو تا روش معروف هست:

  • Reference Counting: هر آبجکت یه شمارنده داره که می‌گه چند نفر بهش اشاره می‌کنن. وقتی شمارنده بشه صفر، آبجکت پاک می‌شه. مشکلش اینه که با ارجاع‌های حلقوی (Circular Reference) گیج می‌شه؛ مثلاً دو آبجکت که به هم اشاره می‌کنن هیچ‌وقت صفر نمی‌شن.
  • Mark-and-Sweep: از چند نقطه‌ی شروع به اسم Root (مثل متغیرهای گلوبال) شروع می‌کنه و هرچی رو که از اونجا قابل رسیدنه علامت می‌زنه. بعدش هرچی علامت نخورده رو جارو می‌کنه و دور می‌ندازه. موتورهای مدرن از این روش استفاده می‌کنن و ارجاع‌های حلقوی هم براش مشکلی نیست.

پس کلید اصلی کلمه‌ی Reachability یا «قابل دسترس بودن»ـه. اگه از Root بشه به یه آبجکت رسید، زنده می‌مونه. اگه نشه، زباله‌ست.

قابل دسترس = زنده می‌مونه غیرقابل دسترس = زباله Root A B C X Y X و Y به هم اشاره می‌کنن، ولی از Root نمی‌رسن
تصویر ۱: توی Mark-and-Sweep فقط چیزی که از Root قابل رسیدنه زنده می‌مونه. حتی اگه دو تا آبجکت به هم اشاره کنن، بازم پاک می‌شن.

ارجاع قوی (Strong) در برابر ارجاع ضعیف (Weak)

هر بار که یه متغیر، یه ویژگی از یه آبجکت، یا یه عضو از یه Map یا Set به یه آبجکت اشاره می‌کنه، این یه ارجاع قویـه. تا وقتی حتی یه ارجاع قوی وجود داشته باشه، GC دستش بهش نمی‌رسه.

تو مقابل، ارجاع ضعیف یعنی «من به این آبجکت اشاره می‌کنم، ولی اگه هیچ‌کس دیگه‌ای بهش نیاز نداشت، منم مانع پاک شدنش نمی‌شم». اینجاست که WeakMap و WeakSet و WeakRef وارد می‌شن.

قبل از اینکه برم سراغ ابزارها، ببین مشکل دقیقاً کجاست:

const cache = new Map();

function attachMeta(user) {
  cache.set(user, { loginCount: 0, lastSeen: Date.now() });
}

let user = { name: "Ali" };
attachMeta(user);

user = null; // فکر می‌کنی آبجکت پاک شد؟ نه!
// هنوز داخل cache یه ارجاع قوی بهش هست، پس تا ابد تو رم می‌مونه

اینجا Map کلید رو محکم نگه می‌داره. آبجکت user دیگه تو برنامه‌ی تو استفاده نمی‌شه، ولی چون تو cache هست، GC نمی‌تونه پاکش کنه. این همون نشتی حافظه‌ست.

Map (ارجاع قوی) user = null ✕ { name: "Ali" } هنوز تو رمه Map نتیجه: نشتی حافظه ❌ WeakMap (ارجاع ضعیف) user = null ✕ { name: "Ali" } آماده‌ی پاک شدن WeakMap نتیجه: GC آزادانه پاکش می‌کنه ✅
تصویر ۲: خط ممتد یعنی ارجاع قوی (جلوی GC رو می‌گیره). خط‌چین یعنی ارجاع ضعیف (جلوی GC رو نمی‌گیره).

WeakMap: نقشه‌ای که کلیدهاش رو محکم نمی‌گیره

WeakMap مثل Mapـه، ولی با چند تفاوت مهم:

  • کلیدها فقط باید آبجکت باشن (یا Symbolهای غیر ثبت‌شده در نسخه‌های جدید). عدد و رشته نمی‌تونن کلید باشن.
  • کلیدها ضعیف نگه داشته می‌شن. اگه جز WeakMap هیچ‌کس دیگه‌ای به کلید اشاره نکنه، کلید و مقدارش هر دو می‌تونن پاک بشن.
  • قابل پیمایش نیست. متدهایی مثل forEach، keys، entries و ویژگی size رو نداره.
  • فقط چهار تا متد داره: set، get، has و delete.

چرا قابل پیمایش نیست؟ چون هر لحظه ممکنه GC یه ورودی رو پاک کنه. اگه می‌شد روش پیمایش کرد، نتیجه بسته به زمان اجرای GC فرق می‌کرد و برنامه غیرقابل پیش‌بینی می‌شد.

const weakMap = new WeakMap();

let objKey = { id: 1 };

weakMap.set(objKey, "این یه مقدار ساده‌ست");

console.log(weakMap.get(objKey)); // "این یه مقدار ساده‌ست"
console.log(weakMap.has(objKey)); // true

// کلید رشته یا عدد؟ ارور می‌گیری
try {
  weakMap.set("name", "Ali");
} catch (e) {
  console.log(e.message); // Invalid value used as weak map key
}

objKey = null;
// حالا هیچ ارجاع قوی‌ای به {id: 1} نیست
// GC هر وقت خواست می‌تونه کلید و مقدارش رو از WeakMap پاک کنه

کاربردهای واقعی WeakMap

۱) چسبوندن اطلاعات اضافه به یه آبجکت بدون دست زدن بهش. فرض کن می‌خوای برای هر المان DOM تعداد کلیک‌ها رو نگه داری. به‌جای اینکه خودت یه پراپرتی روی المان بچسبونی، از WeakMap استفاده می‌کنی. وقتی المان از صفحه حذف بشه و ارجاعی بهش نمونده باشه، اطلاعاتش هم خودکار پاک می‌شه.

const clickCounts = new WeakMap();

function trackClick(element) {
  const current = clickCounts.get(element) ?? 0;
  clickCounts.set(element, current + 1);
}

const btn = document.querySelector("#buy");
btn.addEventListener("click", () => trackClick(btn));

// وقتی btn از DOM حذف بشه و ارجاعی بهش نمونه،
// شمارنده‌اش هم از حافظه می‌ره. نیازی به پاکسازی دستی نیست

۲) کش کردن نتیجه‌ی محاسبات سنگین. اگه نتیجه‌ی یه محاسبه به یه آبجکت بستگی داره، می‌تونی نتیجه رو با خود آبجکت کلید بزنی. تا وقتی آبجکت زنده‌ست کش هست، بعدش خودش از بین می‌ره.

const cache = new WeakMap();

function heavyCalculation(data) {
  if (cache.has(data)) {
    return cache.get(data); // از کش می‌خونیم
  }

  const result = data.items.reduce((sum, n) => sum + n * n, 0);
  cache.set(data, result);
  return result;
}

let report = { items: [1, 2, 3, 4] };
console.log(heavyCalculation(report)); // محاسبه می‌شه
console.log(heavyCalculation(report)); // از کش میاد

report = null; // کش مربوطه هم قابل پاک شدن می‌شه

۳) داده‌های خصوصی (Private Data). قبل از اینکه #private تو کلاس‌ها بیاد، این یکی از روش‌های معروف بود. هنوز هم تو بعضی الگوها به درد می‌خوره:

const secrets = new WeakMap();

class BankAccount {
  constructor(owner, balance) {
    this.owner = owner;
    secrets.set(this, { balance });
  }

  deposit(amount) {
    const data = secrets.get(this);
    data.balance += amount;
  }

  getBalance() {
    return secrets.get(this).balance;
  }
}

const acc = new BankAccount("Sara", 100);
acc.deposit(50);
console.log(acc.getBalance()); // 150
console.log(acc.balance);      // undefined، از بیرون نمی‌شه بهش رسید

یه نکته‌ی ظریف: وقتی مقدار به کلید اشاره می‌کنه

ممکنه فکر کنی اگه مقدارِ یه ورودی به خود کلیدش اشاره کنه، دیگه پاک نمی‌شه. ولی WeakMap اینو هوشمندانه حل کرده. اگه از بیرون هیچ مسیری به کلید نباشه، حتی اگه مقدار به کلید اشاره کنه، هر دو با هم قابل پاک شدنن (به این مکانیزم تو علوم کامپیوتر Ephemeron می‌گن). فقط کافیه یادت باشه که مقدار، ارجاع قوی به چیزهای دیگه‌ای که توش هست داره؛ یعنی اگه مقدار به یه آبجکت سوم اشاره کنه که از جای دیگه‌ای هم زنده‌ست، اون آبجکت سوم زنده می‌مونه.

const weakMap = new WeakMap();

let key = {};
weakMap.set(key, { self: key }); // مقدار به خود کلید اشاره می‌کنه

key = null;
// با اینکه مقدار به کلید اشاره داره، چون از بیرون هیچ راهی
// به کلید نیست، هر دو قابل پاک شدنن

WeakSet: مجموعه‌ای از آبجکت‌ها که قفلشون نمی‌کنه

WeakSet هم مثل Setـه، ولی فقط آبجکت قبول می‌کنه و آبجکت‌ها رو ضعیف نگه می‌داره. فقط سه تا متد داره: add، has و delete. نه size داره نه پیمایش.

به درد کجا می‌خوره؟ وقتی فقط می‌خوای بدونی «آیا این آبجکت قبلاً دیده شده یا نه؟»، بدون اینکه بخوای واسه همیشه نگهش داری.

const visited = new WeakSet();

function processOnce(node) {
  if (visited.has(node)) {
    console.log("این رو قبلاً پردازش کردیم، رد می‌شیم");
    return;
  }

  visited.add(node);
  console.log("در حال پردازش:", node.name);
}

let a = { name: "A" };
processOnce(a); // در حال پردازش: A
processOnce(a); // این رو قبلاً پردازش کردیم، رد می‌شیم

a = null; // آبجکت قابل پاک شدنه و تو WeakSet هم باقی نمی‌مونه

یه مثال باحال دیگه‌اش جلوگیری از حلقه‌ی بی‌نهایت موقع پیمایش آبجکت‌هایی با ارجاع حلقوی‌ـه:

function deepCount(obj, seen = new WeakSet()) {
  if (typeof obj !== "object" || obj === null) return 0;
  if (seen.has(obj)) return 0; // قبلاً شمرده شده، حلقه نزن

  seen.add(obj);

  let count = 1;
  for (const value of Object.values(obj)) {
    count += deepCount(value, seen);
  }
  return count;
}

const a = { name: "a" };
const b = { name: "b", friend: a };
a.friend = b; // ارجاع حلقوی

console.log(deepCount(a)); // 2، بدون گیر کردن تو حلقه

WeakMap و WeakSet رو کنار هم بذاریم

ویژگی WeakSet WeakMap
چی نگه می‌داره؟ مجموعه‌ای از آبجکت‌های یکتا جفت کلید و مقدار
نوع کلید / عضو فقط آبجکت کلید فقط آبجکت
نوع مقدار ندارد (فقط عضویت) هر نوعی (حتی عدد و رشته)
متدها add، has، delete set، get، has، delete
قابل پیمایش؟ نه نه
ویژگی size ندارد ندارد
کاربرد اصلی علامت‌گذاری «دیده شده / تایید شده» چسبوندن داده یا کش به آبجکت

یه راه ساده برای یادآوری: اگه فقط جواب بله/خیر می‌خوای («این آبجکت تو لیست هست؟») از WeakSet استفاده کن. اگه می‌خوای یه چیزی رو به آبجکت وصل کنی («اطلاعات این آبجکت چیه؟») سراغ WeakMap برو.

WeakRef: یه اشاره‌گر که حاضره آبجکت رو ول کنه

WeakMap و WeakSet برای وقتی خوبن که بخوای چیزی رو «به» یه آبجکت وصل کنی. ولی گاهی می‌خوای مستقیم یه ارجاع ضعیف به یه آبجکت داشته باشی، مثلاً برای یه کش که اگه حافظه کم شد، آبجکتای توش بره. WeakRef دقیقاً همینه.

let user = { name: "Reza", age: 30 };

const ref = new WeakRef(user);

// برای خوندن آبجکت از deref() استفاده می‌کنیم
const target = ref.deref();
console.log(target?.name); // "Reza"

متد deref() یا خود آبجکت رو برمی‌گردونه (اگه هنوز زنده باشه)، یا undefined (اگه GC پاکش کرده باشه). برای همین همیشه باید جواب deref() رو چک کنی.

۱) آبجکت هنوز زنده‌ست WeakRef obj deref() → obj ✅ ۲) ارجاع قوی دیگه‌ای نمونده WeakRef obj GC هر وقت خواست پاکش می‌کنه 🗑 ۳) بعد از GC WeakRef خالی deref() → undefined همیشه نتیجه‌ی deref() رو چک کن، چون هر لحظه ممکنه خالی باشه
تصویر ۳: چرخه‌ی عمر یه WeakRef؛ از زنده بودن تا خالی شدن بعد از GC.

مثال عملی: یه کش هوشمند با WeakRef

فرض کن عکس‌هایی رو از سرور می‌گیری و نمی‌خوای هر بار دوباره دانلودشون کنی. ولی نمی‌خوای هم کل عکس‌ها تا ابد تو رم بمونن. WeakRef اینجا عالیه:

const imageCache = new Map(); // key: url, value: WeakRef

async function getImage(url) {
  // ۱) ببین تو کش هست و هنوز زنده‌ست
  const ref = imageCache.get(url);
  const cached = ref?.deref();
  if (cached) {
    console.log("از کش خوندیم");
    return cached;
  }

  // ۲) نبود یا پاک شده، پس دانلود می‌کنیم
  console.log("دانلود از سرور");
  const blob = await fetch(url).then((res) => res.blob());

  // ۳) فقط یه ارجاع ضعیف نگه می‌داریم
  imageCache.set(url, new WeakRef(blob));
  return blob;
}

تا وقتی یه جای برنامه از این عکس استفاده می‌کنه، deref() جواب می‌ده و از کش می‌خونی. وقتی همه ولش کردن و حافظه فشار اومد، GC می‌تونه بلاب رو پاک کنه و دفعه‌ی بعد دوباره دانلود می‌شه.

FinalizationRegistry: خبر دادن بعد از رفتن آبجکت

تو مثال بالا یه مشکل کوچیک هست: هرچند بلاب پاک می‌شه، ولی خود WeakRef و کلیدش (آدرس عکس) تو Map می‌مونن و کم‌کم جمع می‌شن. برای این مشکل FinalizationRegistry هست. بهش می‌گی «وقتی این آبجکت پاک شد، این تابع رو برام صدا بزن».

const imageCache = new Map();

// وقتی آبجکت ثبت‌شده پاک بشه، این کالبک با heldValue (اینجا url) صدا زده می‌شه
const registry = new FinalizationRegistry((url) => {
  console.log("پاک‌سازی ورودی کش برای:", url);
  imageCache.delete(url);
});

async function getImage(url) {
  const cached = imageCache.get(url)?.deref();
  if (cached) return cached;

  const blob = await fetch(url).then((res) => res.blob());

  imageCache.set(url, new WeakRef(blob));
  registry.register(blob, url); // آبجکت رو ثبت می‌کنیم

  return blob;
}

یه نکته‌ی مهم: زمان صدا زده شدن این کالبک مشخص نیست، و حتی ممکنه اصلاً صدا زده نشه (مثلاً اگه برنامه قبل از GC بسته بشه). پس هیچ‌وقت کار حیاتی رو توش انجام نده؛ فقط واسه پاک‌سازی‌های اختیاری و سبک مناسبه.

خودت GC رو تست کن

اگه می‌خوای با چشم خودت ببینی WeakRef چطوری کار می‌کنه، می‌تونی تو Node.js با فلگ --expose-gc اجرا کنی تا بتونی GC رو دستی صدا بزنی:

node --expose-gc test.js
// test.js
let obj = { name: "test" };
const ref = new WeakRef(obj);

console.log(ref.deref()); // { name: 'test' }

obj = null; // ارجاع قوی رو حذف می‌کنیم

// GC رو تو یه تسک جدا صدا می‌زنیم
// چون موتور تا آخر تسک فعلی آبجکت رو زنده نگه می‌داره
setTimeout(() => {
  global.gc();
  console.log(ref.deref()); // معمولاً undefined
}, 0);

توجه کن که این فقط برای آزمایشه. تو پروژه‌ی واقعی روی global.gc() حساب نکن و کدت رو طوری بنویس که چه GC اجرا بشه چه نشه، درست کار کنه.

اشتباهات رایج

  • تکیه کردن روی زمان GC: هیچ تضمینی نیست که GC کِی اجرا بشه. نه می‌شه گفت «بعد از ۵ ثانیه پاک می‌شه»، نه «حتماً پاک می‌شه».
  • استفاده‌ی بی‌جا از WeakRef: بیشتر وقت‌ها WeakMap یا WeakSet کافیه. WeakRef رو فقط وقتی بردار که واقعاً به یه ارجاع ضعیف مستقیم نیاز داری.
  • نگه داشتن نتیجه‌ی deref() تو یه متغیر بلندمدت: اگه جواب deref() رو تو یه متغیر یا کلاس نگه داری، دوباره یه ارجاع قوی ساختی و کل فایده‌ی WeakRef رفته.
  • انتظار پیمایش یا size داشتن: WeakMap و WeakSet نه حلقه دارن نه تعداد. اگه این‌ها رو لازم داری، از Map و Set معمولی استفاده کن.
  • کلید غیر آبجکت: رشته و عدد و بولین رو نمی‌شه کلید WeakMap یا عضو WeakSet کرد و ارور می‌گیری.

کی از کدوم استفاده کنم؟

می‌خوای چی کار کنی؟ داده یا کش رو به یه آبجکت وصل کنم WeakMap فقط علامت بزنم «این آبجکت تو لیست هست یا نه» WeakSet یه اشاره‌گر مستقیم می‌خوام که آبجکت رو قفل نکنه WeakRef + اگه بعد از پاک شدن آبجکت پاک‌سازی لازم داشتی، FinalizationRegistry رو هم اضافه کن
تصویر ۴: راهنمای سریع برای انتخاب ابزار مناسب.

جمع‌بندی

خلاصه‌ی حرفامون:

  • GC فقط چیزهایی رو پاک می‌کنه که از Root قابل دسترس نیستن.
  • Map و Set معمولی ارجاع قوی نگه می‌دارن و ممکنه باعث Memory Leak بشن.
  • WeakMap برای وصل کردن داده یا کش به آبجکت‌هاست؛ کلیدش حتماً آبجکته و پیمایش نداره.
  • WeakSet برای علامت‌گذاری آبجکت‌هاست؛ فقط می‌گه یه آبجکت عضو هست یا نه.
  • WeakRef یه ارجاع ضعیف مستقیمه که با deref() بازش می‌کنی و ممکنه undefined برگرده.
  • FinalizationRegistry بعد از پاک شدن آبجکت بهت خبر می‌ده، ولی زمانش قابل اعتماد نیست.

قانون طلایی: اول سراغ WeakMap و WeakSet برو، چون ساده‌تر و قابل پیش‌بینی‌ترن. WeakRef و FinalizationRegistry رو برای موارد خاص و آخرین راه‌حل نگه دار. و مهم‌تر از همه، کدت رو طوری بنویس که درستی‌اش به زمان اجرای GC بستگی نداشته باشه.

منابع