Многопоточность. Методы класса Thread
Дорогу осилит идущийРанее мы познакомились с классом, представляющим поток в Java. И даже научились создавать новые потоки выполнения с помощью этого класса. Сегодня мы разберем его чуть подробнее, выясним, какие методы он содержит и для чего они могут понадобиться.
В целом, те, кто полностью освоил практику к предыдущим урокам, уже более-менее представляют, о чем пойдет речь.
Отмечу сразу, что перед данным уроком не стоит задачи разобрать все методы Thread (в нем более 30 публичных методов). Часть из них @Deprecated, еще часть нужна под очень узкие задачи, в т.ч. те, для описания которых не хватает информации, предоставленной в рамках канала на данный момент.
Мы же сосредоточимся на тех методах, которые могут быть полезны на практике или которые позволят лучше понять особенности потоков в Java.
Напомню, что краткий обзор наиболее популярных методов можно найти по ссылке: https://metanit.com/java/tutorial/8.1.php
Ниже представлено описание ряда методов, разбитых на условные группы. Сами группы выделены лишь для удобства в рамках этой конкретной статьи и не являются какой-то строгой или общепризнанной классификацией, как это было у операций Stream.
Методы общей информации
static Thread currentThread()
Статический метод, позволяющий получить объект потока, в котором был вызван. Например, если вызвать его в методе main()*, можно получить объекта потока с именем "main" – основной поток приложения.
*Само собой, имеется ввиду вызов в основном потоке, а не в отдельном, просто запущенном в методе main().
void setName(String name) и String getName()
Сеттер и геттер для поля name. Как правило, потоки именуются по принципу
%название группы потоков%-%номер потока в группе%
Но это редко бывает актуально при разработке приложений. О том, что такое группа потоков, поговорим чуть ниже.
ThreadGroup getThreadGroup()
Метод, возвращающий группу потоков потока, у которого метод был вызван.
Группа потоков – объект, объединяющий в себе ряд потоков, по каким-то причинам обобщенный. В дальнейшем возможно управление этими потоками как единым целым через интерфейс группы.
Например, в группу могут быть выделены потоки, предназначенные для решения одной задачи параллельно (например, для заполнения массива, как было в одной из практических задач).
При необходимости, можно настроить отношения между группами потоков. Например, запретить прерывать потоки одной группы из потоков другой. Для этого потребуется некоторая предварительная настройка. См. SecurityManager.
Правда, в Java 17 SecurityManager стал @Deprecated.
long getId()
Метод, возвращающий id потока. Id, в данном случае – какое-то число типа long. Пользователь не может его присвоить. Кроме того, id уникален только в рамках текущего состава потоков. Т.е. вполне возможна ситуация, когда поток с id == 123 был завершен, после чего появился новый поток, которому также был присвоен id == 123.
Таким образом, getId() актуален только в целях мониторинга (или других манипуляций) в рамках жизненного цикла конкретных потоков. Поток с id = 123 сейчас и поток с id = 123 через 5 минут могут быть совершенно разными потоками выполнения.
Методы управления текущим потоком
static void yield()
Статический метод, позволяющий в конкретном месте кода указать для текущего потока, что «ему не к спеху». Таким образом, данный метод позволяет указать планировщику на возможность приостановить текущий поток и предоставить процессорное время другому потоку, если таковой имеется.
При этом гарантий, что поток будет приостановлен нет. Например, потому что данный поток единственный. Или оценен как наиболее приоритетный. Или по иным причинам.
Может иметь смысл в ряде ситуаций:
1. Перед вызовом длительной операции в потоке с низким приоритетом. Скажем, чтение какого-то файла, которое вполне может подождать, в сравнении с потенциальными дешевыми, но более приоритетными операциями в других потоках;
2. Оптимизация нагрузки. Скажем, если ваш поток планирует повисеть в бесконечном цикле, ожидая, пока выполнится определенное условие – вызов yield() и освобождение процессорного времени для потоков, занятых более полезными вещами, выглядит неплохой идеей;
3. Тестирование. В данном случае yield() можно использовать для определения порядка выполнения определенных потоков.
В целом, yield() – своеобразный инструмент. Он, теоретически, может улучшить производительность системы, но делает взаимодействие между потоками менее прозрачным. Наряду с приоритетами потоков (о них ниже), его следует использовать крайне осторожно. Большинство типовых задач можно эффективно решить другими средствами.
static void sleep()
Метод, позволяющий «усыпить» текущий поток выполнения на заданный промежуток времени. Имеет две реализации: одна принимает миллисекунды, другая – миллисекунды и наносекунды. Якобы для большей гибкости.
Де-факто, использование sleep() с двумя параметрами не имеет особого смысла. Второй параметр либо увеличит время сна на 1 миллисекунду (если значение находится между 0 и 999999), либо приведет к исключению.
Как видите, гибкости в этом нет:)
static void onSpinWait()
Достаточно интересный метод, позволяющий показать, что текущий поток находится в режиме ожидания. Например, ожидает выполнение определенного условия.
В таком случае, процессор может переключить ресурсы на другой поток, пока текущий находится в режиме ожидания. Чем-то напоминает yield().
Реализация данного метода зависит от конкретной JVM и конкретной платформы (грубо говоря, конкретного процессора). Поэтому эффективность использования данного метода зависит от используемого окружения.
Методы управления полезной нагрузкой потока
void run()
тот же метод, который объявлен в интерфейсе Runnable. При создании наследника Thread, в этом методе необходимо описать, что будет делать ваша реализация потока.
При создании экземпляра Thread с помощью Runnable, данный метод вызовет лямбду, которую вы передали в конструктор. Или не сделает ничего, если лямбда не была передана при создании экземпляра (Thread имеет в т.ч. конструктор без параметров).
Важно: вызов метода run() просто выполнит возложенную на поток логику в том же потоке, а не в новом. Это буквально то же самое, что и описать лямбда-выражение, а потом сразу вызвать его метод:
((Runnable) () -> System.out.println("Hello world!")).run();
Сделать так можно, но смысла в этом нет.
void start()
Запускает поток. Именно метод start() можно считать точкой входа в многопоточную среду. Грубо говоря, до вызова этого метода, экземпляр Thread – просто объект, не имеющий отношения к реальным потокам выполнения.
void join()
Метод, указывающий, что текущий поток должен дождаться выполнения другого потока (у которого этот метод вызван), прежде чем выполняться дальше.
Имеет несколько реализаций:
1. Без параметров. Основной поток будет ждать до тех пор, пока другой поток не завершится;
2. С параметром, принимающим длительность ожидания в миллисекундах. Передача отрицательного значения вызовет исключение, 0 – к поведению из п.1, положительного числа – к ожиданию, пока поток завершится или пока не пройдет заявленное число миллисекунд;
К слову, поведение с параметром = 0 характерно и для знакомого нам Object#wait(millis).
3. С двумя параметрами: миллисекунды и наносекунды. В целом, схож по поведению с описанным в п.2, правила обработки второго параметра те же, что и в sleep(millis, nanos).
Стоит отметить, что join() внутри себя использует уже знакомый нам механизм Object#wait(). Монитором же выступает сам объект потока.
Методы для работы с прерыванием потока
void interrupt()
Метод, прерывающий (завершающий до выполнения всей возложенной на него логики) поток.
Если быть точным, данный метод устанавливает флаг interrupted в true. И уже данный флаг должен обрабатываться в самом потоке, завершая его корректно.
При этом если поток на момент вызова у него interrupt() находился в состоянии ожидания – например, выполнялся один из методов Thread.sleep(), Object#wait(), Thread#join() – будет выброшено исключение – InterruptedException. Мы уже знакомы с ним - это проверяемое исключение, которое указано в throws у перечисленных методов.
Таким образом:
1. Данный метод не завершает поток сам по себе. Он лишь ставит флаг interrupted в значение true, что можно отследить внутри потока и принять соответствующие меры. Например, корректно завершить поток, не выполнив часть логики;
2. В ряде случаев (указаны выше) вызов interrupt() приводит к выбросу исключения, при обработке которого также можно корректно завершить поток;
3. Такой подход не гарантирует мгновенного завершения потока, но позволяет потоку корректно завершиться по сигналу, без потери данных и прочих неприятностей, связанных с принудительным завершением выполнения;
4. За счет описанной логики, можно не завершать поток, даже получив соответствующий сигнал. Иными словами, решение о том, требуется ли завершать поток по требованию остается на ответственности программиста. писавшего инструкции для конкретного потока.
Устаревшей альтернативой является метод stop(). Он принудительно останавливает поток, но это может приводить к нежелательным последствиям. Что и стало причиной метки @Deprecated для данного метода уже во второй (1.2) версии Java.
static boolean interrupted()
Статически метод, позволяющий проверить значение флага interrupted для текущего потока. И, при необходимости, использовать это во внутренней логике – для таких случаев, когда другой поток уже потребовал прервать текущий, но JVM еще этого не сделала.
boolean isInterrupted()
То же самое, что и предыдущий метод. Но static interrupted() вызывается для текущего потока, а isInterrupted() можно вызвать для любого треда, чей объект нам доступен.
Методы мониторинга состояния потока
boolean isAlive()
Метод, предоставляющий информацию о состоянии потока. Возвращает true, если поток уже был запущен, но еще не завершил свое выполнение. В остальных случаях – false.
State getState()
Еще один метод для получения состояния, но более информативный.
State – вложенный enum класса Thread. Постараемся разобраться, какие элементы в нем есть и как они коррелируют с фактическим состоянием потока (хотя бы в рамках Java):
1. NEW. Поток создан, но еще не запущен. Иными словами, метод start() еще не был вызван;
2. RUNNABLE. Поток выполняется. Если быть точным, это состояние тождественно ситуациям, когда поток находится в процессе выполнения (т.е. процессор выполняет его прямо сейчас), а также если поток готов к выполнению (не ожидает монитора или не заблокирован каким-то иным образом) и ожидает выделения процессорного времени. Во втором случае он не выполняется прямо сейчас, но лишь из-за ограниченных возможностей процессора, а не по каким-то логическим причинам;
3. BLOCKED. Поток заблокирован. Например, ждет, пока освободится монитор (т.е. ожидает возможности войти в блок synchronized), занятый другим потоком. Также актуально для других механизмов (например, локов), с которыми мы познакомимся в следующих уроках;
4. WAITING. Поток ждет чего-то. Характерно именно для ожидания без четкого указания времени (см. ниже). Сюда относятся ситуации, когда в потоке был вызван Object#wait() без параметров или Thread#join() (тоже без параметров);
5. TIMED_WAITING. Поток ждет, но с ограничениями по времени. Т.е. поток сможет вернуться в состояние RUNNABLE либо когда завершится ожидание по логическим причинам (вызов notify(), завершение ожидаемого потока и пр.), либо когда закончится заданное время ожидания.
В этот статус поток приходит после вызова Thread.sleep(), а также Object#wait() или Thread#join() с параметрами (само собой, отличными от нуля);
6. TERMINATED. Поток завершен. Либо потому что выполнил все переданные ему инструкции, либо потому что был прерван с помощью interrupt().
Поток, поправший в состояние TERMINATED, не может быть запущен повторно.
!NB: Помните, что Thread.State работает в рамках интерпретации состояния потоков в Java и определяется в рамках JVM, а не на уровне процессора. Соответственно, при использовании нестандартных способов взаимодействия с потоками, состояние объекта Thread в Java может не совпадать с состоянием потока, с которым ассоциирован объект.
Безусловно, состояние синхронизируется, но не всегда одномоментно.
Вспоминая про метод isAlive(), его значение false соответствует состояниям NEW и TERMINATED, а true – всем остальным.
Методы для потоков-демонов
Потоки-демоны – обозначение для второстепенных потоков, которые завершатся при завершении всех не-демон потоков даже если их (потоков-демонов) логика выполнена не до конца.
Для лучшего понимания, разберем пример (для лаконичности опустим try-catch для Thread.sleep()):
var t = new Thread(() -> {
Thread.sleep(1000);
System.out.println("Hi from additional thread!");
});
t.start();
System.out.println("Hi from main thread!");
Данный код, если поместить его в main() выведет в консоль фразу "Hi from main thread!", а после некоторой паузы – фразу "Hi from additional thread!".
Таким образом программа не завершится, пока второй поток не закончит работу.
Если же мы укажем, что создаваемый нами поток является демоном:
var t = new Thread(() -> {
Thread.sleep(1000);
System.out.println("Hi from additional thread!");
});
t.start();
t.setDaemon(true);
System.out.println("Hi from main thread!");
То программа завершится сразу после вывода в консоль "Hi from main thread!", не дожидаясь, пока доработает второй поток.
!NB: Мы, разумеется, можем это обойти, использовав t.join() после System.out.println("Hi from main thread!"). Но это является некорректным способом взаимодействия с потоками-демонами.
В т.ч. потому что они, зачастую, могут выполняться бесконечно (из-за бесконечного цикла, например), следовательно, join() может заблокировать основной поток выполнения.
Таким образом, мы получаем механизм, позволяющий выполнять какие-либо второстепенные задачи в фоне, при этом не переживая, что программа будет работать «в холостую», если все задачи, кроме фоновых будут завершены.
Для настройки потока как демона в Thread существует соответствующий сеттер и геттер:
void setDaemon(boolean on)
boolean isDaemon()
Обратите внимание: не стоит вызывать setDaemon() после start(). Если на момент обработки setDaemon() этот поток запущен (isAlive() == true), будет выброшено исключение.
Работа с приоритетами потока
Разные потоки могут решать разные задачи, важность которых также может отличаться. Поэтому Java предоставляет механизм, позволяющий явно указать приоритет потока от 1 до 10 (по умолчанию будет 5). 10 – высший приоритет.
Стоит отметить, что приоритет потоков не является ноу-хау JVM. Планирование потоков с учетом приоритета существует для различных ОС и различных архитектур процессора. JVM использует эти механизмы для обеспечения приоритетов, заданных через Thread. Однако конкретные нюансы планирования могут зависеть от конкретной ОС и конкретного процессора, на базе которых работает конкретная JVM (у которой также существуют различные реализации).
Класс Thread предоставляет сеттер и геттер для взаимодействия с приоритетом потока:
void setPriority(int newPriority)
int getPriority()
!NB: Приоритет потока не может превышать максимальный приоритет группы потоков. Даже если группа не была задано явно, она будет взята из потока, в котором был создан текущий.
В целом, не рекомендуется полагаться на явную приоритезацию потоков. При правильно построенном взаимодействии, этот механизм не нужен (или почти не нужен).
В обратной же ситуации, вполне возможно, что приоритезация потоков не поможет. Скажем, из-за засилья потоков с высоким приоритетом, потоки с низким приоритетом вообще не будут получать процессорного времени. Это является частным случаем голодания потока. Его, как и другие проблемы многопоточности, мы разберем в отдельном уроке.
Методы для групп потоков
static int activeCount()
Статический метод, позволяющий узнать, сколько потоков в группе активны (isAlive() == true) на данный момент. Может помочь при мониторинге нагрузки.
По сути, является оберткой над одноименным методом класса ThreadGroup.
Заключение
В рамках текущего урока мы разобрали большую часть публичных методов класса Thread. Часть из них вряд ли понадобится нам на практике, но она позволила разобрать жизненный цикл потока в Java, а также понять некоторые особенности взаимодействия потоков.
Главное, что следует помнить при работе с классом Thread – контекст вызова. Например, статические методы относятся к текущему потоку. Казалось бы логично. Но многие забывают, что, скажем, вызов конструктора нового потока происходит тоже в текущем потоке. И вызванные статические методы Thread в конструкторе будут взаимодействовать с потоком, который вызвал конструктор, а не потоком, который создан этим конструктором. То же самое и с некоторыми методами.
Поэтому при написании собственных многопоточных программ или при самостоятельном изучении исходного кода – помните, в многопоточности очень большое значение имеет место вызова метода, контекст, в котором он был вызван.
С теорией на сегодня все!

Переходим к практике:
Задача 1
Напишите программу из 10 последовательно запускающихся потоков. Каждый из этих потоков должен выводить в консоль сообщение вида «%Имя потока% запущен и не спешит», вызывать yield(), а после выводить сообщение «%Имя потока% завершен».
Обратите внимание на порядок вывода. Как он изменится, если убрать yield()? Как изменится ситуация, если паре потоков выставить приоритет 10?
Задача 2
Напишите программу, заполняющую двумерный массив большого размера (подберите на свой вкус, ограничения могут зависеть от заданного размера хипа в JVM) случайными числами. Параллельно должен работать поток, каждые 100 миллисекунд пишущий в консоль текущее время.
Программа должна завершиться, как только массив будет заполнен. Предоставьте три различных решения данной программы.
Подсказка: https://pastebin.com/w5dShQr0
Если что-то непонятно или не получается – welcome в комменты к посту или в лс:)
Канал: https://t.me/ViamSupervadetVadens
Мой тг: https://t.me/ironicMotherfucker
Дорогу осилит идущий!