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

Thread'ом Java не испортишь: Часть II — синхронизация

Статья из группы Random

Вступление

Итак, мы знаем, что в 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". То есть у каждого потока есть внутренний флаг, недоступный напрямую. Но у нас есть 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("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. Поэтому для простоты, говоря про монитор, мы говорим на самом деле про локи. 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. На эту тему есть интересное обсуждение: "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. Дополнительный материал: #Viacheslav
Комментарии (66)
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ
Anonymous #2502407 Уровень 2
27 октября 2023
Хотелось бы конечно уточнений насчёт RUNNING и RUNNABLE
sultan Уровень 28
30 мая 2023
АААА башка кипит, столько информации.
NacWay Уровень 37
12 апреля 2023
Для управления доступом к ресурсу семафор использует счетчик, представляющий количество разрешений. Если значение счетчика больше нуля, то поток получает доступ к ресурсу, при этом счетчик уменьшается на единицу. После окончания работы с ресурсом поток освобождает семафор, и счетчик увеличивается на единицу. Если же счетчик равен нулю, то поток блокируется и ждет, пока не получит разрешение от семафора. Установить количество разрешений для доступа к ресурсу можно с помощью конструкторов класса Semaphore:

Semaphore(int permits)
Semaphore(int permits, boolean fair)
Подробнее про Semaphore
Viter Уровень 38
2 апреля 2023
"в Java с объектом ассоциирован монитор и поток получается заблокировать этот поток" Видимо мой русский отличается от того который используется в статье
partiec Уровень 33
1 февраля 2023
прикольно
9 декабря 2022
BobaFoba Уровень 28
23 ноября 2022
"WAKED UP"? Серьезно??
Магсумова Диана Уровень 108 Expert
18 ноября 2022
Офигенная статья! ))) Автор молорик ))) хоть и читала я ее почти два часа 😀
Kurama Уровень 50
13 ноября 2022
Этот код выглядит криво:

int secToWait = 1000 * 60;
Thread.currentThread().sleep(secToWait);
Так было бы правильно:

int secToWait = 60;
Thread.currentThread().sleep(secToWait * 1000);
А то в переменной "секунд" хранятся миллисекунды...
Ярослав Уровень 48 Expert
31 октября 2022
убейте меня