بعد أسبوعين في فريق خدمة الطلبات أصبحت تعرف كيف تبني المشروع وتشغل الاختبارات، كما شرحنا في المقال الأول من مرحلة Java، ثم وصلت إلى الفريق ثلاث مشكلات في نفس الأسبوع. الأولى تقرير من الدعم الفني بأن خصم «اشتر 200 قطعة واحصل على هدية» لا يعمل، والسبب سطر يقارن كميتين بالعلامة ==، ويعمل في الاختبار بكمية 100 ويفشل عند 200. والثانية أن خدمة إنشاء الإيصالات التي تعمل في Container بذاكرة 512 ميجابايت تتوقف كل بضعة أيام برسالة OutOfMemoryError، وعندما يعيد الفريق تشغيلها تعمل من جديد، فلا أحد يعرف أين ذهبت الذاكرة في لغة فيها جامع قمامة. والثالثة أن مطوراً جديداً كتب دالة تقرأ ملف أسعار ولم يمسك الاستثناء، فرفض المترجم الكود، فأضاف catch (Exception e) {} فارغة حتى يمر البناء، وصار الملف المفقود يمر بصمت.
والمشكلات الثلاث لها جذر واحد: كل واحدة منها تأتي من شيء تفعله Java بطريقة تختلف عن Python وJavaScript، في الأنواع، وفي إدارة الذاكرة، وفي الأخطاء. وهذا المقال من مرحلة لغة Java للمطورين، وسوف نشرح فيه الأجزاء الثلاثة بتجربة حقيقية على JDK 25: نقيس كم يكلف مليون رقم في الذاكرة بأربعة أشكال، ونشغل نفس البرنامج بأربعة جوامع قمامة، ونرى ماذا تختار الـ JVM من نفسها داخل Container صغير، ونصنع تسرباً في الذاكرة ونكتشفه ونصلحه، ثم نرمي كل نوع من الاستثناءات ونقرأ رسالته. والمفاهيم العامة لإدارة الذاكرة بين اللغات شرحناها في إدارة الذاكرة يدوياً أم Garbage Collector أم Ownership، وطرق الأخطاء بين اللغات في كيف تتعامل لغات البرمجة مع الأخطاء، فلن نكررها، وسوف نركز هنا على ما يخص Java.
وسوف نناقش في هذا المقال ما يلي:
- نظام الأنواع في Java: ثابت وقوي واسمي، والفرق بين الأنواع البدائية والمرجعية، وفخ
Integerالذي سبب خطأ الخصم. - الأعداد والنصوص: التفاف
int، والأعداد العشرية، وBigDecimal، وطول النص العربي في UTF-16. - الأنواع العامة Generics ومحو الأنواع، والسجلات Records والأنماط.
- الذاكرة: الكومة Heap وجامع القمامة، وقياس حجم الكائنات، وسجل الجامع، وأربعة جوامع على نفس البرنامج.
- ما تختاره الـ JVM داخل Container بذاكرة 512 ميجابايت، والمفاجأة التي ظهرت في التجربة.
- تسرب الذاكرة رغم وجود الجامع، وكيف تكتشفه بـ
jcmdوكيف تصلحه. - الاستثناءات المفحوصة وغير المفحوصة، ورسائل
NullPointerException، وإغلاق الموارد، وOptional.
نظام الأنواع Type System في Java: ثابت وقوي واسمي
إذا أردت أن تصف نظام الأنواع في Java في سطر واحد فهو ثابت Static وقوي Strong واسمي Nominal. ثابت لأن كل متغير له نوع يعرفه المترجم قبل التشغيل ولا يتغير، وقوي لأن Java لا تحول القيم بين الأنواع من نفسها إلا في حالات محدودة وآمنة مثل تحويل int إلى long، فلا يوجد ما يشبه جمع نص ورقم في JavaScript ليعطي نصاً دون أن تقصد ذلك. واسمي لأن نوعين لا يتوافقان إلا إذا قلت ذلك صراحة باسم الواجهة أو الـ Class، فلو كان لديك كائنان لهما نفس الحقول تماماً، فإن Java تعتبرهما نوعين مختلفين، بخلاف TypeScript التي تقارن الشكل وليس الاسم، كما شرحنا في TypeScript للمطورين. وقد شرحنا هذه التقسيمات بشكل عام في أنماط البرمجة وأنظمة الأنواع.
والنتيجة العملية لهذا النظام أن كثيراً من الأخطاء التي تظهر في Python وقت التشغيل يمسكها المترجم في Java قبل أن يعمل الكود، مثل استدعاء دالة غير موجودة، أو تمرير نص لدالة تنتظر رقماً. ولكن النظام له فجوات معروفة، أهمها القيمة null التي يمكن أن تأخذ مكان أي نوع مرجعي، ومحو الأنواع في Generics، والتحويل التلقائي بين int وInteger. وهذه الفجوات بالضبط هي التي سوف نزورها في التجربة، لأن أغلب الأخطاء التي تصل إلى الإنتاج في كود Java تمر منها.
الأنواع البدائية Primitive Types والمرجعية Reference Types
في Java ثمانية أنواع بدائية: byte وshort وint وlong للأعداد الصحيحة، وfloat وdouble للأعداد العشرية، وchar للحرف، وboolean. والمتغير من هذه الأنواع يحمل القيمة نفسها، فإذا كان داخل دالة فإنه يعيش في المكدس Stack ويختفي عندما تنتهي الدالة. وكل ما عدا ذلك نوع مرجعي، أي أن المتغير يحمل عنواناً يشير إلى كائن في الكومة Heap، والكائن نفسه يبقى حتى لا يشير إليه أحد فيحذفه جامع القمامة. ولكل نوع بدائي «غلاف» Wrapper مرجعي، مثل Integer لـ int وLong لـ long، لأن المجموعات Collections والأنواع العامة Generics لا تقبل إلا الأنواع المرجعية، والمترجم يحول بينهما تلقائياً فيما يسمى Autoboxing وUnboxing.
والتجربة التالية من الملف Types.java تبين أين يصبح هذا التحويل فخاً، وهي نفس مشكلة الخصم التي بدأنا بها:
int a = 127, b = 127;
Integer x = 127, y = 127, p = 128, q = 128;
System.out.println("int 127 == 127: " + (a == b));
System.out.println("Integer 127 == 127: " + (x == y));
System.out.println("Integer 128 == 128: " + (p == q));
System.out.println("Integer 128 equals: " + p.equals(q));والمخرج سوف يكون كما يلي:
int 127 == 127: true
Integer 127 == 127: true
Integer 128 == 128: false
Integer 128 equals: true
== بين كائنين تقارن المرجع: نفس الكائن للقيمة 127 من الـ Cache، وكائنان مختلفان للقيمة 128.في المخرج أعلاه لاحظ التالي:
- السطر الأول طبيعي:
==بين قيمتين بدائيتين تقارن القيمة. - السطر الثاني أعطى
trueبالصدفة، والسبب أن التحويل التلقائي يستدعي Integer.valueOf، وهذه الدالة تحفظ كائنات جاهزة للقيم من -128 إلى 127 وترجع نفس الكائن في كل مرة، فـxوyيشيران إلى نفس الكائن. - السطر الثالث هو الخطأ: 128 خارج الـ Cache، فأنشأت
valueOfكائنين مختلفين، و==بين كائنين تقارن العنوانين وليس القيمة، فأعطتfalse. - السطر الرابع هو الطريقة الصحيحة:
equalsتقارن القيمة.
وهذا يفسر خطأ الخصم بالضبط: الكمية كانت محفوظة في Map<String, Integer>، والمقارنة بـ == مرت في الاختبار لأن الكمية فيه 100، ثم فشلت عند 200. والقاعدة التي تتجنب بها هذا الخطأ واضحة: استخدم == للأنواع البدائية فقط، وequals لكل كائن، ومنها String وInteger وLong، وإذا أردت مقارنة رقمية آمنة مع القيم الفارغة فاستخدم Objects.equals(a, b). وفحص Error Prone الذي أضفناه في المقال الأول ينبه إلى هذا النمط باسم BoxedPrimitiveEquality.
الأعداد Numbers: الالتفاف Overflow والأعداد العشرية Floating Point
الجزء التالي من نفس الملف يجمع ثلاث مفاجآت مع الأعداد:
System.out.println("0.1 + 0.2 = " + (0.1 + 0.2));
System.out.println("BigDecimal: " + new java.math.BigDecimal("0.1").add(new java.math.BigDecimal("0.2")));
long overflow = Integer.MAX_VALUE + 1; // int arithmetic first, then widened
long ok = Integer.MAX_VALUE + 1L;
System.out.println("int overflow widened: " + overflow + ", long math: " + ok);0.1 + 0.2 = 0.30000000000000004
BigDecimal: 0.3
int overflow widened: -2147483648, long math: 2147483648والسطر الأول ليس خاصاً بـ Java، فالنوع double يتبع معيار IEEE 754 مثل JavaScript وPython، ولا يستطيع تمثيل 0.1 بدقة لأنها في النظام الثنائي عدد لا ينتهي. ولذلك لا تحفظ المبالغ المالية في double إطلاقاً، وأمامك خياران: الأعداد الصحيحة بأصغر وحدة كما نفعل في هذه المرحلة بالهللات، أو BigDecimal التي تحفظ العدد العشري بدقة كاملة كما يبين السطر الثاني، ولكن بثمن في السرعة والذاكرة، ومع شرط أن تنشئها من نص "0.1" وليس من 0.1، لأن الثاني يحمل الخطأ معه من البداية.
أما السطر الثالث فهو الفخ الأخبث: المتغير overflow من نوع long ويستطيع حمل القيمة 2147483648، ومع ذلك جاءت سالبة. والسبب أن Java تحسب الطرف الأيمن أولاً بنوع معاملاته، وInteger.MAX_VALUE و1 كلاهما int، فالتف الجمع داخل int ثم وسعت النتيجة الخاطئة إلى long. ويكفي أن يكون أحد المعاملين long مثل 1L حتى يتم الحساب كله بـ 64 بت. وهذا تفصيل تحدده مواصفة اللغة في فصل التحويلات، ويهمك في حساب مجموع المبيعات أو ضرب السعر في الكمية لطلبيات الجملة.
النصوص String وترميز UTF-16
النص في Java كائن غير قابل للتعديل Immutable، وكل عملية عليه تنشئ نصاً جديداً، وداخلياً يحفظ كسلسلة من وحدات UTF-16، وهذا له أثر على النصوص العربية والرموز التعبيرية. وفي التجربة كتبنا كلمة «قهوة» ورمز عربة التسوق:
length of the Arabic word: 4, cart emoji length: 2, code points: 1والكلمة العربية طولها 4 كما تتوقع، لأن كل حرف عربي وحدة UTF-16 واحدة. ولكن رمز العربة طوله 2 رغم أنه رمز واحد على الشاشة، لأنه خارج المستوى الأساسي فيمثل بوحدتين متلازمتين Surrogate Pair، والدالة codePointCount هي التي تعطيك العدد الحقيقي 1. وهذا يهمك عندما تقص اسم منتج أو تعليق عميل لعدد محدد من الحروف، فالقص بـ substring قد يقطع الرمز من نصفه. ومنذ Java 18 صار UTF-8 هو الترميز الافتراضي لقراءة الملفات وكتابتها، فلم تعد تحتاج -Dfile.encoding كما كانت الفرق تفعل على Windows، ولكن الترميز الداخلي للنص في الذاكرة لم يتغير.
الأنواع العامة Generics ومحو الأنواع Type Erasure
عندما تكتب List<String> فأنت تخبر المترجم أن القائمة فيها نصوص فقط، والمترجم يمنعك من إضافة رقم ويضيف التحويل المطلوب عند القراءة. ولكن هذه المعلومة تحذف بعد الترجمة، فالـ JVM لا ترى إلا ArrayList فيها كائنات من نوع Object، وهذا ما يسمى محو الأنواع Type Erasure، وسببه تاريخي: عندما أضيفت Generics في Java 5 عام 2004 أراد المصممون أن يعمل الكود الجديد مع المكتبات القديمة المترجمة دون تغيير. والتجربة التالية تبين نتيجة ذلك:
List<String> names = new ArrayList<>();
List<Integer> qtys = new ArrayList<>();
System.out.println("same class at runtime: " + (names.getClass() == qtys.getClass()) + " -> " + names.getClass().getName());
List raw = names; // raw type: the compiler only warns
raw.add(42); // an Integer slips into a List<String>
System.out.println("size after raw add: " + names.size());
try {
String first = names.get(0); // the cast the compiler inserted fails here, far from the bug
System.out.println(first);
} catch (ClassCastException e) {
System.out.println("ClassCastException: " + e.getMessage());
}same class at runtime: true -> java.util.ArrayList
size after raw add: 1
ClassCastException: class java.lang.Integer cannot be cast to class java.lang.String (java.lang.Integer and java.lang.String are in module java.base of loader 'bootstrap')
وعند تشغيل الكود سوف تجد أن الإضافة نجحت دون أي اعتراض، فأصبح في قائمة النصوص رقم، والاستثناء لم يظهر إلا عند القراءة، في السطر الذي أضاف فيه المترجم تحويلاً خفياً إلى String. وفي مشروع حقيقي قد يكون سطر الإضافة في مكتبة قديمة وسطر القراءة في كودك بعد ساعات، فتقرأ الـ Stack Trace وتجد نفسك في مكان لا علاقة له بالخطأ. والقاعدة: لا تستخدم الأنواع الخام Raw Types مثل List دون نوع، ولا تتجاهل تحذير unchecked ولا تخفيه بـ @SuppressWarnings دون سبب، فهو المكان الوحيد الذي يخبرك فيه المترجم أن الضمان انتهى. وأثر آخر للمحو سوف تقابله في الإطارات: لا تستطيع أن تكتب new T() أو instanceof List<String>، ولهذا تطلب مكتبات مثل Jackson أن تمرر لها نوع الـ Class صراحة عندما تحول JSON إلى قائمة.
السجلات Records والأنماط Pattern Matching
آخر جزء في Types.java يستخدم سجلاً ونمطاً معاً:
var prod = new Product(1, "coffee", 990);
Object o = prod;
if (o instanceof Product(int id, String name, int price) && price > 500) {
System.out.println("pattern: " + name + " costs " + price);
}
System.out.println("records equal by value: " + prod.equals(new Product(1, "coffee", 990)));pattern: coffee costs 990
records equal by value: trueوالسطر الذي فيه instanceof يفحص النوع ويفكك السجل إلى حقوله في خطوة واحدة، وقديماً كان هذا يحتاج فحصاً ثم تحويلاً يدوياً ثم استدعاء ثلاث دوال. والسجل يقارن بالقيمة لأن equals فيه مولدة من كل الحقول، وهذا ما تريده في كائنات البيانات، بخلاف الـ Class العادية التي تقارن بالمرجع حتى تكتب equals بنفسك. ولاحظ أن السجل نوع مرجعي، أي أن كل Product كائن في الكومة له رأس وعنوان، وهذا ما سوف نقيسه الآن. ويعمل مشروع Valhalla في OpenJDK منذ سنوات على أنواع قيمة Value Classes تحفظ متجاورة دون رأس، ولكنها حتى JDK 25 ليست جزءاً من اللغة.
من يدير الذاكرة؟ الكومة Heap وجامع القمامة Garbage Collector
في Java لا توجد free ولا delete، فأنت تنشئ الكائنات بـ new، والـ JVM تحذفها عندما لا يصل إليها أي مرجع من «الجذور» Roots، وهي متغيرات الدوال التي تعمل الآن والمتغيرات من نوع static وغيرها. والجامع يعمل على ملاحظة بسيطة تسمى فرضية الأجيال Generational Hypothesis: أغلب الكائنات تموت صغيرة، مثل كائن الطلب الذي ينشأ لمعالجة طلب HTTP ثم لا يحتاجه أحد. ولذلك تقسم الكومة إلى جيل صغير Young Generation تنشأ فيه الكائنات وينظف كثيراً وبسرعة، وجيل قديم Old Generation تنتقل إليه الكائنات التي عاشت عدة دورات. والتنظيف في الجيل الصغير رخيص لأنه ينسخ الأحياء القليلة فقط ويترك الباقي، فإذا كان برنامجك ينشئ ملايين الكائنات القصيرة فهذا لا يكلفك كثيراً، أما الكائنات التي تعيش طويلاً ثم تموت فهي الأغلى.
وثمن هذه الراحة يظهر في ثلاثة أماكن: التوقف Pause عندما يحتاج الجامع أن يوقف خيوط برنامجك لحظة، والمعالج Processor الذي يستخدمه الجامع للعمل، والذاكرة الإضافية لأن الجامع يحتاج مساحة فارغة ليعمل بكفاءة. وكل جامع في الـ JVM يوازن بين هذه الثلاثة بطريقة مختلفة، وسوف نراها بالأرقام بعد قليل. ولكن قبل ذلك نحتاج أن نعرف كم يكلف الكائن نفسه.
وقد يتساءل البعض: إذا كان كل new يحجز ذاكرة، فلماذا لا يصبح البرنامج بطيئاً مع ملايين الكائنات كما يحدث مع malloc في C؟ والإجابة أن الحجز في الـ JVM رخيص جداً في الحالة العادية، والسبب أن كل خيط يأخذ قطعة خاصة به من الجيل الصغير تسمى Thread-Local Allocation Buffer واختصاراً TLAB، وحجز كائن جديد داخلها لا يحتاج أكثر من تحريك مؤشر بعدد البايتات المطلوبة، دون أقفال ودون بحث عن مكان فارغ. وعندما تمتلئ القطعة يأخذ الخيط قطعة جديدة، وعندما يمتلئ الجيل الصغير كله يعمل الجامع وينسخ الأحياء القليلة إلى مكان آخر ويعتبر الباقي فارغاً دفعة واحدة. ولهذا فإن تكلفة الكائن في Java لا تأتي من إنشائه، وإنما من بقائه: الكائن الذي يعيش طويلاً ينسخ عدة مرات ثم ينتقل إلى الجيل القديم الذي ينظف بطريقة أغلى، وهذا سبب أن الـ Cache الكبير والتسرب هما ما يتعب الجامع فعلاً، وليس كثرة الكائنات المؤقتة.
كم يكلف الكائن؟ قياس حجم الذاكرة Memory Footprint
لنفرض أن خدمة الأسعار تحمل مليون سعر في الذاكرة. والبرنامج Footprint.java ينشئ نفس المليون رقم بأربعة أشكال، ويقيس الذاكرة المستخدمة قبل كل شكل وبعده مع تشغيل الجامع، وهي طريقة تقريبية ولكنها تكفي لرؤية الفرق:
measure("int[]", () -> { int[] a = new int[n]; for (int i = 0; i < n; i++) a[i] = 1000 + i; return a; });
measure("ArrayList<Integer>", () -> { var l = new ArrayList<Integer>(n); for (int i = 0; i < n; i++) l.add(1000 + i); return l; });
measure("ArrayList<Price>", () -> { var l = new ArrayList<Price>(n); for (int i = 0; i < n; i++) l.add(new Price(1000 + i)); return l; });
measure("HashMap<Integer,Integer>", () -> { var m = new HashMap<Integer, Integer>(); for (int i = 0; i < n; i++) m.put(1000 + i, 1000 + i); return m; });وشغلناه مرتين، مرة بالإعداد الافتراضي ومرة مع رؤوس الكائنات المضغوطة Compact Object Headers من JEP 519، وهي ميزة صارت جزءاً نهائياً من JDK 25 ولكنها غير مفعلة افتراضياً:
## default headers
flags: [-Xmx512m, --add-modules=ALL-DEFAULT]
int[] 3.4 MB ( 3.4 bytes per item)
ArrayList<Integer> 20.0 MB ( 20.0 bytes per item)
ArrayList<Price> 20.0 MB ( 20.0 bytes per item)
HashMap<Integer,Integer> 72.4 MB ( 72.4 bytes per item)
## compact object headers (JEP 519)
flags: [-Xmx512m, -XX:+UseCompactObjectHeaders, --add-modules=ALL-DEFAULT]
int[] 3.6 MB ( 3.6 bytes per item)
ArrayList<Integer> 20.0 MB ( 20.0 bytes per item)
ArrayList<Price> 20.0 MB ( 20.0 bytes per item)
HashMap<Integer,Integer> 64.4 MB ( 64.4 bytes per item)
في المخرج أعلاه لاحظ التالي:
- المصفوفة البدائية تحتاج 4 بايت لكل رقم نظرياً، والقياس أعطى 3.4 و3.6، والفرق من دقة الطريقة لأن الجامع قد ينظف أشياء أخرى بين القياسين، فلا تعتمد على الرقم العشري.
- القائمة من
Integerتحتاج 20 بايت: 4 للمرجع داخل مصفوفة القائمة، لأن الـ JVM تضغط المراجع إلى 32 بت عندما تكون الكومة أصغر من 32 جيجابايت، و16 للكائن نفسه، وهي 12 بايت للرأس Header و4 للقيمة. أي أن الشكل نفسه كلف خمسة أضعاف المصفوفة. - السجل
Priceبحقل واحد كلف بالضبط ما كلفهInteger، فالسجل كائن عادي في الذاكرة ولا يوجد سحر. - القاموس هو الأغلى: لكل عنصر كائن
HashMap$Nodeفيه المفتاح والقيمة والهاش ومؤشر التالي، ثم كائنانIntegerللمفتاح والقيمة، ثم مكان في مصفوفة القاموس. - الرؤوس المضغوطة تجعل الرأس 8 بايت بدل 12، ولم يتغير شيء في
Integerلأن الكائن يقرب إلى مضاعف 8 بايت فيبقى 16، أما الـ Node في القاموس فنزل من 32 إلى 24 بايت، فوفرنا 8 بايت لكل عنصر، أي 11% من ذاكرة القاموس دون أي تغيير في الكود.
والدرس العملي: إذا كانت خدمتك تحمل ملايين الأرقام في الذاكرة، مثل جدول أسعار أو مؤشر بحث، فالشكل الذي تختاره يغير الذاكرة خمسة أضعاف أو أكثر، والمصفوفات البدائية أو المكتبات المتخصصة في المجموعات البدائية هي الحل. أما الكود العادي الذي يعالج طلباً ثم ينتهي فلا تقلق فيه من Integer، لأن هذه الكائنات تموت صغيرة كما سوف ترى.
قراءة سجل الجامع GC Log سطراً بسطر
البرنامج Churn.java يمثل خدمة طلبات: يحمل في الذاكرة Cache بحجم 60 ميجابايت يبقى طوال الوقت، ثم ينشئ 3 ملايين طلب قصير العمر، في كل طلب ثلاثة أسطر ونص. وشغلناه أولاً مع الخيار -Xlog:gc الذي يطبع سطراً لكل دورة جمع، وهذه أول الأسطر:
[0.042s][info][gc] Using G1
[0.404s][info][gc] GC(0) Pause Young (Concurrent Start) (G1 Humongous Allocation) 6M->6M(18M) 25.830ms
[0.404s][info][gc] GC(1) Concurrent Undo Cycle
[0.417s][info][gc] GC(1) Concurrent Undo Cycle 12.813ms
[0.421s][info][gc] GC(2) Pause Young (Concurrent Start) (G1 Humongous Allocation) 12M->12M(18M) 0.886ms
[0.421s][info][gc] GC(3) Concurrent Mark Cycle
[0.465s][info][gc] GC(3) Pause Remark 17M->17M(32M) 0.863ms
[0.537s][info][gc] GC(3) Pause Cleanup 37M->37M(40M) 0.053ms
[0.544s][info][gc] GC(3) Concurrent Mark Cycle 123.243ms
[0.749s][info][gc] GC(4) Pause Young (Prepare Mixed) (G1 Evacuation Pause) 62M->61M(65M) 1.511ms
[0.772s][info][gc] GC(5) Pause Young (Mixed) (G1 Evacuation Pause) 63M->61M(67M) 2.229ms
[0.806s][info][gc] GC(6) Pause Young (Concurrent Start) (G1 Evacuation Pause) 64M->61M(67M) 6.456msوكل سطر يقرأ من اليسار: الوقت منذ بدء الـ JVM، ثم رقم الدورة، ثم نوعها وسببها، ثم الكومة المستخدمة قبل الدورة وبعدها وحجمها الكلي، ثم مدة التوقف. وفي هذه الأسطر لاحظ التالي:
- السطر الأول يؤكد أن الجامع G1، وهو الافتراضي على جهاز بمعالجين وذاكرة كافية، وسوف ترى بعد قليل متى لا يكون الافتراضي.
- الدورات الأولى سببها
G1 Humongous Allocation، أي كائنات ضخمة، وهي مصفوفات الـ Cache بحجم 1 ميجابايت لكل واحدة، وG1 يعامل كل كائن أكبر من نصف المنطقة Region كحالة خاصة، ولذلك ترى6M->6Mلأن هذه الكائنات حية ولا يمكن حذفها. - الدورة 3 دورة تعليم متزامنة Concurrent Mark استغرقت 123 ملي ثانية، ولكنها تعمل بجانب البرنامج، والتوقف الفعلي فيها كان في
Pause RemarkوPause Cleanup، أي أقل من ملي ثانية لكل منهما. - بعد أن امتلأ الـ Cache استقرت الكومة حول 61 ميجابايت بعد كل دورة، وهذا هو حجم ما يبقى حياً، والدورات الصغيرة تنظف الطلبات القصيرة في 1.5 إلى 6.5 ملي ثانية.
والرقم الذي يهمك في خدمة تستقبل طلبات هو مدة التوقف، لأن الطلب الذي يصل أثناء التوقف ينتظر، أما الرقم بعد السهم فيخبرك كم تحتاج خدمتك فعلاً من الذاكرة، وهو أساس اختيار -Xmx.
أربعة جوامع Garbage Collectors على نفس البرنامج
في JDK 25 أربعة جوامع رئيسية تختار بينها بخيار واحد، ويشرحها دليل ضبط الجامع من Oracle. وشغلنا نفس البرنامج بكل جامع ثلاث مرات، بكومة 256 ميجابايت ومعالجين، وطبع البرنامج في النهاية عدد الدورات ووقتها كما تراها الـ JVM نفسها:
## -XX:+UseSerialGC
elapsed 963 ms, checksum 34477888890, cache 60 MB
Copy: 65 runs, 176 ms; MarkSweepCompact: 0 runs, 0 ms;
elapsed 1060 ms, checksum 34477888890, cache 60 MB
elapsed 673 ms, checksum 34477888890, cache 60 MB
## -XX:+UseParallelGC
elapsed 650 ms, checksum 34477888890, cache 60 MB
PS MarkSweep: 0 runs, 0 ms; PS Scavenge: 16 runs, 545 ms;
elapsed 599 ms, checksum 34477888890, cache 60 MB
elapsed 947 ms, checksum 34477888890, cache 60 MB
## -XX:+UseG1GC
elapsed 1155 ms, checksum 34477888890, cache 60 MB
G1 Young Generation: 20 runs, 220 ms; G1 Concurrent GC: 10 runs, 19 ms; G1 Old Generation: 0 runs, 0 ms;
elapsed 1762 ms, checksum 34477888890, cache 60 MB
elapsed 707 ms, checksum 34477888890, cache 60 MB
## -XX:+UseZGC
elapsed 1064 ms, checksum 34477888890, cache 60 MB
ZGC Minor Cycles: 17 runs, 250 ms; ZGC Minor Pauses: 54 runs, 0 ms; ZGC Major Cycles: 6 runs, 823 ms; ZGC Major Pauses: 27 runs, 0 ms;
elapsed 815 ms, checksum 34477888890, cache 60 MB
elapsed 1098 ms, checksum 34477888890, cache 60 MB
وقد يتساءل البعض: أي جامع هو الأسرع إذن؟ والإجابة الصادقة أن هذه التجربة لا تستطيع أن تقول، فالجامع G1 مثلاً أخذ 707 ملي ثانية في مرة و1762 في أخرى، والفرق سببه الجهاز الذي كانت تعمل عليه برامج أخرى في نفس الوقت، وليس الجامع. وما تستطيع التجربة أن تقوله أهم من ذلك: 3 ملايين طلب قصير العمر لم تكلف أي جامع أكثر من ثانيتين، وصفر دورات في الجيل القديم مع Serial وParallel وG1، أي أن الكائنات القصيرة رخيصة فعلاً كما تقول فرضية الأجيال. ولاحظ أيضاً أن الأرقام لا تقرأ بنفس المعنى: في Serial وParallel كل ملي ثانية في السطر هي توقف لبرنامجك، أما في ZGC فالـ 823 ملي ثانية في Major Cycles عمل يجري بجانب البرنامج على خيوط أخرى، وزمن التوقف الفعلي في Pauses صفر ملي ثانية تقريباً، وهذا ما صمم له ZGC: توقف أقل من ملي ثانية حتى مع كومة بعشرات الجيجابايت، بثمن معالج أكثر.
والقاعدة العملية لأغلب الخدمات: ابدأ بـ G1، فهو الافتراضي ومتوازن، وانتقل إلى ZGC إذا كان زمن الاستجابة p99 حساساً والكومة كبيرة، وإلى Parallel لمهام الدفعات التي يهمها مجموع الإنتاجية Throughput لا زمن كل طلب. ولا تختر الجامع من مقال أو من قياس على جهازك المحمول، وإنما من قياس على الخدمة نفسها بحجم الطلبات الحقيقي.
ماذا يحدث إذا شغلت Java في Container بذاكرة 512 ميجابايت؟
نعود إلى خدمة الإيصالات التي تتوقف في الإنتاج. أول سؤال يجب أن تسأله: كم أعطت الـ JVM نفسها من الذاكرة، وأي جامع اختارت؟ والخيار -XX:+PrintFlagsFinal يطبع القيم التي قررتها الـ JVM فعلاً، فشغلناه في Containers بحدود مختلفة:
### heap-default memory=none
size_t MaxHeapSize = 8401190912 {product} {ergonomic}
double MaxRAMPercentage = 25.000000 {product} {default}
bool UseG1GC = true {product} {ergonomic}
bool UseSerialGC = false {product} {default}
### heap-default memory=512m
size_t MaxHeapSize = 134217728 {product} {ergonomic}
double MaxRAMPercentage = 25.000000 {product} {default}
bool UseG1GC = false {product} {default}
bool UseSerialGC = true {product} {ergonomic}وهنا ظهرت المفاجأة التي لم نتوقعها في التجربة: في Container بذاكرة 512 ميجابايت لم تختر الـ JVM الجامع G1 الذي يقرأ عنه الجميع، وإنما Serial، وأعطت الكومة 128 ميجابايت فقط من الـ 512. وكلمة {ergonomic} بجانب القيمة تعني أن الـ JVM قررتها بنفسها من موارد الجهاز، وهذا ما تسميه Oracle الضبط التلقائي Ergonomics. وحتى نفهم القاعدة جربنا ثلاث حالات أخرى:
### docker run --cpus 2 --memory 512m
size_t MaxHeapSize = 134217728 {product} {ergonomic}
bool UseG1GC = false {product} {default}
bool UseSerialGC = true {product} {ergonomic}
### docker run --cpus 2 --memory 2g
size_t MaxHeapSize = 536870912 {product} {ergonomic}
bool UseG1GC = true {product} {ergonomic}
### docker run --cpus 1 --memory 2g
size_t MaxHeapSize = 536870912 {product} {ergonomic}
bool UseG1GC = false {product} {default}
bool UseSerialGC = true {product} {ergonomic}
والقاعدة كما يكتبها دليل Oracle: تعتبر الـ JVM الجهاز «خادماً» Server-Class إذا وجدت معالجين أو أكثر وذاكرة 1792 ميجابايت أو أكثر، وعندها تختار G1، وإلا تختار Serial. والـ JVM الحديثة تقرأ الحدود من الـ Container نفسه عبر cgroups وليس من الجهاز الكامل، وهذا جيد، ولكن النتيجة أن خدمة في Container صغير تعمل بجامع يوقف كل شيء بخيط واحد، وبكومة ربع الذاكرة، والباقي يذهب إلى ذاكرة لا تستخدمها الكومة: مساحة الـ Metaspace للـ Classes، والكود المترجم بـ JIT، ومكدس كل خيط، والمخازن المؤقتة للشبكة. ولذلك عندما رفعنا النسبة جاءت النتيجة كما يلي:
size_t MaxHeapSize = 402653184 {product} {ergonomic}أي 384 ميجابايت مع -XX:MaxRAMPercentage=75. والإعداد المناسب لأغلب الخدمات في Containers هو أن تحدد الجامع والنسبة صراحة، مثل -XX:+UseG1GC -XX:MaxRAMPercentage=75، وألا تعطي الكومة كل الذاكرة، لأن الـ Container يقتل العملية بـ OOMKilled إذا تجاوز المجموع الحد، وهذا خطأ لا تطبع فيه Java أي رسالة لأن النظام هو الذي قتلها. وسوف نطبق هذا الإعداد على خدمة الطلبات الحقيقية في مقال الخدمة من هذه المرحلة، ونقيس ذاكرتها وهي في حد 512 ميجابايت.
تسرب الذاكرة Memory Leak رغم وجود الجامع
الجامع يحذف ما لا يصل إليه أحد، ولكنه لا يعرف ما «لم تعد تحتاجه»، فإذا بقي مرجع إلى كائن لن تستخدمه أبداً فهو حي في نظر الجامع. وأشهر مصدر لهذا في الخدمات قاموس من نوع static يستخدم كـ Cache ولا يحذف منه شيء، وهذا ما يفعله Leak.java: لكل طلب إيصال بحجم 100 كيلوبايت يحفظ في القاموس بمفتاح رقم الطلب:
static final Map<String, byte[]> CACHE = new HashMap<>();
static byte[] render(String orderId) {
return CACHE.computeIfAbsent(orderId, k -> new byte[100_000]); // a 100 KB receipt per order
}وشغلناه بكومة 64 ميجابايت حتى يظهر الخطأ بسرعة:
exit code 1
orders cached: 400, heap used MB: 45
orders cached: 500, heap used MB: 55
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at Leak.lambda$render$0(Leak.java:9)
at Leak$$Lambda/0x0000000035160210.apply(Unknown Source)
at java.base/java.util.HashMap.computeIfAbsent(HashMap.java:1229)وبعد حوالي 500 طلب امتلأت الكومة وتوقف البرنامج. ولاحظ أن الـ Stack Trace يشير إلى السطر الذي طلب الذاكرة الأخيرة، وهو ليس بالضرورة سبب المشكلة، فقد يكون السطر البريء الذي جاء دوره عندما امتلأت الكومة بسبب غيره. ولهذا لا تبحث عن التسرب في الـ Stack Trace، وإنما في محتوى الكومة نفسها. والأداة jcmd تطبع لك ملخصاً بعدد الكائنات وحجمها لكل Class من برنامج يعمل، فشغلنا نفس البرنامج بكومة 2 جيجابايت وطلبنا الملخص بعد ثلاث ثوان:
num #instances #bytes class name (module)
-------------------------------------------------------
1: 23760 749149408 [B ([email protected])
2: 1293 35378288 [Ljdk.internal.vm.FillerElement; ([email protected])
3: 16198 388752 java.lang.String ([email protected])
4: 8497 271904 java.util.HashMap$Node ([email protected])
5: 1697 224600 java.lang.Class ([email protected])والسطر الأول يقول كل شيء: مصفوفات البايت [B تأخذ 749 ميجابايت، ومعها آلاف من HashMap$Node وString، وهذا شكل قاموس مفاتيحه نصوص وقيمه مصفوفات بايت ويكبر. وإذا شغلت الأمر مرتين بفاصل دقيقة ورأيت نفس الـ Class تكبر دون أن تنزل، فقد وجدت المشتبه به. وللتحقيق الأعمق تأخذ تفريغاً كاملاً للكومة jcmd <pid> GC.heap_dump وتفتحه في أداة مثل Eclipse MAT لترى من يمسك هذه الكائنات، كما يشرح دليل Oracle لتشخيص التسرب.
والإصلاح ليس زيادة -Xmx، فهذا يؤخر التوقف من أيام إلى أسابيع فقط، وإنما أن تضع للـ Cache حداً. والملف LeakFixed.java يستخدم LinkedHashMap بترتيب الاستخدام ويحذف الأقدم عندما يتجاوز 200 عنصر:
static final Map<String, byte[]> CACHE = new LinkedHashMap<>(256, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry<String, byte[]> eldest) {
return size() > MAX;
}
};orders served: 0, cached: 1, heap used MB: 8
orders served: 5000, cached: 200, heap used MB: 36
orders served: 10000, cached: 200, heap used MB: 25
orders served: 15000, cached: 200, heap used MB: 44
orders served: 20000, cached: 200, heap used MB: 32
ونفس الكومة 64 ميجابايت خدمت الآن 20,000 طلب، والذاكرة المستخدمة تصعد وتنزل بين 25 و44 ميجابايت لأن كل إيصال يخرج من القاموس يصبح قمامة يجمعها الجامع. وفي خدمة حقيقية استخدم مكتبة Cache مثل Caffeine التي تدعم الحد بالحجم وبالوقت وتعطيك إحصاءات، ولكن الفكرة واحدة: كل Cache يجب أن يكون له حد، وكل قائمة static تكبر مع الطلبات مشتبه بها حتى يثبت العكس.
الاستثناءات Exceptions: طريقة Java في الأخطاء
Java تبلغ عن الأخطاء بالاستثناءات، مثل Python وC#، وليس بقيم راجعة مثل Go، ولكنها تختلف عن كل اللغات الكبيرة في نقطة واحدة أثارت جدلاً طويلاً: الاستثناءات المفحوصة Checked Exceptions، وقد شرحنا هذا الجدل وكيف قررت كل لغة في مقال الأخطاء. والصورة التالية تبين شجرة الاستثناءات، وأين يقع كل استثناء رميناه في التجربة:

المفحوصة Checked وغير المفحوصة Unchecked
الاستثناء المفحوص هو كل ما يرث من Exception دون RuntimeException، مثل IOException، والمترجم يجبر كل دالة تستدعي دالة ترميه أن تمسكه أو تعلنه في توقيعها بـ throws، كما تحدد مواصفة اللغة في فصل الاستثناءات. والملف Unchecked.java يستدعي دالة ترمي IOException دون أن يفعل أياً من الاثنين:
static void load() throws java.io.IOException {
throw new java.io.IOException("prices file missing");
}
public static void main(String[] args) {
load();
}Unchecked.java:8: error: unreported exception IOException; must be caught or declared to be thrown
load();
^
1 errorوهذا ما واجهه المطور في المشكلة الثالثة، وحله بـ catch (Exception e) {} فارغة، وهي أسوأ الطرق على الإطلاق، لأنها تمسك كل شيء حتى الأخطاء البرمجية، وتخفيه. والطرق الصحيحة ثلاث: أن تعلن الاستثناء وتتركه يصعد إلى من يستطيع أن يقرر، مثل طبقة الـ API التي تحوله إلى رد خطأ للعميل، أو أن تمسكه حيث تستطيع أن تفعل شيئاً حقيقياً، مثل استخدام أسعار افتراضية مع تسجيل تحذير في السجلات Logs، أو أن تغلفه في استثناء غير مفحوص يحمل السبب الأصلي، مثل throw new UncheckedIOException(e)، إذا كانت الدالة في مكان لا يسمح بالإعلان، مثل Lambda داخل Stream.
وفي Errors.java عرفنا استثناءً مفحوصاً خاصاً بالمتجر هو OutOfStock، ورمينا معه استثناء غير مفحوص من تحويل نص إلى رقم:
caught checked: dates: asked 5, left 2
unchecked: java.lang.NumberFormatException: For input string: "9.90"والسطر الثاني يبين أن Integer.parseInt لا تقبل رقماً فيه فاصلة عشرية، وهذا خطأ يظهر عندما ترسل الواجهة السعر بالريال بدل الهللات، وهو غير مفحوص لأن المترجم لا يستطيع أن يعرف هل النص رقم صحيح أم لا. والقاعدة التي تتبعها أغلب المكتبات الحديثة، ومنها Spring: الأخطاء التي يتوقعها المستدعي ويستطيع التعامل معها بشكل معقول قد تكون مفحوصة، وأخطاء البرمجة والمدخلات غير الصحيحة غير مفحوصة، وأغلب الكود الجديد يميل إلى غير المفحوصة مع توثيقها.
رسائل NullPointerException المفيدة Helpful NPE Messages
القيمة null هي أكبر فجوة في نظام الأنواع، لأن أي متغير مرجعي يمكن أن يحملها، والمترجم لا يمنعك من استدعاء دالة عليها. وفي التجربة أنشأنا عميلاً دون عنوان ثم طلبنا طول اسم المدينة:
var c = new Customer("Sara", null);
System.out.println(c.address().city().length());Exception in thread "main" java.lang.NullPointerException: Cannot invoke "Errors$Address.city()" because the return value of "Errors$Customer.address()" is null
at Errors.main(Errors.java:51)وقبل Java 14 كانت الرسالة NullPointerException مع رقم السطر فقط، وفي سطر فيه ثلاث استدعاءات متتالية لا تعرف أيها كان null. ومنذ JEP 358 صارت الرسالة تقول بالضبط: لم نستطع استدعاء city() لأن ما رجع من address() كان null. وهذه الرسائل مفعلة افتراضياً، فإذا رأيت في سجلات خدمة قديمة رسالة دون هذا التفصيل فهي تعمل على Java قديمة. ولاحظ أن Errors$Address هو الاسم الداخلي للسجل Address المعرف داخل Errors.
قراءة الـ Stack Trace لملف مفقود
عندما يرمى استثناء ولا يمسكه أحد تطبع الـ JVM سلسلة الاستدعاءات Stack Trace، وهذا ما ظهر عندما حاولنا قراءة ملف أسعار غير موجود:
Exception in thread "main" java.nio.file.NoSuchFileException: /lab/missing-prices.csv
at java.base/sun.nio.fs.UnixException.translateToIOException(UnixException.java:92)
at java.base/sun.nio.fs.UnixException.rethrowAsIOException(UnixException.java:106)
at java.base/sun.nio.fs.UnixException.rethrowAsIOException(UnixException.java:111)
at java.base/sun.nio.fs.UnixFileSystemProvider.newFileChannel(UnixFileSystemProvider.java:213)
at java.base/sun.nio.fs.UnixFileSystemProvider.newByteChannel(UnixFileSystemProvider.java:244)
at java.base/java.nio.file.Files.newByteChannel(Files.java:357)
at java.base/java.nio.file.Files.newByteChannel(Files.java:399)
at java.base/java.nio.file.Files.readAllBytes(Files.java:2973)
at java.base/java.nio.file.Files.readString(Files.java:3043)
at java.base/java.nio.file.Files.readString(Files.java:3006)
at Errors.main(Errors.java:54)والسطر الأول هو الأهم: نوع الاستثناء ورسالته، وهنا اسم الملف كاملاً. ثم الأسطر من الأعلى إلى الأسفل من أعمق استدعاء إلى أول استدعاء، وكل سطر يبدأ بـ java.base/ هو كود الـ JDK نفسه، فتخطاها وابحث عن أول سطر من كودك، وهو الأخير هنا Errors.java:54. وفي خدمة Spring سوف تجد عشرات الأسطر من الإطار بين السطر الأول وكودك، والقاعدة نفسها، وقد شرحناها بالتفصيل في قراءة رسالة الخطأ. وإذا كان الاستثناء مغلفاً فستجد في الأسفل Caused by:، وهو غالباً السبب الحقيقي.
إغلاق الموارد try-with-resources والاستثناءات المكبوتة Suppressed
كل مورد يجب أن يغلق، مثل الملف واتصال قاعدة البيانات واتصال الشبكة، وإذا رمى الكود استثناء قبل الإغلاق فقد يبقى المورد مفتوحاً، وبعد ساعات تنفد اتصالات قاعدة البيانات في الخدمة. والصيغة try (var r = ...) تغلق المورد دائماً عند الخروج، وفي Errors.java جعلنا الطابعة ترمي استثناء أثناء الكتابة ثم استثناء آخر أثناء الإغلاق:
try (var r = new Receipt()) {
r.write("order 42");
} catch (RuntimeException e) {
System.out.println("main: " + e.getMessage());
for (Throwable s : e.getSuppressed()) System.out.println("suppressed: " + s.getMessage());
}main: printer jam while writing order 42
suppressed: java.io.IOException: printer offline on closeوالمهم في المخرج أن الاستثناء الأصلي هو الذي وصل، وخطأ الإغلاق لم يضع وإنما أضيف إليه كاستثناء مكبوت Suppressed. وفي الطريقة القديمة بـ finally كان خطأ الإغلاق يستبدل الخطأ الأصلي، فتقرأ في السجلات «الطابعة غير متصلة» ويضيع السبب الحقيقي. ولذلك استخدم try-with-resources لكل ما ينفذ AutoCloseable، ولا تكتب close() بنفسك في finally.
القيمة الغائبة Optional بدل null
آخر سطر في Errors.java يبحث عن منتج غير موجود:
Map<Integer, String> names = Map.of(1, "coffee");
Optional<String> n = Optional.ofNullable(names.get(7));
System.out.println("optional: " + n.map(String::toUpperCase).orElse("unknown product"));optional: unknown productوالدالة get في القاموس ترجع null عندما لا يوجد المفتاح، وOptional تحولها إلى صندوق قد يكون فارغاً، فتستطيع أن تحول القيمة إن وجدت وتعطي بديلاً إن لم توجد، دون أن يرمي أي سطر NullPointerException. واستخدم Optional كقيمة راجعة من الدوال التي قد لا تجد نتيجة، مثل البحث عن طلب برقمه، ولا تستخدم get() عليها دون فحص لأنها ترمي استثناء إذا كانت فارغة، ولا تضعها في حقول الكائنات أو معاملات الدوال. وإذا أردت أماناً من null في نظام الأنواع نفسه فهذا ما بنيت عليه Kotlin، وسوف تراه في مرحلة Kotlin من نفس المسار.
المشكلة التي ظهرت في التجربة: الجامع Serial في Container صغير
عندما خططنا للتجربة افترضنا أن الـ JVM تستخدم G1 في كل مكان لأنه الافتراضي منذ Java 9، وكتبنا تحليل سجل الجامع على هذا الأساس. ثم شغلنا PrintFlagsFinal داخل Container بحد 512 ميجابايت، وهو نفس الحد الذي سوف نقيس به خدمة الطلبات، فوجدنا UseSerialGC = true. وهذا يعني أن أي قياس لخدمة في Container صغير دون تحديد الجامع هو قياس لجامع بخيط واحد يوقف كل شيء، وأن مقارنته بقياس آخر على جهاز المطور بـ G1 مقارنة غير عادلة. والحل كان تحديد الجامع في أمر التشغيل صراحة، والتحقق منه بالسطر الأول من -Xlog:gc الذي يطبع Using G1. وإذا كانت لديك خدمات Java تعمل الآن في Containers بأقل من 1792 ميجابايت أو بمعالج واحد، فشغل عليها jcmd <pid> VM.flags وتأكد من الجامع الذي تعمل به فعلاً.
أخطاء شائعة Common Mistakes
- مقارنة
IntegerأوStringبـ==: تمر مع القيم الصغيرة وتفشل عند 128 كما رأيت، واستخدمequalsأوObjects.equals. - Unboxing لقيمة
null: إذا رجعmap.get(key)القيمةnullوأسندتها إلىintفسوف ترميNullPointerExceptionفي سطر لا يظهر فيه أي استدعاء. - المال في
double، أوBigDecimalمنdouble: استخدم الهللات فيlong، أوnew BigDecimal("9.90")من نص. - ضرب
intفيintثم الإسناد إلىlong: الالتفاف يحدث قبل الإسناد، فاجعل أحد المعاملينlong. - الأنواع الخام وإخفاء تحذير
unchecked: يظهر الخطأ بعيداً عن سببه كما رأيت معClassCastException. - Cache أو قائمة
staticدون حد: هذا هو التسرب الأشهر في الخدمات، وزيادة-Xmxتؤخره فقط. - ترك الـ JVM تختار الجامع والكومة في Container صغير: سوف تحصل على Serial وربع الذاكرة، فحدد
-XX:+UseG1GCو-XX:MaxRAMPercentage. - إعطاء الكومة كل ذاكرة الـ Container: الذاكرة خارج الكومة موجودة دائماً، والنتيجة
OOMKilledدون رسالة. catch (Exception e) {}فارغة: تخفي الأخطاء البرمجية مع المتوقعة، وأقل ما يجب أن تفعله أن تسجل الخطأ أو تغلفه وتعيد رميه.- إغلاق الموارد في
finallyيدوياً: خطأ الإغلاق يضيع الخطأ الأصلي، واستخدم try-with-resources. Optional.get()دون فحص: مجرد نقل للـNullPointerExceptionإلى سطر آخر، واستخدمorElseأوorElseThrowمع رسالة واضحة.
قائمة تحقق Checklist قبل تشغيل خدمة Java في Container
| السؤال | الأمر أو الإعداد | ما تبحث عنه |
|---|---|---|
| أي جامع تعمل به الخدمة فعلاً؟ | jcmd <pid> VM.flags أو السطر الأول من -Xlog:gc | الجامع الذي اخترته، وليس Serial بالصدفة |
| كم حجم الكومة؟ | -XX:MaxRAMPercentage=75 أو -Xmx صريح | مساحة متبقية للـ Metaspace والخيوط تحت حد الـ Container |
| كم يبقى حياً بعد كل دورة؟ | الرقم بعد السهم في -Xlog:gc | رقم مستقر، لا يصعد مع الوقت |
| هل يوجد تسرب؟ | jcmd <pid> GC.class_histogram مرتين بفاصل | Class تكبر دون أن تنزل |
| ماذا يحدث عند امتلاء الكومة؟ | -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/dumps | ملف تفريغ تحلله بعد التوقف |
| هل الـ Cache له حد؟ | مراجعة الكود، أو مكتبة مثل Caffeine | حد بالحجم أو بالوقت |
| هل الأخطاء تصل إلى السجلات؟ | البحث عن catch فارغة | لا يوجد استثناء يختفي دون أثر |
| هل المبالغ في نوع صحيح؟ | البحث عن double وfloat في كود الأسعار | هللات في long أو BigDecimal |
تدريب عملي
- شغل
Footprint.javaمعHashMap<Integer, Integer>بحجم ابتدائي مليون، ولاحظ هل تغير الرقم، ثم جربint[]بدل القاموس إذا كانت المفاتيح أرقاماً متتالية. - شغل
Churn.javaبكومة 32 ميجابايت مع G1 و-Xlog:gc، ولاحظ متى تظهر دوراتPause Full، وماذا يعني ذلك لخدمة كومتها صغيرة جداً. - أضف إلى
LeakFixed.javaحداً بالوقت إلى جانب الحد بالعدد، ثم استبدل القاموس بمكتبة Caffeine وقارن عدد الأسطر. - غير
OutOfStockلترث منRuntimeException، واحذفthrows، وناقش مع فريقك أي الشكلين أوضح لمن يستدعي الدالة.
الخلاصة
وصلنا لنهاية الموضوع، وأهم ما فيه:
- نظام الأنواع في Java ثابت وقوي واسمي، وفجواته المعروفة هي
nullوالتحويل التلقائي بينintوIntegerومحو الأنواع في Generics، ورأيت كل واحدة منها تمر من المترجم وتفشل وقت التشغيل. ==بين كائنينIntegerأعطتtrueعند 127 وfalseعند 128 بسبب الـ Cache فيInteger.valueOf، والحلequals.- مليون رقم كلفت حوالي 4 بايت لكل رقم في
int[]، و20 في قائمة كائنات، و72 في قاموس، والرؤوس المضغوطة من JEP 519 خفضت القاموس إلى 64. - 3 ملايين كائن قصير العمر لم تكلف أي جامع أكثر من ثانيتين، والفرق بين تشغيلين كان أكبر من الفرق بين الجوامع، وZGC أبقى التوقف قرب الصفر.
- في Container بذاكرة 512 ميجابايت اختارت الـ JVM الجامع Serial وكومة 128 ميجابايت، فحدد الجامع والنسبة صراحة.
- التسرب في Java مرجع منسي، مثل Cache دون حد، ويكتشف بـ
jcmd GC.class_histogram، ويصلح بالحد وليس بزيادة الذاكرة. - الاستثناءات المفحوصة يفرضها المترجم، وغير المفحوصة لا يفرضها، ورسائل
NullPointerExceptionصارت تشير إلى القيمة الفارغة بالضبط، وtry-with-resources تحفظ الخطأ الأصلي.
وفي المقال التالي من مرحلة لغة Java للمطورين ننتقل إلى التزامن والخيوط الافتراضية Virtual Threads، وتجد هنا المقالات المرتبطة:
دليلإدارة الذاكرة: يدوياً أم Garbage Collector أم Ownership؟نقيس بأيدينا في C وGo وPython وRust من يحرر الذاكرة ومتى، وكم يكلف ذلك من الذاكرة والوقت، وما الأخطاء التي تبقى ممكنة في كل طريقة.
دليلكيف تتعامل لغات البرمجة مع الأخطاء؟لماذا ترمي Java وPython الاستثناءات بينما ترجع Go وRust الخطأ كقيمة، وكيف تمنع الخطأ من أن يختفي بصمت في كودك.
دليلأنماط البرمجة وأنظمة الأنواع: لماذا تختلف اللغات؟نحل مسألة واحدة بعدة أساليب برمجة، ونرى هل تكتشف اللغة أخطاءك قبل التشغيل أم بعده، وماذا يعني ذلك عند اختيار لغة لمشروعك.
دليلقراءة ال Stack Trace وتصحيح الأخطاء Debugging للمبتدئينكيف تقرأ رسالة الخطأ في Python وJavaScript سطراً بسطر، ومتى تستخدم الطباعة أو نقاط التوقف في VS Code، وكيف يجد git bisect التعديل الذي أدخل الخطأ.
دليلTypeScript للمطورين: أي الأخطاء تكشفها الأنواع Types؟زرعنا تسعة أخطاء في كود سلة مشتريات وفحصناها بالمترجم tsc بثلاثة إعدادات، لنرى ما يكشفه الوضع الصارم وما يمر رغم كل الأنواع ولماذا تحتاج التحقق وقت التشغيل.
دليلهل Java مجانية؟ أي JDK تستخدم في شركتكمتى تحتاج اشتراكاً في Oracle JDK ومتى لا تحتاجه، وكيف تعرف نسخ Java التي تعمل عندك وتنقلها إلى نسخة مجانية بأمان.سجل التحديثات
- أكتوبر 2026: كتابة المقال، وتنفيذ التجارب على JDK 25.0.5 (Temurin) داخل Docker بحدود معالج وذاكرة مختلفة.