volatile ورؤية الذاكرة
volatile ورؤية الذاكرة
تعرفت سابقًا على ظاهرة سباق البيانات (race condition) التي تحدث حين يعدّل خيطان بياناتٍ مشتركة دون تزامن. لكن ثمة فئة أكثر خفاءً وخطرًا من أخطاء التزامن: يمكن لخيط ما أن يقرأ قيمة قديمة — ليس لأن خيطًا آخر يكتب في نفس اللحظة، بل لأن JVM أو المعالج مسموح له بالاحتفاظ بنسخة خاصة مخزّنة مؤقتًا. فهم هذا هو صميم نموذج الذاكرة في Java.
مشكلة الرؤية
لا تقرأ المعالجات الحديثة من الذاكرة الرئيسية وتكتب إليها في كل تعليمة. لكل نواة سجلاتها الخاصة وطبقات ذاكرة مخبأ (L1 و L2 و L3). يستغل JVM هذا بكثافة: قيمة كتبها الخيط A قد تبقى في ذاكرة المخبأ الخاصة به دون أن تُصرَّف إلى الذاكرة الرئيسية. الخيط B الذي يعمل على نواة مختلفة يقرأ من مخبئه الخاص ويرى القيمة القديمة إلى أجل غير مسمى.
إليك هذا المثال الكلاسيكي:
على كثير من بيئات JVM والأجهزة، لا ينتهي هذا البرنامج أبدًا. يخزّن الخيط العامل keepRunning في سجل ولا يُعيد القراءة من الذاكرة الرئيسية. الكتابة التي يجريها الخيط الرئيسي تبقى غير مرئية له.
happens-before: الضمان الرسمي
لا يفكّر نموذج الذاكرة في Java من منظور ذاكرة المخبأ أو تعليمات المعالج. بدلًا من ذلك يعرّف قاعدة واحدة مستقلة عن الأجهزة تُسمى happens-before.
علاقة happens-before بين فعلين (كتابة وقراءة) تضمن أن الكتابة مرئية للقراءة. بعبارة أخرى: إذا كان الفعل A يسبق الفعل B وفق happens-before، فإن كل كتابة للذاكرة أجراها A (أو أي فعل قبله) مضمونة الرؤية لـ B.
قواعد happens-before الأساسية في JMM:
- ترتيب البرنامج: داخل خيط واحد، كل تعليمة تسبق التالية وفق happens-before.
- فك قفل المزامنة ← قفلها: فك قفل كتلة
synchronizedيسبق أي قفل لاحق على نفس الشاشة (monitor). - كتابة volatile ← قراءة volatile: الكتابة في متغير
volatileتسبق كل قراءة لاحقة لنفس المتغير. - تشغيل الخيط:
Thread.start()يسبق أي فعل في الخيط المُشغَّل. - الانتظار للخيط: كل الأفعال في خيط ما تسبق عودة
Thread.join().
الكلمة المحجوزة volatile
الإعلان عن حقل بالكلمة volatile يُنشئ حافة happens-before بين كل كتابة لذلك الحقل وكل قراءة لاحقة له، عبر جميع الخيوط.
يترتب على ذلك تأثيران ملموسان:
- الرؤية: الكتابة في حقل
volatileتُصرَّف فورًا إلى الذاكرة الرئيسية. والقراءة تجلب دائمًا من الذاكرة الرئيسية (متجاوزةً ذاكرة المخبأ). تختفي مشكلة القراءة القديمة. - لا إعادة ترتيب حول volatile: يُمنع JVM والمعالج من نقل قراءات أو كتابات متغيرات أخرى عبر وصول
volatile. هذا هو تأثير "حاجز الذاكرة".
إصلاح المثال السابق أمر بسيط:
إضافة volatile تضمن أن الخيط العامل سيلاحظ الكتابة في وقت محدود.
volatile ليس بديلًا عن synchronized
volatile يضمن الرؤية لكن لا يضمن الأتمية. خطأ كلاسيكي شائع:
حتى لو كانت كل قراءة لـ count تأتي من الذاكرة الرئيسية، فإن العملية المركّبة اقرأ-عدّل-اكتب الخاصة بـ count++ لا تزال سباقًا: قد يقرأ خيطان نفس القيمة، يضيف كل منهما 1، ويكتبان النتيجة، فيضيع عدّ واحد.
للعمليات المركّبة تحتاج إما إلى synchronized أو AtomicInteger (يُغطّى في الدرس التالي). حالات الاستخدام الصحيحة لـ volatile هي:
- علامة منطقية (boolean) يضبطها خيط واحد ويقرأها الآخرون (مثل علامة الإيقاف).
- مرجع (أو قيمة أولية) يكتبها خيط واحد ويقرأها كثيرون، حيث أحدث قيمة هي كل ما يهم.
- حقول تعمل كحواجز نشر — كتابة حقل
volatileبعد بناء كائن ما تضمن أن الكائن المبني مرئي بأمان للخيوط التي تقرأ ذلك الحقل لاحقًا.
النشر الآمن عبر volatile
أحد استخدامات volatile الدقيقة والمهمة هو النشر الآمن. بدونه قد ترى الخيوط الأخرى كائنًا مبنيًا جزئيًا حتى لو كُتب المرجع بعد عودة المُنشئ — لأن JVM يمكنه إعادة ترتيب الكتابات في حقول الكائن مع إسناد المرجع.
volatile. الرؤية تعني: حين تُكتب قيمة ما، سيراها القرّاء. الأتمية تعني: عملية مركّبة (مثل ++ أو تحديث شرطي) تنجز كخطوة واحدة لا تتجزأ. volatile يمنحك الأولى فقط.
اعتبارات الأداء
قراءة volatile أرخص من synchronized لكنها ليست مجانية. كل قراءة تُجبر على رحلة بروتوكول تماسك ذاكرة المخبأ على الأجهزة الحديثة (حاجز "mfence" على x86 أو ما يعادله). في الحلقات المحكمة التي تقرأ volatile ملايين المرات في الثانية تصبح التكلفة ملموسة. النمط المعياري هو نسخ حقل volatile إلى متغير محلي في بداية الدالة والعمل بالمتغير المحلي داخل الحلقة، وإعادة قراءة volatile فقط عند الحاجة:
الخلاصة
volatile هو أخف أداة للتزامن في Java. يحلّ مشكلة الرؤية من خلال إنشاء علاقة happens-before بين الكتابات والقراءات، مانعًا الخيوط من العمل على قيم مخزّنة قديمة. لكنه لا يوفر الأتمية للعمليات المركّبة — هذه مهمة synchronized أو حزمة java.util.concurrent.atomic. استخدم volatile حين يكون لديك كاتب واحد، أو حين يكون المطلوب فقط أن تكون أحدث قيمة مرئية دائمًا دون حماية ثوابت متعددة الخطوات.