عنوان:

‫مدیریت حافظه و جمع‌آوری زباله (Garbage Collection) - قسمت شانزدهم


نویسنده: امیر مکارچی
تاریخ: ۱۴۰۳/۱۲/۰۳ ۱۰:۴۵
آدرس: www.dntips.ir
جمع‌آوری زباله‌ی نسل‌گرا (Generational Garbage Collection)

الگوریتم‌های جمع‌آوری زباله‌ی نسل‌گرا با استفاده از ایده‌ی "اغلب اشیاء جوان زود می‌میرند" (Weak Generational Hypothesis) تلاش می‌کنند فضای بیشتری را با هزینه‌ی کمتر آزاد کنند. وقتی یک جمع‌کننده‌ی زباله‌ی عادی (مثل Mark-Sweep یا Copying GC) اجرا می‌شود، کل هیپ را پردازش می‌کند. این یعنی حتی اشیاء قدیمی و طولانی‌عمر هم بررسی می‌شوند، که کارایی را پایین می آورد. ایده‌ی نسل‌گرا (Generational Collection) پیشنهاد می‌کند که به‌جای بررسی کل حافظه، تمرکز را روی اشیاء جوان‌تر بگذاریم، چون احتمال مردن آنها بیشتر است.


نحوه‌ی کار الگوریتم نسل‌گرا
مرحله ۱: تقسیم‌بندی هیپ به چند نسل
یک هیپ نسل‌گرا معمولاً شامل این بخش‌هاست:
  1. Young Generation (نسل جوان) : جایی که همه‌ی اشیاء جدید ایجاد می‌شوند. معمولاً توسط Copying GC مدیریت می‌شود.
  2. Old Generation (نسل قدیمی) : جایی که اشیائی که برای مدت طولانی زنده ماندن منتقل (Promote) می شوند. معمولاً با Mark-Sweep یا Mark-Compact GC مدیریت می گردد.
  3. (اختیاری) Permanent/Metaspace Generation : شامل داده‌های دائمی مثل کلاس‌ها و متادیتاست.

مرحله ۲: جمع‌آوری زباله در نسل جوان (Minor GC)
جمع‌آوری زباله‌ی نسل جوان (Young GC) به‌سرعت و با حداقل سربار انجام می‌شود. شیء جدید ایجاد می‌شود. همه‌ی اشیاء جدید، ابتدا در نسل جوان (Nursery) قرار می‌گیرند. GC اجرا می‌شود. وقتی نسل جوان پر می‌شود، GC اجرا شده و اشیاء زنده به نسل قدیمی منتقل می‌شوند (Promotion). فضای نسل جوان خالی می‌شود. بقیه‌ی اشیاء حذف می‌شوند و نسل جوان دوباره آماده‌ی استفاده می‌شود.
ویژگی‌ها:
  1. سرعت بالای پردازش
  2. جمع‌آوری زباله‌ی زیاد با هزینه‌ی کم
  3. مدیریت سریع و بهینه‌ی حافظه

مرحله ۳: جمع‌آوری زباله در نسل قدیمی (Major GC یا Full GC)
وقتی که نسل قدیمی (Old Generation) بیش از حد پر شود، یک GC بزرگ (Full GC) اجرا می‌شود تا زباله‌های قدیمی را حذف کند. معمولاً از الگوریتم‌های Mark-Sweep یا Mark-Compact استفاده می‌شود، چون دیگر نیازی به Copying GC نیست. این فرایند زمان‌بر و پرهزینه‌تراست و ممکن است باعث توقف برنامه (Pause) شود.
ویژگی‌ها:
  1. مدیریت حافظه‌ی طولانی‌مدت
  2. کاهش تعداد دفعات اجرای GC
  3. زمان توقف (Pause Time) بیشتر نسبت به Minor GC

مشکل ارجاعات بین نسل‌ها (Inter-Generational Pointers)
در یک هیپ نسل‌گرا، اشیاء جدید به اشیاء قدیمی ارجاع می‌دهند و برعکس. مشکل این است که وقتی فقط نسل جوان جمع‌آوری می‌شود، باید حواس‌مان باشد که اشیائی که از نسل قدیمی به نسل جوان اشاره می‌کنند، گم نشوند!
راه‌حل: استفاده از "مجموعه‌ی یادآوری" (Remembered Set)
Write Barrier استفاده می‌شود تا ارجاعات بین نسل‌ها را شناسایی کند.
Remembered Set (Remset) لیستی از ارجاعات بین نسل‌ها را ذخیره می‌کند.
وقتی Minor GC اجرا می‌شود، به‌جای اسکن کل هیپ، فقط به Remembered Set نگاه می‌کنیم.

مزایا و معایب GC نسل‌گرا
مزایا
  1. افزایش سرعت جمع‌آوری زباله
  2. کاهش توقف‌های طولانی (Shorter Pause Times)
  3. کاهش پردازش اشیاء قدیمی و بی‌نیاز از جابه‌جایی آن‌ها

معایب
  1. نیاز به نگهداری Remembered Set و Write Barrier
  2. گاهی اوقات Full GC باعث توقف طولانی برنامه می‌شود (Stop-the-World GC)
  3. اگر برنامه داده‌های طولانی‌عمر زیادی تولید کند، نسل جوان سریع پر می‌شود و کارایی کم می‌شود.

در سیستم‌های مدیریت حافظه، زمان دو مفهوم مختلف دارد:
زمان واقعی (Wall-Clock Time):
این معیار به ما کمک می‌کند بفهمیم برنامه چقدر اجرا می‌شود، چه میزان توقف (Pause Time) دارد و چقدر سریع پاسخ می‌دهد.
حجم حافظه‌ی تخصیص‌یافته (Bytes Allocated):
این معیار به ما نشان می‌دهد هر شی چقدر عمر می‌کند، چند بایت حافظه مصرف می‌کند و چقدر روی Garbage Collector فشار می آورد.

فرضیه‌ی ضعیف نسل‌گرا (Weak Generational Hypothesis)
این فرضیه:
بیشتر اشیاء خیلی سریع می‌میرند.
جمع‌آوری زباله‌ی نسل‌جوان (Minor GC) می‌تواند با هزینه‌ی کم، فضای زیادی را آزاد کند.

فرضیه‌ی قوی نسل‌گرا (Strong Generational Hypothesis)
این فرضیه قدم فراتر می‌گذارد :
اشیاء جوان‌تر، نرخ مرگ و میر بیشتری نسبت به اشیاء مسن‌تر دارند. ولی این فرضیه همیشه درست نیست. تحقیقات نشان داده که بعد از حذف اشیاء خیلی زودمرگ، الگوهای متفاوتی ظاهر می‌شوند.
مشکلات فرضیه‌ی قوی نسل‌گرا
  1. الگوی مرگ و میر بعضی اشیاء به فازهای برنامه وابسته‌ست.
  2. بعضی از اشیاء هیچ‌وقت نمی‌میرند (Immortal Objects).
  3. ممکن است بین اندازه‌ی شی و عمرش ارتباط وجود داشته باشد.

چرا درک این فرضیه‌ها مهمه؟
بهینه‌سازی Garbage Collector
چون اگر بدانیم بیشتر اشیاء زود می‌میرند، می‌توانیم طراحی GC را بهینه کنیم:
  1. کمتر روی اشیاء قدیمی تمرکز کنیم.
  2. Minor GC را بیشتر اجرا کنیم و Major GC را کمتر.
  3. اشیاء بزرگ را جداگانه مدیریت کنیم، چون احتمالاً عمر بیشتری دارند.
بهبود کارایی و کاهش Pause Time
برای همین جمع‌آوری زباله‌ی نسل‌گرا در جاوا، سی‌شارپ و بسیاری از زبان‌های مدرن به‌شدت استفاده می‌شود.

نسل‌ها و ساختار حافظه در Garbage Collection
چندین روش مختلف برای سازماندهی نسل‌ها (Generations) وجود دارد:
ممکن است فقط دو نسل (Young & Old)، یا چندین نسل مختلف بسته به سن اشیاء داشته باشیم. هر نسل می‌تواند از نظر فیزیکی جدا باشد یا فقط از نظر منطقی تفکیک شود. ممکن است اندازه‌ی نسل‌ها ثابت یا پویا و متغیر باشد. بعضی نسل‌ها می‌توانند فضای مخصوص برای اشیاء بزرگ (Large Object Space - LOS) داشته باشند.

هدف استفاده از نسل‌ها
  1. کاهش توقف‌های طولانی (Pause Time)
  2. افزایش کارایی و کاهش فشار روی CPU
  3. کاهش تعداد جمع‌آوری‌های کامل حافظه (Full GC)
اصلی‌ترین فاکتور تعیین‌کننده‌ی مدت زمان توقف (Pause Time)، حجم داده‌ای است که بعد از هر Minor GC زنده می‌ماند!

چالش‌های انتخاب اندازه‌ی نسل جوان (Nursery Size)
اندازه‌ی نسل جوان خیلی مهم است و باید بهینه تنظیم شود. اگر خیلی کوچک باشد یا خیلی بزرگ، مشکلاتی پیش می آید:
اگر نسل جوان خیلی کوچک باشد:
Minor GC خیلی زیاد اجرا می‌شود و باعث افزایش هزینه‌های پردازشی می‌شود.
اشیاء زودتر از موعد به نسل قدیمی منتقل می‌شوند (Premature Promotion) که باعث افزایش nepotism می‌شود.
اشیاء زیادی زنده می‌مانند و باید به نسل قدیمی منتقل شوند که باعث اجرای زودهنگام Full GC می‌شود.
اگر نسل جوان خیلی بزرگ باشد:
اجرای هر Minor GC زمان بیشتری می‌برد چون باید تعداد زیادی شی را بررسی کند.
حافظه‌ی بیشتری بلااستفاده باقی می‌ماند که ممکن است باعث کندی عملکرد سیستم شود.

مشکل "Nepotism" در ارتقای زودهنگام (Premature Promotion)
nepotism زمانی رخ می‌دهد که اشیای مرده‌ی موجود در نسل قدیمی، فرزندانشان را در نسل جوان نگه دارند. این باعث افزایش نرخ بقای اشیاء در نسل جوان می‌شود، در حالیکه این اشیاء در واقع دیگر استفاده‌ای ندارند!
مثال:
فرض کنید یک برنامه داریم که اشیائی مثل UserSession را ایجاد می‌کند. این اشیاء به سرعت بی‌استفاده می‌شوند، اما چون قبلاً به نسل قدیمی منتقل شده‌اند، نمی‌توانیم آنها را به سرعت حذف کنیم و مجبور می‌شویم بعداً Full GC اجرا کنیم!
نتیجه:
  1. فضای زیادی بی‌دلیل اشغال می‌شود.
  2. GC بهینه کار نمی‌کند و Full GC زودتر اجرا می‌شود.
  3. عملکرد برنامه کاهش پیدا می‌کند.

تأثیر تغییر مکان اشیاء روی عملکرد CPU و حافظه
تحقیقات نشان داده که اشیاء جدید بیشتر تغییر می‌کنند تا اشیاء قدیمی. این یعنی:
اگر یک شی تازه ساخته شده زودتر از موعد به نسل قدیمی منتقل شود، میزان تغییراتش فشار زیادی به CPU وارد می‌کند. نوشتن در اشیای نسل قدیمی گران‌تر است، چون نیاز به Write Barrier دارد!
مثال:
فرض کنید یه لیست ArrayList جدید داریم که داریم مقدار اضافه می‌کنیم. اگر این شیء خیلی زود به نسل قدیمی منتقل شود، هر بار که یک مقدار جدید به آن اضافه کنیم، Write Barrier فعال می‌شود و سرعت برنامه پایین می آید!

راه‌حل‌ها و استراتژی‌های بهینه‌سازی
۱. استفاده از Write Barrier هوشمند
Write Barrier باید به‌صورت بهینه پیاده‌سازی شود تا هزینه‌ی اضافی روی برنامه تحمیل نکند.
اطلاعات درباره‌ی تغییرات اشیاء را ثبت کند، اما بی‌جهت پردازش نکند.
۲. تنظیم بهینه‌ی اندازه‌ی نسل جوان
نسل جوان باید به‌اندازه‌ای باشد که اکثر اشیاء قبل از انتقال به نسل قدیمی، حذف شوند.
باید به‌صورت پویا تنظیم گردد تا بسته به میزان تخصیص حافظه، تغییر کند.
۳. جلوگیری از ارتقای زودهنگام (Premature Promotion Prevention)
مدیریت دقیق اشیایی که ارتقا داده می‌شوند، مثلاً بررسی تعداد Minor GCهایی که یک شی زنده مانده است.
استفاده از bucket-based age tracking برای بررسی سن اشیاء.