ولی اگر آن متغییر با کلمهی کلیدی volatile تعریف شود، تنها یک نسخه از آن متغیر وجود دارد و تغییراتی که یک thread بر روی آن انجام میدهد، بلافاصله در thread های دیگر منعکس میشود.
هنگامی که در مصاحبههای کاری و استخدامی مرتبط با جاوا شرکت میکنید، ممکن است شخص مصاحبهگر سوالات مختلفی را از شما بپرسد که معمولاً این سوالات به منظور تعیین سطح علمی یا توانایی شما در برنامهنویسی نیستند و بیشتر قصد گیج کردن، پایین نشان دادن سطح معلومات شما و خود بزرگ پنداری (بزرگ نشان دادن شرکت خودشان) را دارند. متاسفانه در موارد بسیاری نیز مصاحبهگر خود را خدای جاوا (یا چیز دیگر) میپندارد (بعضاً نیز مصاحبهگر خود به پاسخ سوالات خود واقف نیست)! به هر جهت، یکی از مواردی که در سوالات مصاحبههای کاری مطرح میشود، پرسش در باب کلمات کلیدی جاواست. volatile از جمله کلمات کلیدی است که شاید در مورد آن نشنیده باشید یا کمتر شنیده باشید؛ و این امکان وجود دارد که در مصاحبهای بخواهند از این کلمه به عنوان برگ برنده بر علیه شما استفاده کنند. در این آموزش با کلمهی کلیدی volatile در جاوا آشنا میشویم.
روش استفاده از volatile
تنها کافیست تا کلمهی کلیدی Volatile را در هنگام تعریف متغیر، قبل از آن بنویسیم:
volatile int fullkade = 20;
طرز کار volatile
عمل خواندن و نوشتن یک متغیر volatile به صورت atomic و در حافظهی اصلی (Main Memory) انجام میشود. (از کش استفاده نمیشود و هر تغییری مستقیماً در حافظه ثبت میشود؛ لذا استفاده از یک متغیر volatile، کارایی (Performance) برنامه را نسبت به استفاده از متغیر معمولی کاهش میدهد).
عمل atomic
عملی است که قابل تجزیه به اعمال کوچکتر نباشد! عمل atomic یا به طور کامل انجام میشود یا انجام نمیشود و هیچ اتفاقی نمیتواند در وسط اجرای یک عمل atomic صورت پذیرد.
- عمل خواندن و نوشتن متغیرهای مرجع (reference variable) و بیشتر دادههای نوع اولیه (به غیر از double و long)، اتمیک به حساب میآید.
- عمل خواندن و نوشتن متغیرهای از نوع volatile (حتی متغیرهای از نوع double و long)، اتمیک به حساب میآید.
جمعبندی
- volatile بهینه نمیشود
optimizer (بهینه کننده)، برای بهبود کدها میتواند کدها را جابهجا کند و در جاهایی روشهای مقداردهی را نیز تغییر دهد؛ تا کد نهایی بهینهتری ایجاد شود. ولی متغیرهایی که از نوع volatile تعریف شوند، توسط optimizer دستکاری نمیشوند. - volaticle نمیتواند final باشد
یک متغیر نمیتواند همزمان هم volatile و هم final باشد! - volatile در محیطهای دارای چند Thread استفاده میشود
همانطور که قبلاً هم اشاره کردیم، volatile در محیطهای Multi Threading استفاده میشود و هنگامی از آن استفاده میکنیم که یک متغیر مشترک (shared variable) بین Thread ها داریم و میخواهیم عمل نوشتن و خواندن آن به صورت اتمیک صورت پذیرد. اساساً استفاده از volatile در برنامههای تک ریسمانی بیهوده بوده و باعت کاهش کارایی (پرفرمنس) برنامه (به دلیل عدم استفاده از کش) میشود.
volatile یا synchronized
ممکن است این سوال برای شما پیش بیاید که synchronized نیز برای اتمیک کردن بلوکی از کد میتواند استفاده شود؛ پس چرا باید از volatile استفاده کنیم؟!
- اگر چه volatile و synchronized از لحاظ مفهوم بسیار به هم شبیه هستند؛ ولی مفاهیم متفاوتی دارند! و در ادامه در قالب یک مثال خواهیم دید که گاهی میتوانند متفاوت باشند (گاهی نیز ممکن است یکسان عمل کنند).
- volatile از synchronized بهینهتر است؛ در مواقعی که بتوانیم یک مسئلهی خاصی را هم با volatile و هم با synchronized حل کنیم، استفاده از volatile منجر به تولید کد بهینهتری میشود.
- volatile هنگامی استفاده میشود که فقط یک ریسمان نویسنده (ریسمانی که روی متغیر مشترک از نوع volatile مینویسد) و چندین ریسمان خواننده داشته باشیم؛ ولی synchronized قادر به حل مسائل گستردهتری است.
- volatile فقط خواندن و نوشتن را اتمیک میکند و نه هر نوع تغییری را ، در صورتی که با استفاده از synchronized قادر هستیم بلوکی از کد را به صورت اتمیک ایجاد کنیم.
مثال
volatile int x=0; int y=0; void nonAtomic(){ x++; x=x*2; x=x-4; } synchronized void atomic(){ y++; y=y*2; y=y-4; }
اگرچه دو متد nonAtomic و atomic عملیات مشابهی انجام می دهند؛ ولی متد nonAtomic میتواند در وسط کار توسط ریسمان دیگری متوقف شود؛ ولی متد atomic هر بار تنها توسط یک ریسمان قابل اجرا خواهد بود.
نه volatile و نه synchronized
معمولاً مسائل مرتبط با Multi Threading یا پیچیده نیستند یا خیلی پیچیده هستند! یعنی در برنامههای چند ریسمانی یا هیچ چیز برای شما اهمیت ندارد؛ یا همه چیز زیادی اهمیت دارند.
در عمل کمتر اتفاق میافتد که استفاده از volatile یا synchronized قادر به حل مسائل ما باشند و در بیشتر مسائل مهم چند ریسمانی باید از مفاهیم پیچیدهتر مانند semaphore استفاده کنید. خوشبختانه java.util.concurrent و java.util.concurrent.lock و java.util.concurrent.atomic ابزارهای بسیار مفیدی (مانند Semaphoe) را در اختیار ما قرار میدهند که میتوانیم از آنها برای حل مسائل مختلف چند ریسمانی استفاده کنیم.
مثال
اگرچه درک volatile و کاربرد آن چندان شفاف نیست و ممکن است در نگاه اول کمی گیج کننده باشد (در عمل نیز احتمال اینکه بخواهید در آینده از آن استفاده کنید بسیار بسیار کم خواهد بود)، ولی سعی میکنم در طی مثال مشهوری از مستندات اوراکل، کاربرد آن را توضیح دهم.
به مثال زیر که اهمیت ترتیب را نشان میدهد، میخواهیم همیشه j بعد از i به روز رسانی شود.
به کلاس زیر دقت کنید:
class Test { static int i = 0, j = 0; static void one() { i++; j++; } static void two() { System.out.println("i=" + i + " j=" + j); } }
فرض کنید یک Thread نویسنده، به صورت پیوسته متد one را اجرا می کند (تا زمانی که مقادیر i و j در int جا شوند) و Thread (یا Threadهای) دیگری به صورت پیوسته متد two را اجرا میکند. چون هیچ گونه شرطی در مورد به روز رسانی متغیرهای i و j توسط Thread نویسنده اعمال نشده است (نه volatile و نه synchronized) لذا ممکن است در مواقعی j زودتر (خارج از ترتیب – out of order) از i آپدیت شود و Thread خواننده در مواقعی مقادیری را چاپ کند که در آن مقدار j از i بیشتر است.
حل با synchronized
class Test { static int i = 0, j = 0; static synchronized void one() { i++; j++; } static synchronized void two() { System.out.println("i=" + i + " j=" + j); } }
در اینجا متدهای one و two هر دو سنکرون سازی شدهاند؛ لذا هیچگاه این دو متد همزمان اجرا نمیشوند؛ به علاوه هنگامی که وارد متد one میشویم، تا زمانی که مقادیر i و j آپدیت نشوند، از متد one خارج نمیشویم؛ پس هنگامی که متد two قصد خواندن مقادیر i و j را داشته باشد، مطئمناً i و j هر دو به روز شدهاند و این یعنی هیچگاه متد two نمیتواند مقادیری را چاپ کند که در آن j بزرگتر از i باشد. به علاوه به دلیل اتمیک شدن متد one؛ همیشه j بعد از i به روز رسانی میشود (ترتیب حفظ میشود).
حل با volatile
به راه حل مبتنی بر volatile دقت کنید؛ ممکن است کمی در ابتدا گیج کننده باشد:
class Test { static volatile int i = 0, j = 0; static void one() { i++; j++; } static void two() { System.out.println("i=" + i + " j=" + j); } }
چون هر دو متغیر i و j از نوع volatile هستند، هیچگاه j زودتر از i به روز رسانی نمیشود! در حقیقت همیشه ابتدا i باید به روز شود و سپس j به روز میشود. (پس ترتیب در نوشتن حفظ میشود). ولی با این وجود، ممکن است متد two مقادیری را چاپ کند که در آن مقادیر j از i بیشتر باشند. و چرا چنین چیزی ممکن است؟! چون متدهای one و two اتمیک نیستند و ممکن است سناریوی زیر اتفاق بیفتد:
- متد one متغیر i را به روز رسانی میکند.
- کنترل CPU از متد one گرفته شده و به متد two داده میشود. (چون دو ریسمان داریم)
- متد two مقدار i را چاپ میکند؛ ولی قبل از اینکه قادر به چاپ j باشد، کنترل CPU از آن گرفته میشود و به Thread نویسنده داده میشود.
- ریسمان نویسنده در فرصتی مناسبت، چندین بار متد one را فراخوانی میکند (i و j چندین بار به روز رسانی میشوند)
- کنترل به ریسمان خواننده داده میشود و ادامهی متد two (یعنی چاپ j) انجام میشود و در این حالت مقدار j، بزرگتر از i چاپ میشود.
مثال فوق نشان میدهد که ریسمان نویسنده ترتیب را حفظ میکند (هیچ گاه j زودتر از i به روز نمیشود)؛ ولی ریسمان خواننده ممکن است به درستی این موضوع را نشان ندهد.
اگر فقط ترتیب در نوشتن برای ما مهم باشد، راه حل volatile در مثال بالا کافی است. ولی اگر ترتیب هم در نوشتن و هم در خواندن مهم باشد، روش استفاده از volatile پاسخگو نیست و باید از synchronized یا روشهای قویتر استفاده کنیم.
دستت طلا بسیار آموزنده بود
یک دنیا ممنون!
بسیار عالی