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

مشروع: تشخيص مشكلة أداء

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

مشروع: تشخيص مشكلة أداء

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

السيناريو

يُبلّغ فريق بأن ReportService يستغرق 12 ثانية لتوليد تقرير يحتوي على 50,000 صف، وأن استخدام الـ heap يرتفع مع كل استدعاء. مهمّتك هي تشخيص المشكلة وإصلاحها دون تخمين. الكود الابتدائي يبدو كالتالي:

// قبل — النسخة البطيئة والمسرّبة للذاكرة public class ReportService { private static final Map<String, List<Row>> cache = new HashMap<>(); public String generateReport(List<Row> rows, String format) { String key = format + rows.hashCode(); if (cache.containsKey(key)) { return cache.get(key); } StringBuilder sb = new StringBuilder(); for (Row row : rows) { String line = ""; for (int i = 0; i < row.columns().size(); i++) { line = line + row.columns().get(i); if (i < row.columns().size() - 1) { line = line + ","; } } sb.append(line).append("\n"); } String result = sb.toString(); cache.put(key, List.of(rows.toArray(new Row[0]))); // خطأ: يُخزّن الصفوف لا النتيجة return result; } }
لا تبدأ أبدًا بالتخمين. يحتوي الكود أعلاه على أربع مشكلات متمايزة على الأقل. بدون قياس، قد تُصلح الخطأ في المكان الخطأ وتُضيع ساعات. نمّط دائمًا قبل أن ترقّع.

الخطوة 1 — إنشاء معيار أساسي للأداء

قبل لمس أي شيء، اكتب معيار JMH للحصول على رقم قابل للتكرار ودافئ بـ JIT للمقارنة لاحقًا.

@BenchmarkMode(Mode.AverageTime) @OutputTimeUnit(TimeUnit.MILLISECONDS) @State(Scope.Benchmark) @Warmup(iterations = 3, time = 1) @Measurement(iterations = 5, time = 2) @Fork(1) public class ReportBenchmark { private List<Row> rows; @Setup public void setUp() { rows = IntStream.range(0, 50_000) .mapToObj(i -> new Row(List.of("col1_" + i, "col2_" + i, "col3_" + i))) .collect(Collectors.toList()); } @Benchmark public String generate(ReportService svc) { return svc.generateReport(rows, "csv"); } } // النتيجة الأساسية: متوسط 11,843 مللي ثانية/عملية، حمل GC: 38%

الخطوة 2 — توصيل مُنمِّط وقراءة مخطط اللهب

شغّل المعيار مع async-profiler (أو JFR) لجمع مخطط لهب CPU:

# تسجيل ملف JFR أثناء المعيار java -XX:+FlightRecorder \ -XX:StartFlightRecording=filename=report.jfr,duration=30s \ -jar benchmarks.jar ReportBenchmark

افتح ملف .jfr في JDK Mission Control. يكشف مخطط اللهب ثلاثة مسارات ساخنة:

  1. تسلسل String داخل الحلقة الداخلية — 54% من وقت المعالج.
  2. rows.hashCode() على قائمة من 50,000 عنصر — يُستدعى في كل مرة — 28% من وقت المعالج.
  3. HashMap.put مع تخصيصات متنامية — 11% من وقت المعالج.

الخطوة 3 — تحليل الـ Heap بتفريغ الذاكرة

أطلق تفريغ heap بعد عشرة توليدات للتقرير وافتحه في Eclipse MAT أو VisualVM:

// إجبار تفريغ heap برمجيًا (مفيد في الاختبارات) com.sun.management.HotSpotDiagnosticMXBean mxBean = ManagementFactory.newPlatformMXBeanProxy( ManagementFactory.getPlatformMBeanServer(), "com.sun.management:type=HotSpotDiagnostic", com.sun.management.HotSpotDiagnosticMXBean.class); mxBean.dumpHeap("heap.hprof", true);

تُظهر "شجرة الهيمنة" في MAT أن ReportService.cache يحتجز 480 ميجابايت — إنه يخزّن كائنات List<Row> لا السلاسل المنسّقة. مفتاح التخزين مبني على rows.hashCode() الذي يتغيّر في كل مرة لأن Row لا تُعيد تعريف hashCode()، فالتخزين المؤقت لا يُصيب أبدًا. هذا هو تسريب الذاكرة.

Map ثابتة غير محدودة الحجم هي أكثر تسريبات الذاكرة شيوعًا في خدمات Java. كل مدخل يُضاف أثناء اختبار الحمل يبقى في الذاكرة حتى يتعطّل الـ JVM أو تُفرّغ الخريطة. استخدم LinkedHashMap بسياسة إزاحة بحد أقصى، أو مخزن مؤقت حقيقي مثل Caffeine.

الخطوة 4 — إصلاح المشكلات واحدة تلو الأخرى

أصلح كل مشكلة بمعزل لتقيس أثر كل تغيير بشكل مستقل.

الإصلاح 1 — استبدال تسلسل السلاسل الضمني بـ StringBuilder مخصص داخل الحلقة الداخلية:
// قبل (يُنشئ كائن String جديد في كل تكرار): line = line + row.columns().get(i); // بعد (StringBuilder واحد يُعاد استخدامه لكل صف): StringBuilder rowBuilder = new StringBuilder(); for (int i = 0; i < cols.size(); i++) { if (i > 0) rowBuilder.append(','); rowBuilder.append(cols.get(i)); } sb.append(rowBuilder).append('\n'); // إضافة حرف لا سلسلة

المعيار بعد الإصلاح 1: 4,210 مللي ثانية/عملية — انخفاض 64% بسبب التسلسل وحده.

الإصلاح 2 — إزالة التخزين المؤقت المعطوب أو استبداله بتخزين محدود وصحيح:
// تخزين مؤقت LRU محدود باستخدام Caffeine private final Cache<String, String> cache = Caffeine.newBuilder() .maximumSize(100) .expireAfterWrite(Duration.ofMinutes(10)) .build(); // في generateReport: اشتقق مفتاحًا ثابتًا من المحتوى الفعلي لا hashCode() String key = format + "-" + rows.size() + "-" + rows.get(0).columns().hashCode();

المعيار بعد الإصلاح 2: 3,980 مللي ثانية/عملية. تستقر الذاكرة عند ~20 ميجابايت بغض النظر عن عدد الاستدعاءات.

الإصلاح 3 — التحديد المسبق لحجم الـ StringBuilder الخارجي:
// تجنّب النسخ المتكرر للمصفوفة الداخلية int estimatedCapacity = rows.size() * 64; // ~64 حرفًا لكل صف في المتوسط StringBuilder sb = new StringBuilder(estimatedCapacity);

المعيار بعد الإصلاح 3: 3,410 مللي ثانية/عملية.

الإصلاح 4 — بثّ المخرجات بدلًا من بناء سلسلة ضخمة واحدة:
public void writeReport(List<Row> rows, String format, OutputStream out) throws IOException { try (BufferedWriter writer = new BufferedWriter( new OutputStreamWriter(out, StandardCharsets.UTF_8), 65_536)) { for (Row row : rows) { List<String> cols = row.columns(); for (int i = 0; i < cols.size(); i++) { if (i > 0) writer.write(','); writer.write(cols.get(i)); } writer.newLine(); } } }

يتجنب البثّ تجميع النتيجة الكاملة في الذاكرة. لتقرير مؤلف من 50,000 صف، يُلغي هذا تخصيص وسيط بحجم 4 ميجابايت. المعيار بعد الإصلاحات الأربعة: 680 مللي ثانية/عملية — تحسين بمقدار 18 ضعفًا عن الأصل البالغ 11,843 مللي ثانية.

الخطوة 5 — التحقق تحت الضغط

المعيار أحادي الخيط ليس كل القصة. تحقق بحمل متزامن باستخدام اختبار إجهاد بسيط قائم على المنفّذ:

ExecutorService pool = Executors.newFixedThreadPool(20); List<Future<Long>> futures = new ArrayList<>(); for (int i = 0; i < 200; i++) { futures.add(pool.submit(() -> { long start = System.nanoTime(); service.generateReport(rows, "csv"); return System.nanoTime() - start; })); } LongSummaryStatistics stats = futures.stream() .mapToLong(f -> { try { return f.get(); } catch (Exception e) { throw new RuntimeException(e); } }) .summaryStatistics(); System.out.printf("p50=%.1f ms, max=%.1f ms%n", stats.getAverage() / 1e6, (double) stats.getMax() / 1e6); pool.shutdown();
قِس دائمًا النسب المئوية لا المتوسطات فقط. ارتفاع زمن الاستجابة عند p99 الذي يُخفيه المتوسط هو ما يختبره مستخدموك فعلًا تحت الضغط. للقياس على مستوى الإنتاج، استخدم HdrHistogram أو المدرجات التكرارية للزمن المبنية في JMH.

الخطوة 6 — توثيق التحقيق

يجب أن يُرافق كل إصلاح أداء احترافي توثيق موجز يشمل: العَرَض الملاحَظ، والأدلة التي أشار إليها كل تنميط لكل سبب جذري، والإصلاح المُطبَّق، وأرقام قبل وبعد. هذا يُنشئ ذاكرة مؤسسية ويمنع الانتكاس ذاته من التسلل عبر مراجعات الكود المستقبلية.

الدروس المستفادة من هذا المشروع

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

الخلاصة

يتّبع تحقيق الأداء المنهجي سير عمل قابلًا للتكرار: معيار أساسي ← مخطط لهب CPU ← تحليل heap ← إصلاحات مستهدفة تُقاس بشكل فردي ← التحقق تحت حمل متزامن ← سجل مكتوب. تتغيّر الأدوات (JFR وasync-profiler وMAT وJMH) لكن العملية دائمًا واحدة. أتقن العملية وستجد المشكلة في كل مرة.