لغات البرمجة عملياً الأدلة التقنية

أخطاء شائعة وأسلوب Java الحديث Modern Java

أربعة عشر خطأً يقع فيها من يأتي إلى Java من لغة أخرى، لكل خطأ تشغيل حقيقي يبين النتيجة الخاطئة ثم الطريقة الصحيحة، ثم خطة قراءة من المصادر الرسمية.

أخطاء شائعة وأسلوب Java الحديث Modern Java

بعد شهر في فريق خدمة الطلبات صرت تكتب Java كل يوم، والبناء ينجح والاختبارات تمر، ولكن مراجعات الكود Code Review ما زالت تعود إليك بتعليقات من نوع «هذا يعمل، ولكنه ليس أسلوب Java». وفي نفس الشهر وصلت إلى الفريق ثلاثة أخطاء من الإنتاج كلها من كود كتبه مطورون جاؤوا من لغات أخرى: تقرير المبيعات الشهري ظهر فيه مجموع سالب في شهر التخفيضات، ومهمة تصدير الفواتير صارت تأخذ دقائق بعد أن كبر عدد الطلبات، وتواريخ بعض الإيصالات جاءت في سنة 2031. ولم يرم أي من هذه الأخطاء استثناء، ولم يعترض المترجم على أي سطر فيها، وكلها مرت من الاختبارات.

وهذا المقال هو الأخير في مرحلة لغة Java للمطورين، وهو مرجع ترجع إليه أكثر من مرة: أربعة عشر خطأً شائعاً يقع فيها من يأتي إلى Java من Python أو JavaScript أو PHP أو C#، ولكل خطأ تشغيل حقيقي على JDK 25 يبين النتيجة الخاطئة، ثم الطريقة الصحيحة بأسلوب Java الحديث وتشغيلها. وبعد الأخطاء خطة قراءة من المصادر الرسمية لمن يريد أن يكمل. وبعض الأخطاء ذكرناها بإيجاز في مقالات سابقة من المرحلة، مثل مقارنة Integer بـ == في مقال الأنواع والذاكرة، فسوف نشير إليها هنا دون أن نعيد شرحها.

وسوف نناقش في هذا المقال ما يلي:

  • كيف بنينا التجربة: ملف واحد فيه كل خطأ بتشغيل خاطئ ثم تشغيل صحيح.
  • أخطاء المساواة والهاش: == على النصوص، وequals دون hashCode، والمفتاح الذي يتغير.
  • أخطاء المجموعات: الحذف أثناء الحلقة، وArrays.asList وList.of، وUnboxing لقيمة غائبة.
  • أخطاء الأعداد: BigDecimal من double، والمساواة بين 2.0 و2.00، والالتفاف في المجاميع، والقسمة الصحيحة.
  • أخطاء النصوص والوقت: += في الحلقات، وSimpleDateFormat المشترك، وswitch القديمة.
  • أخطاء التزامن والموارد: parallel().forEach إلى قائمة عادية، والملفات التي لا تغلق، وOptional.get().
  • قائمة مراجعة لأسلوب Java الحديث، وخطة قراءة من المصادر الرسمية.

كيف بنينا التجربة Lab؟

كل الأخطاء في ملف واحد اسمه Idioms.java، وكل خطأ حالة case في switch تشغل الطريقة الخاطئة ثم الطريقة الصحيحة وتطبع النتيجتين، وخطأ الملفات المفتوحة في ملف منفصل Leaks.java لأنه يحتاج حداً لعدد الملفات. وشغلنا كل الحالات داخل Container من نفس الـ Image التي استخدمناها في كل المرحلة، وفيها JDK 25.0.5، بحد معالجين:

mkdir -p /tmp/i && javac -d /tmp/i Idioms.java Leaks.java
for c in strings hash mutablekey cme aslist bigdecimal divide concat dateformat optional parallel average unboxing switch; do
  echo "### $c"; java -cp /tmp/i Idioms $c
done
bash -c 'ulimit -n 256; java -cp /tmp/i Leaks leak; java -cp /tmp/i Leaks close'

والصورة التالية تجمع الأخطاء الأربعة عشر في ست مجموعات حسب مصدرها، وسوف نمر عليها بنفس الترتيب:

ست مجموعات من الأخطاء الشائعة في Java: المساواة والهاش، والمجموعات، والأعداد، والنصوص والوقت، والتزامن، والموارد
أربعة عشر خطأً في ست مجموعات، وكلها مرت من المترجم دون اعتراض.

ولاحظ شيئاً مشتركاً بين أغلب هذه الأخطاء قبل أن نبدأ: كثير منها لا يرمي أي استثناء، وإنما يعطي قيمة خاطئة تمر بهدوء إلى قاعدة البيانات أو التقرير، وهذا أخطر من الاستثناء لأنه لا يظهر في السجلات Logs. ولهذا فإن معرفة هذه الأنماط مسبقاً، ووجود أداة فحص مثل Error Prone التي أضفناها في المقال الأول من المرحلة، أرخص بكثير من اكتشافها في الإنتاج.

وقبل أن نبدأ، ملاحظة عن الأرقام: ثلاث حالات من الأربع عشرة تعتمد على التوقيت بين الخيوط أو على سرعة الجهاز، وهي dateformat وparallel وconcat، فإذا شغلتها على جهازك فسوف تحصل على أعداد مختلفة من النتائج الخاطئة وأزمنة مختلفة، وقد تحصل أحياناً على صفر أخطاء في تشغيل واحد من parallel. وهذا جزء من الدرس وليس عيباً في التجربة، فالخطأ الذي يظهر في بعض التشغيلات فقط هو بالضبط النوع الذي يمر من الاختبار ويظهر في الإنتاج، ولهذا كررنا حالة parallel ثلاث مرات في نفس التشغيل. أما الحالات الإحدى عشرة الباقية فنتيجتها ثابتة في كل تشغيل، لأنها تعتمد على قواعد اللغة والمكتبة وحدها، وتستطيع أن تشغل أي حالة منفردة بتمرير اسمها، مثل java Idioms.java hash، دون أن تحتاج Gradle أو أي مشروع.

المساواة Equality والهاش Hashing

الخطأ 1: مقارنة النصوص Strings بالعلامة ==

في Python وJavaScript تقارن النصوص بـ == وتقارن المحتوى، وفي Java تقارن == بين كائنين هل هما نفس الكائن في الذاكرة. والفخ أن هذا يعمل أحياناً، لأن النصوص الثابتة المكتوبة في الكود يحفظها المترجم في مكان واحد String Pool، فيصبح "coffee" == "coffee" صحيحاً. ولكن النص الذي يأتي من طلب HTTP أو من ملف أو من قاعدة بيانات كائن جديد، وهذا ما مثلناه بقراءة النص من Scanner:

String fromInput = new Scanner("coffee\n").nextLine();     // like a value read from a request
System.out.println("== on equal text:     " + (fromInput == "coffee"));
System.out.println("equals on equal text: " + fromInput.equals("coffee"));
== on equal text:     false
equals on equal text: true

والنص هو نفسه حرفاً بحرف، ومع ذلك أعطت == القيمة false. والطريقة الصحيحة equals دائماً، وإذا كان أحد الطرفين قد يكون null فاكتب الثابت أولاً "coffee".equals(input) أو استخدم Objects.equals(a, b). ونفس القاعدة تنطبق على Integer وLong كما رأيت في مقال الأنواع، حيث أعطت المقارنة true للقيمة 127 وfalse للقيمة 128.

الخطأ 2: equals دون hashCode

عندما تعرف equals في class لتقول إن منتجين متساويان إذا كان لهما نفس الرمز SKU، يجب أن تعرف hashCode بنفس المنطق، وهذا عقد مكتوب في توثيق Object: الكائنان المتساويان يجب أن يكون لهما نفس الهاش. ومن يأتي من Python يعرف هذا من __eq__ و__hash__، ولكن Python تجعل الكائن غير قابل للهاش إذا عرفت الأولى دون الثانية، أما Java فتترك hashCode الافتراضي المبني على هوية الكائن، ولا تعترض:

static class Sku {                       // equals without hashCode
    final String code;
    Sku(String code) { this.code = code; }
    @Override public boolean equals(Object o) { return o instanceof Sku s && s.code.equals(code); }
}
record SkuRecord(String code) {}         // equals and hashCode generated together
HashSet<Sku> size after adding an equal Sku: 2, contains: false
HashSet<SkuRecord> size: 1, contains: true
مخطط أدراج HashSet: كائنان متساويان في درجين مختلفين بسبب غياب hashCode، وكائن record في درج واحد، ومفتاح تغير بعد وضعه
الـ HashSet يختار الدرج من hashCode أولاً، ولا يستدعي equals إلا داخل نفس الدرج.

والنتيجة أن المجموعة فيها نفس المنتج مرتين، والبحث عنه يعطي false. والسبب أن HashSet يحسب الهاش أولاً ليختار الدرج Bucket، ثم يقارن بـ equals فقط مع ما في نفس الدرج، والهاش الافتراضي مختلف لكل كائن فلا تصل المقارنة أصلاً. والحل الأنسب في Java الحديثة أن تستخدم record لكل كائن قيمة Value Object، فهو يولد equals وhashCode وtoString من نفس الحقول معاً، وإذا احتجت class عادية فاجعل بيئة التطوير تولد الدالتين معاً، أو استخدم Objects.hash.

الخطأ 3: مفتاح Map يتغير بعد وضعه

الخطأ الثالث أخفى من الثاني، لأن الدالتين موجودتان وصحيحتان. وضعنا سلة مشتريات كمفتاح في HashMap وهي فارغة، ثم أضفنا لها منتجاً:

Map<Cart, String> owners = new HashMap<>();
Cart cart = new Cart();
owners.put(cart, "customer-7");
cart.items.add("dates");                       // the key changes after it was stored
System.out.println("get after mutation: " + owners.get(cart) + ", size: " + owners.size() + ", containsValue: " + owners.containsValue("customer-7"));
get after mutation: null, size: 1, containsValue: true

والقاموس فيه عنصر واحد، والقيمة موجودة، ولكن البحث بنفس الكائن يعطي null. والسبب أن الهاش يحسب من محتوى السلة، فعندما تغير المحتوى تغير الهاش، والعنصر بقي في الدرج الذي حسب له عند الإدخال، فالبحث يذهب إلى درج آخر فارغ. والقاعدة: مفاتيح Map وعناصر Set يجب أن تكون ثابتة Immutable، ولهذا فإن String وInteger والسجلات Records مفاتيح ممتازة، أما الكائن الذي يتغير فاستخدم معرفه الثابت كمفتاح بدلاً منه، مثل رقم السلة. ولاحظ أن record وحده لا يحمي إذا كان أحد حقوله قائمة قابلة للتعديل، فاجعل الحقل List.copyOf(...) في منشئ السجل.

المجموعات Collections

الخطأ 4: الحذف داخل حلقة for-each ConcurrentModificationException

في Python يمكنك أن تحذف من قائمة أثناء المرور عليها وتحصل على نتيجة خاطئة بصمت، وفي Java ترمي القائمة استثناء، وهذا أفضل، ولكنه يفاجئ من لم يره من قبل. أردنا حذف المنتجات التي نفد مخزونها:

List<Integer> stock = new ArrayList<>(List.of(5, 0, 3, 0, 8));
try {
    for (Integer s : stock) if (s == 0) stock.remove(s);
} catch (ConcurrentModificationException e) {
    System.out.println("for-each + remove: " + e);
}
stock = new ArrayList<>(List.of(5, 0, 3, 0, 8));
stock.removeIf(s -> s == 0);
System.out.println("removeIf: " + stock);
for-each + remove: java.util.ConcurrentModificationException
removeIf: [5, 3, 8]

والاسم ConcurrentModificationException مضلل، فلا يوجد أي خيط آخر هنا، وإنما الحلقة تستخدم مكرراً Iterator يحفظ عدد التعديلات على القائمة، فإذا تغير العدد من خارجه رمى الاستثناء في الدورة التالية، كما يشرح توثيق الاستثناء. والطريقة الصحيحة removeIf التي تحذف في مرور واحد، أو stream().filter(...).toList() إذا أردت قائمة جديدة. ولاحظ تفصيلاً آخر في الكود الخاطئ: stock.remove(s) مع Integer تستدعي الحذف بالقيمة، أما مع int فتستدعي الحذف بالموقع، وهذا فخ آخر من نفس العائلة.

الخطأ 5: القوائم الثابتة Immutable Lists من Arrays.asList أو List.of ليست ArrayList

List<String> fixed = Arrays.asList("coffee", "dates");
try { fixed.add("cups"); } catch (UnsupportedOperationException e) { System.out.println("Arrays.asList add: " + e); }
try { List.of("coffee", null); } catch (NullPointerException e) { System.out.println("List.of with null: " + e); }
List<String> growable = new ArrayList<>(List.of("coffee", "dates"));
growable.add("cups");
Arrays.asList add: java.lang.UnsupportedOperationException
List.of with null: java.lang.NullPointerException
new ArrayList<>(...): [coffee, dates, cups]

كل هذه الطرق ترجع النوع List، فالمترجم يسمح لك باستدعاء add عليها، ولكن كل واحدة لها سلوك مختلف وقت التشغيل: Arrays.asList غلاف حول مصفوفة بحجم ثابت، تقبل التعديل بـ set ولا تقبل الإضافة أو الحذف، وList.of غير قابلة للتعديل إطلاقاً ولا تقبل null حتى عند الإنشاء، وnew ArrayList<>(...) هي القائمة القابلة للتعديل بالكامل. والقاعدة: استخدم List.of للقوائم الثابتة، وهي الافتراضية في الكود الحديث لأنها تمنع التعديل غير المقصود، وانسخها إلى ArrayList صراحة عندما تحتاج التعديل، ولا ترجع من دالة عامة قائمة يتوقع المستدعي أن يعدلها وهي ثابتة دون أن توثق ذلك.

الخطأ 6: Unboxing لقيمة null من Map

Map<String, Integer> stock = new HashMap<>(Map.of("coffee", 12));
int dates = stock.get("dates");                 // null unboxed to int
unboxing null: Cannot invoke "java.lang.Integer.intValue()" because the return value of "java.util.Map.get(Object)" is null
getOrDefault: 0

السطر لا يحتوي أي استدعاء ظاهر، ومع ذلك رمى NullPointerException. فالمفتاح غير موجود، وget رجعت null، والمترجم أضاف بصمت استدعاء intValue() ليحول Integer إلى int، والرسالة المفيدة من JDK 14 تكشف هذا الاستدعاء الخفي. ومن يأتي من Python ينتظر KeyError واضحاً، ومن يأتي من JavaScript ينتظر undefined يمر بهدوء، وJava تعطيك الاثنين في أسوأ شكل: لا خطأ عند الغياب، ثم خطأ غامض بعده. والطريقة الصحيحة أن تقرر ماذا يعني الغياب: getOrDefault("dates", 0) إذا كان صفراً منطقياً، أو Optional.ofNullable(...) إذا كان حالة يجب أن يعالجها المستدعي، أو merge وcomputeIfAbsent عندما تعدل القاموس.

الأعداد Numbers

الخطأ 7: BigDecimal من double، والمساواة بين 2.0 و2.00

من يعرف أن double لا يصلح للمال ينتقل إلى BigDecimal، ثم يقع في فخين داخلها:

new BigDecimal(9.90):      9.9000000000000003552713678800500929355621337890625
new BigDecimal("9.90"):    9.90
2.0 equals 2.00:           false
2.0 compareTo 2.00 == 0:   true
HashSet of 2.0 and 2.00:   2 elements

السطر الأول يبين أن المنشئ الذي يأخذ double يحفظ القيمة الثنائية الحقيقية للعدد 9.90 بكل أرقامها، فالخطأ دخل قبل أن تصل إلى BigDecimal، والطريقة الصحيحة أن تنشئها من نص كما في السطر الثاني، أو من BigDecimal.valueOf(double) التي تمر عبر التمثيل النصي المختصر. والسطر الثالث أغرب: الرقمان 2.0 و2.00 غير متساويين بـ equals، لأن BigDecimal تعتبر عدد الخانات العشرية Scale جزءاً من القيمة، ولذلك أصبح في HashSet عنصران لنفس السعر. والمقارنة الرقمية تكون بـ compareTo(...) == 0، وإذا احتجت أن تستخدمها مفتاحاً فوحد الخانات بـ setScale(2, RoundingMode.HALF_UP) قبل ذلك. ولهذا السبب نحسب في هذه المرحلة بالهللات في long، فهو أبسط وأسرع ولا فخاخ فيه طالما أن أصغر وحدة معروفة.

وفخ ثالث في BigDecimal يظهر عند أول قسمة. فإذا أردت أن تقسم 100 ريال على ثلاثة أقساط دون أن تحدد التقريب، فإن divide ترمي استثناء بدل أن تعطيك نتيجة تقريبية، لأنها لا تستطيع أن تمثل الناتج بعدد محدود من الخانات:

divide without rounding: Non-terminating decimal expansion; no exact representable decimal result.
divide(3, 2, HALF_UP): 33.33

وهذا سلوك جيد في الحقيقة، لأنه يجبرك أن تقرر قاعدة التقريب بدل أن تقررها اللغة عنك، فاكتب دائماً عدد الخانات ونوع التقريب RoundingMode في كل قسمة، وفكر أين تذهب الهللة الباقية، فثلاثة أقساط من 33.33 مجموعها 99.99، والمتجر يحتاج أن يضيف الهللة إلى أحد الأقساط.

الخطأ 8: مجموع int يلتف، والقسمة الصحيحة Integer Division

هذا هو خطأ تقرير المبيعات الذي بدأنا به. طلبيتا جملة في شهر التخفيضات، كل واحدة بأكثر من مليار هللة:

int[] basketTotals = {1_500_000_000, 1_200_000_000};        // halalas, two big wholesale orders
int sum = 0;
for (int t : basketTotals) sum += t;
int sum: -1594967296, int average: -797483648
long sum: 2700000000, average: 1350000000
7 / 2 = 3, 7 / 2.0 = 3.5

والمجموع تجاوز الحد الأعلى لـ int وهو حوالي 2.1 مليار، فالتف إلى قيمة سالبة دون استثناء، والمتوسط سالب أيضاً. والحل long لكل مجموع، أو Math.addExact إذا أردت استثناء عند التجاوز. أما السطر الأخير فيذكرك أن القسمة بين عددين صحيحين في Java تحذف الجزء العشري، مثل C# وGo وبخلاف Python 3 وJavaScript، فمتوسط 7 على 2 هو 3. وفي حسابات المال لا تحول إلى double لتحل هذا، وإنما قرر قاعدة التقريب واكتبها صراحة، كما فعلنا في دالة الضريبة بإضافة النصف قبل القسمة.

جدول بأربعة أخطاء في الأعداد: BigDecimal من double، وequals بين 2.0 و2.00، ومجموع int، والقسمة الصحيحة، مع ما طبعته Java والطريقة الصحيحة
أربعة أخطاء في الأعداد، لا يعترض عليها المترجم ولا ترمي أي استثناء.

النصوص Strings والوقت Time

الخطأ 9: تجميع النص String Concatenation بـ += داخل حلقة طويلة

هذا خطأ مهمة تصدير الفواتير. بناء نص طويل بإضافة جزء صغير في كل دورة يبدو طبيعياً في Python وJavaScript، ومحركاتهما تحسن هذا النمط في كثير من الحالات، أما في Java فالنص غير قابل للتعديل، وكل += ينشئ نصاً جديداً وينسخ إليه كل ما قبله:

n=20,000  += in loop:   344 ms   StringBuilder:   8 ms   same: true
n=40,000  += in loop:   624 ms   StringBuilder:  10 ms   same: true
n=80,000  += in loop:  2368 ms   StringBuilder:   0 ms   same: true
أعمدة تبين زمن بناء نص بالجمع داخل حلقة مقابل StringBuilder لثلاثة أطوال
مضاعفة الطول من 40,000 إلى 80,000 ضاعفت الزمن أربع مرات تقريباً مع +=، وبقي StringBuilder عند بضع ملي ثوان.

ولاحظ شكل النمو: من 40,000 إلى 80,000 حرف تضاعف الطول، وتضاعف الزمن حوالي أربع مرات، وهذا ما تتوقعه من نسخ يكبر مع مربع الطول. وفي مهمة التصدير التي كانت تعمل بسرعة مع ألف فاتورة، صار الزمن دقائق مع عشرين ألفاً دون أن يتغير الكود. والحل StringBuilder الذي يكبر مخزنه الداخلي ويضيف إليه دون نسخ كل مرة، أو String.join وCollectors.joining إذا كانت لديك قائمة جاهزة، أو الكتابة مباشرة إلى Writer إذا كان الناتج ملفاً. ولا تحتاج StringBuilder في سطر واحد مثل "a" + b + "c"، فالمترجم يحوله إلى كود فعال من نفسه، والمشكلة فقط في الحلقة.

الخطأ 10: SimpleDateFormat مشترك بين الخيوط

وهذا خطأ الإيصالات التي ظهرت بتاريخ 2031. في كود Java القديم ستجد غالباً حقلاً static من نوع SimpleDateFormat يستخدمه الجميع، لأن إنشاءه يبدو مكلفاً. والمشكلة أن هذا الكلاس يحفظ حالة داخلية أثناء التحليل والتنسيق، فإذا استخدمته عدة خيوط في نفس الوقت اختلطت حالاتها، وهذا مكتوب في توثيقه تحت Synchronization. وفي التجربة حللنا ثم نسقنا أربعة تواريخ معروفة 4,000 مرة بثمانية خيوط وكائن واحد مشترك:

SimpleDateFormat shared by 8 threads: 2271 wrong results of 4000, e.g. [2026-03-15 -> 2031-10-04, 2026-03-15 -> 2026-07-15, 2026-07-04 -> 2026-07-15]
java.time LocalDate + DateTimeFormatter: 0 wrong results of 4000
now in Riyadh time zone: 2026-10-31T12:00+03:00[Asia/Riyadh]
نتائج SimpleDateFormat المشترك بين ثمانية خيوط مع تواريخ خاطئة، ونتائج parallel forEach إلى ArrayList مع عناصر ضائعة
كائن غير آمن للخيوط في يد عدة خيوط: أكثر من نصف التواريخ خاطئة، وعناصر تضيع من القائمة، دون أي استثناء.

أكثر من نصف النتائج خاطئة، ومنها 15 مارس 2026 الذي أصبح 4 أكتوبر 2031، وكلها دون استثناء واحد. والحل ليس قفلاً حول الكائن، وإنما مكتبة java.time الموجودة منذ Java 8: LocalDate للتاريخ دون وقت، وInstant للحظة زمنية، وZonedDateTime مع ZoneId للوقت في منطقة زمنية مثل Asia/Riyadh التي تمثل توقيت السعودية، وDateTimeFormatter للتنسيق. وكلها ثابتة Immutable وآمنة للخيوط، فأعطت صفر أخطاء في نفس التجربة، ويمكنك أن تحفظ الـ Formatter في حقل static final دون قلق. وإذا رأيت java.util.Date أو Calendar أو SimpleDateFormat في كود جديد فهذه علامة على أن المطور نقل الكود من مصدر قديم.

وقد يتساءل البعض: لماذا يكون كلاس في المكتبة القياسية غير آمن بهذا الشكل؟ والإجابة أن SimpleDateFormat صمم في Java 1.1 عام 1997، وهو يحفظ داخله كائن Calendar يكتب فيه أجزاء التاريخ أثناء التحليل ثم يقرؤها، فإذا كتب خيطان في نفس الكائن في نفس اللحظة قرأ كل منهما خليطاً من أجزاء التاريخين، وهذا يفسر التواريخ الغريبة مثل 2031. وعندما صممت java.time في Java 8 بني كل شيء فيها ثابتاً من البداية، فكل عملية ترجع كائناً جديداً ولا يوجد شيء يتغير بين الخيوط. ولاحظ أيضاً أن الخطأ لم يظهر في اختبار يشغل التحويل مرة واحدة، ولن يظهر في بيئة التطوير التي يصلها طلب واحد في كل مرة، وإنما يظهر تحت الضغط في الإنتاج، وبنسبة تتغير مع عدد الطلبات المتزامنة.

الخطأ 11: switch القديمة دون break

switch (status) {                                   // old switch: falls through without break
    case "paid": label += "paid ";
    case "shipped": label += "shipped ";
    default: label += "unknown";
}
String modern = switch (status) {
    case "paid" -> "paid";
    case "shipped" -> "shipped";
    default -> "unknown";
};
paid: old switch -> "paid shipped unknown", arrow switch -> "paid"
shipped: old switch -> "shipped unknown", arrow switch -> "shipped"

الصيغة القديمة الموروثة من C تكمل التنفيذ إلى الحالة التالية Fall Through إذا لم تكتب break، فالطلب المدفوع حصل على الحالات الثلاث. ومن يأتي من Python وGo لا يتوقع هذا السلوك، لأن match في Python وswitch في Go لا تكمل إلى ما بعدها. والصيغة الحديثة بالسهم -> الموجودة منذ Java 14 لا تكمل أبداً، وهي تعبير يرجع قيمة، والمترجم يجبرك أن تغطي كل الحالات عندما تكون على enum أو sealed. واستخدمها في كل كود جديد، ولا تخلط الصيغتين في نفس switch.

التزامن Concurrency والموارد Resources

الخطأ 12: parallel().forEach إلى ArrayList

من قرأ أن parallel() تسرع التدفقات يضيفها إلى كود يجمع النتائج في قائمة خارجية:

List<Integer> collected = new ArrayList<>();
IntStream.range(0, 100_000).parallel().forEach(collected::add);   // shared, not thread-safe
run 1 forEach into ArrayList: size 89667 (expected 100000)
run 2 forEach into ArrayList: size 93634 (expected 100000)
run 3 forEach into ArrayList: size 99806 (expected 100000)
toList(): size 100000

والنتيجة ناقصة في كل مرة وبعدد مختلف، لأن ArrayList ليست آمنة للخيوط، وعدة خيوط من المجمع المشترك تكتب في نفس المصفوفة الداخلية في نفس الوقت، وهي نفس حالة السباق Race Condition التي شرحناها في مقال التزامن. وقد يرمي نفس الكود أحياناً ArrayIndexOutOfBoundsException، ولهذا وضعنا له catch في التجربة، ولكنه في الثلاث مرات ضيع العناصر بصمت. والطريقة الصحيحة أن تترك التدفق يجمع النتيجة بنفسه بـ toList() أو collect، فهو يجمع كل جزء في خيطه ثم يدمج الأجزاء بأمان. والقاعدة الأعم: الدوال داخل التدفق يجب ألا تعدل شيئاً خارجه، وإذا احتجت أثراً جانبياً فاستخدم حلقة عادية.

الخطأ 13: ملف لا يغلق Too many open files

في Python يغلق الملف غالباً عندما يختفي المتغير لأن عد المراجع Reference Counting يحذف الكائن فوراً، وفي Java لا يحدث ذلك، فالجامع قد لا يعمل لدقائق، والملف يبقى مفتوحاً حتى ذلك الحين. وفي Leaks.java قرأنا نفس ملف الأسعار 2,000 مرة دون إغلاقه، مع حد 256 ملفاً مفتوحاً للعملية بالأمر ulimit -n 256:

var r = new BufferedReader(new FileReader(f.toFile()));   // never closed
r.readLine();
leak: failed after 252 reads: java.io.FileNotFoundException: /tmp/prices.csv (Too many open files)
close: read the file 2000 times

وبعد 252 قراءة رفض النظام فتح ملف جديد، والعدد أقل من 256 لأن الـ JVM نفسها تفتح بضعة ملفات. ولاحظ أن الرسالة FileNotFoundException مضللة، فالملف موجود، والسبب الحقيقي بين القوسين. ومع try (var r = ...) نجحت القراءات الألفين، لأن كل ملف أغلق عند نهاية الكتلة. وفي الخدمات يظهر نفس الخطأ مع اتصالات قاعدة البيانات واتصالات HTTP، فتنفد بعد ساعات من التشغيل، ولهذا تعامل مع كل ما ينفذ AutoCloseable بـ try-with-resources، كما شرحنا في مقال الأنواع.

والحد الذي وضعناه في التجربة 256 صغير عمداً حتى يظهر الخطأ بسرعة، أما في الإنتاج فالحد عادة بالآلاف، ولهذا لا يظهر الخطأ في اليوم الأول، وإنما بعد ساعات أو أيام عندما تتراكم الملفات أو الاتصالات المفتوحة، وغالباً في وقت الذروة. وقد يتساءل البعض: ألا يغلق جامع القمامة الملف عندما يحذف الكائن؟ والإجابة أن بعض الكلاسات في الـ JDK تنظف مواردها عند جمعها عبر Cleaner، ولكن لا يوجد أي ضمان متى يعمل الجامع، وفي الكومة الكبيرة قد لا يعمل لدقائق إذا لم يكن هناك ضغط على الذاكرة، والملفات تنفد قبل الذاكرة. ولهذا فإن الاعتماد على الجامع لإغلاق الموارد من أسوأ الطرق على الإطلاق، والطريقة الوحيدة المضمونة هي الإغلاق الصريح. ولتعرف عدد الملفات المفتوحة في خدمة تعمل على Linux انظر في المجلد /proc/<pid>/fd، فإذا كان العدد يكبر مع الوقت دون أن ينزل فلديك تسرب في الموارد.

الخطأ 14: Optional.get() دون فحص

Optional.get(): java.util.NoSuchElementException: No value present
orElseThrow with a message: order 9 not found

من يتعلم Optional لأول مرة يستخدمها كما كان يستخدم null: ينشئها ثم يستدعي get() مباشرة، فيحصل على NoSuchElementException: No value present بدل NullPointerException، دون أي معلومة عن الطلب المفقود. والطرق الصحيحة: orElse لقيمة بديلة، وmap وifPresent لتعمل على القيمة إن وجدت، وorElseThrow مع استثناء يحمل رسالة واضحة كما في السطر الثاني، وهو الذي تحوله طبقة الـ API إلى رد 404، كما فعلنا في خدمة الطلبات.

ماذا تمسك الأدوات؟ Error Prone على نفس الملف

بعد أن جمعنا الأخطاء في ملف واحد صار السؤال العملي: كم منها كان سيمسكه الفحص الثابت Static Analysis قبل أن يصل إلى المراجعة؟ فأخذنا Idioms.java وLeaks.java كما هما، ووضعناهما في مشروع Gradle صغير فيه Error Prone 2.51.0 بنفس الإعداد الذي استخدمناه في المقال الأول من المرحلة، وشغلنا الترجمة فقط:

Idioms.java:1: warning: [DefaultPackage] Java classes shouldn't use default package
Idioms.java:34: warning: [ReferenceEquality] Comparison using reference equality instead of value equality
Idioms.java:18: error: [EqualsHashCode] Classes that override equals should also override hashCode.
Idioms.java:55: warning: [ListRemoveAmbiguous] Ambiguous call to List.remove; clarify if index-based or value-based removal was intended by adding a comment
Idioms.java:55: warning: [ModifyCollectionInEnhancedForLoop] Modifying a collection while iterating over it in a loop may cause a ConcurrentModificationException to be thrown or lead to undefined behavior.
Idioms.java:72: warning: [BigDecimalLiteralDouble] new BigDecimal(double) loses precision in this case. The exact value here is `new BigDecimal("9.9000000000000003552713678800500929355621337890625")`.
Idioms.java:74: warning: [BigDecimalEquals] BigDecimal#equals has surprising behavior: it also compares scale.
Idioms.java:104: warning: [FutureReturnValueIgnored] Return value of methods returning Future must be checked. Ignoring returned Futures suppresses exceptions thrown from the code that completes the Future.
Idioms.java:120: warning: [FutureReturnValueIgnored] Return value of methods returning Future must be checked. Ignoring returned Futures suppresses exceptions thrown from the code that completes the Future.
Idioms.java:130: error: [ReturnValueIgnored] Return value of 'orElseThrow' must be used
Idioms.java:171: warning: [FallThrough] Execution may fall through from the previous case; add a `// fall through` comment before this line if it was deliberate
Idioms.java:172: warning: [FallThrough] Execution may fall through from the previous case; add a `// fall through` comment before this line if it was deliberate
Leaks.java:1: warning: [DefaultPackage] Java classes shouldn't use default package
Leaks.java:14: warning: [DefaultCharset] Implicit use of the platform default charset, which can result in differing behaviour between JVM executions or incorrect behavior if the encoding of the data source doesn't match expectations.
Leaks.java:16: warning: [DefaultCharset] Implicit use of the platform default charset, which can result in differing behaviour between JVM executions or incorrect behavior if the encoding of the data source doesn't match expectations.

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

  • أمسك Error Prone خمسة من الأخطاء الأربعة عشر: == على النصوص باسم ReferenceEquality، وequals دون hashCode كخطأ يوقف البناء، والحذف أثناء الحلقة باسمين، وBigDecimal من double مع القيمة الحقيقية كاملة، وBigDecimal.equals مع 2.0 و2.00، وswitch القديمة باسم FallThrough.
  • ولم يمسك التسعة الباقية: المفتاح الذي يتغير، وArrays.asList مع add، وUnboxing القيمة الغائبة، والتفاف المجموع، و+= في الحلقة، وSimpleDateFormat المشترك، وparallel().forEach إلى قائمة، والملف الذي لا يغلق، وOptional.get(). وأغلبها يحتاج معرفة بما يحدث وقت التشغيل لا تعرفه أداة تقرأ الكود دون أن تشغله، وبعضها له فحوص في Error Prone غير مفعلة افتراضياً.
  • وأمسك أشياء لم نقصدها: FutureReturnValueIgnored لأننا أهملنا ما ترجعه submit في حالة التواريخ، فلو رمت مهمة استثناء لضاع، وReturnValueIgnored في السطر الذي استدعينا فيه orElseThrow لنرى الاستثناء فقط دون أن نستخدم القيمة، وDefaultCharset لأن FileReader دون ترميز يعتمد على ترميز النظام، وهذا خطأ حقيقي في كود يقرأ ملفات فيها نص عربي.

والنتيجة العملية أن أداة الفحص تغطي قرابة نصف القائمة بتكلفة سطرين في ملف البناء، وهذا يستحق، ولكنها لا تغني عن معرفة الأنماط، ولا عن الاختبارات التي تشغل الكود فعلاً، ولا عن مراجعة يقرأ فيها زميل الكود بعين من يعرف هذه الفخاخ. ولاحظ أن الرسائل نفسها تعلمك، فرسالة BigDecimalLiteralDouble تكتب لك القيمة الحقيقية التي حفظتها، ورسالة FallThrough تخبرك كيف توثق السقوط إذا كان مقصوداً، ورسالة ListRemoveAmbiguous تذكرك بالفرق بين الحذف بالقيمة والحذف بالموقع الذي ذكرناه في الخطأ 4.

من أين تأتي هذه العادات؟ لغتك الأولى First Language

كل لغة تترك في المطور افتراضات لا يراها حتى تخالفها لغة أخرى، ومعرفة افتراضاتك تختصر عليك نصف الأخطاء. والقائمة التالية تعيد ترتيب الأخطاء الأربعة عشر نفسها حسب اللغة التي يأتي منها المطور غالباً:

  • من Python: تتوقع أن == تقارن المحتوى، وأن الأعداد الصحيحة لا تلتف أبداً، وأن القسمة بـ / تعطي عدداً عشرياً، وأن الملف يغلق عندما يختفي المتغير، وأن dict.get يرجع قيمة بديلة. وفي Java كل هذا مختلف: equals، وint بحجم ثابت، والقسمة الصحيحة، وtry-with-resources، وgetOrDefault.
  • من JavaScript وTypeScript: تعودت أن النص قيمة بدائية تقارن بـ ===، وأن المصفوفة تقبل أي إضافة، وأن الكود يعمل في خيط واحد فلا يوجد سباق إلا بين await وآخر. وفي Java تعمل الخيوط بالتوازي فعلاً، ولهذا يصبح SimpleDateFormat المشترك وparallel().forEach إلى قائمة أخطاء حقيقية، وقد شرحنا نموذج JavaScript في حلقة الأحداث.
  • من PHP: المصفوفة في PHP قائمة وقاموس معاً وتنسخ عند الإسناد، وكل طلب يبدأ بذاكرة نظيفة وينتهي، فلا يوجد تسرب يعيش أياماً ولا حالة مشتركة بين الطلبات. وفي خدمة Java تعيش الكائنات بين الطلبات، فالحقل static مشترك بين كل الطلبات وكل الخيوط، وهذا مصدر خطأ التواريخ وخطأ التسرب الذي رأيته في مقال الذاكرة.
  • من C#: اللغة الأقرب إلى Java، ومع ذلك فإن == على النصوص في C# تقارن المحتوى لأن اللغة تعيد تعريفها، وdecimal نوع مدمج لا يحتاج BigDecimal، وswitch في C# تمنع السقوط إلى الحالة التالية. فمطور C# يقع تحديداً في الأخطاء 1 و7 و11.
  • من Go: تعودت على القيم الصفرية بدل null، وعلى الأخطاء كقيم راجعة، وعلى أن map ترجع قيمة صفرية للمفتاح الغائب. ولهذا يفاجئك Unboxing القيمة الغائبة في الخطأ 6، وOptional.get() في الخطأ 14.

والنصيحة العملية: عندما تبدأ لغة جديدة، اكتب لنفسك قائمة قصيرة بافتراضات لغتك الأولى، وتحقق من كل واحد منها في اللغة الجديدة بتجربة صغيرة في JShell قبل أن تبني عليه كوداً حقيقياً، فهذه التجارب الصغيرة هي بالضبط ما فعلناه في هذا المقال، وهي أرخص بكثير من اكتشاف الافتراض الخاطئ في تقرير مبيعات شهري.

كيف تكتب اختبارات Tests تمسك هذه الأخطاء؟

الأخطاء التي لم تمسكها أداة الفحص يمسكها اختبار مكتوب بوعي بها، والفرق بين اختبار يمسكها واختبار يمر رغمها غالباً في اختيار القيم. وفيما يلي القواعد التي خرجنا بها من هذه الحالات، وكل واحدة مرتبطة بخطأ رأيته في التجربة:

  • اختبر بقيم خارج الحالات السهلة: مقارنة Integer بـ == مرت في الاختبار بكمية 100 وفشلت عند 200، فضع في الاختبارات قيماً أكبر من 127، ومبالغ قريبة من حدود int كما في الخطأ 8، ونصوصاً عربية ورموزاً خارج المستوى الأساسي.
  • اختبر القيم الغائبة صراحة: لكل دالة تقرأ من Map أو قاعدة بيانات اختبار للمفتاح غير الموجود، فهذا يكشف الخطأ 6 و14 قبل الإنتاج.
  • اختبر التساوي من الجهتين: إذا كتبت equals فاختبر أيضاً أن كائنين متساويين يعاملان ككائن واحد في HashSet وفي مفاتيح HashMap، فهذا الاختبار وحده كان سيمسك الخطأ 2.
  • اختبر التزامن بانطلاق متزامن: الخطأ 10 والخطأ 12 لا يظهران بخيط واحد، واختبار يطلق عدة خيوط معاً بعد CountDownLatch ويكرر العملية آلاف المرات يكشفهما، وهذا ما فعلناه في اختبار المخزون بعشرين طلباً متزامناً في خدمة الطلبات.
  • اختبر الحجم لا الصحة فقط: الخطأ 9 لا يغير النتيجة، وإنما الزمن، فاختبار أداء بسيط بحجم بيانات قريب من الإنتاج، يشغل في الـ CI ويفشل إذا تجاوز حداً واسعاً، يكشف النمو غير الخطي قبل أن يكشفه العميل.
  • اختبر الموارد بحدود صغيرة: كما فعلنا مع ulimit -n 256، شغل اختبارات التكامل بحدود صغيرة للملفات وللاتصالات في مجمع قاعدة البيانات، فالتسرب يظهر في دقائق بدل أيام.

وقد شرحنا أنواع الاختبارات ومتى تكتب كل نوع في أنواع الاختبارات، وطريقة تقييم جودة الاختبارات نفسها بالطفرات في اختبار الطفرات، وهي طريقة مفيدة هنا تحديداً، لأنها تغير equals إلى == أو تحذف break وتسألك: هل فشل أي اختبار؟

أسلوب Java الحديث Modern Java في جدول واحد

أغلب الأخطاء السابقة لها جذر مشترك: كود مكتوب بعادات لغة أخرى، أو بأسلوب Java قبل عشر سنوات. والجدول التالي يجمع ما تراه في الكود القديم، وما يقابله في Java 21 و25، وهو ما تبحث عنه في المراجعة:

بدلاً مناكتبمنذ
class بيانات بـ getters وequals وhashCode يدويةrecordJava 16
instanceof ثم تحويل يدويif (o instanceof Card c) أو أنماط السجلاتJava 16 و21
switch بـ breakswitch بالسهم كتعبير، مع sealedJava 14 و17
Date وCalendar وSimpleDateFormatjava.timeJava 8
Arrays.asList وCollections.unmodifiableListList.of وMap.of وList.copyOfJava 9 و10
stream().collect(Collectors.toList())stream().toList()Java 16
نص بعدة أسطر بـ + و\nText Block """Java 15
إغلاق في finallytry-with-resourcesJava 7
مجمع خيوط لطلبات تنتظر الشبكةالخيوط الافتراضية Virtual ThreadsJava 21
null كقيمة راجعة لـ «لا يوجد»Optional مع orElseThrowJava 8 و10
تحديد النوع في كل متغير محلي واضحvarJava 10

ولا يعني هذا أن تعيد كتابة كل الكود القديم في الفريق، فهذا مكلف ولا يضيف قيمة للمستخدم. وإنما أن تكتب الكود الجديد بهذا الأسلوب، وأن تحسن الكود القديم الذي تلمسه في كل تعديل، وأن تضيف أدوات مثل Error Prone التي تمسك أنماطاً مثل equals دون hashCode و== على النصوص قبل أن تصل إلى المراجعة.

وماذا عن Lombok؟ السجلات Records أم توليد الكود

في كثير من مشاريع Java ستجد مكتبة Lombok، وهي تولد الـ getters والـ setters وequals وhashCode من تعليقات مثل @Data و@Value، وكانت لسنوات الحل الأشهر لطول كود كائنات البيانات. ومع السجلات Records صار جزء كبير من استخدامها غير ضروري للكود الجديد، فالسجل جزء من اللغة نفسها، والمترجم يفهمه، ولا يحتاج إضافة في بيئة التطوير ولا في البناء، ويعمل مع أنماط السجلات في switch وinstanceof. ولكن السجل ثابت دائماً، فإذا كان المشروع يعتمد على كائنات تتغير، مثل الكيانات Entities في JPA، فستبقى Lombok أو الكود اليدوي هناك. والقاعدة في مشروع قائم: لا تخلط الأسلوبين في نفس النوع من الكائنات دون سبب، واتبع ما يقرره الفريق، واستخدم record لكل كائن بيانات جديد لا يتغير، مثل طلبات الـ API وردودها كما فعلنا في خدمة الطلبات.

قائمة مراجعة Code Review Checklist لكود Java

  • هل كل مقارنة بين كائنات بـ equals أو Objects.equals، وكل == بين أنواع بدائية فقط؟
  • هل كل class فيها equals فيها hashCode، أو هي record؟
  • هل مفاتيح Map وعناصر Set ثابتة؟
  • هل الحذف من المجموعات بـ removeIf أو مكرر صريح، وليس داخل for-each؟
  • هل يوجد get من Map يتحول إلى نوع بدائي دون فحص الغياب؟
  • هل المبالغ في long بأصغر وحدة أو BigDecimal من نص، والمجاميع في long؟
  • هل يوجد += على نص داخل حلقة قد تطول؟
  • هل يوجد Date أو SimpleDateFormat، وخاصة في حقل static؟
  • هل الدوال داخل التدفقات خالية من الآثار الجانبية، وparallel() مستخدمة لسبب مقاس؟
  • هل كل ملف واتصال ومورد في try-with-resources؟
  • هل يوجد Optional.get() أو catch فارغة؟
  • هل switch الجديدة بالسهم، وهل تغطي كل حالات enum وsealed؟

خطة القراءة Reading Plan من المصادر الرسمية

إذا وصلت إلى هنا فأنت تعرف ما يكفي لتعمل في فريق Java، والخطوة التالية أن تبني فهماً منظماً من المصادر الرسمية بدل الإجابات المتفرقة. والخطة التالية لأربعة أسابيع بساعة يومياً تقريباً، وكلها مصادر مجانية من Oracle وOpenJDK والمشاريع نفسها:

خطة قراءة من أربعة أسابيع: اللغة، ثم المكتبة، ثم التشغيل، ثم المشروع، من المصادر الرسمية
أربعة أسابيع من المصادر الرسمية: اللغة، ثم المكتبة، ثم التشغيل، ثم أدوات المشروع.
  1. الأسبوع الأول، اللغة: ابدأ بموقع dev.java/learn الذي تكتبه Oracle وفريق Java، وفيه مسار Language الذي يشرح الصيغة الحديثة كاملة، ثم صفحة Records. ولا تقرأ كل شيء بالترتيب، فأنت تعرف البرمجة، وإنما ركز على ما لا يوجد في لغتك: الأنواع البدائية والمرجعية، وGenerics، والواجهات Interfaces، وsealed.
  2. الأسبوع الثاني، المكتبة القياسية: مسار Collections and Streams ثم Date Time، ومعهما توثيق Java SE 25 API، وتعود إليه لكل كلاس تستخدمه، واقرأ فيه التحذيرات المكتوبة تحت Implementation Note وSynchronization، فهي التي تشرح أغلب الأخطاء في هذا المقال.
  3. الأسبوع الثالث، التشغيل: صفحة Virtual Threads، ثم دليل ضبط جامع القمامة، ثم المراقبة والتشخيص لأدوات مثل jcmd وJFR التي استخدمناها في هذه المرحلة.
  4. الأسبوع الرابع، المشروع: دليل Gradle Building Java Projects، ثم مرجع Spring Boot إذا كان فريقك يستخدمه، ثم صفحة JDK 25 في OpenJDK التي تجمع كل JEP في الإصدار الذي تعمل عليه.

وعندما تختلف إجابتان في الإنترنت عن سلوك اللغة، فالمرجع الأخير هو مواصفة لغة Java للإصدار الذي تعمل عليه، وهي ليست للقراءة من البداية إلى النهاية، ولكن فصل الاستثناءات والتحويلات فيها أوضح مما يتوقع كثيرون. وأخيراً، جرب الأمثلة الصغيرة في Java Playground في المتصفح أو في JShell قبل أن تكتبها في المشروع.

تدريب عملي

  • شغل Error Prone على Idioms.java كما في المقال الأول من المرحلة، وعد كم خطأ من الأربعة عشر أمسكه وحده، وأي الأسماء أعطاها لها.
  • أصلح Cart بحيث لا يمكن أن يتغير بعد إنشائه، باستخدام record وList.copyOf، ثم أعد تشغيل الحالة mutablekey.
  • غير حالة concat لتكتب إلى ملف بـ BufferedWriter بدل بناء نص في الذاكرة، وقس الزمن والذاكرة لمليون سطر.
  • ابحث في مشروع فريقك عن SimpleDateFormat وnew BigDecimal( متبوعة برقم، وOptional.get()، وقيم كم منها يحتاج إصلاحاً.

الخلاصة

وصلنا لنهاية الموضوع ونهاية مرحلة Java، وأهم ما فيه:

  • أغلب الأخطاء الأربعة عشر لم ترم أي استثناء ولم يعترض عليها المترجم، وإنما أعطت قيماً خاطئة: مجموع سالب، وتاريخ في 2031، وعناصر ضائعة من قائمة.
  • المساواة في Java بـ equals للكائنات، وequals يحتاج hashCode معه، والسجلات Records تحل الاثنين، ومفاتيح القواميس يجب ألا تتغير.
  • List.of وArrays.asList وArrayList ثلاثة سلوكيات مختلفة لنفس النوع، والحذف أثناء الحلقة يكون بـ removeIf.
  • المال في long بالهللات أو BigDecimal من نص، والمجاميع في long، و2.0 لا تساوي 2.00 بـ equals.
  • += في الحلقة كلف 2.4 ثانية لنص من 80,000 حرف، وSimpleDateFormat المشترك أعطى 2,271 تاريخاً خاطئاً من 4,000، وjava.time صفراً.
  • الدوال داخل التدفقات بلا آثار جانبية، وكل مورد في try-with-resources، وOptional مع orElseThrow برسالة واضحة.
  • Error Prone أمسك خمسة من الأخطاء الأربعة عشر قبل التشغيل، ومعها ثلاث مشكلات لم نقصدها مثل الترميز الافتراضي، والباقي يمسكه اختبار يختار قيمه بوعي بهذه الفخاخ، مثل الأرقام فوق 127 والمفاتيح الغائبة والخيوط المتزامنة.
  • تعلم الباقي من dev.java وتوثيق Java SE 25 ومواصفة اللغة، بخطة قصيرة ومركزة على ما يختلف عن لغتك.

وبهذا تكتمل مرحلة لغة Java للمطورين، وتجد هنا مقالاتها والمقالات المرتبطة بها، وفي مرحلة Kotlin من نفس المسار نبني نفس الخدمة على نفس الـ JVM بلغة أقصر وأمان من القيم الفارغة:

دليلJava لمن يعرف البرمجة: JDK 25 والأدوات Toolchainتثبيت JDK 25 وجولة في صيغة Java مقارنة بما تعرفه، ثم مشروع Gradle فيه منسق وفاحص واختبارات، مع أخطاء زرعناها لنرى أين يمسكها البناء.دليلالأنواع والذاكرة Memory والاستثناءات في Javaلماذا أعطت مقارنة 128 بـ 128 القيمة false، وكم يكلف مليون رقم في الذاكرة، وماذا تختار الـ JVM داخل Container صغير، وكيف تقرأ استثناءات Java وتصلح تسرب الذاكرة.دليلالتزامن في Java: الخيوط الافتراضية Virtual Threadsنفس المئة طلب ونفس الألفي بصمة SHA-256 بكل أدوات التزامن في Java 25 مع الأرقام، ثم سباق على المخزون وجمود بين خيطين نصنعهما ونصلحهما.دليلكيف تتعامل لغات البرمجة مع الأخطاء؟لماذا ترمي Java وPython الاستثناءات بينما ترجع Go وRust الخطأ كقيمة، وكيف تمنع الخطأ من أن يختفي بصمت في كودك.دليلقراءة ال Stack Trace وتصحيح الأخطاء Debugging للمبتدئينكيف تقرأ رسالة الخطأ في Python وJavaScript سطراً بسطر، ومتى تستخدم الطباعة أو نقاط التوقف في VS Code، وكيف يجد git bisect التعديل الذي أدخل الخطأ.

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

  • أكتوبر 2026: كتابة المقال، وتشغيل الحالات الأربع عشرة على JDK 25.0.5 (Temurin) داخل Docker.
نشرة عرب رووت | ArabRoot

معرفة تستحق مكاناً في بريدك.

مقالات مختارة وأدوات مفيدة وأفكار لمشروعك القادم، في رسالة واحدة كل أسبوع.

يمكنك إلغاء الاشتراك متى شئت. الخصوصية

تم استلام طلبك. افتح بريدك واضغط رابط التأكيد لإتمام الاشتراك.