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

قياس الأداء والمعايرة

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

قياس الأداء والمعايرة

قياس الأداء في Java أصعب مما يبدو للوهلة الأولى. إنّ JVM بيئة تشغيل تكيّفية للغاية: تُفسّر البايت كود، وتُنشئ ملفًا تشخيصيًا للمسارات الساخنة، وتُجمّلها إلى كود نيتف أثناء التنفيذ، وتُجري جمع القمامة، وتُدمج استدعاءات الدوال عبر مواقع الاستدعاء — وكل ذلك أثناء تشغيل المعيار القياسي. تجاهل هذه الديناميكيات يعني أن أرقامك مجرد وهم. ستتعلم في هذا الدرس لماذا تكذب القياسات الساذجة، وما هو الإحماء ولماذا يهم، وكيف يحل Java Microbenchmark Harness (JMH) المشكلة بشكل صحيح.

لماذا القياس الساذج يكذب

أول ما يخطر ببال معظم المطوّرين هو تغليف الكود بـ System.nanoTime() وحساب الفارق. هذا الأسلوب معطوب في قياسات الدقيقة من عدة أوجه مختلفة.

تجميع JIT ليس فوريًا. عندما يصادف JVM دالةً لأول مرة يُفسّرها ببطء. بعد استدعاء الدالة نحو 10,000 مرة (عتبة C1) يُجمّل JIT الكود إلى كود نيتف مُحسَّن. وبعد 10,000 مرة أخرى قد يُعيد التجميل مع تحسينات C2 الأكثر عدوانية. إن شغّل معيارك دالةً 100 مرة فإن التنفيذات الـ 80 الأولى مُفسَّرة والـ 20 الأخيرة مُجمَّلة: والمتوسط لا معنى له.

إزالة الكود الميت. إن استطاع JIT إثبات أن نتيجة حساب ما لن تُستخدم فإنه يُزيله كليًا. معيار يحسب مجموعًا ويتجاهل النتيجة قد لا يقيس شيئًا على الإطلاق.

طيّ الثوابت. جسم حلقة يعتمد فقط على ثوابت وقت التجميل قد يُحسب مرة واحدة وتُزال الحلقة. تقيس عملية لا شيء.

تدخّل جامع القمامة. توقّف جمع القمامة في منتصف القياس يُضخّم التوقيت. بدون التحكّم في GC تختلف النتائج المتتالية بحسب حالة GC.

اضطراب جدولة نظام التشغيل. سلب الخيوط لمعالج المهام، وضبط تردد المعالج (turbo boost، وأوضاع توفير الطاقة)، وتأثيرات ذاكرة NUMA تُضيف ضوضاء إلى النتائج.

لا تنشر نتائج قياسات الدقيقة من حلقة توقيت بسيطة. منحنى إحماء JIT يعني أن التكرارات الأولى غير ممثّلة. رقم واحد مأخوذ بعد 100 تكرار قد يكون أبطأ 5-50 مرة من التكلفة الفعلية في حالة الثبات التي يدفعها كودك الإنتاجي فعلًا.

فهم الإحماء

الإحماء هو المرحلة التي ينتقل فيها JVM بقطعة من الكود من التنفيذ المُفسَّر إلى الكود النيتف المُحسَّن بالكامل. يتدرّج خط أنابيب تجميل JVM المتعدد المستويات كما يلي:

  • المستوى 0: تفسير بحت.
  • المستوى 1-3: مُجمّل C1 (مُجمّل العميل) — تجميل سريع مع تحسينات أساسية.
  • المستوى 4: مُجمّل C2 (مُجمّل الخادم) — تحسينات تخمينية عدوانية، ودمج استدعاءات، وتحليل الهروب.

يجب أن يقيس المعيار فقط إنتاجية حالة الثبات على المستوى 4. وهذا يعني تشغيل الكود عددًا كافيًا من التكرارات — عادةً آلاف الاستدعاءات — قبل البدء بتسجيل القياسات. يختلف العدد الفعلي من تكرارات الإحماء اللازمة بحسب تعقيد الدالة وقرارات تحليل JVM.

انظر هذا المثال المضلّل:

import java.util.List; public class NaiveBenchmark { static int sumList(List<Integer> list) { int total = 0; for (int v : list) total += v; return total; } public static void main(String[] args) { var data = List.of(1, 2, 3, 4, 5); // القياس الأول — مُفسَّر في معظمه long t0 = System.nanoTime(); for (int i = 0; i < 100; i++) sumList(data); long t1 = System.nanoTime(); System.out.printf("متوسط أول 100 تكرار: %.0f ns%n", (t1 - t0) / 100.0); // القياس الثاني — JIT جمّل sumList الآن long t2 = System.nanoTime(); for (int i = 0; i < 100; i++) sumList(data); long t3 = System.nanoTime(); System.out.printf("متوسط ثاني 100 تكرار: %.0f ns%n", (t3 - t2) / 100.0); } }

من الناحية العملية الكتلة الثانية أسرع 5-20 مرة من الأولى حتى على كود بسيط. لا الرقمان خطأ — إنهما يقيسان حالتين مختلفتين للـ JVM. يعمل الكود الإنتاجي دائمًا في حالة الثبات؛ كذلك يجب أن يعمل معيارك.

Java Microbenchmark Harness (JMH)

طوّر مهندسو أداء JVM في Oracle أداة JMH وتوزّع عبر OpenJDK، وهي الأداة القياسية لكتابة معايير الدقيقة الصحيحة في Java. تتعامل تلقائيًا مع الإحماء، ومنع إزالة الكود الميت (عبر Blackhole واستهلاك النتيجة)، وعمليات JVM المتشعّبة، والتجميع الإحصائي.

إضافة JMH إلى مشروع Maven

<!-- تبعيات pom.xml --> <dependencies> <dependency> <groupId>org.openjdk.jmh</groupId> <artifactId>jmh-core</artifactId> <version>1.37</version> </dependency> <dependency> <groupId>org.openjdk.jmh</groupId> <artifactId>jmh-generator-annprocess</artifactId> <version>1.37</version> <scope>provided</scope> </dependency> </dependencies>

معيار JMH أدنى صالح

import org.openjdk.jmh.annotations.*; import org.openjdk.jmh.infra.Blackhole; import java.util.concurrent.TimeUnit; import java.util.ArrayList; import java.util.List; import java.util.stream.IntStream; @BenchmarkMode(Mode.AverageTime) // قياس متوسط الوقت لكل عملية @OutputTimeUnit(TimeUnit.MICROSECONDS) // الإبلاغ بالميكروثانية @State(Scope.Benchmark) // نسخة واحدة مشتركة بين كل الخيوط @Warmup(iterations = 5, time = 1) // 5 تكرارات إحماء بثانية لكل منها @Measurement(iterations = 10, time = 1) // 10 تكرارات قياس @Fork(2) // التشغيل في عمليتي JVM جديدتين public class ListSumBenchmark { private List<Integer> data; @Setup public void setup() { data = new ArrayList<>(IntStream.rangeClosed(1, 1_000) .boxed() .toList()); } @Benchmark public void imperativeSum(Blackhole bh) { int total = 0; for (int v : data) total += v; bh.consume(total); // يمنع إزالة الكود الميت } @Benchmark public void streamSum(Blackhole bh) { int total = data.stream().mapToInt(Integer::intValue).sum(); bh.consume(total); } }
معامل Blackhole ضروري. بدون استهلاك النتيجة يحق لـ JIT تحديد أن الحساب غير مستخدم وإزالته كليًا. bh.consume(value) يُنشئ تبعية مزيّفة تُبطل هذا التحسين دون إضافة عبء ملحوظ في حد ذاتها.

شرح تعليمات JMH الرئيسية

  • @BenchmarkModeMode.AverageTime أو Mode.Throughput أو Mode.SampleTime أو Mode.SingleShotTime. اختر بحسب ما يهمك: متوسط الكُمون، أو الإنتاجية، أو توزيع النسب المئوية.
  • @Fork — يُشغّل كل معيار في JVM جديد. يعزل هذا حالة JIT بين المعايير ويمنع قرارات تحليل أحد المعايير من التأثير على آخر. لا تشغّل قط بـ @Fork(0) في القياسات الإنتاجية.
  • @Warmup / @Measurement — تتحكّم في مرحلتي الإحماء والقياس بشكل منفصل. تكرارات الإحماء تُتجاهل؛ فقط تكرارات القياس تُساهم في النتيجة المُبلَّغ عنها.
  • @StateScope.Benchmark (مشترك)، أو Scope.Thread (نسخة لكل خيط)، أو Scope.Group (لكل مجموعة معايير). يحدد مشاركة الكائنات في المعايير متعددة الخيوط.
  • @Setup / @TearDown — تهيئة الحالة وتنظيفها؛ لا تُضعا داخل دالة @Benchmark أبدًا.

تشغيل JMH وقراءة المخرجات

ابنِ JAR شاملًا وشغّله من سطر الأوامر:

mvn clean package -DskipTests java -jar target/benchmarks.jar ListSumBenchmark -rf json -rff results.json

يطبع JMH جدولًا كهذا:

Benchmark Mode Cnt Score Error Units ListSumBenchmark.imperativeSum avgt 20 2.341 ± 0.041 us/op ListSumBenchmark.streamSum avgt 20 3.912 ± 0.088 us/op

قيمة ± هي فاصل الثقة بنسبة 99.9% عبر عمليات التشعّب والتكرارات. فاصل ضيّق يعني أن القياس مستقر. فاصل واسع يعني تباينًا عاليًا — تحتاج إلى المزيد من التكرارات وعمليات التشعّب أو جهاز أكثر عزلًا.

شغّل المعايير على جهاز خامل تمامًا. تبويبات المتصفح وعميل البريد الإلكتروني والعمليات الخلفية تسرق وقت المعالج وتُدخل ضوضاء. للقياسات الحرجة، اربط عملية المعيار بنواة معالج مخصصة باستخدام taskset (Linux) وعطّل ضبط التردد.

مصائد المعايرة الشائعة لتجنّبها

  • دمج حلقات المعيار: إن كانت دالة معيارك سريعة جدًا بحيث تستغرق تكرارات الاستدعاء نانوثانيات قليلة، فقد يدمج JIT التكرارات ويُقلّل تكاليف الإعداد. استخدم @OperationsPerInvocation أو أعد هيكلة الدالة.
  • تكرارات إحماء قليلة جدًا: الدوال المعقدة ذات رسوم الاستدعاء العميقة تحتاج إحماءً أطول. ابدأ بخمسة تكرارات على الأقل بثانية واحدة لكل منها وتحقق بـ -prof gc أن GC لا يتدخّل.
  • قياس الشيء الخطأ: قياس HashMap.get() مع مفاتيح String مُنشأة داخل جسم المعيار يقيس تخصيص String وحساب التجزئة، لا الاسترجاع وحده.
  • تجاهل معدّل التخصيص: استخدم -prof gc لرؤية البايتات المُخصَّصة لكل عملية. دالة تُخصّص كثيرًا ستُطلق توقفات GC في الإنتاج حتى لو بدت سريعة بمعزل عن سياقها.

الخلاصة

القياس الساذج بـ System.nanoTime() ينتج نتائج غير موثوقة لأن تجميل JIT وإزالة الكود الميت وGC تعمل بالتزامن مع قياسك. الإحماء هو انتقال JVM من التنفيذ المُفسَّر إلى الكود المُحسَّن بالكامل — القياسات المأخوذة قبل اكتمال الإحماء تعكس أداء المُفسِّر لا أداء الإنتاج. JMH يحل كل هذه المشكلات عبر مراحل إحماء محكومة وعمليات JVM متشعّبة واستهلاك النتائج بـ Blackhole والتجميع الإحصائي. استخدم JMH لأي معيار تنوي التصرّف بناءً على نتائجه.