Вступ
Отже, ми знаємо, що в Java є потоки, про що можна прочитати в огляді " Thread'ом Java не зіпсуєш: Частина I - потоки ". Потоки потрібні, щоб виконувати роботу одночасно. Тому дуже ймовірно, що потоки якось взаємодіятимуть між собою. Давайте розберемося, як це відбувається і які базові засоби управління ми маємо.Yield
Метод Thread.yield() загадковий і рідко використовується. Існує багато варіацій його опису в Інтернеті. Аж до того, деякі пишуть про якусь чергу потоків, у якій потік переміститься вниз з урахуванням їх пріоритетів. Хтось пише, що потік змінить статус з running на runnable (хоча поділу на ці статуси немає, і Java їх не розрізняє). Але насправді все набагато невідоміше і в якомусь сенсі простіше. На тему документації методуyield
є баг " JDK-6416721: (spec thread) Fix Thread.yield() javadoc ". Якщо прочитати його, то зрозуміло, що насправді методyield
Лише передає деяку рекомендацію планувальнику потоків Java, що цьому потоку можна дати менше часу виконання. Але що буде насправді, чи планатор почує рекомендацію і що взагалі він робитиме — залежить від реалізації JVM та операційної системи. А може, і ще від якихось інших факторів. Вся плутанина склалася, швидше за все, через переосмислення багатопоточності у розвитку мови Java. Докладніше можна прочитати в огляді " Brief Introduction to Java Thread.yield() ".
Sleep - Засипання потоку
Потік у процесі виконання може засипати. Це найпростіший тип взаємодії коїться з іншими потоками. В операційній системі, на якій встановлена віртуальна Java машина, де виконується Java код, є свій планувальник потоків, що називається Thread Scheduler. Саме він вирішує, який потік колись запускати. Програміст не може взаємодіяти з цим планувальником безпосередньо з Java коду, але він може через JVM попросити планувальник на якийсь час поставити потік на паузу, "приспати" його. Докладніше можна прочитати у статтях " Thread.sleep() " і " How Multithreading works ". Більше того, можна дізнатися, як влаштовані потоки в Windows OS: " Internals of Windows Thread ". А тепер побачимо це на власні очі. Збережемо у файлHelloWorldApp.java
наступний код:
class HelloWorldApp {
public static void main(String []args) {
Runnable task = () -> {
try {
int secToWait = 1000 * 60;
Thread.currentThread().sleep(secToWait);
System.out.println("Waked up");
} catch (InterruptedException e) {
e.printStackTrace();
}
};
Thread thread = new Thread(task);
thread.start();
}
}
Як видно, ми маємо деяке завдання (task), в якому виконується очікування в 60 секунд, після чого завершується програма. Виконуємо компіляцію javac HelloWorldApp.java
та запуск java HelloWorldApp
. Запуск краще виконати у окремому вікні. Наприклад, Windows це буде так: start java HelloWorldApp
. За допомогою команди jps дізнаємося про PID процесу і відкриємо список потоків за допомогою jvisualvm --openpid pidПроцесса
: Як видно, наш потік перейшов у статус Sleeping. Насправді, сон поточного потоку можна зробити красивіше:
try {
TimeUnit.SECONDS.sleep(60);
System.out.println("Waked up");
} catch (InterruptedException e) {
e.printStackTrace();
}
Ви напевно помітабо, що ми скрізь обробляємо InterruptedException
? Давайте зрозуміємо, навіщо.
Переривання потоку або Thread.interrupt
Вся річ у тому, що поки потік чекає уві сні, хтось може захотіти перервати це очікування. На цей випадок ми опрацьовуємо такий виняток. Зроблено це після того, як методThread.stop
оголосабо Deprecated, тобто. застарілим та небажаним до використання. Причиною тому було те, що за виклику методу stop
потік просто "вбивався", що було дуже непередбачувано. Ми не могли знати, коли потік буде зупинено, не могли гарантувати консистентність даних. Уявіть, що ви пишете дані у файл, і тут потік знищують. Тому вирішабо, що логічніше потік не вбиватиме, а інформуватиме його про те, що йому слід перерватися. Як на це реагувати – справа самого потоку. Більш детально можна прочитати у Oracle у " Why is Thread.stop deprecated? ". Подивимося на приклад:
public static void main(String []args) {
Runnable task = () -> {
try {
TimeUnit.SECONDS.sleep(60);
} catch (InterruptedException e) {
System.out.println("Interrupted");
}
};
Thread thread = new Thread(task);
thread.start();
thread.interrupt();
}
У цьому прикладі ми не чекатимемо 60 секунд, а відразу надрукуємо 'Interrupted'. Все тому, що ми викликали у потоку метод interrupt
. Цей метод виставляє "internal flag called interrupt status". Тобто кожен поток має внутрішній прапор, недоступний безпосередньо. Але ми маємо нативні методи для взаємодії з цим прапором. Але це єдиний спосіб. Потік може бути в процесі виконання, не чекати на щось, а просто виконувати дії. Але може передбачити, що його захочуть завершити у певний момент його роботи. Наприклад:
public static void main(String []args) {
Runnable task = () -> {
while(!Thread.currentThread().isInterrupted()) {
//Do some work
}
System.out.println("Finished");
};
Thread thread = new Thread(task);
thread.start();
thread.interrupt();
}
У прикладі вище видно, що цикл while
виконуватиметься доти, поки потік не перервуть зовні. Для прапора isinterrupted важливо знати те, що якщо ми зловабо InterruptedException
, прапор isInterrupted
скидається, і тоді isInterrupted
буде повертати false. Є також статичний метод у класу Thread, який відноситься тільки до поточного потоку Thread.interrupted() , але даний метод скидає значення прапора на false! Докладніше можна прочитати в розділі " Thread Interruption ".
Join — Очікування завершення іншого потоку
Найпростішим типом очікування є очікування завершення іншого потоку.public static void main(String []args) throws InterruptedException {
Runnable task = () -> {
try {
TimeUnit.SECONDS.sleep(5);
} catch (InterruptedException e) {
System.out.println("Interrupted");
}
};
Thread thread = new Thread(task);
thread.start();
thread.join();
System.out.println("Finished");
}
У цьому прикладі новий потік спатиме 5 секунд. У той же час, головний потік main буде чекати, поки сплячий потік не прокинеться і не завершить свою роботу. Якщо подивитися через JVisualVM, стан потоку буде виглядати так: Завдяки засобам моніторингу можна побачити, що відбувається з потоком. Метод join
досить простий, тому що є просто методом з кодом java, який виконує wait
, поки потік, на якому він викликаний, живе. Як тільки потік вмирає (при завершенні), очікування переривається. Ось і вся магія методу join
. Тому перейдемо до найцікавішого.
Концепція Монітор
Багатопоточність є таке поняття, як Monitor. Взагалі слово монітор з латинського перекладається як "наглядач" або "наглядач". У рамках цієї статті спробуємо згадати суть, а хто хоче - за подробицями прошу поринути у матеріал із посилань. Почнемо наш шлях зі специфікації мови Java, тобто з JLS: " 17.1. Synchronization ". Там сказано наступне: Виходить, що з метою синхронізації між потоками Java використовує якийсь механізм, який називається "Монітор". З кожним об'єктом асоційований деякий монітор, а потоки можуть заблокувати його "lock" або розблокувати "unlock". Далі, знайдемо на сайті Oracle навчальний tutorial: " Intrinsic Locks and SynchronizationУ даному туторіалі говориться, що синхронізація в Java побудована навколо внутрішньої сутності (internal entity), відомої як intrinsic lock або monitor lock. Часто такий лок називають просто "монітор". Також ми знову бачимо, що кожен об'єкт у Java має асоційований з Далі важливо зрозуміти , яким чином об'єкт в Java може бути пов'язаний з монітором.У кожного об'єкта в Java є заголовок (header) - свого роду внутрішні метадані, які недоступні програмісту з коду, але які потрібні віртуальній машині, щоб працювати з об'єктами правильно.https://edu.netbeans.org/contrib/slides/java-overview-and-java-se6.pdf
public class HelloWorld{
public static void main(String []args){
Object object = new Object();
synchronized(object) {
System.out.println("Hello World");
}
}
}
Отже, за допомогою ключового слова synchronized
поточний потік (в якому виконуються ці рядки коду) намагається використовувати монітор, асоційований з об'єктомobject
і "отримати лок" або "захопити монітор" (другий варіант навіть краще). Якщо за монітор немає суперництва (тобто ніхто більше не хоче виконати synchronized за таким самим об'єктом), Java може спробувати виконати оптимізацію, звану "biased locking". У заголовку об'єкта Mark Word виставиться відповідний тег і запис про те, до якого потоку прив'язаний монітор. Це дозволяє скоротити накладні витрати під час захоплення монітора. Якщо монітор раніше був прив'язаний до іншого потоку, тоді такого блокування недостатньо. JVM перемикається на наступний тип блокування – basic locking. Вона використовує compare-and-swap (CAS) операції. При цьому в заголовку Mark Word вже зберігається не сам Mark Word, а посилання на його зберігання + змінюється тег, щоб JVM зрозуміла, що у нас використовується базове блокування. Якщо ж виникає суперництво (contention) за монітор кількох потоків (один захопив монітор, а другий чекає звільнення монітора), тоді тег у Mark Word змінюється, і Mark Word починає зберігатися посилання вже на монітор як об'єкт — деяку внутрішню сутність JVM. Як сказано в JEP, в такому випадку потрібно місце в Native Heap області пам'яті для зберігання цієї сутності. Посилання на місце зберігання цієї внутрішньої сутності буде знаходитися в Mark Word об'єкта. Таким чином, як бачимо, монітор — це справді механізм забезпечення синхронізації доступу кількох потоків до загальних ресурсів. Існує кілька реалізацій цього механізму, між якими перемикається JVM. Тому для простоти, говорячи про монітор, ми говоримо насправді про локи. а другий чекає звільнення монітора), тоді тег у Mark Word змінюється, і Mark Word починає зберігатися посилання вже на монітор як об'єкт — деяку внутрішню сутність JVM. Як сказано в JEP, в такому випадку потрібно місце в Native Heap області пам'яті для зберігання цієї сутності. Посилання на місце зберігання цієї внутрішньої сутності буде знаходитися в Mark Word об'єкта. Таким чином, як бачимо, монітор — це справді механізм забезпечення синхронізації доступу кількох потоків до загальних ресурсів. Існує кілька реалізацій цього механізму, між якими перемикається JVM. Тому для простоти, говорячи про монітор, ми говоримо насправді про локи. а другий чекає звільнення монітора), тоді тег у Mark Word змінюється, і Mark Word починає зберігатися посилання вже на монітор як об'єкт — деяку внутрішню сутність JVM. Як сказано в JEP, в такому випадку потрібно місце в Native Heap області пам'яті для зберігання цієї сутності. Посилання на місце зберігання цієї внутрішньої сутності буде знаходитися в Mark Word об'єкта. Таким чином, як бачимо, монітор — це справді механізм забезпечення синхронізації доступу кількох потоків до загальних ресурсів. Існує кілька реалізацій цього механізму, між якими перемикається JVM. Тому для простоти, говорячи про монітор, ми говоримо насправді про локи. у такому випадку потрібно місце в Native Heap області пам'яті для зберігання цієї сутності. Посилання на місце зберігання цієї внутрішньої сутності буде знаходитися в Mark Word об'єкта. Таким чином, як бачимо, монітор — це справді механізм забезпечення синхронізації доступу кількох потоків до загальних ресурсів. Існує кілька реалізацій цього механізму, між якими перемикається JVM. Тому для простоти, говорячи про монітор, ми говоримо насправді про локи. у такому випадку потрібно місце в Native Heap області пам'яті для зберігання цієї сутності. Посилання на місце зберігання цієї внутрішньої сутності буде знаходитися в Mark Word об'єкта. Таким чином, як бачимо, монітор — це справді механізм забезпечення синхронізації доступу кількох потоків до загальних ресурсів. Існує кілька реалізацій цього механізму, між якими перемикається JVM. Тому для простоти, говорячи про монітор, ми говоримо насправді про локи. між якими перемикається JVM. Тому для простоти, говорячи про монітор, ми говоримо насправді про локи. між якими перемикається JVM. Тому для простоти, говорячи про монітор, ми говоримо насправді про локи.
Synchronized та очікування по локу
З поняттям монітора, як ми бачабо, тісно пов'язане поняття "блок синхронізації" (або як ще називають - критична секція). Погляньмо на приклад:public static void main(String[] args) throws InterruptedException {
Object lock = new Object();
Runnable task = () -> {
synchronized (lock) {
System.out.println("thread");
}
};
Thread th1 = new Thread(task);
th1.start();
synchronized (lock) {
for (int i = 0; i < 8; i++) {
Thread.currentThread().sleep(1000);
System.out.print(" " + i);
}
System.out.println(" ...");
}
}
Тут головний потік спочатку відправляє завдання task в новий потік, а потім відразу ж захоплює лок і виконує з ним довгу операцію (8 секунд). Весь цей час task не може для свого виконання зайти в блок synchronized
, т.к. лок вже зайнятий. Якщо потік не може отримати лок, він чекатиме цього біля монітора. Як тільки отримає – продовжить виконання. Коли потік виходить з під монітора, він звільняє лок. У JVisualVM це буде виглядати так: Як видно, статус JVisualVM називається "Monitor", тому що потік заблокований і не може зайняти монітор. У коді теж можна дізнатися про стан потоку, але назва цього стану не збігається з термінами JVisualVM, хоча вони і схожі. В даному випадку th1.getState()
в циклі for
повертатиме BLOCKED, т.к. поки виконується цикл, монітор lock
зайнятий main
потоком, а потік th1
заблокований і не може продовжувати роботу, поки лок не повернуть. Крім блоків синхронізації, може бути синхронізований цілий метод. Наприклад, метод із класу HashTable
:
public synchronized int size() {
return count;
}
В одну одиницю часу цей метод виконуватиметься лише одним потоком. Але нам потрібний лок? Так, потрібний. У разі методів об'єкта локом виступатиме this
. На цю тему є цікаве обговорення: " Чи є можливе використання Synchronized Method instead of a Synchronized Block? ". Якщо метод статичний, то локом буде this
(т.к. для статичного методу може бути this
), а об'єкт класу (Наприклад, Integer.class
).
Wait та очікування по монітору. Методи notify і notifyAll
Thread має ще один метод очікування, який при цьому пов'язаний з монітором. На відміну відsleep
і join
, його не можна просто так викликати. І звуть його wait
. Виконується метод wait
на об'єкті, на моніторі якого хочемо виконати очікування. Подивимося приклад:
public static void main(String []args) throws InterruptedException {
Object lock = new Object();
// task будет ждать, пока его не оповестят через lock
Runnable task = () -> {
synchronized(lock) {
try {
lock.wait();
} catch(InterruptedException e) {
System.out.println("interrupted");
}
}
// После оповещения нас мы будем ждать, пока сможем взять лок
System.out.println("thread");
};
Thread taskThread = new Thread(task);
taskThread.start();
// Ждём и после этого забираем себе лок, оповещаем и отдаём лок
Thread.currentThread().sleep(3000);
System.out.println("main");
synchronized(lock) {
lock.notify();
}
}
У JVisualVM це буде виглядати так: Щоб розібратися, як це працює, слід згадати, що методи wait
і notify
відносяться до java.lang.Object
. Здається дивним, що методи, які стосуються потоків, перебувають у класі Object
. Але тут і криється відповідь. Як ми пам'ятаємо, кожен об'єкт Java має заголовок. У заголовку міститься різна службова інформація, у тому числі інформація про монітор - дані про стан блокування. І як ми пам'ятаємо, кожен об'єкт (тобто кожен instance) має асоціацію із внутрішньою сутністю JVM, яка називається локом (intrinsic lock), який так само називають монітором. У прикладі вище в задачі task описано, що ми входимо в блок синхронізації монітора, асоційованого з lock
. Якщо вдається отримати лок на цьому моніторі, то виконуєтьсяwait
. Потік, що виконує цей task, звільнятиме монітор lock
, але ставатиме в чергу потоків, що очікують сповіщення по монітору lock
. Ця черга потоків називається WAIT-SET, що правильно відображає суть. Це скоріше набір, а не черга. Потік main
створює новий потік із завданням task, запускає його та чекає 3 секунди. Це дозволяє з великою ймовірністю нового потоку захопити лок перш, ніж потік main
, і встати в чергу по монітору. Після чого потік main
сам входить у блок синхронізації lock
і виконує повідомлення потоку по монітору. Після того, як повідомлення надіслано, потік main
звільняє монітор lock
, а новий потік (який раніше чекав), дочекавшись звільнення монітораlock
, продовжує виконання. Існує можливість надіслати повідомлення лише одному з потоків ( notify
) або відразу всім потокам із черги ( notifyAll
). Докладніше можна прочитати в " Difference between notify() and notifyAll() in Java ". Важливо, що порядок повідомлення залежить від JVM. Докладніше можна прочитати в " How to solve starvation with notify and notifyall? ". Синхронізацію можна виконувати без вказівки об'єкта. Це можна зробити, коли синхронізовано не окрему ділянку коду, а цілий метод. Наприклад, для статичних методів локом буде об'єкт класу (отриманий через .class
):
public static synchronized void printA() {
System.out.println("A");
}
public static void printB() {
synchronized(HelloWorld.class) {
System.out.println("B");
}
}
З точки зору використання локів обидва методи однакові. Якщо метод не статичний, то синхронізація виконуватиметься за поточним instance
, тобто this
. До речі, ми говорабо, що за допомогою методу getState
можна отримати статус потоку. Так от потік, який стає в чергу по монітору, статус буде WAITING або TIMED_WAITING, якщо в методі wait
було вказано обмеження часу очікування.
Життєвий цикл потоку
Як ми бачабо, потік у процесі життя змінює свій статус. Насправді ці зміни і є життєвим циклом потоку. Коли потік створений, він має статус NEW. У такому положенні він ще не запущений і планувальник потоків Java (Thread Scheduler) ще не знає нічого про новий потік. Для того, щоб дізнатися про потік планувальник потоків, необхідно викликати методthread.start()
. Тоді потік перейде у стан RUNNABLE. В інтернеті є багато неправильних схем, де поділяють стани Runnable та Running. Але це помилка, т.к. Java не відрізняє статус "готовий до роботи" та "працює (виконується)". Коли потік живий, але не активний (не Runnable), він знаходиться в одному з двох станів:
- BLOCKED - очікує заходу в захищену (protected) секцію, тобто. у
synchonized
блок. - WAITING - очікує інший потік за умовою. Якщо умова виконується, планувальник потоків запускає потік.
getState
. У потоків також є метод isAlive
, який повертає true, якщо потік не Terminated.
LockSupport та паркування потоків
Починаючи з Java 1.6, з'явився цікавий механізм, званий LockSupport . Даний клас асоціює з кожним потоком, що його використовує, "permit" або дозвіл. Виклик методуpark
повертається негайно, якщо permit доступний, займаючи цей самий permit у процесі виклику. Інакше він блокується. Виклик методу unpark
робить доступ доступним, якщо він ще недоступний. Permit є всього 1. У Java API LockSupport
посилаються на якийсь Semaphore
. Давайте подивимося на простий приклад:
import java.util.concurrent.Semaphore;
public class HelloWorldApp{
public static void main(String[] args) {
Semaphore semaphore = new Semaphore(0);
try {
semaphore.acquire();
} catch (InterruptedException e) {
// Просим разрешение и ждём, пока не получим его
e.printStackTrace();
}
System.out.println("Hello, World!");
}
}
Цей код буде вічно чекати, тому що в семафорі зараз 0 permit. А коли в коді викликається acquire
(тобто запитати дозвіл), то потік чекає, поки дозвіл не отримає. Оскільки ми чекаємо, то повинні обробити InterruptedException
. Цікаво, що семафор реалізує окремий стан потоку. Якщо ми подивимося в JVisualVM, то побачимо, що у нас стан не Wait, а Park. Подивимося ще один приклад:
public static void main(String[] args) throws InterruptedException {
Runnable task = () -> {
//Запаркуем текущий поток
System.err.println("Will be Parked");
LockSupport.park();
// Как только нас распаркуют - начнём действовать
System.err.println("Unparked");
};
Thread th = new Thread(task);
th.start();
Thread.currentThread().sleep(2000);
System.err.println("Thread state: " + th.getState());
LockSupport.unpark(th);
Thread.currentThread().sleep(2000);
}
Статус потоку буде WAITING, але JVisualVM відрізняється wait
від synchronized
і park
від LockSupport
. Чому такий важливий цей LockSupport
? Звернемося знову до Java API і подивимося про Thread State WAITING . Як бачимо, до нього можна потрапити лише трьома способами. 2 способи - це wait
і join
. А третій – це LockSupport
. Локи в Java побудовані також LockSupport
і представляють більш високорівневі інструменти. Спробуємо скористатися таким. Подивимося, наприклад, на ReentrantLock
:
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class HelloWorld{
public static void main(String []args) throws InterruptedException {
Lock lock = new ReentrantLock();
Runnable task = () -> {
lock.lock();
System.out.println("Thread");
lock.unlock();
};
lock.lock();
Thread th = new Thread(task);
th.start();
System.out.println("main");
Thread.currentThread().sleep(2000);
lock.unlock();
}
}
Як і в попередніх прикладах, тут все просто. lock
очікує, доки хтось звільнить ресурс. Якщо подивитися в JVisualVM, ми побачимо, що новий потік буде запаркований доти, доки main
потік не віддасть йому лок. Докладніше про локи можна прочитати тут: " Многопоточне програмування в Java 8. Частина друга. Синхронізація доступу до змінних об'єктів " та " Java Lock API. Теорія та приклад використання ". Щоб краще зрозуміти реалізацію локів, корисно прочитати про Phazer в огляді " Клас Phaser ". А говорячи про різні синхронізатори, обов'язкова до прочитання стаття на хабрі " Довідник із синхронізаторів java.util.concurrent.* ".
Разом
У цьому огляді ми розглянули основні способи взаємодії потоків Java. Додатковий матеріал:- Monitors – The Basic Idea of Java Synchronization
- Довідник із синхронізаторів java.util.concurrent.*
- Відповіді на запитання щодо multithreading на співбесіді
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ