Багатопотоковість у Java. Лекція 2: потоки, властивості потоків, блокування

6 травня
Володимир Фролов, Java-розробник, Микита Сізінцев, Android-розробник
Багатопотоковість у Java. Лекція 2: потоки, властивості потоків, блокування

Темну силу відчуваю я.
Даєш парсек за три роки.

Вступну статтю про багатопотоковість у Java читайте тут! У її продовження ми розглянемо основи багатопотокових програм: створення, запуск і властивості потоку, синхронізацію потоків. Далі поговоримо про використання ключового слова synchronized, volatile змінні та відношення happens-before.

2.1 ЗАСОБИ ДЛЯ РОБОТИ З БАГАТОПОТОКОВІСТЮ У JAVA ТА МОДЕЛІ БАГАТОПОТОКОВИХ ПРОГРАМ

У першій версії Java інструментів для роботи з багатопотоковістю було небагато. Основні засоби: клас Thread, інтерфейс Runnable, ключове слово synchronized і методи для синхронізації wait(), notify() і notifyAll() у класі Object. У версію Java 1.5 вже було включено пакет java.util.concurrent, у якому з'явилося багато нових класів і інтерфейсів. У версії Java 1.8 додали клас ComplitableFuture, який дозволяє будувати ланцюжки з асинхронних задач і комбінувати їх.

Існують декілька підходів (моделей) у багатопотоковому програмуванні:

  • синхронізація, блокування та ключове слово volatile;
  • транзакційна пам'ять — прошарок між JVM і API програми, рекурсивний паралелізм;
  • модель акторів — коли кожен об'єкт є потоком, який обмінюються повідомленнями з іншими потоками.

Наразі процесори добре підтримують концепцію потоків. Наприклад, akka (фреймворк для роботи з багатопотоковістю, портований на різні мови програмування: Java, Scala, C#), написаний на основі потоків і блокувань.

Способи організації багатопотоковості у програмах:

  • потоки не взаємодіють один з одним, працюють самі собою;
  • потоки взаємодіють один з одним;
  • потоки працюють самі собою, а потім збирають дані в єдиний результат.

2.2  ВЛАСТИВОСТІ ПОТОКІВ, ЗАПУСК ПОТОКІВ, ПРИЄДНАННЯ ІНШИХ ПОТОКІВ

Всі методи програми виконуються в будь-якому потоці. Потік, який викликає метод main, є головним потоком додатку та має ім'я main.

У Java потік представлено класом Thread. Створити та запустити потік можна двома способами:

1) Створити спадкоємця від класу Thread і перевизначити метод run().

 

Лістинг 1:

public class MyThread extends Thread {
    public void run() {
        long sum = 0;
        for (int i = 0; i < 1000; ++i) {
            sum += i;
        }
        System.out.println(sum);
    }
}

MyThread t = new MyThread();

2) Реалізувати інтерфейс Runnable і передати об'єкт отриманого класу в конструктор класу Thread.

 

Лістинг 2:

Runnable r = new MyRunnable() { () ->
    System.out.println(“Hello!”);
}

Thread t = new Thread(r);

Для запуску потоку необхідно використовувати метод Thread.start(). Якщо викликати метод run(), то він виконається у викликаючому потоці:

Лістинг 3:

Thread t = new Thread(r);

t.run(); //код r виконується у поточному потоці

t.start(); //код r виконується у новому потоці

Не слід запускати потік з конструктора класу. Деякі фреймворки, такі як Spring, створюють динамічні підкласи для підтримки перехоплення методів. У кінцевому підсумку, ми отримаємо два потоки, запущені з двох примірників.

Об'єкт поточного потоку можна отримати, викликавши статичний метод: Thread.currentThread().

Імена потокам можна задавати через метод setName() або через параметр конструктора. Рекомендується давати потокам осмислені імена, це стане у пригоді при налагодженні. Не рекомендується давати потокам однакові імена, хоча імена потоків не валідуються JVM.

Стандартний формат імен потоків, які було створено поодиноко — thread-N, де N позначає порядковий номер потоку. Для пулу потоків стандартне найменування — pool-N-thread-M, де N позначає порядковий номер пулу (щоразу, коли ви створюєте новий пул, глобальний лічильник N збільшується), а M — порядковий номер потоку в пулі.

Потоки мають пріоритет, який можна задати цілим числом від 1 до 10. Чим більше число, тим вищий пріоритет потоку. Потік main має пріоритет 5. А пріоритет нових потоків дорівнює пріоритету потоку-батька, його можна змінити за допомогою методу setPriority(int). Потік із більшим пріоритетом матиме більше процесорного часу на виконання. Якщо два потоки мають однаковий пріоритет, то рішення про те, який з них виконуватиметься першим, залежить від алгоритму планувальника: (Round-Robin, First Come First Serve).

Є декілька констант для пріоритету потоків:

  • Thread.MIN_PRIORITY — мінімальний пріоритет, значення 1;
  • Thread.NORM_PRIORITY — мінімальний пріоритет, значення 5;
  • Thread.MAX_PRIORITY — максимальний пріоритет, значення 10.

Лістинг 4:

public class Main {
    public static void main(String[] args) {
        System.out.println(Thread.currentThread().getName());
        Thread.currentThread().setPriority(8);
        Thread thread = new Thread() {
            public void run() {
                Thread.currentThread().setName("My name");
                System.out.println(Thread.currentThread().getName());
                System.out.println(Thread.currentThread().getPriority());
            }
        };
        thread.start();
    }
}

У Java є таке поняття, як потік-демон. Робота JVM закінчується, коли закінчив виконуватись останній потік не-демон, незважаючи на працюючі потоки-демони. Для роботи з цією властивістю існують два методи: setDaemon() та isDaemon().

Клас ThreadGroup. Всі потоки знаходяться у групах, представлених екземплярами класу ThreadGroup. Група вказується при створенні потоку. Якщо групу не було вказано, то потік поміщається в ту саму групу, в якій знаходиться потік-батько. Методи activeCount() та enumerate() повертають, відповідно, кількість і повний список всіх активних потоків у групі.

Способи призупинення виконання потоку на вказану кількість часу: Thread.sleep(long millis) і TimeUnit.<UNIT>.sleep(long timeout). Вони призупиняють виконання поточного потоку на вказаний період часу. Виклик методів вимагає обробки виключення InterruptedException.

Нестатичний метод join() дозволяє одному потоку дочекатися виконання іншого. Якщо поточний потік t1 викликає в іншого потоку t2h2t2.join(), то потік th2 зупиняється, доки потік t2 не завершить свою роботу. Викликати метод join() можна також і з аргументом, що вказує ліміт часу очікування (в мілісекундах або в мілісекундах з наносекундами). Якщо цільовий потік t2 закінчить роботу за вказаний період часу, метод join() все одно поверне управління ініціатору t1.

2.3. ЗУПИНКА ТА ПЕРЕРИВАННЯ ПОТОКІВ

Для зупинки потоку у Java версії 1 використовувався метод stop(). Однак у версії Java 1.1 цей метод зробили deprecated, тому що використання методу stop() не гарантує коректного завершення роботи потоку та стабільної роботи програми загалом. Тому при написанні програм використовувати його не рекомендується.

Замість методу stop() слід використовувати метод interrupt(). На відміну від методу stop(), який примусово зупиняв потік, метод interrupt() пропонує потоку зупинити своє виконання шляхом установки прапора interrupted у true всередині потоку. Цей прапор відображає статус переривання та має початкове значення false. Коли потік переривається іншим потоком, відбувається одне з двох:

  • Якщо потік чекає на виконання методу блокування, що переривається, наприклад, Thread.sleep(), Thread.join() або Object.wait(), то очікування переривається і метод генерує InterruptedException. Прапор interrupted встановлюється у false.
  • Прапор interrupted встановлюється у true.

Існують три методи для роботи з перериванням потоку:

Лістинг 5:

public class Thread {
    public void interrupt() { ... }
    public boolean isInterrupted() { ... }
    public static boolean interrupted() { ... }
...
}

  • Роботу методу interrupt() описано вище.
  • isInterrupted() повертає значення прапора та не змінює його.
  • interruped() повертає значення прапора та встановлює його значення у false.
    Якщо прапор interrupted встановлено у true та викликається цей метод, то в перший раз метод поверне true, а наступні виклики повернуть false.

Існують два види операцій: блокуючі та неблокуючі. Неблокуючі операції не зупиняють виконання потоку. До блокуючих операцій можна віднести виклики методів sleep(), wait(), join() і, наприклад, деякі методи класу Socket. Якщо потік було перервано, поки він виконував неблокуючі обчислення, їх не буде перервано негайно. Однак потік вже помічено як перерваний, тому будь-яка наступна блокуюча операція негайно перерветься та викине InterruptedException. 

Для обробки переривання у потоці, який не використовує блокуючі операції, слід додати перевірку прапора interrupted. Приклад у лістингу 6.

Лістинг 6:

public void run() {
    while (Thread.currentThread().isInterrupted()) {
        someHeavyComputations();
    }
}

Обробка InterruptedException

Коли в сигнатурі методу є InterruptedException, це ще раз нагадує програмісту, що цей метод є блокуючим. InterruptedException сигналізує про те, що роботу потоку хочуть завершити. При цьому не просять зробити це негайно.

Перший спосіб обробки InterruptedException — оголошення цього винятку у вищому методі. Також під час перехоплення методу InterruptedException можна зробити якісь дії (наприклад, очищення ресурсів або змінних) і повторно прокинути InterruptedException.

У другому випадку, коли InterruptedException оголосити неможливо, при генерації та перехопленні InterruptedException прапор interrupted встановлюється у false, і викликаючі методи не побачать, що було потік було перервано. Однак можна відновити прапор переривання, викликавши Thread.currentThread().Interrupt() при обробці переривання.

Також відновлення прапора interrupted може бути корисним, коли перший потік має посилання на другий потік, і перший хоче дізнатися стан прапора другого.

Варто уважно стежити за обробкою цього винятку, коли код виконується у threadpool. InterruptedException може бути “цікавим” не лише коду, а й потоку, який виконує цей код.

Лістинг 7:

try {
    Object o = queue.take();
} catch InterruptedException e) {
}

Цей код є некоректним, тому що поглинає (swallows) переривання. Якщо цей код виконується у tread pool, то воркер (thread pool worker) tread pool`а має завершити виконання, але цього не станеться, тому що виключення буде поглинено, і прапор буде скинуто.
Коректний код виглядатиме так:

Лістинг 8:

try {
    Object o = queue.take();
} catch InterruptedException e) {
    Thread.currentThread().interrupt();
}

У блоці catch відбувається перехоплення виключення та установка прапора у true.

Не варто поглинати виняток просто так (код у лістингу 7), також не варто тільки записувати в лог при обробці InterruptedException. Тому що, коли лог буде прочитано, додаток може цілком прийти в неробочий стан.

2.4 СИНХРОНІЗАЦІЯ МІЖ ПОТОКАМИ

Якщо два потоки виконуватимуть код, який змінює одну й ту саму змінну, значення у змінній матиме непередбачуване значення.

Класичний приклад такої поведінки: два потоки інкрементують одне значення. Оскільки операція інкременту не виконується за одну інструкцію процесора, то два потоки змінять значення змінної довільним чином — це називається race condition. Блоки коду, в яких може виникнути race condition, називаються критичними секціями. Щоб уникнути такої ситуації, у Java передбачено способи синхронізації потоків.

Найпростіший спосіб синхронізації — концепція “монітора” та ключове слово synchronized. Спочатку цю концепцію було введено в мові Pascal. У Java класу “монітор” немає, однак кожен об'єкт типу Object має свій власний “монітор”. Оскільки в усіх класів загальний батько — Object, всі вони мають свій власний “монітор”.

Концепція “монітор” всередині себе містить 4 поля:

  1. locked типу boolean, яке показує, чи захоплений монітор;
  2. owner типу Thread — до цього поля записується потік, який захопив даний монітор;
  3. blocked set — до цієї множини потрапляють потоки, які не змогли захопити блокування, або потік, який виходить зі стану wait;
  4. wait set — до цієї множини потрапляють потоки, для яких було викликано метод wait.
img

Малюнок 1. Внутрішній устрій концепції “монітора”

Blocked set, як і wait set, є невпорядкованою множиною, що не допускає дублікатів. Тобто у wait set або blocked set один і той самий потік не може бути записано двічі.

Поля монітора неможливо отримати через рефлексію. Кожен об'єкт має методи wait(), notify() і notifyAll(), які він успадкував від класу Object. Використання ключового слова synchronized гарантує, що блоки коду виконуватимуться тільки одним потоком у кожну конкретну одиницю часу.

Є два варіанти використання ключового слова synchronized:

  1. Два потоки виконують код (так звана критична секція), який у кожен момент часу може виконувати тільки один потік. 
  2. Один потік очікує якусь подію. Це поведінка забезпечується методами wait(), notify() і notifyAll().

Розглянемо першу ситуацію: потік потрапляє до synchronized блоку, виконує критичну секцію та виходить із блоку синхронізації. Ключове слово synchronized завжди використовується з об'єктом монітора. Спершу перевіряються змінні locked і owner. Якщо ці поля false і null, відповідно, вони заповнюються. Поле locked приймає значення true, а до поля owner записується посилання на захоплюючий потік. Як тільки це сталося, вважається, що потік виконав код, який відповідає відкриваючій фігурній дужці synchronized блоку, і потік зайняв це блокування. Після того як потік виконав код, який відповідає закриваючій фігурній дужці блоку синхронізації, змінні locked і owner у моніторі очищаються.

Розглянемо ситуацію, коли потік намагається захопити вже зайнятий монітор. Спочатку перевіряється, що змінна locked == true, потім порівнюється змінна owner. Якщо змінна owner не дорівнює тому потоку, який хоче захопити монітор, то другий потік блокується і потрапляє у blocked set монітора. Якщо порівняння змінних owner дає результат true, це означає, що один і той самий потік намагається захопити монітор ще раз — у цьому випадку потік не блокується. Така поведінка називається реентернабельністю. Приклад такої ситуації — рекурсивні методи. Після того, як блокування звільнилось, інший потік залишає blocked set і захоплює монітор. У blocked set може знаходитися безліч потоків. У цьому випадку вибирається довільний потік, який далі може захопити монітор.

Монітором може виступати простий об'єкт, ключове слово this, а також об'єкт типу .class. Приклади в лістингу 9.

Лістинг 9:

public class SomeClass {
    private final Object PRIVATE_LOCK_OBJECT = new Object();
    public synchronized void firstMethod() {
        //some code
    }
    public void theSameAsFirstMethod() {
        synchronized(this) {
            //some code
        }
    }
    public void theBestMethodUsingSynchr() {
        synchronised(PRIVATE_LOCK_OBJECT) {
            //some code
        }
    }
    public static void synchronizedOnStaticMethod() {
        synchronized(SomeClass.class) {
            //some code
        }
    }
    public static synchronized void synchronizedOnStaticMethod() {
        //some code
    }
}

Коли метод оголошується з ключовим словом synchronized, це еквівалентно коду, коли все його тіло обгорнуте в synchronized блок і блокуванням служить об'єкт this. Коли статичний метод використовується з ключовим словом synchronized, це еквівалентно тому, коли в ролі блокування допомагає об'єкт SomeClass.class. Однак найкращий спосіб — оголосити private final константу, за якою і проводиться синхронізація. Варто зауважити, що конструкція з використанням ключового слова synchronized є синтаксичною та перевіряється компілятором. Тобто завжди має бути відкриваюча фігурна дужка та відповідна їй закриваюча фігурна дужка synchronized блоку. Synchronized блоки можуть бути вкладеними один в одного (див. лістинг 10).

Лістинг 10:

……

final Object LOCK = new Object();
synchronized(LOCK) {
    synchronized(LOCK) {
        synchronized(LOCK) {
        }
    }
}

Як показано в лістингу 10, можна декілька разів захопити монітор на одному й тому самому об'єкті. Немає способу визначити, скільки разів було захоплено монітор, і не варто будувати таку логіку у програмі. Звільнення монітора відбувається після виходу з верхнього synchronized блоку. У лістингу 11 показано ще один варіант вкладених синхронізацій.

Лістинг 11:

….

Object LOCK_A = new Object();
Object LOCK_B = new Object();
Object LOCK_C = new Object();
synchronized(LOCK_A) {
    synchronized(LOCK B) {
        synchronized(LOCK_C) {
        }
    }
}

У лістингу 11 спочатку захоплюються монітори LOCK_A, потім LOCK_B та LOCK_С, а звільняються монітори у зворотному порядку.

Ще одна ситуація, в якій використовується ключове слово synchronized — використання методів wait(), notify() і notifyAll(). При використанні цих методів необхідно завжди захоплювати монітор об'єкта, на якому викликатимуться ці методи. Якщо не захоплювати монітор, буде згенеровано IllegalMonitorStateException (див. лістинг 12).

Лістинг 12:

public class MainClass  {
    public static void main(String [] args) throws InterruptedException {
        final Object lock = new Object();
        lock.wait(); //будет сгенерирован IllegalMonitorStateException
    }
}

Лістинг 13:

public class MainClass  {
    private static final Object LOCK = new Object();
    public static void main(String [] args) throws InterruptedException {
        synchronized(LOCK) {
            LOCK.wait();
        }
    }
}

У лістингу 13 потік main захоплює монітор об'єкта LOCK і викликає метод wait() на LOCK. Після виклику цього методу потік main потрапляє у wait set монітора LOCK. При цьому монітор LOCK ЗВІЛЬНЯЄТЬСЯ, тобто очищається поле owner, а поле locked приймає значення false. Така поведінка гарантує, що якщо якийсь інший потік захоче очікувати якоїсь події на цьому об'єкті, то він може захопити монітор LOCK і потрапити у wait set.

Для того, щоб потоки, які знаходяться у wait set, продовжили своє виконання, інший потік має захопити монітор LOCK і на LOCK викликати методи notify() чи notifyAll(). Після виклику методу notify() із wait set вибирається довільний потік і переводиться у blocked set. Якщо було викликано метод notifyAll(), то всі потоки з wait set переводяться у blocked set. Це відбувається тому, що монітор LOCK зайнятий тим потоком, який викликав метод notify() або notifyAll(). Після того, як цей потік вийде з synchronized блоку, нотифіковані потоки будуть по одному захоплювати монітор і продовжувати виконання. Методи wait(), notify() і notifyAll() використовуються для очікування виконання якоїсь умови, а не для передачі даних.

Зі стану wait можна вийти декількома способами:

  • було викликано методи notify() чи notifyAll();
  • у потоку викликали метод interrupt(), після чого буде згенеровано InterruptedException;
  • потік випадково прокинувся (spurious wakeup);
  • по закінченню тайм-ауту, якщо використовували wait із тайм-аутом.

Іноді потік, що викликав метод wait на якомусь об'єкті блокування, може випадково прокинутись. Ця ситуація називається spurious wakeup. Випадкові пробудження трапляються вкрай рідко (такого майже не буває), але щоб гарантовано уникнути цього ефекту, необхідно викликати метод wait() у циклі.

Лістинг 14:

synchronized(obj) {
    while(<condition doesn`t hold>) {
        obj.wait();
    }
}

Існують два випадки, коли потік може потрапити у blocked set:

  1. Блокування зайняте іншим потоком, як це було показано раніше.
  2. Потік після очікування у wait set продовжує виконання, проміжно потрапляючи у blocked set. Це відбувається тому, що інший потік, який викликав notify() чи notifyAll(), захопив блокування.

Розглянемо, чому об'єкт блокування необхідно завжди робити закритою незмінною змінною у класі private final Object obj = new Object(). Вважається поганим стилем, якщо об'єкт синхронізації видно зовні класу.

Лістинг 15: 

class X {
    public synchronized void method1() {
    }
}

public class TestX {
    public void someMethod(X x) {
        synchronized(x) {
            while(true);
        }
    }
}

У такому коді ніякий потік не зможе викликати метод method1() у об'єкта X. Усі потоки, які спробують викликати метод method1() у об'єкта X, буде заблоковано. Ще один некоректний приклад у лістингу 16.

Лістинг 16:

public class TestX {
    public void someMethod(X x) {
        synchronized(x) {
            while(true) {
                x.wait();
            }
        }
    }
}

Якщо в об'єкта X викликатимуть x.notify(), цикл у лістингу 16 поглинатиме всі виклики методу notify(), тобто потік, який виконує код, буде завжди у wait set. Щоб уникнути таких помилок, слід використовувати private final об'єкт-блокування, як в одному з прикладів вище. Також не слід використовувати об'єкт-блокування для зберігання будь-якої інформації. Це порушує принцип single responsibility та ускладнює читання і розуміння програми.

2.5 СТАНИ ПОТОКУ

Потоки мають такі стани:

  • NEW — об'єкт потоку було створено, але не запущено; 
  • RUNNABLE — потік запущено і він виконує код;
  • WAITING — потік не виконує код і знаходиться у wait set монітора;
  • BLOCKED — потік не виконує код і знаходиться у blocked set монітора;
  • TERMINATED — потік завершив своє виконання.
img

Малюнок 2. Схема переходів потоку з одного стану в інший

Стани потоків представлені в перерахуванні Thread.State.

2.6 КЛЮЧОВЕ СЛОВО VOLATILE

Ключове слово volatile вказує, що взаємодія зі змінною в пам'яті має відбуватися минаючи кеші процесора, тобто безпосередньо.

У багатопотоковому додатку, коли потоки використовують не volatile змінні, вони можуть скопіювати значення змінних у кеш процесора для поліпшення продуктивності. Якщо у процесорі кілька ядер і кожен потік виконується на окремому ядрі процесора, одна й та сама змінна може мати різне значення на кожному ядрі процесора. В результаті буде декілька копії однієї й тієї самої змінної: копії в кеші кожного ядра процесора та копія змінної в основний пам'яті. При використанні не volatile змінних не можна знати напевно, коли JVM читає значення змінної з головної пам'яті та коли значення змінної записується в головну пам'ять. Це може призвести до проблем. Припустимо, є два потоки, що мають доступ до загального об'єкту, який має лічильник (див. лістинг 17).

Лістинг 17:

public class SharedObject {
    public int counter = 0;
}

Припустимо, що тільки перший потік інкрементує змінну та обидва потоки можуть читати змінну. Якщо змінна counter не volatile, то немає ніякої гарантії, коли змінну буде записано в основну пам'ять, щоб другий потік побачив знову змінене значення змінної. Ця проблема вирішується шляхом оголошення змінної counter як volatile (див. лістинг 18).

Лістинг 18:

public class SharedObject {
    public volatile int counter = 0;
}

Оголошення змінної як volatile гарантує, що будь-яке читання та будь-який запис у цю змінну відразу потраплятиме в головну пам'ять. Оголошення змінної counter як volatile достатньо, коли один потік змінює змінну, а інший потік читає її значення. Якщо два потоки змінюють загальну змінну, то використання ключового слова volatile недостатньо — буде race condition. Ключове слово volatile гарантує наступне:

  • Якщо перший потік записує у volatile змінну, а потім другий потік читає значення з цієї змінної, то всі змінні, видимі потоку A перед записом у змінну volatile, також буде видно потоку B після того, як він прочитав змінну volatile
  • Якщо потік A зчитує змінну volatile, то всі змінні, видимі потоку A при читанні змінної volatile, також буде перечитано з основної пам'яті. Приклад у лістингу 19.

Лістинг 19:

public class MyClass {
    private int years;
    private int months
    private volatile int days;
    public void update(int years, int months, int days){
        this.years  = years;
        this.months = months;
        this.days   = days;
    }
}

При запису значення у volatile змінну days гарантовано, що запис інших змінних years і months теж буде проведено в головну пам'ять. Читання можна виконати наступним способом (див. лістинг 20).

Лістинг 20:

public class MyClass {
    private int years;
    private int months
    private volatile int days;
    public int totalDays() {
        int total = this.days;
        total += months * 30;
        total += years * 365;
        return total;
    }
    public void update(int years, int months, int days){
        this.years  = years;
        this.months = months;
        this.days   = days;
    }
}

У лістингу 20 у методі totalDays спочатку проводиться читання volatile змінної days, а потім — читання інших змінних. Це читання проводиться з головної пам'яті програми.

JVM залишає за собою право переупорядкувати інструкції для збільшення продуктивності, не змінюючи при цьому семантики програми. Приклад у лістингу 21.

Лістинг 21:

int a = 1;
int b = 2;
a++;
b++;
//changes to
int a = 1;
a++;
int b = 2;
b++;

Розглянемо модифікований код з лістингу 22.

Лістинг 22:

public void update(int years, int months, int days){
    this.days   = days;
    this.months = months;
    this.years  = years;
}

У лістингу 22 змінено порядок запису у volatile змінну та у звичайні змінні в порівнянні з прикладом з лістингу 20. У Java є вирішення проблеми перестановки інструкцій, яке буде розглянуто в наступному пункті.

У програмах, які використовують багатопотоковість, зустрічаються ситуації, коли використання ключового слова volatile недостатньо для коректної роботи програми цілком. Наприклад, є два потоки, які одночасно змінюють загальний лічильник. Необхідно прочитати значення зі змінної, збільшити значення змінної, а потім записати значення до загальної пам'яті. Припустимо, що два потоки прочитали одне й те саме значення, що дорівнює, наприклад, одиниці. Кожен потік збільшив значення на 1, і перший потік записав значення 2 до головної пам'яті, а потім і другий потік записав значення 2 до загальної пам'яті. Однак після запису другого потоку до загальної пам'яті значення має бути 3. Така логіка призведе до race condition і некоректного поводження програми. У цьому випадку треба використовувати ключове слово synchronized або атомарні змінні, які буде розглянуто в наступній статті.

Також слід пам'ятати, що читання і запис у volatile змінні відбувається довше, ніж у звичайні змінні, тому що запис у кеш ядра процесора відбувається набагато швидше, ніж в оперативну пам'ять.

2.7 ВІДНОШЕННЯ happens-before

Відношення happens-before гарантує, що результати операції в одному потоці буде видно в іншій дії в іншому потоці. Відношення happens-before визначає часткове впорядкування всіх дій всередині програми. Щоб гарантувати, що потік, який виконує дію Y, може бачити результати дії X (незалежно від того, чи відбуваються X та Y у різних потоках), між X та Y має існувати відношення happens-before. За відсутності відношення happens-before між двома діями JVM може переставити операції як завгодно, це відбувається за рахунок оптимізації компілятора JVM.

img

Малюнок 3. Відношення happens-before

Відношення happens-before це не тільки перерозподіл дій у часі, а й гарантія відсутності перестановок читання, а також запису в пам'ять. Якщо відношення happens-before не буде, два потоки, які читають і пишуть в один і той самий простір пам'яті, можуть бути послідовними в термінах часу, але не зможуть послідовно побачити зміни один одного.

Відношення happens-before можливе в наступних випадках:

  • Відношення happens-before в одному потоці — дія, що виконується в одному потоці, завжди happens-before іншої дії, що виконується пізніше в цьому ж потоці.
img

Малюнок 4. Відношення happens-before в одному потоці.

  • Захоплення і звільнення монітора — відпускання монітора (вихід із синхронізованого методу або вихід із блоку) завжди перебуває у відношенні happens-before до захоплення цього ж монітора.
img

Малюнок 5. Відношення happens-before при захопленні та відображення монітора.

  • Читання/запис змінних не може бути переміщено та поставлено до читання volatile поля, якщо спочатку вони знаходилися після нього. При цьому є можливість перемістити читання змінних, які знаходилися до читання volatile поля, щоб вони відбулися після нього.
  • Запуск потоку — виклик методу start() завжди у відношенні happens-before до першої операції у потоці, що запускається.
img

Малюнок 6. Відношення happens-before при запуску потоку.

  • Виконання happens-before для методу join():
img

Малюнок 7. Відношення happens-before при використанні методу join.

2.8 ВИСНОВОК

У цій статті ми розглянули основи багатопотокових програм: створення та запуск потоку, властивості потоку, синхронізація потоків, стани, в яких може перебувати потік. Наведені приклади демонструють, як коректно використовувати ключове слово synchronized і до яких наслідків може призвести їхнє неправильне використання. Наприкінці розповіли про volatile змінні та відношення happens-before. Такі знання мають стати хорошою основою для подальшого вивчення, розуміння і написання багатопотокових програм.