محمدرضا دهقانی‌زاده
محمدرضا دهقانی‌زاده
خواندن ۱۰ دقیقه·۳ سال پیش

آنچه یک توسعه‌دهنده جاوا باید در مورد Garbage Collector بداند - قسمت اول

یکی از چیزهایی که برنامه‌نویس‌های جاوا معمولا اون رو دست کم می‌گیرن، زباله‌روبه (Garbage Collector). وقتی با توسعه‌دهنده‌های جاوا در مورد زباله‌روب صحبت می‌کنم، روایت‌های متنوع و گاها عجیبی در مورد چیستی و چرایی اون، انواعش، طرز کارش، اهمیتش، تنظیماتش و … می‌شنوم. توسعه‌دهنده‌های خیلی خفن‌تر هم با این‌که گاها اطلاعات دقیق و کاملی در مورد زباله‌روب جاوا دارن، اما باز هم گاهی تاثیر اون رو دست کم می‌گیرن، یا تا وقتی که به مشکل جدی برخورد نکنن سراغش نمی‌رن. نهایتاً اگه خیلی وقت بگذارن، اندازه Xmx و Xms و ... رو تنظیم می‌کنن که اون هم معمولا غلط از آب در می‌آد.

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

در مورد زباله‌روب مطالب خیلی زیادی در دسترس هست اما فکر کردم که نوشتن یه مطلب خلاصه می‌تونه برای همه‌ی برنامه‌نویس‌های جاوا مفید باشه هر چند ممکنه بخش‌هایی از این نوشته براتون تکراری باشه.

مسئله چیه؟

برنامه‌ها در طول اجرا، بخش‌هایی از حافظه رو به خودشون اختصاص می‌دن و داده‌های خودشون رو اونجا می‌گذارن. همین‌طور زمانی که دیگه به داده‌ای نیاز ندارن، لازمه که فضای اشغال شده توسط اون داده رو برای استفاده‌ی دوباره‌ی خودشون یا برنامه‌های دیگه آزاد کنن. این‌جا وقتی می‌گیم «حافظه»، منظورمون «Heap» هست و فعلاً کاری با بخش‌های دیگه‌ی حافظه، یا برنامه‌های خیلی خاص نداریم.

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

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

پس حواسمون هست که هر دو روش مزایا و معایبی دارن و هیچ کدوم از همه‌ی جهات، بهتر از اون یکی نیست.

زبان‌های برنامه‌نویسی، در زمان طراحی، از یک یا هر دوی این روش‌ها استفاده کردن. مثلاً در C++/C روش اول روش پیش‌فرضه و در Java هم روش دوم استفاده شده.

(طبق معمول موارد تخیلی‌تری هم وجود داره. مثلاً در Ada با این‌که یه memory pool پیش‌فرض با مدیریت خودکار وجود داره اما برنامه‌نویس می‌تونه برای خودش memory pool‌های مختلفی با مدیریت خودش داشته باشه. یا در بعضی زبان‌ها با این‌که حافظه به صورت خودکار مدیریت می‌شه، خود برنامه‌نویس هم اجازه داره مستقیما اشیاء رو از حافظه پاک کنه.)

خب حالا برگردیم سر موضوع خودمون که «مدیریت حافظه‌ی خودکار» توسط زباله‌روب هست. زباله‌روب باید فضاهای تخصیص داده شده‌ای که دیگه به اون‌ها نیازی نیست بدون این که برنامه‌نویس نگرانشون باشه، پیدا و آزاد کنه. اصطلاحاً به داده‌هایی که در این فضاها قرار دارن زباله (garbage) گفته می‌شه. برای انجام این کار چندین راه‌برد وجود داره اما بزارین قبل از بررسی اون‌ها، چندتا اصطلاح رو با هم مرور کنیم:

شیء «ریشه» (Root): اشیائی در حافظه، که زباله‌روب از قبل می‌دونه زباله نیستن.

چندتا مثال از اشیاء ریشه توی جاوا:

  • ریسمان اصلی برنامه و متغیرهای محلی داخل تابع main
  • ریسمان‌های فعال برنامه به همراه متغیرهای داخل پشته‌ی اون‌ها
  • شیء «زنده»: اشیائی هستن که برنامه در ادامه‌ی روند اجرا هنوز به اون‌ها نیاز داره و زباله‌روب نباید پاکشون کنه. زباله‌روب از آینده خبر نداره، بنابراین تشخیص دقیق این‌که کدوم اشیاء زنده هستن عموما غیر ممکنه. در عوض تشخیص این‌که کدوم اشیاء صددرصد مرده هستن ساده‌تره.

شیء «مرده»: اشیائی هستن که زنده نیستن! زباله‌روب می‌تونه اشیاء مرده رو با خیال راحت پاک کنه.

ارجاع (reference): ارجاع‌ها در‌واقع لینک‌هایی برای اشاره به اشیاء‌ در حافظه هستن.

وقتی می‌نویسیم

Object obj = new Object();

در‌ واقع یه شیء از نوع Object درست می‌کنیم و یک ارجاع به اون رو داخل obj قرار می‌دیم. حالا اگه در ادامه نوشته باشیم:

Object otherObj = obj;

یه ارجاع دیگه درست کردیم که به همون شیء قبلی اشاره داره. پس الان به اون شیء دو تا ارجاع وجود داره.

ارجاع‌ها در طول اجرای برنامه تغییر می‌کنن و بعضی از اونها دیگه به درد نمی‌خورن. مثلا اگر در ادامه‌‌ی خط‌های قبلی بنویسیم:

otherObj = new Object();

حالا دیگه otherObject به شیء جدیدی ارجاع داره و از تعداد ارجاعات به شیء اول یه دونه کم میشه.

اما حواسمون هست که ارجاع با اشاره‌گر (pointer) یا آدرس تفاوت داره. یه زباله‌روب می‌تونه یه شیء رو در حافظه جابجا کنه اما ارجاعات به اون شیء تغییری نمی‌کنه بلکه آدرس اون شیء عوض می‌شه.

گراف ارجاعات:‌

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


راهبردهای زباله‌روبی

راهبردهای مختلفی برای کار زباله‌روبی وجود داره. از جمله:

راهبرد شمارش ارجاعات

در این راهبرد همزمان با اجرای برنامه، تعداد ارجاعات به هر شیء، یک جا نگه داشته می‌شه. هر وقت تعداد ارجاعات به یک شیء صفر بشه، اون شیء به عنوان زباله شناخته می‌شه. این روش ایراداتی از جمله وجود سربار هنگام خوندن و نوشتن داره. یا مثلاً فرض کنید در گراف ارجاعات یه حلقه ایجاد شده باشه اما به هیچ کدوم از عناصر تشکیل‌دهنده‌ی اون حلقه ارجاعی وجود نداشته باشه، با این روش این حلقه به سادگی قالب کشف نیست. توی شکل پایین بعد از این‌که شیء A رو حذف کنیم، اشیاء B و C و D هم باید بعد از حذف ارجاع A به B به عنوان زباله حذف بشن، اما چون هنوز به اون‌ها ارجاع وجود داره (R=1)، حذف نمی‌شن.

کشف نشدن حلقه‌های مرده در روش شمارش ارجاعات
کشف نشدن حلقه‌های مرده در روش شمارش ارجاعات


این روش در کنار روش‌های دیگه توی زبان‌هایی مثل پایتون استفاده می‌شه و یادتون باشه که نمی‌شه به تنهایی روش حساب کرد.

راه‌برد ردیابی (Tracing)

اگه نتونیم با گذشتن از یه زنجیره از ارجاعات به صورت مستقیم یا غیرمستقیم، از اشیاء ریشه به یک شیء برسیم، اون شیء مُرده. در راه‌برد ردیابی از الگوریتم‌های مختلفی برای این کار استفاده می‌شه.

ساده‌ترین اون‌ها روش Mark & Sweep هست که به طور خلاصه به این شکل کار می‌کنه که با شروع از اشیاء ریشه، یه الگوریتم پیمایش روی گراف ارجاعات، اجرا می‌شه و هر شیء که پیمایش شد علامت‌گذاری می‌شه. در پایان، یک بار دیگه حافظه مرور می‌شه و هر شیء که علامت نخورده باشه، به عنوان زباله شناخته می‌شه.

گراف ارجاعات قبل از اجرای فاز علامت‌گذاری
گراف ارجاعات قبل از اجرای فاز علامت‌گذاری


گراف ارجاعات بعد از علامت‌گذاری اشیاء در دسترس
گراف ارجاعات بعد از علامت‌گذاری اشیاء در دسترس


گراف ارجاعات بعد از مرحله ی جاروب (Sweep)
گراف ارجاعات بعد از مرحله ی جاروب (Sweep)


البته در این روش ساده هم مشکلاتی وجود داره، مثلاً این‌که در حین انجام این کار درخت ارجاعات نباید تغییر کنه. پس قبل از شروع، برنامه باید متوقف بشه و منتظر تمام شدن کار زباله‌روب بمونه. در مورد راه حل این مشکل در قسمت‌های بعدی صحبت خواهیم کرد.


دسته‌بندهای رایج برای زباله‌روب‌ها

وقتی زباله‌روب‌های مختلف رو با هم مقایسه می‌کنیم. با توجه به طراحی و هدفشون می‌شه در دسته‌های زیر قرارشون داد:

زباله‌روب Concurrent (هم‌روند) در مقابل Stop-The-World

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

در مقابل زباله‌روب Stop-The-World برای انجام کارش، اجرای برنامه رو کاملا متوقف می‌کنه. بنابراین کار نسبتاً ساده‌تری انجام می‌ده.

زباله‌روب STW در مقایسه با زباله‌روب همروند
زباله‌روب STW در مقایسه با زباله‌روب همروند


زباله‌روب Serial در مقابل Parallel

زباله‌روب سریال فقط در یک ریسمان (thread) اجرا می‌شه در حالی که زباله‌روب موازی، از چندین ریسمان به طور همزمان استفاده می‌کنه.

زباله‌روب سریال در مقایسه با زباله‌روب موازی
زباله‌روب سریال در مقایسه با زباله‌روب موازی


نکته‌ای که اینجا به دردمون می‌خوره اینه که «هم‌روند» و «موازی» با هم فرق دارن، هر چند گاهی به جای هم‌دیگه استفاده می‌شن. پس:

- ممکنه یه زباله‌روب نه هم‌روند باشه نه موازی.

- ممکنه یه زباله‌روب هم‌روند (با برنامه) باشه اما موازی (یعنی در چند ریسمان) نباشه.

- ممکنه یه زباله‌روب موازی باشه اما هم‌روند نباشه.

- ممکنه یه زباله‌روب هم موازی باشه هم هم‌روند.

زباله‌روب Incremental (افزایشی) در مقابل non-Incremental (یک‌جا)

زباله‌روب Incremental کار زباله‌روبی رو به صورت یک‌جا انجام نمی‌ده. در‌واقع می‌تونه کل کار رو به قسمت‌های کوچک‌تر تقسیم کنه و در بازه‌های زمانی متفاوت، به تدریج انجامشون بده. همین‌طور براساس هدفی که براش تعریف شده، ممکنه بعضی جاها صلاح بدونه اولویت کارها رو جابجا کنه.

زباله‌روب Generational در مقابل non-Generational

یه فرضیه تجربی داریم که خلاصه‌ی حرفش اینه: «عمده‌ی اشیاء، جوان می‌میرند!» و «اشیائی که از یه مدت زمان خاص، بیشتر زنده بمونن، به احتمال زیاد، خیلی پیر می‌شن!»

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

زباله‌روب Compacting در مقابل non-Compacting

بعضی از زباله‌روب‌ها، صرفا فضای اشغال شده توسط اشیاء مرده رو آزاد می‌کنن، بنابراین به تدریج مستعد ایجاد fragmentation هستن. بعضی دیگه از زباله‌روب‌ها به این موضوع فکر کردن. مثلاً سعی می‌کنن اشیاء زنده رو کنار هم قرار بدن تا حافظه از حالت fragmented خارج بشه!

زباله‌روب Compacting در مقابل non-Compacting
زباله‌روب Compacting در مقابل non-Compacting


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


بده بستان (Trade-off) در زباله‌روب‌ها کجاست؟

برای تصمیم‌گیری در مورد انتخاب زباله‌روب مناسب و میزون کردنش، لازمه که بدونیم با چه بده بستان‌هایی مواجهیم. در مورد زباله‌روب‌ها معمولا ۳ تا شاخص اصلی وجود داره:

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

توقف های طولانی در مقایسه با توقف های کوتاه برای کار یکسان
توقف های طولانی در مقایسه با توقف های کوتاه برای کار یکسان


توی شکل بالا، با اینکه در هر دو حالت کار یکسانی انجام شده اما در حالت دوم تأخیرهای طولانی‌تری وجود داره. در بسیاری از برنامه‌ها وجود چنین تاخیرهایی قابل تحمل نیست.

توان عملیاتی (Throughput): بیان‌گر اینه که چه نسبتی از زمان اجرا، صرف انجام کار زباله‌روبی نشده و در اختیار برنامه بوده. معمولاً Throughput رو باید در بازه‌های بلندمدت در نظر گرفت.

مثلاً فرض کنید یه برنامه ۲ تا هسته در اختیار داشته و ۲۴ ساعت در حال اجرا بوده (یعنی جمعا ۴۸ هسته*ساعت). در این بازه جمعا ۱ ساعت از هر هسته توسط زباله‌روب استفاده شده. بنابراین توان عملیاتی ما برابر 46/48 = 95.83 درصد می‌شه.

گاهی اوقات حاضریم به قیمت داشتن توقف‌های طولانی‌تر، در مجموع توان عملیاتی رو بیشینه کنیم.

اگه بخوام یه مثال خیلی خیلی ساده بزنم، فرض کنید الگوریتم زباله‌روب ما جوری باشه که در هر بار اجرا شدن یه کار ثابت رو باید انجام بده و بقیه‌ی کار هم متناسب با زباله‌هایی که تا الان تولید کردیم هزینه داشته باشه و چندان نگران محدودیت حافظه هم نباشیم، در این‌جا بهتره تا جای ممکن تعداد اجراهای زباله‌روب رو کم‌تر کنیم تا اون هزینه‌ی ثابت رو به میزان کمتری داده باشیم. در‌ واقع از اون هزینه‌ی ثابت فاکتور بگیریم.

توان عملیاتی بالاتر در ازای توقف‌های طولانی‌تر
توان عملیاتی بالاتر در ازای توقف‌های طولانی‌تر


دقت کنین که توی دو تا شکل بالا، میزان کار انجام شده در هر دو حالت برابره. اگر چه در حالت دوم توقف‌های طولانی‌تری داریم، اما کارمون زودتر تموم شده.

ردپای حافظه (Foot Print): میزان حافظه‌ای که اشغال می‌کنیم. بعضی مواقع ممکنه ترجیح بدیم حافظه‌ی بیشتری مصرف کنیم، اما در عوض توان عملیاتی بیشتر یا تأخیر کمتری داشته باشیم.

در چند قسمت بعد سراغ جاوا می‌ریم و زباله‌روب‌هایی که امروزه قابل استفاده هستن رو با هم بررسی می‌کنیم.




جاوابرنامه‌نویسیgarbage collectorمدیریت حافظه
شاید از این پست‌ها خوشتان بیاید