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

كيف تعمل JVM

25 دقيقة الدرس 1 من 13

كيف تعمل JVM

يعرف كل مطور Java شعار "اكتب مرة، نفّذ في كل مكان"، لكن ما الذي يجعل ذلك ممكنًا فعلًا؟ الـ Java Virtual Machine (JVM) هي المحرك الواقع بين شيفرتك المصدرية ونظام التشغيل المضيف. فهم آلياتها الداخلية — تحميل الكلاسات وتنفيذ البايت-كود ومناطق البيانات وقت التشغيل — يمكّنك من التفكير بثقة حول سلوك الإقلاع وضغط الذاكرة واللحظات التي ينبري فيها مترجم JIT للعمل.

من الشيفرة المصدرية إلى التنفيذ: الصورة الكاملة

الرحلة من ملف .java إلى تعليمات قابلة للتنفيذ تمر بثلاث مراحل متمايزة:

  1. التجميع — يُجمّع javac ملفات .java إلى ملفات .class تحتوي على بايت-كود، وهو مجموعة تعليمات مدمجة محايدة النظام.
  2. تحميل الكلاسات — في وقت التشغيل تقرأ JVM ملفات .class عند الطلب، وتتحقق منها، وتُعدّ تمثيلها في الذاكرة.
  3. التنفيذ — يُنفَّذ البايت-كود إما بالتفسير المباشر عبر المفسّر، أو بتجميله إلى كود أصلي للمعالج عبر مترجم Just-In-Time (JIT). (يُغطى JIT بالتفصيل في الدرس 5؛ نركّز هنا على المفسّر ومناطق البيانات.)
البايت-كود ليس كود آلة. يحتوي ملف .class على تعليمات مثل invokevirtual وiload_1 تعرّفها مواصفة JVM. هذه التعليمات لا معنى لها للمعالج حتى تُترجمها JVM — إما بتفسيرها أو بتجميلها إلى تعليمات أصلية.

نظام الفرعي لتحميل الكلاسات

يحدث تحميل الكلاسات بشكل كسول: تُحمَّل الكلاس في المرة الأولى التي يُشير إليها شيء ما. للنظام الفرعي ثلاث مراحل:

  1. التحميل (Loading) — يحدّد ClassLoader بيانات الكلاس الثنائية في .class (من نظام الملفات، أو JAR، أو الشبكة، أو كوداً مُولَّداً في وقت التشغيل) ويُنشئ كائن java.lang.Class في الـ Heap.
  2. الربط (Linking)
    • التحقق (Verification) — يتحقق مُتحقق البايت-كود من القيود الهيكلية: أنواع المُعاملات الصحيحة، وعدم نفاد المكدس، وصحة أهداف القفز. هذه هي البوابة الأمنية التي تمنع البايت-كود المشوّه من إفساد JVM.
    • الإعداد (Preparation) — تُخصَّص الحقول الستاتيكية وتُعطى قيمًا افتراضية (0 وnull وfalse). مُهيّئاتك لم تعمل بعد.
    • الحل (Resolution) — تُستبدل المراجع الرمزية (مثل "الكلاس com.example.Order") بمراجع ذاكرة مباشرة. قد يحدث الحل بشكل متحمّس أو كسول حسب تنفيذ JVM.
  3. التهيئة (Initialization) — يُنفَّذ مُهيّئ الكلاس <clinit>: تعمل المُهيّئات الستاتيكية وتعيينات الحقول الستاتيكية بالترتيب النصي. هنا فعلًا يُعيَّن static final String VERSION = "1.0";.

نموذج التفويض في ClassLoader

تُكوّن محمّلات الكلاسات (ClassLoaders) تسلسلًا هرميًا أب-ابن وتتبع نموذج التفويض: قبل تحميل كلاس بنفسها تسأل والدها. السلسلة القياسية هي:

  • Bootstrap ClassLoader — مدمج في JVM (كود أصلي)؛ يحمّل java.lang وjava.util وبقية وحدات JDK الأساسية.
  • Platform ClassLoader (Java 9+، يُعرف سابقًا بـ Extension) — يحمّل وحدات JDK الاختيارية.
  • Application ClassLoader — يحمّل الكلاسات من مسار الكلاسات الخاص بالتطبيق.
  • محمّلات مخصصة (Custom ClassLoaders) — تُضيف أطر عمل كـ OSGi وحاويات السيرفلت وأدوات إعادة التحميل الساخن طبقاتها الخاصة.
// فحص آباء ClassLoader في وقت التشغيل public class ClassLoaderDemo { public static void main(String[] args) { ClassLoader app = ClassLoaderDemo.class.getClassLoader(); System.out.println("App: " + app); // jdk.internal.loader.ClassLoaders$AppClassLoader System.out.println("Platform: " + app.getParent()); // jdk.internal.loader.ClassLoaders$PlatformClassLoader System.out.println("Bootstrap:" + app.getParent().getParent()); // null (أصلي، لا يمكن تمثيله) } }
تسرّبات ClassLoader مشكلة إنتاج حقيقية. في خوادم التطبيقات (Tomcat، JBoss) يحصل كل تطبيق مُنشر على محمّل كلاسات خاص به. إذا احتفظت مكتبة بمرجع لكلاس خاص بالتطبيق (مثل ThreadLocal ستاتيكية تحمل كائن كلاس)، فلن يستطيع ذلك المحمّل — وكل كلاس حمّله — التحرر بواسطة مجمّع البيانات المهملة بعد رفع نشر التطبيق. العَرَض هو OutOfMemoryError: Metaspace بعد عدة عمليات إعادة نشر ساخنة.

مناطق البيانات وقت التشغيل

تُعرّف مواصفة JVM ست مناطق للبيانات وقت التشغيل. معرفة أي بيانات تقع أين هي أساس كل تحليل للأداء والتسرّبات في الذاكرة.

1. سجل عداد البرنامج (PC Register)

لكل خيط سجل PC خاص به يحمل عنوان تعليمة البايت-كود المنفَّذة حاليًا. بالنسبة للمناهج الأصلية (native methods) يكون الـ PC غير معرّف. هذه إحدى أصغر المناطق وأكثرها خفاءً — لن تضبطها أبدًا — لكنها ما يجعل الخيوط المتزامنة ممكنة: كل خيط يعرف موقعه الخاص.

2. مكدس JVM (JVM Stack)

لكل خيط مكدس JVM خاص به. كل استدعاء دالة يدفع إطار مكدس (stack frame) عليه؛ يُسحب الإطار عند عودة الدالة أو رمي استثناء. يحتوي الإطار على:

  • مصفوفة المتغيرات المحلية — فتحات لجميع المتغيرات المحلية ومعاملات الدالة. تُخزَّن هنا الأوليّات ومراجع الكائنات (ليس الكائنات نفسها).
  • مكدس المُعاملات (Operand stack) — منطقة عمل يستخدمها مفسّر البايت-كود لتقييم التعبيرات (كأنه مجموعة سجلات المعالج لكن على شكل مكدس).
  • بيانات الإطار — مرجع إلى تجمّع الثوابت وقت التشغيل للكلاس، ومعلومات عنوان العودة.
// كل استدعاء لـ factorial() يدفع إطارًا على مكدس JVM. // تكرار عميق بدون حالة أساسية يُفيض المكدس → StackOverflowError. public static long factorial(int n) { if (n <= 1) return 1; return n * factorial(n - 1); // إطار جديد لكل استدعاء }

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

3. مكدس الأساليب الأصلية (Native Method Stack)

يدعم تنفيذ الأساليب الأصلية (C/C++). في HotSpot يندمج هذا عمليًا مع مكدس JVM.

4. الكومة (Heap)

الـ Heap مشتركة بين جميع الخيوط وتحمل كل كائن ومصفوفة أُنشئت على الإطلاق. هي الهدف الرئيسي لمجمّع البيانات المهملة (GC). تنقسم الـ Heap إلى أجيال (Young وOld/Tenured) بواسطة خوارزميات GC الجيلية — يُناقش هذا بعمق في الدرسين 3 و4.

حدّد حجم الـ Heap بتعمّد. استخدم -Xms (الحجم الابتدائي) و-Xmx (الحجم الأقصى). ضبطهما متساويين (-Xms4g -Xmx4g) يمنع توقف JVM لتوسيع الـ Heap في وقت التشغيل، وهو سبب شائع لارتفاع التأخير في خدمات الإنتاج.

5. منطقة الأساليب (Metaspace)

تخزّن منطقة الأساليب بيانات كل كلاس: تجمّع الثوابت وقت التشغيل، وبيانات وصفية للحقول والأساليب، والبايت-كود المُجمَّع. في HotSpot قبل Java 8 كانت تُسمّى PermGen وكانت تعيش داخل الـ Java Heap. استبدلت Java 8 هذه المنطقة بـ Metaspace التي تُخصَّص من الذاكرة الأصلية خارج الـ Heap.

تنمو Metaspace ديناميكيًا افتراضيًا. اضبط -XX:MaxMetaspaceSize لفرض سقف؛ بدونه يمكن لـ JVM أن تستهلك ذاكرة أصلية غير محدودة في الأنظمة التي تحمّل كلاسات كثيرة ديناميكيًا (ORMs ومحركات السكريبت ومُعالجات التعليقات التوضيحية).

6. تجمّع الثوابت وقت التشغيل (Runtime Constant Pool)

مجموعة فرعية لكل كلاس من منطقة الأساليب. يحمل الثوابت الرمزية والعددية من جدول constant_pool في ملف الكلاس — سلاسل الحرفية وأسماء الكلاسات وواصفات الحقول والأساليب. تُدرج (intern) سلاسل الحرفية هنا؛ لهذا يكون "hello" == "hello" صحيحًا، بينما new String("hello") == new String("hello") خاطئ.

// إدراج السلاسل وتجمّع الثوابت String a = "hello"; // من تجمّع الثوابت (مُدرجة) String b = "hello"; // نفس المرجع 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 — نفس المحتوى System.out.println(c.intern() == a); // true — intern() يُعيد مرجع التجمّع

الصورة مكتملة: تتبّع استدعاء دالة

عند استدعاء order.calculateTotal() تقوم JVM بـ:

  1. حل المرجع الرمزي calculateTotal في تجمّع الثوابت إلى مرجع دالة مباشر (إذا لم يُحل بعد).
  2. التحقق من تحميل كلاس order؛ وإن لم تكن محمّلة تُطلق عملية التحميل.
  3. دفع إطار مكدس جديد لـ calculateTotal على مكدس JVM للخيط المستدعي.
  4. تنفيذ تعليمات البايت-كود في مكدس مُعاملات الإطار، مع القراءة من مصفوفة المتغيرات المحلية والكتابة إليها.
  5. عند عودة الدالة، سحب الإطار ووضع القيمة المُعادة على مكدس مُعاملات المستدعي.
يمكنك فحص البايت-كود بنفسك. شغّل javap -c -p MyClass.class (أو javap -verbose لتجمّع الثوابت). هذا ذو قيمة بالغة عند تشخيص تكاليف Boxing والتخصيصات غير المتوقعة للكائنات وقرارات الـ inlining — مواضيع تُغطى في الدروس القادمة.

الخلاصة

تُحوّل JVM البايت-كود المحايد للمنصة إلى برامج قابلة للتنفيذ عبر تحميل الكلاسات (تحميل → ربط → تهيئة)، وتسلسل هرمي من محمّلات الكلاسات يعتمد التفويض، وست مناطق للبيانات وقت التشغيل. مكدس JVM خاص بكل خيط ومبني على الإطارات؛ الـ Heap مشتركة وتُديرها GC؛ وMetaspace تحمل بيانات وصفية للكلاسات في ذاكرة أصلية. هذا النموذج الذهني هو الشرط الأساسي لكل موضوع أداء يلي: سلوك GC وتجميل JIT وتسرّبات الذاكرة والتوصيف — كلها تبدو منطقية فقط حين تعرف أين تقع البيانات.