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

أخطاء الأداء الشائعة

15 دقيقة الدرس 9 من 13

أخطاء الأداء الشائعة

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

١. الـ Autoboxing والـ Unboxing

يفصل نظام أنواع Java بين الأنواع الأولية (int, long, double, …) ونظيراتها من الأغلفة (Integer, Long, Double, …). الـ autoboxing هو التحويل التلقائي الذي يجريه المُترجم بين النوعين. ورغم أنه يُتيح واجهات برمجية مريحة، فإنه يُخفي تكاليف حقيقية: تخصيص ذاكرة على الكومة (heap)، وضغط على GC، وإهدار ذاكرة التخزين المؤقت للمعالج.

// مُجمِّع يبدو بريئًا Long total = 0L; // نوع غلاف for (long i = 0; i < 10_000_000L; i++) { total += i; // unbox ثم جمع ثم rebox — 10 مليون تخصيص على الكومة } System.out.println(total);

يُحوّل المُترجم total += i تقريبًا إلى:

total = Long.valueOf(total.longValue() + i);

يُخصَّص عشرة ملايين كائن Long ويُهمَل كل منها فورًا، مما يُجهد GC ويُضيّع ذاكرة التخزين المؤقت للمعالج. الحل بسيط للغاية:

long total = 0L; // نوع أولي — لا تخصيصات على الإطلاق for (long i = 0; i < 10_000_000L; i++) { total += i; }

النسخة الأولية أسرع بنحو 5–10 مرات على JVM حديثة ولا تُخصّص شيئًا. القاعدة هي: استخدم الأنواع الأولية في المسارات الحرجة الثقيلة حسابيًا؛ واحتفظ بالأغلفة حين تفرضها الواجهة (الجينيريك، الحقول القابلة للـ null، المجموعات).

Autoboxing خفي: المجموعات. Map<String, Integer> تخزّن أعدادًا صحيحة مُغلَّفة. كل map.get(key) يُعيد Integer؛ واستخدامه في حسابات يُجري unboxing فورًا. إن كنت تُكرّر خريطة وتجمع قيمها، فكّر في مصفوفات int[] أو خرائط الأنواع الأولية من Eclipse Collections أو Koloboke في المسارات الساخنة.

فخ ثانٍ للـ autoboxing هو مقارنة المساواة:

Integer a = 1000; Integer b = 1000; System.out.println(a == b); // false — كائنان مختلفان على الكومة System.out.println(a.equals(b)); // true — مساواة في القيمة

تُخزّن JVM مثيلات Integer للقيم من −128 إلى 127 (قابلة للضبط عبر -XX:AutoBoxCacheMax)، لذا تعيد == true للقيم الصغيرة صدفةً — وهذه ثغرة تظهر فقط في الإنتاج مع الأعداد الكبيرة. استخدم دائمًا equals() لمقارنة الأغلفة.

التحليل الساكن. يُعلِّم كلٌّ من IntelliJ IDEA وSpotBugs على الـ boxing داخل الحلقات الضيّقة. فعِّل فحص Primitive types can replace wrappers في بيئة التطوير لديك لرصد هذه الحالات تلقائيًا أثناء التطوير.

٢. تسلسل النصوص داخل الحلقات

String غير قابل للتعديل. كل + أو += على String يُنشئ كائن String جديدًا وينسخ جميع الأحرف الموجودة إليه. داخل حلقة، ينتج عن ذلك نسخ أحرف بتعقيد O(n²) — وهو الخوارزمية الكلاسيكية لـ "Schlemiel the Painter".

// النمط المضاد: تخصيصات O(n²) String result = ""; for (String line : lines) { result += line + "\n"; // كائن String جديد في كل تكرار }

مع 10,000 سطر، التكرار رقم 5,000 ينسخ ~5,000 حرف للمحتوى الموجود فقط، ثم ينسخ السطر الجديد. العمل الإجمالي يتناسب مع n². لا تُحوّل JVM والـ JIT هذا تلقائيًا إلى StringBuilder داخل جسم الحلقة.

// الصحيح: O(n) — مخزن مؤقت واحد يتنامى StringBuilder sb = new StringBuilder(lines.size() * 80); // حجّم مسبقًا إن عرفت متوسط الطول for (String line : lines) { sb.append(line).append('\n'); // إلحاق للمخزن المؤقت الموجود دون نسخ المقدمة } String result = sb.toString(); // تخصيص واحد في النهاية
متى يساعدك المُترجم. التسلسل البسيط وقت الترجمة مثل "Hello " + name + "!" خارج الحلقة يُعيد المُترجم كتابته إلى تسلسل StringBuilder واحد (Java 9+ تستخدم invokedynamic + StringConcatFactory وهو أسرع من ذلك). المشكلة حصرًا عندما يقع التسلسل داخل حلقة، لأن المُترجم لا يعرف عدد التكرارات وقت الترجمة.

لبناء نصوص منظّمة على نطاق واسع، فضّل String.join() أو StringJoiner أو Collectors.joining() من Streams — وكلها تستخدم StringBuilder داخليًا:

// قائم على Streams: نظيف وكفؤ String result = lines.stream() .collect(Collectors.joining("\n")); // StringJoiner: مفيد عند الحاجة إلى بادئة ولاحقة StringJoiner sj = new StringJoiner(", ", "[", "]"); items.forEach(sj::add); String formatted = sj.toString();

٣. سوء تحديد حجم المجموعات

مجموعات Java القابلة للتوسع — ArrayList وHashMap وHashSet وStringBuilder — تبدأ صغيرة وتتضاعف حين تمتلئ. التضاعف يعني: تخصيص مصفوفة جديدة بضعف الحجم تقريبًا، ونسخ كل العناصر الموجودة إليها، ثم التخلص من المصفوفة القديمة. إن أضفت مليون عنصر إلى ArrayList بالحجم الافتراضي، فستُشغّل ~20 دورة توسّع ونسخ وتنتج ~20 مصفوفة مهملة على GC.

// الحجم الافتراضي 16 — يُشغّل ~17 توسّعًا لـ 100,000 عنصر List<String> list = new ArrayList<>(); for (int i = 0; i < 100_000; i++) { list.add("item-" + i); } // تحجيم مسبق — لا توسّعات، مصفوفة واحدة مُخصَّصة، لا ضغط على GC List<String> list = new ArrayList<>(100_000); for (int i = 0; i < 100_000; i++) { list.add("item-" + i); }

بالنسبة لـ HashMap وHashSet يختلف الحساب قليلًا لأن عامل التحميل الافتراضي هو 0.75 — أي تتوسّع الخريطة حين تبلغ 75% من امتلائها. لتجنب أي توسّع حين تعرف الحجم النهائي، مرّر capacity = expectedSize / 0.75 + 1:

int expectedSize = 1_000; // بدون هذا، خريطة 1000 عنصر تتوسّع عند 768 إدخالًا Map<String, Integer> map = new HashMap<>((int)(expectedSize / 0.75) + 1);

تُغلّف Maps.newHashMapWithExpectedSize(n) من Guava تلك المعادلة فلا تحتاج إلى حسابها:

Map<String, Integer> map = Maps.newHashMapWithExpectedSize(1_000);
الإفراط في التحجيم أيضًا تكلفة. تخصيص new ArrayList<>(10_000_000) حين تنتهي بتخزين 100 عنصر يُهدر 10 ملايين خانة على الكومة. حجّم مسبقًا حين تملك تقديرًا جيدًا؛ وإلا دع المجموعة تنمو طبيعيًا. التوازن المثالي هو تقدير حدّ أعلى معقول، لا الحد الأقصى المطلق.

القياس لا التخمين

كل خطأ من الأخطاء أعلاه غير مرئي بدون قياس. قبل أي تحسين:

  1. حلّل أولًا (JFR / async-profiler / VisualVM) للتأكد من المسار الساخن.
  2. قِس بـ JMH للحصول على أرقام قبل/بعد قابلة للإعادة.
  3. طبّق تغييرًا واحدًا في كل مرة ثم أعد القياس.

التحسين الأعمى كثيرًا ما ينقل العمل من مسار بارد إلى مسار ساخن. الأخطاء الثلاثة في هذا الدرس آمنة للإصلاح المسبق فقط في الحلقات الضيّقة أو المسارات الحرجة التي تفهم تكلفتها جيدًا.

الخلاصة

  • Autoboxing: استخدم الأنواع الأولية في الحلقات الساخنة؛ كن حذرًا من أنواع الأغلفة في الحسابات؛ استخدم equals() لمقارنة الأغلفة.
  • تسلسل النصوص: لا تستخدم += في حلقة أبدًا؛ استخدم StringBuilder أو StringJoiner أو Collectors.joining().
  • تحجيم المجموعات: حجّم مسبقًا حين تعرف الحجم التقريبي النهائي؛ تذكّر عامل التحميل 0.75 للخرائط؛ تجنّب الإفراط في التحجيم.