یکی از چیزهایی که برنامهنویسهای جاوا معمولا اون رو دست کم میگیرن، زبالهروبه (Garbage Collector). وقتی با توسعهدهندههای جاوا در مورد زبالهروب صحبت میکنم، روایتهای متنوع و گاها عجیبی در مورد چیستی و چرایی اون، انواعش، طرز کارش، اهمیتش، تنظیماتش و … میشنوم. توسعهدهندههای خیلی خفنتر هم با اینکه گاها اطلاعات دقیق و کاملی در مورد زبالهروب جاوا دارن، اما باز هم گاهی تاثیر اون رو دست کم میگیرن، یا تا وقتی که به مشکل جدی برخورد نکنن سراغش نمیرن. نهایتاً اگه خیلی وقت بگذارن، اندازه Xmx و Xms و ... رو تنظیم میکنن که اون هم معمولا غلط از آب در میآد.
اخیراً به مشکلاتی خوردم که ریشه در زبالهروب و تنظیماتش داشت. بنابراین کمی با زبالهروب دست و پنجه نرم کردم. در حین بررسی فهمیدم که برخی از دانستههایی که از گذشته داشتم دقیق نبودن یا دچار تغییر شدن. به همین بهونه، کمی بیشتر زبالهروب رو بررسی کردم.
در مورد زبالهروب مطالب خیلی زیادی در دسترس هست اما فکر کردم که نوشتن یه مطلب خلاصه میتونه برای همهی برنامهنویسهای جاوا مفید باشه هر چند ممکنه بخشهایی از این نوشته براتون تکراری باشه.
برنامهها در طول اجرا، بخشهایی از حافظه رو به خودشون اختصاص میدن و دادههای خودشون رو اونجا میگذارن. همینطور زمانی که دیگه به دادهای نیاز ندارن، لازمه که فضای اشغال شده توسط اون داده رو برای استفادهی دوبارهی خودشون یا برنامههای دیگه آزاد کنن. اینجا وقتی میگیم «حافظه»، منظورمون «Heap» هست و فعلاً کاری با بخشهای دیگهی حافظه، یا برنامههای خیلی خاص نداریم.
به طور کلی برای تخصیص، مدیریت و آزادسازی این فضاها دو روش متداول وجود داره، روش دستی و روش خودکار:
پس حواسمون هست که هر دو روش مزایا و معایبی دارن و هیچ کدوم از همهی جهات، بهتر از اون یکی نیست.
زبانهای برنامهنویسی، در زمان طراحی، از یک یا هر دوی این روشها استفاده کردن. مثلاً در C++/C روش اول روش پیشفرضه و در Java هم روش دوم استفاده شده.
(طبق معمول موارد تخیلیتری هم وجود داره. مثلاً در Ada با اینکه یه memory pool پیشفرض با مدیریت خودکار وجود داره اما برنامهنویس میتونه برای خودش memory poolهای مختلفی با مدیریت خودش داشته باشه. یا در بعضی زبانها با اینکه حافظه به صورت خودکار مدیریت میشه، خود برنامهنویس هم اجازه داره مستقیما اشیاء رو از حافظه پاک کنه.)
خب حالا برگردیم سر موضوع خودمون که «مدیریت حافظهی خودکار» توسط زبالهروب هست. زبالهروب باید فضاهای تخصیص داده شدهای که دیگه به اونها نیازی نیست بدون این که برنامهنویس نگرانشون باشه، پیدا و آزاد کنه. اصطلاحاً به دادههایی که در این فضاها قرار دارن زباله (garbage) گفته میشه. برای انجام این کار چندین راهبرد وجود داره اما بزارین قبل از بررسی اونها، چندتا اصطلاح رو با هم مرور کنیم:
شیء «ریشه» (Root): اشیائی در حافظه، که زبالهروب از قبل میدونه زباله نیستن.
چندتا مثال از اشیاء ریشه توی جاوا:
شیء «مرده»: اشیائی هستن که زنده نیستن! زبالهروب میتونه اشیاء مرده رو با خیال راحت پاک کنه.
ارجاع (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 هست که به طور خلاصه به این شکل کار میکنه که با شروع از اشیاء ریشه، یه الگوریتم پیمایش روی گراف ارجاعات، اجرا میشه و هر شیء که پیمایش شد علامتگذاری میشه. در پایان، یک بار دیگه حافظه مرور میشه و هر شیء که علامت نخورده باشه، به عنوان زباله شناخته میشه.
البته در این روش ساده هم مشکلاتی وجود داره، مثلاً اینکه در حین انجام این کار درخت ارجاعات نباید تغییر کنه. پس قبل از شروع، برنامه باید متوقف بشه و منتظر تمام شدن کار زبالهروب بمونه. در مورد راه حل این مشکل در قسمتهای بعدی صحبت خواهیم کرد.
وقتی زبالهروبهای مختلف رو با هم مقایسه میکنیم. با توجه به طراحی و هدفشون میشه در دستههای زیر قرارشون داد:
زبالهروب Concurrent (همروند) در مقابل Stop-The-World
یه زبالهروب همروند بدون اینکه روند اجرای برنامه رو متوقف کنه، کار خودش رو انجام میده. در حین کار زبالهروبی، ممکنه اشیاء و ارجاعات جدیدی ساخته بشن یا تغییر پیدا کنن، و حتی در این حین ممکنه یه سری زبالهی جدید هم ایجاد بشن، بنابراین زبالهروب همروند باید حواسش به این جور چالشها باشه.
در مقابل زبالهروب Stop-The-World برای انجام کارش، اجرای برنامه رو کاملا متوقف میکنه. بنابراین کار نسبتاً سادهتری انجام میده.
زبالهروب Serial در مقابل Parallel
زبالهروب سریال فقط در یک ریسمان (thread) اجرا میشه در حالی که زبالهروب موازی، از چندین ریسمان به طور همزمان استفاده میکنه.
نکتهای که اینجا به دردمون میخوره اینه که «همروند» و «موازی» با هم فرق دارن، هر چند گاهی به جای همدیگه استفاده میشن. پس:
- ممکنه یه زبالهروب نه همروند باشه نه موازی.
- ممکنه یه زبالهروب همروند (با برنامه) باشه اما موازی (یعنی در چند ریسمان) نباشه.
- ممکنه یه زبالهروب موازی باشه اما همروند نباشه.
- ممکنه یه زبالهروب هم موازی باشه هم همروند.
زبالهروب Incremental (افزایشی) در مقابل non-Incremental (یکجا)
زبالهروب Incremental کار زبالهروبی رو به صورت یکجا انجام نمیده. درواقع میتونه کل کار رو به قسمتهای کوچکتر تقسیم کنه و در بازههای زمانی متفاوت، به تدریج انجامشون بده. همینطور براساس هدفی که براش تعریف شده، ممکنه بعضی جاها صلاح بدونه اولویت کارها رو جابجا کنه.
زبالهروب Generational در مقابل non-Generational
یه فرضیه تجربی داریم که خلاصهی حرفش اینه: «عمدهی اشیاء، جوان میمیرند!» و «اشیائی که از یه مدت زمان خاص، بیشتر زنده بمونن، به احتمال زیاد، خیلی پیر میشن!»
بسیاری از زبالهروبها براساس این فرضیه، اشیاء یا بخشهای حافظه (یا خود اشياء) رو به نسلهای متفاوت تقسیم میکنن (مثلا جوان و پیر) و بعد با هر نسل با روشها و راهبردهای متفاوتی برخورد میکنن.
زبالهروب Compacting در مقابل non-Compacting
بعضی از زبالهروبها، صرفا فضای اشغال شده توسط اشیاء مرده رو آزاد میکنن، بنابراین به تدریج مستعد ایجاد fragmentation هستن. بعضی دیگه از زبالهروبها به این موضوع فکر کردن. مثلاً سعی میکنن اشیاء زنده رو کنار هم قرار بدن تا حافظه از حالت fragmented خارج بشه!
در ادامه خواهیم دید که بیشتر زبالهروبهایی که امروزه به کار میرن معمولاً کل کار رو به مراحل مختلفی تقسیم میکنن و هر کدوم از این مراحل میتونه ترکیبی از ویژگیهای بالا رو داشته باشه.
برای تصمیمگیری در مورد انتخاب زبالهروب مناسب و میزون کردنش، لازمه که بدونیم با چه بده بستانهایی مواجهیم. در مورد زبالهروبها معمولا ۳ تا شاخص اصلی وجود داره:
تأخیر (Latency): حداکثر مدت زمانی که اجرای برنامه، به خاطر انجام کار زبالهروب متوقف بشه و اصطلاحاً برنامه پاسخگو (Responsive) نباشه. بعضی مواقع دوست نداریم برنامه توقفهای طولانی مدت داشته باشه، در عوض حاضریم از چیزهای دیگه مثل توان عملیاتی هزینه کنیم.
توی شکل بالا، با اینکه در هر دو حالت کار یکسانی انجام شده اما در حالت دوم تأخیرهای طولانیتری وجود داره. در بسیاری از برنامهها وجود چنین تاخیرهایی قابل تحمل نیست.
توان عملیاتی (Throughput): بیانگر اینه که چه نسبتی از زمان اجرا، صرف انجام کار زبالهروبی نشده و در اختیار برنامه بوده. معمولاً Throughput رو باید در بازههای بلندمدت در نظر گرفت.
مثلاً فرض کنید یه برنامه ۲ تا هسته در اختیار داشته و ۲۴ ساعت در حال اجرا بوده (یعنی جمعا ۴۸ هسته*ساعت). در این بازه جمعا ۱ ساعت از هر هسته توسط زبالهروب استفاده شده. بنابراین توان عملیاتی ما برابر 46/48 = 95.83 درصد میشه.
گاهی اوقات حاضریم به قیمت داشتن توقفهای طولانیتر، در مجموع توان عملیاتی رو بیشینه کنیم.
اگه بخوام یه مثال خیلی خیلی ساده بزنم، فرض کنید الگوریتم زبالهروب ما جوری باشه که در هر بار اجرا شدن یه کار ثابت رو باید انجام بده و بقیهی کار هم متناسب با زبالههایی که تا الان تولید کردیم هزینه داشته باشه و چندان نگران محدودیت حافظه هم نباشیم، در اینجا بهتره تا جای ممکن تعداد اجراهای زبالهروب رو کمتر کنیم تا اون هزینهی ثابت رو به میزان کمتری داده باشیم. در واقع از اون هزینهی ثابت فاکتور بگیریم.
دقت کنین که توی دو تا شکل بالا، میزان کار انجام شده در هر دو حالت برابره. اگر چه در حالت دوم توقفهای طولانیتری داریم، اما کارمون زودتر تموم شده.
ردپای حافظه (Foot Print): میزان حافظهای که اشغال میکنیم. بعضی مواقع ممکنه ترجیح بدیم حافظهی بیشتری مصرف کنیم، اما در عوض توان عملیاتی بیشتر یا تأخیر کمتری داشته باشیم.
در چند قسمت بعد سراغ جاوا میریم و زبالهروبهایی که امروزه قابل استفاده هستن رو با هم بررسی میکنیم.