Вітання! Ми продовжуємо вивчення багатопоточності, і сьогодні познайомимося з новим ключовим словом – volatile та методом yield(). Давай розберемося, що це таке :)
Ключове слово volatile
Під час створення багатопотокових програм ми можемо зіткнутися з двома серйозними проблемами. По-перше, у процесі роботи багатопотокового додатку різні потоки можуть кешувати значення змінних (докладніше про це поговоримо в лекції «Застосування volatile» ). Можлива ситуація, коли один потік змінив значення змінної, а другий не побачив цієї зміни, тому що працював зі своєю кешованою копією змінної. Звичайно, наслідки можуть бути серйозними. Уяви, що це не просто якась «змінна», а, наприклад, баланс твоєї банківської картки, який раптом почав рандомно сякати сюди-туди :) Не дуже приємно, так? По-друге, Java операції читання і запису полів всіх типів, крімlong
і double
, є атомарними. Що таке атомарність? Ну, наприклад, якщо ти в одному потоці змінюєш значення змінної int
, а в іншому потоці читаєш значення цієї змінної, ти отримаєш або її старе значення, або нове - те, що вийшло після зміни в потоці 1. Ніяких «проміжних варіантів» там з'явитися не може. Однак і long
це double
не працює. Чому? Через кросплатформність. Пам'ятаєш, ми ще на перших рівнях говорабо, що принцип Java – «написано одного разу – працює скрізь»? Це і є кросплатформність. Тобто Java-додаток запускається на абсолютно різних платформах. Наприклад, на операційних системах Windows, різних варіантах Linux або MacOS, і скрізь ця програма стабільно працюватиме. long
іdouble
— «найважкіші» примітиви в Java: вони важать по 64 біти. І в деяких 32-бітових платформах просто не реалізовано атомарність читання та запису 64-бітних змінних. Такі змінні читаються та записуються у дві операції. Спочатку в змінну записуються перші 32 біти, потім ще 32. Відповідно, у цих випадках може виникнути проблема. Один потік записує якесь 64-бітове значення зміннуХ
, і робить він це «в два заходи». У той же час другий потік намагається прочитати значення цієї змінної, причому робить це якраз посередині, коли перші 32 біти вже записані, а другі ще немає. У результаті він читає проміжне, некоректне значення і виходить помилка. Наприклад, якщо на такій платформі ми спробуємо записати в змінну кількість - 9223372036854775809 - воно займатиме 64 біти. У двійковій формі воно буде виглядати так: 1000000000000000000000000000000000000000000000000000000000000001 Перший потік почне запис цього числа000000 000000000000000000 а потім другі 32: 000000000000000000000000000001 І в цей проміжок може вклинитися другий потік, і прочитати проміжне значення змінної - 10000000000000000 32 біти, які вже було записано. У десятковій системі це число дорівнює 2147483648. Тобто ми всього лише хотіли записати число 9223372036854775809 в змінну, але через те, що ця операція на деяких платформах є не атомарною, у нас з нізвідки виникло «ліве»8, непотрібне4 і невідомо, як воно вплине на роботу програми. Другий потік просто прочитав значення змінної до того, як воно остаточно записалося, тобто перші 32 біти він побачив, а другі 32 біти — ні. Ці проблеми, звичайно, виникли не вчора, і в Java вони вирішуються за допомогою всього одного ключового слова. у нас звідки виникло «ліве», непотрібне нам число 2147483648, і невідомо як воно вплине на роботу програми. Другий потік просто прочитав значення змінної до того, як воно остаточно записалося, тобто перші 32 біти він побачив, а другі 32 біти — ні. Ці проблеми, звичайно, виникли не вчора, і в Java вони вирішуються за допомогою всього одного ключового слова. у нас звідки виникло «ліве», непотрібне нам число 2147483648, і невідомо як воно вплине на роботу програми. Другий потік просто прочитав значення змінної до того, як воно остаточно записалося, тобто перші 32 біти він побачив, а другі 32 біти — ні. Ці проблеми, звичайно, виникли не вчора, і в Java вони вирішуються за допомогою всього одного ключового слова.volatile . Якщо ми оголошуємо у нашій програмі якусь змінну, зі словом volatile…
public class Main {
public volatile long x = 2222222222222222222L;
public static void main(String[] args) {
}
}
…це означає, що:
- Вона завжди буде атомарно читатись і записуватись. Навіть якщо це 64-біт
double
абоlong
. - Java-машина не поміщатиме її в кеш. Тому ситуація, коли 10 потоків працюють зі своїми локальними копіями виключена.
Метод yield()
Ми розглянули вже багато методів класуThread
, але є один важливий, який буде тобі новим. Це метод yield() . З англійської перекладається як «поступатися». І це те, що метод робить! Коли ми викликаємо метод yield біля потоку, він фактично говорить іншим потокам: «Так, хлопці, я нікуди особливо не поспішаю, тому якщо комусь із вас важливо отримати час процесора — беріть, мені не терміново». Ось простий приклад того, як це працює:
public class ThreadExample extends Thread {
public ThreadExample() {
this.start();
}
public void run() {
System.out.println(Thread.currentThread().getName() + "поступається своїм місцем іншим");
Thread.yield();
System.out.println(Thread.currentThread().getName() + " has finished executing.");
}
public static void main(String[] args) {
new ThreadExample();
new ThreadExample();
new ThreadExample();
}
}
Ми послідовно створюємо та запускаємо три потоки — Thread-0
, Thread-1
і Thread-2
. Thread-0
запускається першим і відразу поступається місцем іншим. Після нього запускається Thread-1
, і теж поступається. Після — запускається Thread-2
, який теж поступається. Більше потоків у нас немає, і після того, як Thread-2
останнім поступився своїм місцем, планувальник потоків дивиться: «Так, нових потоків більше немає, хто там у черзі? Хто поступався своїм місцем останнім, перед Thread-2
? Здається, це був Thread-1
? Окей, отже, нехай він і виконується». Thread-1
виконує свою роботу до кінця, після чого планувальник потоків продовжує координацію: Окей, Thread-1 виконався. Чи є у нас хтось ще в черзі?». У черзі є Thread-0: він поступався своїм місцем відразу до Thread-1. Тепер дійшло до нього, і він виконується до кінця. Після чого планувальник закінчує координацію потоків: «Гаразд, Thread-2, ти поступився місцем іншим потокам, вони всі вже відпрацювали. Ти поступався місцем останнім, так що тепер твоя черга». Після цього відпрацьовує остаточно потік Thread-2. Висновок у консоль буде виглядати так: Thread-0 поступається своїм місцем іншим Thread-1 поступається своїм місцем іншим Thread-2 поступається своїм місцем іншим Thread-1 закінчив виконання. Thread-0 закінчив виконання. Thread-2 закінчив виконання. p align="justify"> Планувальник потоків, звичайно, може запустити потоки в іншому порядку (наприклад, 2-1-0 замість 0-1-2), але сам принцип незмінний.
Правила "happens-before"
Останнє, чого ми торкнемося сьогодні, це принципи " happens before ". Як ти вже знаєш, у Java основну частину роботи з виділення часу та ресурсів потоків для виконання їхніх завдань виконує планувальник потоків. Також ти не раз бачив, як потоки виконуються у довільному порядку, і найчастіше передбачити його неможливо. Та й взагалі, після «послідовного» програмування, яким ми займалися до цього, багатопотоковість виглядає рандомною штукою. Як ти вже переконався, хід багатопоточної програми можна контролювати за допомогою цілого набору методів. Але на додачу до багатопоточності Java існує ще один «острівець стабільності» — 4 правила під назвою « happens-before». Дослівно з англійської це перекладається як «відбувається перед», або «відбувається раніше, ніж». Зрозуміти зміст цих правил досить просто. Уяви, що ми маємо два потоки —A
і B
. Кожен із цих потоків може виконувати операції 1
та 2
. І коли в кожному з правил ми говоримо " A happens-before B ", це означає, що всі зміни, виконані потоком A
до моменту операції 1
та зміни, які спричинила ця операція, видно потоку B
в момент виконання операції 2
та після виконання цієї операції. Кожне з цих правил дає гарантію, що при написанні багатопотокової програми одні події у 100% випадків відбуватимуться раніше, ніж інші, і що потік B
у момент виконання операції2
завжди буде в курсі змін, які потік А
зробив під час операції 1
. Давай розглянемо їх.
Правило 1.
Звільнення м'ютексу happens before відбувається раніше захоплення цього ж монітора іншим потоком. Ну, тут начебто все зрозуміло. Якщо м'ютекс об'єкта чи класу захоплений одним потоком, наприклад, потокомА
, інший потік (потік B
) неспроможна у цей час його захопити. Потрібно почекати, доки м'ютекс не звільниться.
Правило 2
МетодThread.start()
happens before Thread.run()
. Теж нічого складного. Ти вже знаєш: щоб почав виконуватися код усередині методу run()
, необхідно викликати у потоку метод start()
. Саме його, а не сам метод run()
! Це правило гарантує, що встановлені до запуску Thread.start()
значення всіх змінних будуть видні всередині методу, що почав виконання run()
.
Правило 3
Завершення методуrun()
happens before вихід із методу join()
. Повернемося до наших двох потоків - А
і B
. Ми викликаємо метод join()
таким чином, щоб потік B
обов'язково дочекався завершення A
, перш ніж виконувати свою роботу. Це означає, що метод run()
об'єкта A обов'язково відпрацює до кінця. І всі зміни в даних, які відбудуться в методі run()
потоку, A
стовідсотково будуть видні в потоці B
, коли він дочекається завершення A
і почне роботу сам.
Правило 4
Запис у volatile мінливу happens-before читання з тієї ж змінної. При використанні ключового слова volatile ми фактично завжди будемо отримувати актуальне значення. Навіть у випадку зlong
і double
, про проблеми з якими йшлося раніше. Як ти вже зрозумів, зміни, зроблені в одних потоках, далеко не завжди видно іншим потокам. Але, звичайно, дуже часто трапляються ситуації, коли подібна поведінка програми нас не влаштовує. Припустимо, в потоці A
ми надали значення змінної:
int z;
….
z= 555;
Якщо наш потік B
повинен вивести значення змінної z
на консоль, він може вивести 0, тому що не знає про присвоєне їй значення. Так ось, Правило 4 гарантує нам: якщо оголосити змінну z
як volatile, зміни її значень в одному потоці завжди буде видно в іншому потоці. Якщо ми додамо до попереднього коду слово volatile...
volatile int z;
….
z= 555;
...ситуація, коли потік B
виведе в консоль 0, виключена. Запис у volatile-змінні відбувається раніше, ніж читання з них.