عنوان:

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


نویسنده: امیر مکارچی
تاریخ: ۱۴۰۳/۱۲/۰۸ ۱۹:۳۵
آدرس: www.dntips.ir
مدیریت ارجاعات از کد خارجی (References from External Code)
بعضی زبان‌ها مثل Java، C Sharp و Python، از مدیریت خودکار حافظه (Automatic Memory Management) استفاده می‌کنند. اما گاهی لازم می‌شود که کدهای خارجی استفاده شده‌ی در آن‌ها (مثلاً C یا ++C) هم با اشیای این حافظه کار کنند.
دو چالش اصلی این کار:
  1. چگونه یک شیء را تا زمانیکه کد خارجی از آن استفاده می‌کند، زنده نگه داریم؟
  2. چگونه موقعیت یک شیء را به کد خارجی اطلاع دهیم؛ به‌ویژه اگر Garbage Collector آن را جابه‌جا کند؟

چالش ۱: جلوگیری از آزاد شدن شیء در هنگام استفاده‌ی کد خارجی
دو راهکار کلی برای حل این مشکل وجود دارد:
راهکار ۱: تضمین حیات شیء در طول یک فراخوانی خارجی
اگر شیء فقط در طول یک فراخوانی به کد خارجی زنده بماند، می‌توانیم آن را در استک (Stack) نگه داریم. Garbage Collector استک را بررسی می‌کند و اگر ارجاعی به یک شیء در استک باشد، آن را آزاد نمی‌کند.
راهکار ۲: ثبت اشیاء در یک جدول مخصوص (Registered Objects Table)
اگر کد خارجی برای مدت طولانی‌تری به شیء نیاز دارد، باید آن را در یک جدول ثبت (Registered Object Table) ذخیره کنیم. این جدول همیشه مثل یک ریشه‌ی زنده (Root) در GC در نظر گرفته می‌شود و تا زمانیکه کد خارجی شیء را از ثبت خارج نکند (Deregister)، حذف نمی‌شود.

چالش ۲: اطلاع دادن مکان جدید شیء به کد خارجی
این مشکل فقط در جمع‌آوری‌کننده‌های متحرک (Moving Collectors) اتفاق می‌افتد، چون آن‌ها برای فشرده‌سازی حافظه اشیاء را جابه‌جا می‌کنند.
راهکار ۱: استفاده از دسته‌ها (Handles)
به‌جای اینکه کد خارجی، مستقیم به شیء اشاره کند، یک دسته (Handle) به آن داده می‌شود. دسته یک اشاره‌گر غیرمستقیم (Indirect Pointer) به شیء در heap است. اگر GC شیء را جابه‌جا کند، فقط مقدار داخل دسته آپدیت می‌شود.
راهکار ۲: ثابت نگه‌داشتن اشیاء برای کد خارجی (Pinning)
بعضی کدهای خارجی مثل سیستم‌عامل (OS) نمی‌توانند از دسته‌ها استفاده کنند. در این موارد، باید شیء را ثابت (Pinned) نگه داریم تا GC آن را جابه‌جا نکند.

چالش ۳: مدیریت اشیای پین‌شده (Pinned Objects)
Pinning یعنی جلوگیری از جابجایی یک شیء در طول یک جمع‌آوری زباله (GC Cycle).
روش‌های پین کردن:
اگر موقع تخصیص بدانیم که شیء باید ثابت بماند، آن را در یک فضای غیرقابل جابه‌جایی (Non-Moving Space) قرار می‌دهیم. اما معمولاً از قبل نمی‌دانیم! پس GC باید موقع اجرا این کار را انجام بدهد.

روش‌های GC برای مدیریت اشیای پین‌شده:
جمع‌آوری را به تعویق اندازد (Defer Collection) ولی اگر زیاد طول بکشد، ممکن است حافظه تمام شود! شیء را به یک فضای غیرمتحرک انتقال بدهد (Move to Non-Moving Space). این روش در جمع‌آوری‌کننده‌های نسل‌گرا (Generational Collectors) که داده‌های قدیمی را جابجا نمی‌کنند، خوب کار می‌کند.

چالش ۴: مشکلات هم‌زمانی (Race Conditions) بین GC و کد خارجی
چند راهکار برای حل این مشکل:
همه‌ی اشیایی را که یک شیء پین‌شده، به آن اشاره می‌کند هم پین کنیم! پین کردن را فقط برای اشیایی انجام بدهیم که واقعاً لازم است! در بعضی زبان‌ها، اجازه‌ی تغییر مستقیم به اشیای پین‌شده ندهیم!

چالش ۵: پین کردن در زبان‌های سطح بالا (مانند Java، C Sharp)
در زبان‌هایی مثل Java و C Sharp که GC دارند، معمولاً دو رویکرد استفاده می‌شود:
۱. دسته‌های مخصوص برای تعامل با کد خارجی (Handles for External Code)
مثلاً در Java از JNI Global References برای نگه داشتن اشیاء استفاده می‌شود.
۲. قابلیت pin/unpin در C Sharp و Rust
در C Sharp، کلمه‌ی کلیدی fixed به شیء اجازه نمی‌دهد که جابه‌جا شود.
در Rust، از <Pin<T برای جلوگیری از جابجایی اشیاء استفاده می‌شود.

Points و Mutator Suspension در مدیریت حافظه
موضوع این است که Garbage Collector (GC) نمی‌تواند در هر لحظه‌ای اجرا شود. بعضی قسمت‌های کد نیاز دارند که کاملاً اجرا شوند و نباید وسط اجرای آن‌ها GC اتفاق بیفتد. این بخش‌ها معمولاً شامل موارد زیر هستند:
  • Write Barriers: وقتی یک اشاره‌گر در حافظه تغییر می‌کند، GC باید هم مقدار جدید را ثبت کند و هم مقدار قدیمی را پردازش کند. اگر وسط این فرایند GC اتفاق بیفتد، داده‌ها ممکن است گم شوند!
  • ایجاد فریم جدید در استک (Stack Frame Creation): اگر در وسط ساخت یک فریم جدید در استک، GC فعال شود، اطلاعات ناتمام روی استک ممکن است پردازش شوند و باعث کرش گردند.
  • مقداردهی اولیه به اشیای جدید (Object Initialization): قبل از اینکه یک شیء جدید به‌طور کامل مقداردهی شود، نباید GC آن را پردازش کند.

چرا GC را به‌گونه‌ای طراحی نکنیم که در هر لحظه‌ای قابل اجرا باشد؟
دو دلیل مهم وجود دارد:
نیاز به Stack Maps برای هر Instruction Pointer (IP)
اگه GC بخواهد در هر نقطه‌ای از برنامه اجرا شود، باید برای تک‌تک دستورالعمل‌ها، Stack Map ذخیره کند که نشان بدهد کدام متغیرها اشاره‌گر هستند. این حجم زیادی از حافظه را اشغال می‌کند.
وجود بخش‌هایی که باید کاملاً اجرا شوند (Atomic Sequences)
بعضی کدها باید بدون توقف اجرا شوند، مثل تغییر اشاره‌گرها یا مقداردهی اولیه‌ی اشیا. اگه GC وسط اجرای این دستورات متوقف گردد، ممکن است داده‌ها خراب شوند.

GC از دو مکانیزم اصلی برای شروع کار خود استفاده می‌کند:
Polling (چک کردن پرچم GC توسط نخ‌ها)
در این روش، هر نخ در نقاط خاصی از برنامه (GC Safe-Points) یک پرچم (Flag) را چک می‌کند.
اگر پرچم روشن باشد، نخ می‌فهمد که GC باید اجرا شود و خودش را متوقف می‌کند.

Patching (تغییر کد در GC Safe-Points)
در این روش، GC کد بعدی که باید اجرا شود را تغییر می‌دهد و وقتی برنامه به آن نقطه می‌رسد، مستقیماً متوقف می‌شود و GC اجرا می‌شود.
این روش مثل این است که در یک دیباگر، Breakpoint (نقطه‌ی توقف) قرار بدهیم.

وادار کردن نخ به رفتن به Safe-Point (Forcing Safe-Point Entry)
در این روش، GC یک سیگنال (Signal) یا وقفه (Interrupt) را به نخ ارسال می‌کند تا آن را مجبور کند به Safe-Point بعدی برود. این کار می‌تواند از طریق تغییر Return Address در استک انجام شود.

GC Safe-Points در زبان‌های مختلف
بسته به نوع زبان و Garbage Collector، روش‌های مختلفی وجود دارد:
  • Java & C Sharp: از Polling و GC Check-Points برای مدیریت Safe-Points استفاده می‌کنند.
  • C++ & C: معمولاً GC ندارند، اما اگر داشته باشند، از روش‌های Conservative (مثل Boehm GC) استفاده می‌کنند که نیازی به Safe-Points ندارند.
  • Lisp & Smalltalk: از Object Tables برای راحت‌تر کردن مدیریت Safe-Points استفاده می‌کنند.

انتخاب اندازه‌ی مناسب برای Heap در GC
اندازه‌ی Heap می‌تواند تأثیر زیادی روی سرعت اجرای برنامه (Mutator Throughput) و هزینه‌ی جمع‌آوری زباله (Garbage Collection Cost) داشته باشد.
اگر Heap کوچک باشد:
حافظه‌ی کش (Cache) و Translation Lookaside Buffer (TLB) بهتر کار می‌کنند. تأخیر در دسترسی به حافظه کم‌تر می‌شود. جمع‌آوری زباله بیشتر اجرا می‌شود که باعث افزایش زمان توقف (Pause Time) می‌شود.
اگر Heap خیلی بزرگ باشد:
جمع‌آوری زباله کم‌تر انجام می‌شود، چون فضای بیشتری برای تخصیص داریم. اما اگر حافظه‌ی واقعی (Physical Memory) کم باشد، ممکن است باعث Thrashing شود (صفحات زیادی بین حافظه‌ی اصلی و دیسک جابه‌جا بشن). بهترین اندازه‌ی Heap معمولاً طوری تنظیم می‌شود که تعادل بین این دو حالت حفظ گردد.

چرا انتخاب استراتژی مناسب برای تخصیص حافظه و GC اهمیت دارد؟
چون تصمیماتی که در سطح طراحی و پیاده‌سازی گرفته می‌شود، تأثیر مستقیمی روی عملکرد، بهینه‌سازی و پایداری برنامه‌ها دارد.
برای مثال، تخصیص حافظه (Memory Allocation) باید به‌گونه‌ای طراحی شود که:
  1. سریع و کارآمد باشد.
  2. نیازهای مختلف مثل هم‌ترازی (Alignment)، مقداردهی اولیه (Initialization) و دسترسی به اشاره‌گرها (Pointers) را در نظر بگیرد.
  3. از ایجاد نشت حافظه (Memory Leak) و کاهش عملکرد جلوگیری کند.
GC هم باید بهینه پیاده‌سازی شود تا:
  1. تأخیر (Latency) در اجرای برنامه کم شود.
  2. سربار پردازشی (Overhead) برای پردازشگر حداقل گردد.
  3. بدون ایجاد مشکل در کدهای چندنخی (Multi-threading) و فراخوانی‌های خارجی (Native Code Calls) کار کند.
پس انتخاب روش مناسب برای تخصیص و GC، به نوع برنامه، زبان برنامه‌نویسی و سطح همزمانی بستگی دارد.

چالش‌های مهم در تخصیص حافظه و Garbage Collection
1. چه اطلاعاتی در زمان تخصیص لازم است؟
تخصیص حافظه فقط شامل گرفتن فضا از Heap نیست، بلکه باید مشخص کنیم که چه اطلاعاتی باید مقداردهی اولیه (Initialization) شوند:
  • آیا فقط یک بلاک حافظه با اندازه‌ی مشخص نیاز داریم؟
  • آیا باید اطلاعات هدر (Header Fields) قبل از استفاده مقداردهی شوند؟
  • آیا فیلدهای داخلی آبجکت‌ها باید مقدار اولیه داشته باشند؟
مثال:
در برخی زبان‌ها مثل Java و #C، تمام متغیرهای شیء، مقدار اولیه می‌گیرند، اما در C و ++C چنین الزامی وجود ندارد و ممکن است مقداردهی اولیه بهینه‌تر باشد.

2. هم‌ترازی و سازمان‌دهی داده‌ها (Alignment & Object Layout)
برخی پردازنده‌ها (مثل ARM و x86) نیاز به هم‌ترازی حافظه (Memory Alignment) دارند. اگر داده‌ها در آدرس‌های بهینه قرار نگیرند، ممکن است عملکرد پردازنده کاهش پیدا کند.
مثال:
  • در معماری x86، متغیرهای int (4 بایتی) باید در آدرس‌های مضربی از 4 قرار بگیرند.
  • در معماری ARM، متغیرهای double (8 بایتی) باید در آدرس‌های مضربی از 8 قرار بگیرند.

3. تخصیص درون‌خطی (Inlining Allocation) و بهینه‌سازی رجیسترها
کد تخصیص حافظه باید تا حد ممکن کوتاه و بهینه باشد. در بیشتر سیستم‌ها، یک مسیر سریع (Fast Path) برای تخصیص حافظه درون‌خطی پیاده‌سازی می‌شود که:
  • بدون نیاز به قفل (Lock) کار می‌کند.
  • فقط برای موارد خاص (مثل کمبود حافظه) مسیر کند (Slow Path) را فراخوانی می‌کند.

4. مقداردهی اولیه حافظه (Zeroing Memory)
مقداردهی اولیه‌ی حافظه (Zeroing Memory) در برخی زبان‌ها اجباری است، ولی همیشه بهترین راه نیست.
چند راه برای مقداردهی حافظه:
  • مقداردهی در زمان تخصیص (Eager Initialization) – برای امنیت و جلوگیری از استفاده داده‌های قدیمی.
  • مقداردهی در زمان استفاده (Lazy Initialization) – برای افزایش عملکرد، چون داده‌ها فقط وقتی نیاز باشد مقداردهی می‌شوند.

5. نوع‌یابی اشاره‌گرها (Pointer Identification)
GC باید بتواند اشاره‌گرها را از داده‌های معمولی تشخیص بدهد.
دو روش وجود دارد:
  • دقیق (Precise GC): فقط اشاره‌گرهای واقعی را دنبال می‌کند.
  • تقریبی (Conservative GC): هر چیزی که شبیه اشاره‌گر باشد را دنبال می‌کند (ممکن است باعث نشت حافظه شود).

6. مدیریت استک در GC
GC می‌تواند استک (Stack) را یک‌باره یا مرحله‌ای اسکن کند. اسکن تدریجی (Incremental Stack Scanning) باعث کاهش تأخیر می‌شود، ولی پیاده‌سازی پیچیده‌ای دارد.

7. مدیریت اشاره‌گرهای داخلی (Interior Pointers) و کد بومی (Native Code)
در زبان‌هایی مثل C++/C، اشاره‌گرها می‌توانند به داخل آبجکت اشاره کنند، نه فقط ابتدای آن. GC باید بتواند این اشاره‌گرهای داخلی را هم تشخیص بدهد و درست مدیریت کند.
مثال:
  • JVM و NET. برای فراخوانی‌های Native از Handles استفاده می‌کنند.
  • در C++/C باید از Pinning Objects استفاده شود تا GC آن‌ها را جابه‌جا نکند.

8. هماهنگ‌سازی GC و نخ‌ها (Thread Synchronization & GC Safe-Points)
بعضی از GCها می‌توانند در هر نقطه‌ای اجرا شوند، ولی معمولاً فقط در نقاط ایمن GC (GC Safe-Points) این اتفاق میفتد.
چند روش برای مدیریت GC Safe-Points:
  • Polling : هر نخ بررسی می‌کند که GC نیاز به توقف دارد یا خیر.
  • Patching : کد نخ تغییر می‌کند که وقتی اجرا شد، متوقف شود.

9. بررسی اشاره‌گرهای جالب (Interesting Pointers) و ثبت آن‌ها
بعضی از GCها نیاز دارند که تغییرات در اشاره‌گرها را رهگیری کنند. برای این کار از Read Barriers و Write Barriers استفاده می‌شود که در سطح سخت‌افزار یا نرم‌افزار پیاده‌سازی می‌شوند.
چالش‌ها:
  • چه اندازه‌ای از حافظه باید در نظر گرفته شود؟
  • چطور با پردازنده‌های چندهسته‌ای (Multi-core CPUs) هماهنگ شود؟
  • آیا باید از صفحات مجازی (Virtual Memory Page Protection) برای بهینه‌سازی استفاده شود؟