جاوااسکریپت چطور حافظه را مدیریت می‌کند؟

از تخصیص حافظه تا Garbage Collection و Memory Leak

احتمالاً تا حالا با خودتون فکر کردید که وقتی توی جاوااسکریپت یه متغیر می‌سازید، اون داده کجا ذخیره می‌شه و چه زمانی از بین می‌ره؟ خبر خوب اینه که جاوااسکریپت برخلاف زبان‌هایی مثل C که باید با malloc() و free() دستی حافظه بگیرید و آزاد کنید، خودش این کار رو انجام می‌ده. خبر بد هم اینه که همین «خودکار بودن» باعث می‌شه خیلی از برنامه‌نویس‌ها اصلاً به مدیریت حافظه فکر نکنن، و همین‌جاست که Memory Leak سر و کله‌ش پیدا می‌شه. توی این مقاله قراره از صفر تا صد ببینیم حافظه توی جاوااسکریپت چطور تخصیص داده می‌شه، Garbage Collector چطور کار می‌کنه، و چطور جلوی نشتی حافظه رو بگیریم.

چرخه‌ی زندگی حافظه (Memory Life Cycle)

فرقی نمی‌کنه چه زبانی استفاده کنید، هر حافظه‌ای یه چرخه‌ی سه مرحله‌ای داره:

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

مرحله‌ی وسط توی همه‌ی زبان‌ها دستیه، ولی مرحله‌ی اول و آخر توی جاوااسکریپت به‌صورت پیش‌فرض خودکاره.

تخصیص حافظه توی جاوااسکریپت

هر بار که یه مقدار جدید می‌سازید، جاوااسکریپت خودش پشت‌صحنه حافظه رزرو می‌کنه:

const n = 123; // حافظه برای یک عدد
const s = "string"; // حافظه برای یک رشته

const o = {
  a: 1,
  b: null,
}; // حافظه برای آبجکت و مقادیر داخلش

const a = [1, null, "str2"]; // حافظه برای آرایه و اعضاش

function f(a) {
  return a + 2;
} // حافظه برای یک تابع (که خودش یک آبجکت قابل فراخوانی‌ست)

بعضی فراخوانی‌های تابع هم باعث تخصیص حافظه می‌شن، مثلاً وقتی یه آبجکت جدید یا یه المنت DOM می‌سازید:

const d = new Date(); // یک آبجکت Date
const el = document.createElement("div"); // یک المنت DOM جدید

const s = "string";
const s2 = s.substring(0, 3); // یک رشته‌ی جدید

استفاده از حافظه

این مرحله یعنی خوندن یا نوشتن روی چیزی که تخصیص دادید؛ چه از طریق یه متغیر باشه، چه یه پراپرتی آبجکت، چه پاس دادن یه آرگومان به تابع.

آزادسازی حافظه

اینجا جاییه که بیشترِ دردسرها شروع می‌شه، چون تشخیص اینکه «کِی یه حافظه دیگه لازم نیست» اصلاً کار ساده‌ای نیست. توی زبان‌های سطح‌پایین این تشخیص با خود برنامه‌نویسه، ولی جاوااسکریپت از یه سیستم خودکار به اسم Garbage Collection استفاده می‌کنه که وظیفه‌ش پیدا کردن حافظه‌های بی‌استفاده و پس گرفتنشونه.

Garbage Collection چیست و چطور کار می‌کند؟

مشکل اصلی اینه که تشخیص دقیق «این حافظه دیگه لازم نیست» از نظر ریاضی غیرقابل‌حل (Undecidable) هست؛ یعنی هیچ الگوریتمی نمی‌تونه همیشه و برای همه‌ی حالت‌ها درست تشخیص بده. به همین خاطر، Garbage Collector‌ها یه راه‌حلِ تقریبی و محدودشده استفاده می‌کنن، نه یه راه‌حل کامل.

مفهوم Reference

پایه‌ی همه‌ی الگوریتم‌های Garbage Collection روی مفهوم ارجاع (Reference) بناست. می‌گیم یه آبجکت به آبجکت دیگه ارجاع داره اگه بهش دسترسی داشته باشه؛ چه مستقیم چه غیرمستقیم. مثلاً هر آبجکت جاوااسکریپتی یه ارجاع ضمنی به prototype خودش داره و یه ارجاع صریح به مقادیر پراپرتی‌هاش.

الگوریتم Reference Counting (دیگه استفاده نمی‌شه)

این ساده‌ترین و قدیمی‌ترین الگوریتمه: هر آبجکت یه شمارنده داره که تعداد ارجاع‌هایی که بهش اشاره می‌کنن رو نگه می‌داره. وقتی این شمارنده به صفر برسه، یعنی هیچ‌کس دیگه به اون آبجکت دسترسی نداره، پس قابل جمع‌آوریه.

let x = { a: { b: 2 } };
// دو آبجکت ساخته شد؛ یکی توسط دیگری ارجاع داده شده

let y = x;
// حالا y هم به همون آبجکت اشاره می‌کنه

x = null;
// آبجکت اصلی هنوز یک ارجاع دارد: متغیر y

y = null;
// حالا هیچ ارجاعی نمونده، آبجکت قابل جمع‌آوریه

مشکل بزرگ این روش، ارجاع‌های دایره‌ای (Circular References) هست. توی مثال زیر، دو آبجکت به هم ارجاع می‌دن؛ حتی بعد از اینکه تابع تمام می‌شه و دیگه از بیرون قابل دسترسی نیستن، شمارنده‌ی هرکدوم هنوز صفر نمی‌شه:

function f() {
  const x = {};
  const y = {};
  x.a = y; // x به y ارجاع می‌دهد
  y.a = x; // y به x ارجاع می‌دهد

  return "azerty";
}

f();
// x و y دیگر از بیرون قابل‌دسترسی نیستند
// اما چون به هم ارجاع دارند، با شمارش ارجاع هرگز پاک نمی‌شوند

الگوریتم Mark-and-Sweep (روش امروز موتورهای جاوااسکریپت)

همه‌ی موتورهای مدرن جاوااسکریپت (مثل V8 توی کروم و Node.js) از این الگوریتم استفاده می‌کنن. به‌جای اینکه بپرسه «چند تا ارجاع به این آبجکت هست؟»، می‌پرسه «آیا این آبجکت از یه نقطه‌ی ریشه (Root) قابل دسترسیه یا نه؟»

توی جاوااسکریپت، ریشه همون آبجکت گلوبال (مثلاً window توی مرورگر) هست. Garbage Collector به‌صورت دوره‌ای از این ریشه شروع می‌کنه، هر چیزی که از اونجا قابل دسترسیه رو «Reachable» علامت می‌زنه، و هر چیزی که علامت نخوره رو بی‌استفاده در نظر می‌گیره و از حافظه پاک می‌کنه.

فایده‌ی بزرگ این روش اینه که مشکل ارجاع‌های دایره‌ای رو حل می‌کنه؛ چون حتی اگه دو آبجکت به هم ارجاع بدن، اگه از ریشه بهشون راهی نباشه، هردوشون Unreachable در نظر گرفته می‌شن و پاک می‌شن. تمام بهبودهایی که این سال‌ها توی Garbage Collection موتورهای جاوااسکریپت اومده (نسلی، تدریجی، همزمان، موازی) همه بهبودِ همین الگوریتم mark-and-sweep هستن، نه جایگزینی براش.

نکته‌ی مهم اینه که هیچ راهی برای فراخوانی دستیِ Garbage Collector توی کد جاوااسکریپت وجود نداره. فقط توی Node.js می‌شه با فلگ زیر GC رو برای دیباگ در معرض دید گذاشت:

node --expose-gc --inspect index.js

Memory Leak چیست و چرا اتفاق می‌افتد؟

با اینکه GC خودکاره، بازم امکان داره حافظه نشتی پیدا کنه. نشتی یعنی یه آبجکتی که دیگه واقعاً لازمش ندارید، هنوز از طریق یه ارجاعِ فراموش‌شده به ریشه (root) وصله؛ پس GC اون رو Reachable می‌بینه و پاکش نمی‌کنه. نتیجه‌ش اینه که مصرف حافظه‌ی برنامه کم‌کم بالا می‌ره، اپلیکیشن کند می‌شه و توی اپ‌های طولانی‌مدت (مثل SPA‌ها) ممکنه حتی کرش کنه. بیایید رایج‌ترین دلایلش رو با هم ببینیم.

۱. متغیرهای گلوبال سرزده

اگه یه متغیر رو بدون let، const یا var تعریف کنید، بدون اینکه بخواید یه متغیر گلوبال ساختید که تا آخر عمر صفحه توی حافظه می‌مونه.

function processData() {
  data = "یک دیتاست بزرگ"; // بدون let/const/var → گلوبال می‌شود
}
processData();
// حالا data تا وقتی صفحه باز است در حافظه باقی می‌ماند

راه‌حل ساده‌ست: همیشه از "use strict" یا ماژول‌های ES استفاده کنید تا این اشتباه به یه خطا تبدیل بشه، نه یه نشتی خاموش.

۲. تایمرها و Intervalهایی که پاک نمی‌شن

اگه یه setInterval رو با clearInterval متوقف نکنید، تا ابد اجرا می‌شه و هر چیزی که توی closure‌ش نگه داشته (حتی بعد از حذف کامپوننت یا المنت) هم توی حافظه می‌مونه.

const intervalId = setInterval(() => {
  console.log("هنوز در حال اجراست...");
}, 1000);

// اگر جایی این خط را فراموش کنید، تایمر تا ابد زنده می‌ماند
clearInterval(intervalId);

۳. Event Listener‌های حذف‌نشده

هر addEventListener یه ارجاع به المنت و توابعی که داخلش استفاده شده نگه می‌داره. اگه المنت رو از DOM حذف کنید ولی listener رو removeEventListener نکنید، ممکنه ارجاع همچنان زنده بمونه.

function setup() {
  const btn = document.getElementById("btn");
  function handleClick() {
    console.log("کلیک شد");
  }
  btn.addEventListener("click", handleClick);

  // موقع حذف یا آنمانت شدن، حتماً همین را هم صدا بزنید:
  // btn.removeEventListener("click", handleClick);
}

۴. Closure‌هایی که بیشتر از حد لازم نگه می‌دارن

کلوژر یعنی یه تابع همچنان به متغیرهای اسکوپ بیرونی‌ش دسترسی داره، حتی بعد از تمام شدن اون اسکوپ. این ویژگی فوق‌العاده‌ست، ولی اگه یه دیتای بزرگ رو بی‌دلیل داخل کلوژر نگه دارید، اون دیتا تا وقتی کلوژر زنده‌ست پاک نمی‌شه.

function outer() {
  const bigData = new Array(1_000_000).fill("*"); // دیتای بزرگ
  return function inner() {
    console.log(bigData.length);
  };
}

const leak = outer();
// bigData همچنان توسط leak نگه داشته می‌شود، حتی اگر دیگر لازمش نداشته باشیم

۵. المنت‌های Detached DOM

یکی از رایج‌ترین و سخت‌تشخیص‌ترین نشتی‌ها. وقتی یه المنت رو از صفحه حذف می‌کنید (removeChild) ولی یه متغیر جاوااسکریپتی هنوز بهش ارجاع داره، اون المنت دیگه توی صفحه دیده نمی‌شه ولی همچنان توی حافظه‌ست؛ به این می‌گن Detached DOM Node.

let detachedDiv;

function createElement() {
  const div = document.createElement("div");
  div.id = "detached";
  return div;
}

function deleteElement() {
  document.body.removeChild(document.getElementById("detached"));
}

detachedDiv = createElement();
document.body.appendChild(detachedDiv);
deleteElement();
// المنت از صفحه حذف شده، اما detachedDiv هنوز به آن اشاره می‌کند
// در Heap Snapshot به‌صورت «Detached HTMLDivElement» دیده می‌شود

۶. کش‌های بدون محدودیت

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

const cache = new Map();

function addToCache(key, value) {
  if (cache.size > 100) {
    // ساده‌ترین راه: قدیمی‌ترین ورودی را حذف کن
    const oldestKey = cache.keys().next().value;
    cache.delete(oldestKey);
  }
  cache.set(key, value);
}

ابزارهایی که جاوااسکریپت برای مدیریت بهتر حافظه بهمون می‌ده

WeakMap و WeakSet

WeakMap و WeakSet دقیقاً شبیه Map و Set هستن، با یه فرق مهم: کلیدهاشون رو به‌صورت «ضعیف» (Weakly Held) نگه می‌دارن. یعنی اگه هیچ ارجاع دیگه‌ای به اون آبجکت نباشه، Garbage Collector می‌تونه آزادانه پاکش کنه؛ حتی اگه هنوز داخل WeakMap باشه.

const cache = new WeakMap();

function process(domNode) {
  if (!cache.has(domNode)) {
    cache.set(domNode, expensiveComputation(domNode));
  }
  return cache.get(domNode);
}

// وقتی domNode از هرجای دیگه‌ای ارجاع نداشته باشد،
// خودِ node و دیتای مرتبطش در WeakMap هم قابل جمع‌آوری می‌شوند

همین ویژگی باعث می‌شه WeakMap بهترین گزینه برای ذخیره‌ی متادیتای مرتبط با المنت‌های DOM باشه، بدون اینکه نگران نشتی حافظه باشید.

WeakRef و FinalizationRegistry

WeakRef یه ارجاع «ضعیف» به یه آبجکت می‌سازه که جلوی Garbage Collection رو نمی‌گیره، ولی تا وقتی آبجکت زنده‌ست می‌تونید بهش دسترسی داشته باشید. FinalizationRegistry هم بهتون اجازه می‌ده وقتی یه آبجکت جمع‌آوری شد، یه کالبک اجرا بشه. این دو ابزار قدرتمندن ولی چون رفتارشون تضمین‌شده و قابل‌پیش‌بینی نیست، فقط برای بهینه‌سازی‌های غیربحرانی پیشنهاد می‌شن، نه به‌عنوان راه اصلی مدیریت منابع.

function cachedFetch(getter) {
  const cache = new Map();
  const registry = new FinalizationRegistry((key) => {
    if (!cache.get(key)?.deref()) {
      cache.delete(key);
    }
  });

  return async (key) => {
    const existing = cache.get(key)?.deref();
    if (existing) return existing;

    const value = await getter(key);
    cache.set(key, new WeakRef(value));
    registry.register(value, key);
    return value;
  };
}

چطور نشتی حافظه را پیدا کنیم؟

  • از تب Memory کروم دواتولز برای گرفتن Heap Snapshot استفاده کنید و دنبال «Detached DOM Trees» و آبجکت‌هایی بگردید که بین دو اسنپ‌شات باید پاک می‌شدن ولی هنوز هستن.
  • توی Node.js می‌تونید با فلگ --max-old-space-size سقف حافظه رو تنظیم کنید یا با process.memoryUsage().heapUsed مصرف حافظه رو در طول زمان مانیتور کنید.
  • عملیات مشکوک به نشتی رو چند بار تکرار کنید و ببینید مصرف حافظه بعد از هر بار GC واقعاً برمی‌گرده به سطح قبل یا مدام بالاتر می‌ره.
node --max-old-space-size=6000 index.js

چند نکته‌ی خلاصه برای جلوگیری از نشتی حافظه

  • همیشه از "use strict" یا ماژول‌های ES استفاده کنید.
  • هر addEventListener باید یه removeEventListener متناظر داشته باشه (مخصوصاً موقع unmount شدن کامپوننت).
  • هر setInterval/setTimeout رو با تابع پاک‌کننده‌ش جفت کنید.
  • برای متادیتای مرتبط با المنت‌ها یا آبجکت‌های موقت، به‌جای Map عادی از WeakMap استفاده کنید.
  • برای هر کشی که می‌سازید، محدودیت اندازه یا TTL بذارید.
  • متغیرها رو تا حد امکان توی اسکوپ محلی نگه دارید، نه گلوبال.

جمع‌بندی

جاوااسکریپت مدیریت حافظه رو خودکار کرده تا زندگی برنامه‌نویس راحت‌تر بشه، ولی این خودکار بودن به معنیِ «هیچ‌وقت نگران نباش» نیست. الگوریتم mark-and-sweep به‌خوبی مشکل ارجاع‌های دایره‌ای رو حل کرده، اما اگه یه ارجاع فراموش‌شده به ریشه وصل بمونه (چه یه متغیر گلوبال، چه یه listener، چه یه المنت detached)، هیچ Garbage Collectorای نمی‌تونه کمکتون کنه. شناخت این الگوها و استفاده‌ی درست از ابزارهایی مثل WeakMap و WeakRef، بهترین راه برای نوشتن اپلیکیشن‌های جاوااسکریپتیِ پایدار و کم‌مصرفه.

منابع