Вступ

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

Yield

Метод Thread.yield() загадковий і рідко використовується. Є багато варіацій його описання в інтернеті. Навіть до того, що деякі пишуть про якусь чергу потоків, у якій потік переміститься вниз з урахуванням їхніх пріоритетів. Хтось пише, що потік змінить статус з running на runnable (хоча розподілу на ці статуси немає, і Java їх не розрізняє). Але насправді все куди невідоміше і в певному сенсі простіше. Thread'ом Java не зіпсуєш: Частина II — синхронізація - 2На тему документації методу 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("Прокинувся");
            } 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Процесу: Thread'ом Java не зіпсуєш: Частина II — синхронізація - 3Як видно, наш потік перейшов у статус Sleeping. Насправді, сон поточного потоку можна зробити більш красиво:

try {
	TimeUnit.SECONDS.sleep(60);
	System.out.println("Прокинувся");
} 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". Тобто у кожного потоку є внутрішній прапорець, недоступний напряму. Але у нас є native методи для взаємодії з цим прапорцем. Але це не єдиний спосіб. Потік може бути в процесі виконання, не чекати чогось, а просто виконувати дії. Але може передбачити, що його захочуть завершити у певний момент його роботи. Наприклад:

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, то стан потоку виглядатиме так: Thread'ом Java не зіпсуєш: Частина II — синхронізація - 4Завдяки засобам моніторингу можна побачити, що відбувається з потоком. Метод join доволі простий, тому що є просто методом з java кодом, який виконує wait, поки потік, на якому він викликаний, живе. Як тільки потік завершується, очікування переривається. Ось і вся магія методу join. Тож, перейдемо до найцікавішого.

Поняття Монітор

У багатопоточності є таке поняття, як Monitor. Взагалі, слово монітор з латинської перекладається як "наглядач" або "наглядач". У межах цієї статті спробуємо згадати суть, а хто хоче — за подробицями прошу зануритись у матеріал із посилань. Почнемо нашу подорож зі специфікації мови Java, тобто з JLS: "17.1. Synchronization". Там сказано наступне: Thread'ом Java не зіпсуєш: Частина II — синхронізація - 5Виходить, що для цілей синхронізації між потоками Java використовує певний механізм, який називається "Монітор". З кожним об'єктом асоційований деякий монітор, а потоки можуть його заблокувати "lock" або розблокувати "unlock". Далі знайдемо на сайті Oracle навчальний tutorial: "Intrinsic Locks and Synchronization". У цьому туторіалі говориться, що синхронізація в Java побудована навколо внутрішньої сутності (internal entity), відомої як intrinsic lock або monitor lock. Часто цей лок називають просто "монітор". Також ми знову бачимо, що кожен об'єкт у Java має асоційований із ним intrinsic lock. Почитати можна "Java - Intrinsic Locks and Synchronization". Далі важливо зрозуміти, яким чином об'єкт у Java може бути пов'язаний із монітором. У кожного об'єкта в Java є заголовок (header) — свого роду внутрішні метадані, які недоступні програмісту з коду, але які потрібні віртуальній машині, щоб працювати з об'єктами правильно. До складу заголовка об'єкта входить MarkWord, яке виглядає наступним чином: Thread'ом Java не зіпсуєш: Частина II — синхронізація - 6

https://edu.netbeans.org/contrib/slides/java-overview-and-java-se6.pdf

Тут дуже стане у пригоді стаття з хабра: "А як же все-таки працює багатопоточність? Частина I: синхронізація". До цієї статті варто додати опис із Summary блоку таска з багтрекера JDK: "JDK-8183909". Можна те ж саме прочитати в "JEP-8183909". Отже, у Java з об'єктом асоційований монітор, і потік отримує можливість заблокувати цей потік або, як ще кажуть, "отримати лок". Найпростіший приклад:

public class HelloWorld{
    public static void main(String []args){
        Object object = new Object();
        synchronized(object) {
            System.out.println("Привіт Світ");
        }
    }
}
Отже, за допомогою ключового слова 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. Тому для простоти, кажучи про монітор, ми говоримо насправді про локи. Thread'ом Java не зіпсуєш: Частина II — синхронізація - 7

Synchronized та очікування по локу

З поняттям монітора, як ми раніше бачили, тісно пов'язане поняття "блок синхронізації" (або як його ще називають — критична секція). Подивимось на приклад:

public static void main(String[] args) throws InterruptedException {
	Object lock = new Object();

	Runnable task = () -> {
		synchronized (lock) {
			System.out.println("потік");
		}
	};

	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 це виглядатиме наступним чином: Thread'ом Java не зіпсуєш: Частина II — синхронізація - 8Як видно, статус у JVisualVM називається "Monitor", тому що потік заблокований і не може зайняти монітор. У коді теж можна дізнатися стан потоку, але назва цього стану не збігається з термінами JVisualVM, хоча вони й схожі. У цьому випадку th1.getState() у циклі for буде повертати BLOCKED, тому що поки виконується цикл, монітор lock зайнятий потоком main, а потік th1 заблокований і не може продовжувати роботу, поки лок не буде звільнений. Окрім блоків синхронізації можна синхронізувати цілий метод. Наприклад, метод із класу HashTable:

public synchronized int size() {
	return count;
}
У будь-який момент часу цей метод буде виконуватись лише одним потоком. Але ж потрібен лок? Так, потрібен. У випадку методів об'єкта локом виступатиме this. На цю тему є цікаве обговорення: "Is there an advantage to use a 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 це виглядатиме ось так: Thread'ом Java не зіпсуєш: Частина II — синхронізація - 10Щоб зрозуміти, як це працює, варто згадати, що методи 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 було вказано обмеження часу очікування. Thread'ом Java не зіпсуєш: Частина II — синхронізація - 11

Життєвий цикл потоку

Як ми бачили, потік у процесі свого життя змінює свій статус. По суті, ці зміни і є життєвим циклом потоку. Коли потік тільки створено, він має статус NEW. У такому положенні він ще не запущений, і планувальник потоків Java (Thread Scheduler) ще не знає нічого про новий потік. Для того, щоб про потік дізнався планувальник потоків, необхідно викликати метод thread.start(). Тоді потік перейде у стан RUNNABLE. В Інтернеті є багато неправильних схем, де розділяють стани Runnable і Running. Але це помилка, тому що Java не розділяє статус "готовий до роботи" і "працює (виконується)". Коли потік живий, але не активний (не Runnable), він знаходиться в одному з двох станів:
  • BLOCKED — чекає входу в захищену (protected) секцію, тобто в synchonized блок.
  • WAITING — чекає іншого потоку за умовою. Якщо умова виконується, планувальник потоків запускає потік.
Якщо потік чекає за часом, він перебуває у статусі TIMED_WAITING. Якщо потік більше не виконується (завершився успішно або з exception), він переходить у статус TERMINATED. Щоб дізнатись стан потоку (його state), використовується метод getState. У потоків також є метод isAlive, що повертає true, якщо потік не Terminated.

LockSupport і паркування потоків

Починаючи з Java 1.6 з'явився цікавий механізм, що зветься LockSupport. Thread'ом Java не зіпсуєш: Частина II — синхронізація - 12Цей клас асоціює з кожним потоком, який його використовує, "permit" або дозвіл. Виклик методу park повертається негайно, якщо permit доступний, займаючи цей самий permit у процесі виклику. Інакше він блокується. Виклик методу unpark робить permit доступним, якщо він ще недоступний. 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. Thread'ом Java не зіпсуєш: Частина II — синхронізація - 13Подивімось на ще один приклад:

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. Додатковий матеріал: