JavaRush /Java блог /Random UA /Thread'ом Java не зіпсуєш: Частина II - синхронізація
Viacheslav
3 рівень

Thread'ом Java не зіпсуєш: Частина II - синхронізація

Стаття з групи Random UA

Вступ

Отже, ми знаємо, що в Java є потоки, про що можна прочитати в огляді " Thread'ом Java не зіпсуєш: Частина I - потоки ". Потоки потрібні, щоб виконувати роботу одночасно. Тому дуже ймовірно, що потоки якось взаємодіятимуть між собою. Давайте розберемося, як це відбувається і які базові засоби управління ми маємо. Thread'ом Java не зіпсуєш: Частина II - синхронізація - 1

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("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Процесса: Thread'ом Java не зіпсуєш: Частина II - синхронізація - 3Як видно, наш потік перейшов у статус 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, стан потоку буде виглядати так: 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 має асоційований з Далі важливо зрозуміти , яким чином об'єкт в Java може бути пов'язаний з монітором.У кожного об'єкта в Java є заголовок (header) - свого роду внутрішні метадані, які недоступні програмісту з коду, але які потрібні віртуальній машині, щоб працювати з об'єктами правильно. 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("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. Тому для простоти, говорячи про монітор, ми говоримо насправді про локи. 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");
		}
	};

	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. На цю тему є цікаве обговорення: " Чи є можливе використання 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 є всього 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. Додатковий матеріал: #Viacheslav
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ