> ## Content Index
> Fetch the complete content index at: https://arabroot.io/llms.txt
> Use this file to discover other available public pages before exploring further.

# الأنواع والذاكرة Memory والاستثناءات في Java
- URL: https://arabroot.io/articles/الانواع-والذاكرة-والاستثناءات-في-java/
- Published: 2026-10-31T07:40:13.000Z
- Updated: 2026-10-31T08:43:06.000Z
- Description: لماذا أعطت مقارنة 128 بـ 128 القيمة false، وكم يكلف مليون رقم في الذاكرة، وماذا تختار الـ JVM داخل Container صغير، وكيف تقرأ استثناءات Java وتصلح تسرب الذاكرة.
- Author: فريق عرب رووت
- Tags: الأدلة التقنية, لغات البرمجة عملياً

بعد أسبوعين في فريق خدمة الطلبات أصبحت تعرف كيف تبني المشروع وتشغل الاختبارات، كما شرحنا في [المقال الأول من مرحلة Java](https://arabroot.io/start/#stage-lang-java)، ثم وصلت إلى الفريق ثلاث مشكلات في نفس الأسبوع. الأولى تقرير من الدعم الفني بأن خصم «اشتر 200 قطعة واحصل على هدية» لا يعمل، والسبب سطر يقارن كميتين بالعلامة `==`، ويعمل في الاختبار بكمية 100 ويفشل عند 200\. والثانية أن خدمة إنشاء الإيصالات التي تعمل في Container بذاكرة 512 ميجابايت تتوقف كل بضعة أيام برسالة `OutOfMemoryError`، وعندما يعيد الفريق تشغيلها تعمل من جديد، فلا أحد يعرف أين ذهبت الذاكرة في لغة فيها جامع قمامة. والثالثة أن مطوراً جديداً كتب دالة تقرأ ملف أسعار ولم يمسك الاستثناء، فرفض المترجم الكود، فأضاف `catch (Exception e) {}` فارغة حتى يمر البناء، وصار الملف المفقود يمر بصمت.

والمشكلات الثلاث لها جذر واحد: كل واحدة منها تأتي من شيء تفعله Java بطريقة تختلف عن Python وJavaScript، في الأنواع، وفي إدارة الذاكرة، وفي الأخطاء. وهذا المقال من مرحلة [لغة Java للمطورين](https://arabroot.io/start/#stage-lang-java)، وسوف نشرح فيه الأجزاء الثلاثة بتجربة حقيقية على JDK 25: نقيس كم يكلف مليون رقم في الذاكرة بأربعة أشكال، ونشغل نفس البرنامج بأربعة جوامع قمامة، ونرى ماذا تختار الـ JVM من نفسها داخل Container صغير، ونصنع تسرباً في الذاكرة ونكتشفه ونصلحه، ثم نرمي كل نوع من الاستثناءات ونقرأ رسالته. والمفاهيم العامة لإدارة الذاكرة بين اللغات شرحناها في [إدارة الذاكرة يدوياً أم Garbage Collector أم Ownership](https://arabroot.io/articles/%D8%A7%D8%AF%D8%A7%D8%B1%D8%A9-%D8%A7%D9%84%D8%B0%D8%A7%D9%83%D8%B1%D8%A9-%D9%8A%D8%AF%D9%88%D9%8A%D8%A7-%D8%A7%D9%85-garbage-collector-%D8%A7%D9%85-ownership/)، وطرق الأخطاء بين اللغات في [كيف تتعامل لغات البرمجة مع الأخطاء](https://arabroot.io/articles/%D9%83%D9%8A%D9%81-%D8%AA%D8%AA%D8%B9%D8%A7%D9%85%D9%84-%D9%84%D8%BA%D8%A7%D8%AA-%D8%A7%D9%84%D8%A8%D8%B1%D9%85%D8%AC%D8%A9-%D9%85%D8%B9-%D8%A7%D9%84%D8%A7%D8%AE%D8%B7%D8%A7%D8%A1/)، فلن نكررها، وسوف نركز هنا على ما يخص 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 للمطورين](https://arabroot.io/articles/typescript-%D9%84%D9%84%D9%85%D8%B7%D9%88%D8%B1%D9%8A%D9%86-%D9%85%D8%A7-%D8%A7%D9%84%D8%B0%D9%8A-%D8%AA%D9%83%D8%B4%D9%81%D9%87-%D8%A7%D9%84%D8%A7%D9%86%D9%88%D8%A7%D8%B9/). وقد شرحنا هذه التقسيمات بشكل عام في [أنماط البرمجة وأنظمة الأنواع](https://arabroot.io/articles/%D8%A7%D9%86%D9%85%D8%A7%D8%B7-%D8%A7%D9%84%D8%A8%D8%B1%D9%85%D8%AC%D8%A9-%D9%88%D8%A7%D9%86%D8%B8%D9%85%D8%A9-%D8%A7%D9%84%D8%A7%D9%86%D9%88%D8%A7%D8%B9-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/).

والنتيجة العملية لهذا النظام أن كثيراً من الأخطاء التي تظهر في 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` تبين أين يصبح هذا التحويل فخاً، وهي نفس مشكلة الخصم التي بدأنا بها:

```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));
```

والمخرج سوف يكون كما يلي:

```text
int 127 == 127:      true
Integer 127 == 127:  true
Integer 128 == 128:  false
Integer 128 equals:  true
```

![مخطط يبين متغيرات int في المكدس ومتغيرات Integer تشير إلى كائنات في الكومة، وكائن محفوظ للقيمة 127 وكائنان مختلفان للقيمة 128](https://arabroot.io/content/images/2026/10/java-types-memory-01-primitive-reference.webp)

العلامة `==` بين كائنين تقارن المرجع: نفس الكائن للقيمة 127 من الـ Cache، وكائنان مختلفان للقيمة 128.

في المخرج أعلاه لاحظ التالي:

- السطر الأول طبيعي: `==` بين قيمتين بدائيتين تقارن القيمة.
- السطر الثاني أعطى `true` بالصدفة، والسبب أن التحويل التلقائي يستدعي [Integer.valueOf](https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/lang/Integer.html?ref=arabroot.io#valueOf%28int%29)، وهذه الدالة تحفظ كائنات جاهزة للقيم من -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](https://errorprone.info/bugpattern/BoxedPrimitiveEquality?ref=arabroot.io).

### الأعداد Numbers: الالتفاف Overflow والأعداد العشرية Floating Point

الجزء التالي من نفس الملف يجمع ثلاث مفاجآت مع الأعداد:

```java
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);
```

```text
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 بت. وهذا تفصيل تحدده [مواصفة اللغة في فصل التحويلات](https://docs.oracle.com/javase/specs/jls/se25/html/jls-5.html?ref=arabroot.io)، ويهمك في حساب مجموع المبيعات أو ضرب السعر في الكمية لطلبيات الجملة.

### النصوص String وترميز UTF-16

النص في Java كائن غير قابل للتعديل Immutable، وكل عملية عليه تنشئ نصاً جديداً، وداخلياً يحفظ كسلسلة من وحدات UTF-16، وهذا له أثر على النصوص العربية والرموز التعبيرية. وفي التجربة كتبنا كلمة «قهوة» ورمز عربة التسوق:

```text
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 هو الترميز الافتراضي](https://openjdk.org/jeps/400?ref=arabroot.io) لقراءة الملفات وكتابتها، فلم تعد تحتاج `-Dfile.encoding` كما كانت الفرق تفعل على Windows، ولكن الترميز الداخلي للنص في الذاكرة لم يتغير.

### الأنواع العامة Generics ومحو الأنواع Type Erasure

عندما تكتب `List<String>` فأنت تخبر المترجم أن القائمة فيها نصوص فقط، والمترجم يمنعك من إضافة رقم ويضيف التحويل المطلوب عند القراءة. ولكن هذه المعلومة تحذف بعد الترجمة، فالـ JVM لا ترى إلا `ArrayList` فيها كائنات من نوع `Object`، وهذا ما يسمى [محو الأنواع Type Erasure](https://docs.oracle.com/javase/tutorial/java/generics/erasure.html?ref=arabroot.io)، وسببه تاريخي: عندما أضيفت Generics في Java 5 عام 2004 أراد المصممون أن يعمل الكود الجديد مع المكتبات القديمة المترجمة دون تغيير. والتجربة التالية تبين نتيجة ذلك:

```java
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());
}
```

```text
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')
```

![مخطط يبين أن List of String وList of Integer نفس الـ Class وقت التشغيل، وأن إضافة رقم عبر نوع خام تظهر كاستثناء عند القراءة](https://arabroot.io/content/images/2026/10/java-types-memory-02-erasure.webp)

المترجم يعرف نوع العناصر، والـ JVM لا تعرفه، والنوع الخام Raw Type يفتح الباب بين الاثنين.

وعند تشغيل الكود سوف تجد أن الإضافة نجحت دون أي اعتراض، فأصبح في قائمة النصوص رقم، والاستثناء لم يظهر إلا عند القراءة، في السطر الذي أضاف فيه المترجم تحويلاً خفياً إلى `String`. وفي مشروع حقيقي قد يكون سطر الإضافة في مكتبة قديمة وسطر القراءة في كودك بعد ساعات، فتقرأ الـ Stack Trace وتجد نفسك في مكان لا علاقة له بالخطأ. والقاعدة: لا تستخدم الأنواع الخام Raw Types مثل `List` دون نوع، ولا تتجاهل تحذير `unchecked` ولا تخفيه بـ `@SuppressWarnings` دون سبب، فهو المكان الوحيد الذي يخبرك فيه المترجم أن الضمان انتهى. وأثر آخر للمحو سوف تقابله في الإطارات: لا تستطيع أن تكتب `new T()` أو `instanceof List<String>`، ولهذا تطلب مكتبات مثل Jackson أن تمرر لها نوع الـ Class صراحة عندما تحول JSON إلى قائمة.

### السجلات Records والأنماط Pattern Matching

آخر جزء في `Types.java` يستخدم سجلاً ونمطاً معاً:

```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)));
```

```text
pattern: coffee costs 990
records equal by value: true
```

والسطر الذي فيه `instanceof` يفحص النوع ويفكك السجل إلى حقوله في خطوة واحدة، وقديماً كان هذا يحتاج فحصاً ثم تحويلاً يدوياً ثم استدعاء ثلاث دوال. والسجل يقارن بالقيمة لأن `equals` فيه مولدة من كل الحقول، وهذا ما تريده في كائنات البيانات، بخلاف الـ Class العادية التي تقارن بالمرجع حتى تكتب `equals` بنفسك. ولاحظ أن السجل نوع مرجعي، أي أن كل `Product` كائن في الكومة له رأس وعنوان، وهذا ما سوف نقيسه الآن. ويعمل مشروع [Valhalla](https://openjdk.org/projects/valhalla/?ref=arabroot.io) في 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` ينشئ نفس المليون رقم بأربعة أشكال، ويقيس الذاكرة المستخدمة قبل كل شكل وبعده مع تشغيل الجامع، وهي طريقة تقريبية ولكنها تكفي لرؤية الفرق:

```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](https://openjdk.org/jeps/519?ref=arabroot.io)، وهي ميزة صارت جزءاً نهائياً من JDK 25 ولكنها غير مفعلة افتراضياً:

```text
## 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)
```

![أعمدة تقارن البايت لكل عنصر في مصفوفة int وArrayList of Integer وArrayList of record وHashMap، مع وبدون رؤوس الكائنات المضغوطة](https://arabroot.io/content/images/2026/10/java-types-memory-03-footprint.webp)

نفس المليون رقم: حوالي 4 بايت في مصفوفة بدائية، و20 في قائمة كائنات، وأكثر من 64 في قاموس.

في المخرج أعلاه لاحظ التالي:

- المصفوفة البدائية تحتاج 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` الذي يطبع سطراً لكل دورة جمع، وهذه أول الأسطر:

```text
[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](https://docs.oracle.com/en/java/javase/25/gctuning/available-collectors.html?ref=arabroot.io). وشغلنا نفس البرنامج بكل جامع ثلاث مرات، بكومة 256 ميجابايت ومعالجين، وطبع البرنامج في النهاية عدد الدورات ووقتها كما تراها الـ JVM نفسها:

```text
## -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
```

![جدول يقارن الجوامع Serial وParallel وG1 وZGC في زمن البرنامج وعدد الدورات ومتى يناسب كل منها](https://arabroot.io/content/images/2026/10/java-types-memory-04-gc-compare.webp)

3 ملايين طلب في أقل من ثانيتين مع كل الجوامع، والفرق بين تشغيلين لنفس الجامع أكبر من الفرق بين الجوامع.

وقد يتساءل البعض: أي جامع هو الأسرع إذن؟ والإجابة الصادقة أن هذه التجربة لا تستطيع أن تقول، فالجامع 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 بحدود مختلفة:

```text
### 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](https://docs.oracle.com/en/java/javase/25/gctuning/ergonomics.html?ref=arabroot.io). وحتى نفهم القاعدة جربنا ثلاث حالات أخرى:

```text
### 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}
```

![جدول يبين حجم الكومة والجامع الذي تختاره الـ JVM حسب حدود المعالج والذاكرة في الـ Container](https://arabroot.io/content/images/2026/10/java-types-memory-05-container-heap.webp)

الـ JVM تقرأ حدود الـ Container، وتعطي الكومة ربع الذاكرة، ولا تختار G1 إلا إذا كان «الجهاز» خادماً.

والقاعدة كما يكتبها دليل Oracle: تعتبر الـ JVM الجهاز «خادماً» Server-Class إذا وجدت معالجين أو أكثر وذاكرة 1792 ميجابايت أو أكثر، وعندها تختار G1، وإلا تختار Serial. والـ JVM الحديثة تقرأ الحدود من الـ Container نفسه عبر cgroups وليس من الجهاز الكامل، وهذا جيد، ولكن النتيجة أن خدمة في Container صغير تعمل بجامع يوقف كل شيء بخيط واحد، وبكومة ربع الذاكرة، والباقي يذهب إلى ذاكرة لا تستخدمها الكومة: مساحة الـ Metaspace للـ Classes، والكود المترجم بـ JIT، ومكدس كل خيط، والمخازن المؤقتة للشبكة. ولذلك عندما رفعنا النسبة جاءت النتيجة كما يلي:

```text
   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 كيلوبايت يحفظ في القاموس بمفتاح رقم الطلب:

```java
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 ميجابايت حتى يظهر الخطأ بسرعة:

```text
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](https://docs.oracle.com/en/java/javase/25/docs/specs/man/jcmd.html?ref=arabroot.io) تطبع لك ملخصاً بعدد الكائنات وحجمها لكل Class من برنامج يعمل، فشغلنا نفس البرنامج بكومة 2 جيجابايت وطلبنا الملخص بعد ثلاث ثوان:

```text
 num     #instances         #bytes  class name (module)
-------------------------------------------------------
   1:         23760      749149408  [B (java.base@25.0.5)
   2:          1293       35378288  [Ljdk.internal.vm.FillerElement; (java.base@25.0.5)
   3:         16198         388752  java.lang.String (java.base@25.0.5)
   4:          8497         271904  java.util.HashMap$Node (java.base@25.0.5)
   5:          1697         224600  java.lang.Class (java.base@25.0.5)
```

والسطر الأول يقول كل شيء: مصفوفات البايت `[B` تأخذ 749 ميجابايت، ومعها آلاف من `HashMap$Node` و`String`، وهذا شكل قاموس مفاتيحه نصوص وقيمه مصفوفات بايت ويكبر. وإذا شغلت الأمر مرتين بفاصل دقيقة ورأيت نفس الـ Class تكبر دون أن تنزل، فقد وجدت المشتبه به. وللتحقيق الأعمق تأخذ تفريغاً كاملاً للكومة `jcmd <pid> GC.heap_dump` وتفتحه في أداة مثل Eclipse MAT لترى من يمسك هذه الكائنات، كما يشرح [دليل Oracle لتشخيص التسرب](https://docs.oracle.com/en/java/javase/25/troubleshoot/troubleshooting-memory-leaks.html?ref=arabroot.io).

والإصلاح ليس زيادة `-Xmx`، فهذا يؤخر التوقف من أيام إلى أسابيع فقط، وإنما أن تضع للـ Cache حداً. والملف `LeakFixed.java` يستخدم `LinkedHashMap` بترتيب الاستخدام ويحذف الأقدم عندما يتجاوز 200 عنصر:

```java
static final Map<String, byte[]> CACHE = new LinkedHashMap<>(256, 0.75f, true) {
    @Override
    protected boolean removeEldestEntry(Map.Entry<String, byte[]> eldest) {
        return size() > MAX;
    }
};
```

```text
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
```

![مقارنة بين Cache دون حد ينتهي بـ OutOfMemoryError وCache بحد 200 عنصر تبقى ذاكرته ثابتة، مع ملخص jcmd للكومة](https://arabroot.io/content/images/2026/10/java-types-memory-06-leak.webp)

نفس الكومة 64 ميجابايت: الأول توقف بعد 500 طلب، والثاني خدم 20,000 طلب وذاكرته تصعد وتنزل.

ونفس الكومة 64 ميجابايت خدمت الآن 20,000 طلب، والذاكرة المستخدمة تصعد وتنزل بين 25 و44 ميجابايت لأن كل إيصال يخرج من القاموس يصبح قمامة يجمعها الجامع. وفي خدمة حقيقية استخدم مكتبة Cache مثل Caffeine التي تدعم الحد بالحجم وبالوقت وتعطيك إحصاءات، ولكن الفكرة واحدة: كل Cache يجب أن يكون له حد، وكل قائمة `static` تكبر مع الطلبات مشتبه بها حتى يثبت العكس.

## الاستثناءات Exceptions: طريقة Java في الأخطاء

Java تبلغ عن الأخطاء بالاستثناءات، مثل Python وC#، وليس بقيم راجعة مثل Go، ولكنها تختلف عن كل اللغات الكبيرة في نقطة واحدة أثارت جدلاً طويلاً: الاستثناءات المفحوصة Checked Exceptions، وقد شرحنا هذا الجدل وكيف قررت كل لغة في [مقال الأخطاء](https://arabroot.io/articles/%D9%83%D9%8A%D9%81-%D8%AA%D8%AA%D8%B9%D8%A7%D9%85%D9%84-%D9%84%D8%BA%D8%A7%D8%AA-%D8%A7%D9%84%D8%A8%D8%B1%D9%85%D8%AC%D8%A9-%D9%85%D8%B9-%D8%A7%D9%84%D8%A7%D8%AE%D8%B7%D8%A7%D8%A1/). والصورة التالية تبين شجرة الاستثناءات، وأين يقع كل استثناء رميناه في التجربة:

![شجرة Throwable وفروعها Error وException وRuntimeException مع أمثلة من التجربة لكل فرع](https://arabroot.io/content/images/2026/10/java-types-memory-07-exceptions.webp)

ثلاثة فروع: أخطاء الـ JVM، والمفحوصة التي يفرضها المترجم، وغير المفحوصة التي لا يفرضها.

### المفحوصة Checked وغير المفحوصة Unchecked

الاستثناء المفحوص هو كل ما يرث من `Exception` دون `RuntimeException`، مثل `IOException`، والمترجم يجبر كل دالة تستدعي دالة ترميه أن تمسكه أو تعلنه في توقيعها بـ `throws`، كما تحدد [مواصفة اللغة في فصل الاستثناءات](https://docs.oracle.com/javase/specs/jls/se25/html/jls-11.html?ref=arabroot.io). والملف `Unchecked.java` يستدعي دالة ترمي `IOException` دون أن يفعل أياً من الاثنين:

```java
static void load() throws java.io.IOException {
    throw new java.io.IOException("prices file missing");
}

public static void main(String[] args) {
    load();
}
```

```text
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`، ورمينا معه استثناء غير مفحوص من تحويل نص إلى رقم:

```text
caught checked: dates: asked 5, left 2
unchecked: java.lang.NumberFormatException: For input string: "9.90"
```

والسطر الثاني يبين أن `Integer.parseInt` لا تقبل رقماً فيه فاصلة عشرية، وهذا خطأ يظهر عندما ترسل الواجهة السعر بالريال بدل الهللات، وهو غير مفحوص لأن المترجم لا يستطيع أن يعرف هل النص رقم صحيح أم لا. والقاعدة التي تتبعها أغلب المكتبات الحديثة، ومنها Spring: الأخطاء التي يتوقعها المستدعي ويستطيع التعامل معها بشكل معقول قد تكون مفحوصة، وأخطاء البرمجة والمدخلات غير الصحيحة غير مفحوصة، وأغلب الكود الجديد يميل إلى غير المفحوصة مع توثيقها.

### رسائل NullPointerException المفيدة Helpful NPE Messages

القيمة `null` هي أكبر فجوة في نظام الأنواع، لأن أي متغير مرجعي يمكن أن يحملها، والمترجم لا يمنعك من استدعاء دالة عليها. وفي التجربة أنشأنا عميلاً دون عنوان ثم طلبنا طول اسم المدينة:

```java
var c = new Customer("Sara", null);
System.out.println(c.address().city().length());
```

```text
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](https://openjdk.org/jeps/358?ref=arabroot.io) صارت الرسالة تقول بالضبط: لم نستطع استدعاء `city()` لأن ما رجع من `address()` كان `null`. وهذه الرسائل مفعلة افتراضياً، فإذا رأيت في سجلات خدمة قديمة رسالة دون هذا التفصيل فهي تعمل على Java قديمة. ولاحظ أن `Errors$Address` هو الاسم الداخلي للسجل `Address` المعرف داخل `Errors`.

### قراءة الـ Stack Trace لملف مفقود

عندما يرمى استثناء ولا يمسكه أحد تطبع الـ JVM سلسلة الاستدعاءات Stack Trace، وهذا ما ظهر عندما حاولنا قراءة ملف أسعار غير موجود:

```text
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 سوف تجد عشرات الأسطر من الإطار بين السطر الأول وكودك، والقاعدة نفسها، وقد شرحناها بالتفصيل في [قراءة رسالة الخطأ](https://arabroot.io/articles/%D9%82%D8%B1%D8%A7%D8%A1%D8%A9-%D8%B1%D8%B3%D8%A7%D9%84%D8%A9-%D8%A7%D9%84%D8%AE%D8%B7%D8%A7-stack-trace-%D9%88%D8%A7%D9%84%D8%AA%D8%B5%D8%AD%D9%8A%D8%AD-debugging/). وإذا كان الاستثناء مغلفاً فستجد في الأسفل `Caused by:`، وهو غالباً السبب الحقيقي.

### إغلاق الموارد try-with-resources والاستثناءات المكبوتة Suppressed

كل مورد يجب أن يغلق، مثل الملف واتصال قاعدة البيانات واتصال الشبكة، وإذا رمى الكود استثناء قبل الإغلاق فقد يبقى المورد مفتوحاً، وبعد ساعات تنفد اتصالات قاعدة البيانات في الخدمة. والصيغة `try (var r = ...)` تغلق المورد دائماً عند الخروج، وفي `Errors.java` جعلنا الطابعة ترمي استثناء أثناء الكتابة ثم استثناء آخر أثناء الإغلاق:

```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());
}
```

```text
main: printer jam while writing order 42
suppressed: java.io.IOException: printer offline on close
```

والمهم في المخرج أن الاستثناء الأصلي هو الذي وصل، وخطأ الإغلاق لم يضع وإنما أضيف إليه كاستثناء مكبوت Suppressed. وفي الطريقة القديمة بـ `finally` كان خطأ الإغلاق يستبدل الخطأ الأصلي، فتقرأ في السجلات «الطابعة غير متصلة» ويضيع السبب الحقيقي. ولذلك استخدم [try-with-resources](https://docs.oracle.com/javase/tutorial/essential/exceptions/tryResourceClose.html?ref=arabroot.io) لكل ما ينفذ `AutoCloseable`، ولا تكتب `close()` بنفسك في `finally`.

### القيمة الغائبة Optional بدل null

آخر سطر في `Errors.java` يبحث عن منتج غير موجود:

```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"));
```

```text
optional: unknown product
```

والدالة `get` في القاموس ترجع `null` عندما لا يوجد المفتاح، و[Optional](https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/Optional.html?ref=arabroot.io) تحولها إلى صندوق قد يكون فارغاً، فتستطيع أن تحول القيمة إن وجدت وتعطي بديلاً إن لم توجد، دون أن يرمي أي سطر `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 للمطورين](https://arabroot.io/start/#stage-lang-java) ننتقل إلى التزامن والخيوط الافتراضية Virtual Threads، وتجد هنا المقالات المرتبطة:

[![](https://arabroot.io/content/images/2026/10/memory-management-cover-cover.webp)دليلإدارة الذاكرة: يدوياً أم Garbage Collector أم Ownership؟نقيس بأيدينا في C وGo وPython وRust من يحرر الذاكرة ومتى، وكم يكلف ذلك من الذاكرة والوقت، وما الأخطاء التي تبقى ممكنة في كل طريقة.](https://arabroot.io/articles/%D8%A7%D8%AF%D8%A7%D8%B1%D8%A9-%D8%A7%D9%84%D8%B0%D8%A7%D9%83%D8%B1%D8%A9-%D9%8A%D8%AF%D9%88%D9%8A%D8%A7-%D8%A7%D9%85-garbage-collector-%D8%A7%D9%85-ownership/)[![](https://arabroot.io/content/images/2026/10/error-handling-cover-cover.webp)دليلكيف تتعامل لغات البرمجة مع الأخطاء؟لماذا ترمي Java وPython الاستثناءات بينما ترجع Go وRust الخطأ كقيمة، وكيف تمنع الخطأ من أن يختفي بصمت في كودك.](https://arabroot.io/articles/%D9%83%D9%8A%D9%81-%D8%AA%D8%AA%D8%B9%D8%A7%D9%85%D9%84-%D9%84%D8%BA%D8%A7%D8%AA-%D8%A7%D9%84%D8%A8%D8%B1%D9%85%D8%AC%D8%A9-%D9%85%D8%B9-%D8%A7%D9%84%D8%A7%D8%AE%D8%B7%D8%A7%D8%A1/)[![](https://arabroot.io/content/images/2026/10/paradigms-types-cover-cover.webp)دليلأنماط البرمجة وأنظمة الأنواع: لماذا تختلف اللغات؟نحل مسألة واحدة بعدة أساليب برمجة، ونرى هل تكتشف اللغة أخطاءك قبل التشغيل أم بعده، وماذا يعني ذلك عند اختيار لغة لمشروعك.](https://arabroot.io/articles/%D8%A7%D9%86%D9%85%D8%A7%D8%B7-%D8%A7%D9%84%D8%A8%D8%B1%D9%85%D8%AC%D8%A9-%D9%88%D8%A7%D9%86%D8%B8%D9%85%D8%A9-%D8%A7%D9%84%D8%A7%D9%86%D9%88%D8%A7%D8%B9-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/)[![](https://arabroot.io/content/images/2026/10/debugging-basics-cover-cover.webp)دليلقراءة ال Stack Trace وتصحيح الأخطاء Debugging للمبتدئينكيف تقرأ رسالة الخطأ في Python وJavaScript سطراً بسطر، ومتى تستخدم الطباعة أو نقاط التوقف في VS Code، وكيف يجد git bisect التعديل الذي أدخل الخطأ.](https://arabroot.io/articles/%D9%82%D8%B1%D8%A7%D8%A1%D8%A9-%D8%B1%D8%B3%D8%A7%D9%84%D8%A9-%D8%A7%D9%84%D8%AE%D8%B7%D8%A7-stack-trace-%D9%88%D8%A7%D9%84%D8%AA%D8%B5%D8%AD%D9%8A%D8%AD-debugging/)[![](https://arabroot.io/content/images/2026/10/typescript-for-devs-cover-cover.webp)دليلTypeScript للمطورين: أي الأخطاء تكشفها الأنواع Types؟زرعنا تسعة أخطاء في كود سلة مشتريات وفحصناها بالمترجم tsc بثلاثة إعدادات، لنرى ما يكشفه الوضع الصارم وما يمر رغم كل الأنواع ولماذا تحتاج التحقق وقت التشغيل.](https://arabroot.io/articles/typescript-%D9%84%D9%84%D9%85%D8%B7%D9%88%D8%B1%D9%8A%D9%86-%D9%85%D8%A7-%D8%A7%D9%84%D8%B0%D9%8A-%D8%AA%D9%83%D8%B4%D9%81%D9%87-%D8%A7%D9%84%D8%A7%D9%86%D9%88%D8%A7%D8%B9/)[![](https://arabroot.io/content/images/2026/10/java-licensing-cover-cover.webp)دليلهل Java مجانية؟ أي JDK تستخدم في شركتكمتى تحتاج اشتراكاً في Oracle JDK ومتى لا تحتاجه، وكيف تعرف نسخ Java التي تعمل عندك وتنقلها إلى نسخة مجانية بأمان.](https://arabroot.io/articles/%D9%87%D9%84-java-%D9%85%D8%AC%D8%A7%D9%86%D9%8A%D8%A9-%D9%88%D8%A7%D9%8A-jdk-%D8%AA%D8%B3%D8%AA%D8%AE%D8%AF%D9%85-%D9%81%D9%8A-%D8%B4%D8%B1%D9%83%D8%AA%D9%83/)

## سجل التحديثات

- أكتوبر 2026: كتابة المقال، وتنفيذ التجارب على JDK 25.0.5 (Temurin) داخل Docker بحدود معالج وذاكرة مختلفة.