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

نموذج ذاكرة Java

15 دقيقة الدرس 2 من 13

نموذج ذاكرة Java

كل بايت يخصّصه برنامجك في Java يقع في مكان محدد — في الـ Heap، أو على الـ Stack، أو في Metaspace. معرفة أين بالضبط تعيش كل فئة من البيانات، ولماذا تعيش هناك، وماذا يحدث حين تمتلئ تلك المناطق، هي معرفة أساسية لكتابة Java عالية الأداء منخفضة الضغط على جامع المهملات. هذا الدرس يمنحك تلك الخريطة.

الصورة الكاملة: مناطق البيانات أثناء التشغيل

تُعرِّف مواصفات JVM عدة مناطق بيانات أثناء التشغيل (runtime data areas). من منظور الأداء اليومي، ثلاثة منها تهم أكثر من غيرها:

  • الـ Heap — حيث تعيش جميع نسخ الكائنات والمصفوفات.
  • الـ JVM Stack (واحد لكل خيط تنفيذ) — حيث تعيش إطارات التوابع والمتغيرات المحلية ومكدسات المعاملات.
  • Metaspace (استبدل PermGen في Java 8) — حيث تعيش بيانات وصف الفئات (class metadata).

تُكمل منطقتان أصغر الصورة: سجل PC (عداد البرنامج لكل خيط) ومكدسات التوابع الأصلية (Native Method Stacks) لكود JNI. نادرًا ما يُضبَط هاتان المنطقتان مباشرة.

مناطق الذاكرة ليست مستقلة. تطبيق خادم بـ 32 خيط تنفيذ يحتوي على 32 مكدسًا تعمل بالتزامن، كلها تشترك في Heap واحد. ذاكرة المكدس تُستهلك خطيًا مع عدد الخيوط؛ ضغط الـ Heap يتراكم من كل خيط يخصّص كائنات. يجب تحجيم كليهما معًا.

الـ Heap

الـ Heap هو المخصّص المركزي لجميع كائنات Java. حين تكتب new ArrayList<>()، يستقر الكائن الناتج على الـ Heap — ويبقى هناك حتى يُثبت جامع المهملات أنه غير قابل للوصول.

يُقسّم JVM داخل الـ Heap المساحة إلى أجيال (لجامعي مهملات كـ G1 وParallel GC) أو مناطق (regions) (G1) / شرائح (segments) (ZGC، Shenandoah). النموذج الكلاسيكي الذي لا يزال يُنظَّم به التفكير يتضمن ثلاث مناطق:

  • الجيل الشاب (Young Generation: Eden + فضاءا Survivor) — حيث يُخصَّص كل كائن جديد في البداية. تعمل Minor GC هنا بتكرار؛ معظم الكائنات تموت شابة (وهو ما يُعرف بـ"فرضية الأجيال").
  • الجيل القديم (Old Generation / Tenured) — تُرقَّى إليه الكائنات التي تنجو من دورات كافية من Minor GC. تستعيد منه Major GC (أو Full GC) المساحة.

أعلام الـ Heap الأساسية (مثال مع G1):

# ضبط حجم الـ Heap الابتدائي والأقصى (احرص على تساويهما في بيئة الإنتاج لتجنب توقفات إعادة الحجم) java -Xms4g -Xmx4g -XX:+UseG1GC -jar app.jar # فحص استخدام الـ Heap الحالي في وقت التشغيل عبر JMX أو: jcmd <pid> VM.native_memory summary
اضبط -Xms مساوية لـ -Xmx في بيئة الإنتاج. إن اختلف الحجمان الابتدائي والأقصى، يضطر JVM لإعادة تحجيم الـ Heap أثناء التشغيل، مما يستلزم توقفًا كاملًا (stop-the-world). تثبيتهما عند نفس القيمة يُلغي توقفات إعادة الحجم ويمنع نظام التشغيل من استرداد الذاكرة وإعادة منحها تحت الضغط.

تخطيط الكائن في الـ Heap

فهم كيفية تخطيط الكائنات في الذاكرة يُفسّر لماذا تُخصّص بعض الأنماط أكثر مما يبدو. كل كائن على الـ Heap يحتوي على رأس (header) (12 بايت عادةً عند تفعيل OOPs المضغوطة، أو 16 بايت بدونها) يليه حقول النسخة، مع حشو للمحاذاة. هذا يعني أن فئة بحقل byte واحد تشغل 16 بايت على الأقل.

// تبدو صغيرة — لكنها تشغل ~16 بايت على الـ Heap (رأس + حقل + حشو) public class Flag { private boolean active; } // المصفوفات تحمل رأسًا أيضًا بالإضافة لتخزين العناصر // int[1000] = ~16 بايت رأس + 4000 بايت = ~4016 بايت int[] counters = new int[1000];

أداة JOL (Java Object Layout) تطبع التخطيط الدقيق أثناء التشغيل:

// أضف jol-core لبنائك، ثم: System.out.println(ClassLayout.parseInstance(new Flag()).toPrintable());

هذا مهم عند تصميم هياكل بيانات صديقة للذاكرة المؤقتة، أو عند تتبع انتفاخ ظاهري في الـ Heap ناجم عن تكلفة الرأس في ملايين الكائنات الصغيرة.

الـ JVM Stack

كل خيط تنفيذ يمتلك JVM Stack خاصًا به، يُنشأ عند بدء الخيط ويُتلف عند انتهائه. المكدس مؤلف من إطارات (frames): يُدفع إطار لكل استدعاء تابع ويُسحب عند عودة ذلك التابع أو إلقاء استثناء.

يحتوي الإطار على:

  • مصفوفة المتغيرات المحلية — تحمل معاملات التابع والمتغيرات المُعلَنة. الأنواع الأولية (int، long، double إلخ) تُخزَّن مباشرةً كقيم هنا.
  • مكدس المعاملات (Operand Stack) — مساحة العمل لعمليات البايت-كود.
  • مرجع إلى مجمع الثوابت أثناء التشغيل — روابط رمزية تُحلّ إلى عناوين ذاكرة ملموسة.
public static long sumRange(int start, int end) { // 'start' و'end' و'sum' تعيش في مصفوفة المتغيرات المحلية لهذا الإطار // إنها على المكدس — لا تخصيص على الـ Heap أبدًا long sum = 0; for (int i = start; i <= end; i++) { sum += i; } return sum; }

الانعكاس الحاسم: الأنواع الأولية في المتغيرات المحلية لا تلمس الـ Heap. المراجع (references) فقط تُخزَّن في مصفوفة المتغيرات المحلية — الكائن الذي تشير إليه يعيش على الـ Heap، لكن المرجع ذاته (مؤشر 4 أو 8 بايت) يجلس على المكدس.

حجم المكدس يُتحكّم به لكل خيط بـ -Xss (الافتراضي عادةً 512 كيلوبايت – 1 ميغابايت). التعاود العميق يُنمّي المكدس حتى يُلقى StackOverflowError. كثرة الخيوط تعني مزيدًا من إجمالي ذاكرة المكدس حتى لو كانت الخيوط خاملة.

إنشاء آلاف الخيوط مكلف، وليس فقط من حيث جدولة المعالج. مع حجم مكدس افتراضي 512 كيلوبايت، يستهلك 2000 خيط 1 غيغابايت من الذاكرة الأصلية للمكدسات وحدها، قبل أي تخصيص على الـ Heap. تحل الخيوط الافتراضية (Project Loom، متاحة عمومًا في Java 21) هذه المشكلة بمنح كل خيط افتراضي مكدسًا صغيرًا قابلًا للنمو تديره JVM بدلًا من نظام التشغيل.

Metaspace

قبل Java 8، كانت بيانات وصف الفئات تُخزَّن في الجيل الدائم (PermGen) — منطقة ذات حجم ثابت داخل الـ Heap. كانت مصدرًا شهيرًا لـ OutOfMemoryError: PermGen space في التطبيقات التي تُحمّل فئات كثيرة. استبدلتها Java 8 بـ Metaspace.

تُخزّن Metaspace:

  • هياكل الفئات (واصفات الحقول، واصفات التوابع، vtable)
  • بايت-كود التوابع ومؤشرات الكود الأصلي المُجمَّع بـ JIT
  • مدخلات مجمع الثوابت أثناء التشغيل
  • التعليقات التوضيحية (Annotations)

الفرق الجوهري عن PermGen: تعيش Metaspace في الذاكرة الأصلية (native memory) خارج الـ Heap وتنمو تلقائيًا بالافتراضي. يُلغي هذا مشكلة الحجم الثابت القديمة، لكنه يُدخل مشكلة جديدة: تطبيق يعاني من تسرب في ClassLoader سيستهلك الذاكرة الأصلية بصمت حتى ينفد مخزون نظام التشغيل.

# تحديد سقف لـ Metaspace لمنع الاستهلاك الجامح للذاكرة الأصلية java -XX:MaxMetaspaceSize=256m -jar app.jar # مراقبة استخدام Metaspace jstat -gcmetacapacity <pid>

تُستعاد Metaspace حين يصبح ClassLoader الفئة نفسه غير قابل للوصول. في تطبيق Spring Boot العادي نادرًا ما يحدث هذا (ClassLoader واحد لعمر العملية). في OSGi وJakarta EE مع النشر الحار والأطر التي تُولّد فئات وكيلة (Hibernate، ByteBuddy)، تسربات ClassLoader حقيقية وستستنفد Metaspace مع الوقت.

مجمع السلاسل النصية ومجمع الثوابت

السلاسل النصية الحرفية تُضاف إلى مجمع السلاسل (String pool) — جدول تديره JVM لكائنات String غير مكررة مُخزَّنة على الـ Heap (منذ Java 7). حرفيان متطابقان يشتركان في نفس كائن الـ Heap:

String a = "hello"; // مُضافة إلى مجمع السلاسل String b = "hello"; // نفس مرجع 'a' String c = new String("hello"); // يُجبر إنشاء كائن Heap جديد — يتجاوز المجمع System.out.println(a == b); // true — نفس الكائن المجمَّع System.out.println(a == c); // false — 'c' كائن Heap مستقل System.out.println(a.equals(c)); // true — نفس المحتوى

استخدام new String(…) صريحًا خطأ في كل الأحوال تقريبًا. يتجاوز المجمع ويهدر الـ Heap، وهو السبب الحقيقي لقاعدة "قارن السلاسل بـ equals() لا بـ ==".

تحليل الهروب والتخصيص على المكدس

يُجري مُجمِّع JIT تحليل الهروب (escape analysis): إن أثبت أن كائنًا لا "يهرب" من التابع المنشئ (لا يُخزَّن في حقل، ولا يُمرَّر لخيط آخر، ولا يُعاد)، قد يُخصّصه على المكدس أو يُلغي التخصيص كليًا (scalar replacement).

// قد يُلغي JIT تخصيص 'Point' كليًا إن لم يهرب public static double distance(double x1, double y1, double x2, double y2) { // 'dx' و'dy' محليتان — يمكن لـ JIT تخصيصهما على المكدس أو تضمينهما // لا حاجة لتخصيص على الـ Heap double dx = x2 - x1; double dy = y2 - y1; return Math.sqrt(dx * dx + dy * dy); }

لهذا السبب، حلقات داخلية مُحكمة تُنشئ كائنات مساعدة صغيرة كثيرًا ما تؤدي أفضل مما يُوحي التحليل الساذج — قد يُزيل JIT التخصيص كليًا. القياس بـ JMH (درس 6) هو الطريقة الموثوقة الوحيدة للتحقق من هذا السلوك.

استراتيجية عملية لتحجيم الذاكرة

  1. القياس أولًا — استخدم jmap -histo أو VisualVM أو async-profiler لتحديد ما هو موجود فعلًا على الـ Heap قبل ضبط الأعلام.
  2. حجّم الـ Heap انطلاقًا من مجموعة البيانات الحية — قِس المجموعة الحية بعد Full GC؛ استهدف 2–3 أضعافها كحد أقصى للـ Heap ليتنفس جامع المهملات.
  3. ضع سقفًا لـ Metaspace — اضبط دائمًا -XX:MaxMetaspaceSize في الإنتاج لاكتشاف تسربات ClassLoader مبكرًا.
  4. احسب الذاكرة الأصلية — ذاكرة المكدس + Metaspace + المخازن المؤقتة المباشرة + كاش كود JIT كلها تعيش خارج الـ Heap. RSS الكلي للـ JVM = Heap + أصلية. لا تدع قاتل OOM في الحاوية يكتشف هذا على حسابك.
نشر الحاويات يُضيف تعقيدًا. قبل Java 10، كان JVM يقرأ /proc/meminfo للذاكرة الكلية للجهاز ويضبط -Xmx افتراضيًا على 25% منها — متجاهلًا حدود ذاكرة الحاوية. Java 10+ تُراعي حدود cgroup عبر -XX:+UseContainerSupport (مُفعَّلة افتراضيًا منذ Java 11). تحقق دائمًا أن JVM في حاويتك يقرأ الحد الصحيح بـ java -XX:+PrintFlagsFinal -version | grep MaxHeapSize.

الخلاصة

الـ Heap هو موطن جميع نسخ الكائنات؛ هيكله الجيلي يُتيح جمعًا فعّالًا للمهملات. مكدس الخيط يحمل الإطارات والأنواع الأولية والمراجع — دون لمس الـ Heap لأنواع القيم. Metaspace استبدلت PermGen وتُخزّن بيانات وصف الفئات في الذاكرة الأصلية التي يجب تحديد سقف لها لاكتشاف التسربات. فهم أين تعيش البيانات يُمكّنك من التنبؤ بضغط التخصيص، وتحجيم مناطق الذاكرة بشكل صحيح، وتفسير سجلات جامع المهملات بذكاء — مهارات ستطبّقها طوال بقية هذا البرنامج التعليمي.