Вступление
Итак, мы знаем, что в 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Процесса
:

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, то состояние потока будет выглядеть так:

join
довольно прост, потому что является просто методом с java кодом, который выполняет wait
, пока поток, на котором он вызван, живёт. Как только поток умирает (при завершении), ожидание прерывается. Вот и вся магия метода join
. Поэтому, перейдём к самому интересному.
Понятие Монитор
В многопоточности есть такое понятие, как Monitor. Вообще, слово монитор с латинского переводится как "надзиратель" или "надсмотрщик". В рамках данной статьи попытаемся вспомнить суть, а кто хочет — за подробностями прошу погрузиться в материал из ссылок. Начнём наш путь со спецификации языка Java, то есть с JLS: "17.1. Synchronization". Там сказано следующее:

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. Поэтому для простоты, говоря про монитор, мы говорим на самом деле про локи.

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 это будет выглядеть следующим образом:

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 это будет выглядеть следующим образом:

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.
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.

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 на собеседовании
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ