بنية JVM والأداء

أساسيات جمع المهملات

20 دقيقة الدرس 3 من 13

أساسيات جمع المهملات

يُعدّ جامع المهملات (GC) في Java أحد السمات المميِّزة للـ JVM. فهو يُحرِّر تلقائيًا الذاكرة المشغولة بالكائنات التي لم يعد البرنامج قادرًا على الوصول إليها، مما يُلغي فئات بأكملها من الأخطاء — مؤشرات معلّقة، وتحرير مزدوج — التي يعانيها مطوّرو C و C++. إنّ فهم كيفية اتخاذ GC لقراراته أمرٌ لا غنى عنه لكتابة تطبيقات Java عالية الإنتاجية ومنخفضة الزمن الكامن.

قابلية الوصول: ما الذي يتتبّعه GC فعليًا؟

لا يتتبّع GC كل كائن على حدة، بل يعمل في اتجاه عكسي من مجموعة نقاط بداية محدَّدة تُسمّى جذور GC، ويجتاز كل مرجع تستطيع تلك الجذور الوصول إليه بصورة مباشرة أو غير مباشرة. أيّ كائن يمكن الوصول إليه من جذر GC يُعدّ حيًّا ولا يجوز جمعه. وأيّ كائن لا يمكن الوصول إليه من أيّ جذر يُعدّ غير مُتاح ومؤهَّلًا للجمع.

يتعرَّف JVM على عدة أنواع من جذور GC:

  • المتغيرات المحلية والمعاملات على مكدّس استدعاء أي خيط نشط.
  • الحقول الساكنة للفئات المحمَّلة — تبقى حيّةً طالما الفئة محمَّلة.
  • الخيوط النشطة ذاتها (كائن Thread يكون دائمًا جذرًا أثناء تشغيله).
  • مراجع JNI — الكائنات التي يحتفظ بها الكود الأصلي عبر JNI.
  • أقفال المراقبة — الكائنات التي يحتفظ بها كتلة synchronized في الوقت الحالي.
قابلية الوصول تعمل بشكل تعدّي. إذا احتفظ جذر بمرجع للكائن A، واحتفظ A بمرجع للكائن B، فكلاهما حيٌّ — بصرف النظر عمّا إذا كان الكود سيستخدم B فعليًا. هذه بالضبط الطريقة التي تحدث بها تسرّبات الذاكرة: جذر منسيّ (خريطة ساكنة، مورد غير مُغلَق) يُبقي رسمًا بيانيًا كاملًا من الكائنات حيًّا.

لاحظ المثال التالي:

public class ReachabilityDemo { // حقل ساكن — جذر GC؛ كل ما يشير إليه يبقى حيًّا private static final List<String> CACHE = new ArrayList<>(); public static void main(String[] args) { // 'local' جذر GC (متغير مكدّس) طالما main() على مكدّس الاستدعاء String local = "hello"; String unreachable = new String("bye"); // مُخصَّص … unreachable = null; // … غير مُتاح الآن — مؤهَّل لـ GC CACHE.add("persistent"); // مُحتجَز عبر حقل ساكن — لن يُجمَع أبدًا } }

جمع المهملات الجيلي

تتبّع كل كائن في كل دورة GC مكلفٌ جدًا على الأكوام الكبيرة. يستغلّ JVM ملاحظةً تجريبيةً قويةً تُسمّى الفرضية الجيلية:

معظم الكائنات تموت وهي صغيرة.

تُظهر دراسة تطبيقات Java الحقيقية باستمرار أن الغالبية العظمى من الكائنات — نتائج مؤقتة، بيانات نطاق الطلب، كائنات تكرارية — تصبح غير متاحة في غضون ميلي ثانية من إنشائها. في المقابل، تبقى أقلية ضئيلة من الكائنات (التخزين المؤقت، وتجمّعات الاتصال، والحالة على مستوى الفئة) طوال عمر التطبيق. يستغلّ GC الجيلي هذه الحقيقة بتقسيم الكومة إلى مناطق وجمع المنطقة الشابة بتكرار أعلى بكثير من المنطقة القديمة.

تخطيط الكومة

تنقسم كومة JVM إلى منطقتين رئيسيتين:

  • الجيل الشاب (Eden + مساحتا Survivor S0 وS1): تُخصَّص هنا جميع الكائنات الجديدة. يعمل GC الشاب (المعروف بـ minor GC) بصورة متكررة ويجمع هذه المنطقة فقط — ولأن معظم كائناتها ميتة أصلًا، فهو سريع جدًا.
  • الجيل القديم (مساحة Tenured): تُرقَّى الكائنات التي تنجو من عدد كافٍ من دورات minor GC إلى الجيل القديم. يعمل GC القديم (المعروف بـ major أو full GC) بصورة نادرة وهو أكثر تكلفةً بكثير.
Metaspace (مُقدَّمة في Java 8 خلفًا لـ PermGen) تحتوي على بيانات وصفية للفئات — بايت كود التوابع، وتجمّعات الثوابت. تعيش في ذاكرة أصلية لا في كومة Java، ولا تُجمَع إلا عند إلغاء تحميل الفئة. نادرًا ما تكون مصدرًا للضغط على GC في التطبيقات النموذجية.

الجيل الشاب بالتفصيل

يستخدم الجيل الشاب خوارزمية النسخ (semi-space):

  1. التخصيص — تذهب الكائنات الجديدة إلى Eden باستخدام مُخصِّص bump-pointer البسيط (سريع للغاية).
  2. إشغال minor GC — عندما يمتلئ Eden، يبدأ minor GC وتتوقف جميع الخيوط (إيقاف العالم).
  3. تتبّع الحيّة — يتتبّع GC من جذور GC ويُحدِّد الكائنات الحيّة في Eden ومساحة Survivor النشطة.
  4. النسخ — تُنسخ الكائنات الحيّة إلى مساحة Survivor الأخرى (الفارغة). الكائنات الميتة تُترك — لا توجد عملية تحرير لكل كائن على حدة.
  5. الترقية — تُرقَّى الكائنات التي نجت من عدد قابل للضبط من دورات GC (عتبة الاستمرارية، 15 افتراضيًا لمعظم الجامعين) إلى الجيل القديم.
  6. التبديل — تتبادل مساحتا Survivor الأدوار؛ المساحة التي أُفرِغت للتو تصبح النشطة الجديدة.
// أعلام الضبط (للسياق فقط — لا تغيّرها دون بيانات من أداة التنميط) // -XX:NewRatio=2 → الجيل القديم ضعف حجم الجيل الشاب (الافتراضي) // -XX:SurvivorRatio=8 → Eden : كل Survivor = 8:1 (الافتراضي) // -XX:MaxTenuringThreshold=15 → ترقية بعد 15 نجاةً (الافتراضي)

الترقية والجيل القديم

تصل الكائنات إلى الجيل القديم بطريقتين:

  • تجاوزها عتبة الاستمرارية.
  • امتلاء مساحة Survivor بما لا يتّسع لها (الترقية المبكرة).

الترقية المبكرة مشكلة أداء شائعة: إذا كان تطبيقك يُنشئ أعدادًا كبيرة من الكائنات متوسطة العمر، تمتلئ مساحات Survivor بسرعة، وتُرقَّى الكائنات مبكرًا، ويمتلئ الجيل القديم أسرع مما ينبغي — مما يُشغِّل دورات major GC مكلفة.

توقّف الجميع (Stop-the-World)

يستلزم معظم عمل GC إيقافًا شاملًا (STW): تتوقف جميع خيوط التطبيق بينما يعمل GC، ثم تُستأنَف. فترات توقف minor GC عادةً بضع ميلي ثانية. أما فترات توقف major GC فقد تصل إلى مئات الميلي ثانية أو أكثر على الأكوام الكبيرة مع الجامعين القدامى.

معدّل التخصيص أهم من حجم الكومة. كومة أكبر تُؤخِّر GC لكنها لا تُقلِّل الحجم الكلي للعمل المنجَز. تطبيق يُخصِّص 1 غيغابايت/ثانية سيظل بحاجة لجمع 1 غيغابايت/ثانية — لكنه سيفعل ذلك في توقفات أكبر وأندر وأطول. تقليل التخصيص غير الضروري هو دائمًا الرافعة الأولى قبل ضبط أعلام GC.

قوّة المراجع وقابلية الجمع

تمتلك Java أربع درجات لقوّة المراجع تؤثر في أهلية الجمع:

  • مرجع قوي — مرجع Java عادي؛ الكائن لا يُجمَع أبدًا طالما هذا المرجع مُتاح.
  • SoftReference<T> — يُجمَع فقط حين يكون JVM على وشك رمي OutOfMemoryError. مفيد للتخزين المؤقت الحساس للذاكرة.
  • WeakReference<T> — يُجمَع في دورة GC التالية حين لا تبقى مراجع قوية أو ليّنة. يستخدمه WeakHashMap.
  • PhantomReference<T> — يُوضَع في القائمة بعد الإنهاء؛ يُستخدم للتنظيف بعد الاستخدام (يُفضَّل Cleaner في Java الحديثة).
import java.lang.ref.WeakReference; import java.util.WeakHashMap; public class ReferenceDemo { public static void main(String[] args) throws InterruptedException { // WeakHashMap: تُزال الإدخالات عند جمع المفتاح WeakHashMap<Object, String> cache = new WeakHashMap<>(); Object key = new Object(); cache.put(key, "value tied to key lifetime"); key = null; // المرجع القوي اختفى System.gc(); // اقتراح بالجمع (غير مضمون التنفيذ الفوري) Thread.sleep(100); // قد تكون الإدخالات أُزيلت — لا تعتمد على وجود قيم مرجع ضعيفة System.out.println("Cache size: " + cache.size()); // على الأرجح 0 } }

خلاصات عملية

يُمكِّنك فهم GC الجيلي وقابلية الوصول من التفكير في الأداء بدلًا من التخمين:

  • الكائنات التي تموت قبل minor GC التالي تكون "مجانية" عمليًا — فهي لا تلمس الجيل القديم أبدًا.
  • الكائنات طويلة العمر (المُفردات، التخزين المؤقت) ينبغي أن تكون طويلة العمر حقًا — تجنّب الأنماط التي تُرقِّي الكائنات ثم تُخلِّصها من الجيل القديم بصورة متكررة.
  • المجموعات الساكنة هي جذور GC؛ أي شيء يُضاف إليها يبقى حيًّا حتى تُمسَح المجموعة أو يُزال الإدخال.
  • استخدم WeakReference أو SoftReference لحالات التخزين المؤقت التي لا ينبغي أن تمنع الجمع.
  • راقب مخرجات -Xlog:gc* لرؤية التكرار الفعلي لـ minor/major GC وأوقات التوقف قبل ضبط أيّ أعلام.

الدرس التالي يمتدّ بهذا الأساس إلى خوارزميات GC المحددة المتاحة في JVM — Serial وParallel وG1 وZGC — ويوضح كيفية اختيار كل منها وضبطها وفق أنماط أعباء العمل المختلفة.