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

ولاحظ شيئاً مشتركاً بين أغلب هذه الأخطاء قبل أن نبدأ: كثير منها لا يرمي أي استثناء، وإنما يعطي قيمة خاطئة تمر بهدوء إلى قاعدة البيانات أو التقرير، وهذا أخطر من الاستثناء لأنه لا يظهر في السجلات 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 togetherHashSet<Sku> size after adding an equal Sku: 2, contains: false
HashSet<SkuRecord> size: 1, contains: true
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 intunboxing 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 لتحل هذا، وإنما قرر قاعدة التقريب واكتبها صراحة، كما فعلنا في دالة الضريبة بإضافة النصف قبل القسمة.

النصوص 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 الذي يكبر مخزنه الداخلي ويضيف إليه دون نسخ كل مرة، أو 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]
أكثر من نصف النتائج خاطئة، ومنها 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-saferun 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 يدوية | record | Java 16 |
instanceof ثم تحويل يدوي | if (o instanceof Card c) أو أنماط السجلات | Java 16 و21 |
switch بـ break | switch بالسهم كتعبير، مع sealed | Java 14 و17 |
Date وCalendar وSimpleDateFormat | java.time | Java 8 |
Arrays.asList وCollections.unmodifiableList | List.of وMap.of وList.copyOf | Java 9 و10 |
stream().collect(Collectors.toList()) | stream().toList() | Java 16 |
نص بعدة أسطر بـ + و\n | Text Block """ | Java 15 |
إغلاق في finally | try-with-resources | Java 7 |
| مجمع خيوط لطلبات تنتظر الشبكة | الخيوط الافتراضية Virtual Threads | Java 21 |
null كقيمة راجعة لـ «لا يوجد» | Optional مع orElseThrow | Java 8 و10 |
| تحديد النوع في كل متغير محلي واضح | var | Java 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 والمشاريع نفسها:

- الأسبوع الأول، اللغة: ابدأ بموقع dev.java/learn الذي تكتبه Oracle وفريق Java، وفيه مسار Language الذي يشرح الصيغة الحديثة كاملة، ثم صفحة Records. ولا تقرأ كل شيء بالترتيب، فأنت تعرف البرمجة، وإنما ركز على ما لا يوجد في لغتك: الأنواع البدائية والمرجعية، وGenerics، والواجهات Interfaces، وsealed.
- الأسبوع الثاني، المكتبة القياسية: مسار Collections and Streams ثم Date Time، ومعهما توثيق Java SE 25 API، وتعود إليه لكل كلاس تستخدمه، واقرأ فيه التحذيرات المكتوبة تحت Implementation Note وSynchronization، فهي التي تشرح أغلب الأخطاء في هذا المقال.
- الأسبوع الثالث، التشغيل: صفحة Virtual Threads، ثم دليل ضبط جامع القمامة، ثم المراقبة والتشخيص لأدوات مثل jcmd وJFR التي استخدمناها في هذه المرحلة.
- الأسبوع الرابع، المشروع: دليل 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.