CPJ. Using Concurrency Constructs
по мотивам «Concurrent Programming in Java»
- 1 Конкурентное (concurrent) объектно-ориентированное программирование
- 1.1 Использование абстракций параллелизма
- Механизм потоков
1 Конкурентное (concurrent) объектно-ориентированное программирование
в Java имеют место три вида абстракций параллелизма:
-
Взаимоисключения (exclusion) - обеспечивают согласованное состояние объектов предотвращая нежелательное взаимодействие конкурентных операций, обычно с использованием
synchronized
-методов. -
Обусловленность состоянием (state dependence) - запуск (triggering), препятствование (preventing), отсрочивание (postponing), или возобновление (recovering) действий взависимости от того находится ли объект в состоянии при котором эти действия могут (или могли бы) продолжить выполнение. Обычно реализуется с использованием методов монитора (monitor) таких как
Object::wait
,Object::notify
, иObject::notifyAll
. -
Создание потоков - организация и управление параллелизмом используя объекты класса
Thread
.
1.1 Использование абстракций параллелизма
Каждый объект класса Object
(и его подклассов) имеет блокировку, которая захватывается при входе в synchronized
методы, и автоматически освобождается при выходе из них. Конструкция synchronized
для блока кода работает точно также за исключением того, что принимает аргумент, который задает объект для получения блокировки. Наиболее распространенным объектом, используемым в качестве параметра является this
, означая использование блокировки объекта метод которого и вызывается. При владении блокировкой одним потоком другие потоки должны ждать пока она не будет освобождена. Захват блокировки не оказывает влияния на несинхронизированные методы, которые могут быть выполнены даже если блокировкой владеет другой поток.
Использование блокировок обеспечивает защиту как от верхнеуровневых, так и от низкоуровневых конфликтов обеспечивая атомарность методов и блоков кода синхронизированных на одном и том же объекте. Атомарные операции выполняются как единое целое, не допуская какого-либо пересечения с действиями других потоков. Но, чрезмерное ипользование блокировок может привести к проблеме живучести, следствием которой является замирание программы. Ниже приведены несколько основных правил по написанию методов, которые предотвращают возникновение проблем взаимного вмешательства:
- Используйте блокировку при обновлении полей объекта.
- Используйте блокировку при доступе к, возможно, обновляемым полям.
- Не используйте блокировку при вызове методов других объектов.
Данные правила имеют множество исключений и уточнений.
Рассмотрим пример:
import java.util.Random;
class Particle {
protected int x;
protected int y;
protected final Random rng = new Random();
public Particle(int initialX, int initialY) {
x = initialX;
y = initialY;
}
public synchronized void move() {
x += rng.nextInt(10) - 5;
y += rng.nextInt(20) - 10;
}
public void draw(Graphics g) {
int lx, ly;
synchronized (this) {
lx = x;
ly = y;
}
g.drawRect(lx, ly, 10, 10);
}
}
Замечания:
-
Использование
final
при объявлении генератора случайных чиселrng
отражает решение о том, что эта ссылка должна быть константной, и поэтому она не подчиняется правилам использования блокировок. Многие многопоточные программы обильно используютfinal
отчасти как полезную, автоматически обеспечиваемую документацию архитектурного решения, уменьшающую потребность в синхронизации. -
В методе
Particle::draw
необходимо получить согласованный снапшот (snapshot) значенийx
иy
. Для этих целей используется синхронизированный блок кода. -
Метод
Particle::draw
подчиняется правилу #3 и освобождает блокировку до вызова методов другого объекта (в данном случаеg::drawRect
). Однако, методParticle::move
, кажется, его нарушает вызываяrng::nextInt
. В данном случае это приемлемо, т.к. каждый объект классаParticle
обладает собственным экземпляромrng
, и, концептуально,rng
является частью самого этого объекта, и поэтому не рассматривается как другой объект из правила #3.
Выполняемые внутри нового потока действия должны быть определены в классах реализующих интерфейс java.lang.Runnable
. Этот интерфейс имеет единственный метод run
без аргументов, возвращаемого значения, и проверяемых исключений.
public interface java.lang.Runnable {
void run();
}
interface
инкапсулирует связанный набор сервисов и атрибутов (в более широком понимании - роль) не связывая эту функциональность с каким-либо объектом или кодом. Интерфейсы более абстрактны, чем классы, т.к. они ничего не говорят о реализации или коде. Они только описывают сигнатуры (имена, аргументы, возвращаемые типы и исключения) открытых (public) операций не накладывая ограничений на классы объектов, которые могут их реализовывать. Классы, реализующие Runnable
, как правило, не имеют каких-либо особенностей кроме наличия метода run
.
Каждый объект класса Thread
поддерживает управляющее состояние (control state) необходимое для исполнения и управления последовательностью вызовов составляющих его действия. Наиболее используемые конструкторы класса Thread
принимают объект класса Runnable
в качестве аргумента, который используется для вызова метода Runnable::run
при старте потока. Часто вместо реализации Runnable
более удобно использовать анонимный внутренний класс или лямбда-выражение.
Механизм потоков
Поток - это последовательность вызовов, выполняющаяся независимо от других, и, в то же время, потенциально использующая системные ресурсы (например, файлы), и другие, созданные в рамках этой же программы, объекты. Объекты класса java.lang.Thread
выполняют учет и контроль этой деятельности.
Каждая программа состоит из, по крайней мере, одного потока - того, который запускает метод main
класса переданного в качестве аргумента при старте JVM. Другие внутренние фоновые потоки, также, могут быть запущены при инициализации JVM. Количество и природа таких потоков может различаться в зависимости от реализации JVM. Однако, все потоки пользовательского уровня явно создаются и запускаются либо из главного потока, либо из любого другого созданного потока.
Ниже рассмотрены основные методы и свойства класса Thread
, и приведены некоторые замечания по их использованию.
Создание
Конструкторы класса Thread
принимают различные комбинации аргументов включающие:
-
Объект
Runnable
. В этом случае при последующем вызовеThread::start
будет вызван методrun
переданного объекта. РеализацияThread::run
по-умолчанию просто возвращает управление. -
Объект
String
, служащий идентификатором для данногоThread
. Может быть полезен для отслеживания и отладки, но не играет другой роли. -
Объект
ThreadGroup
в которую должен быть помещен новыйThread
. В случае отсутствия доступа к даннойThreadGroup
будет возбуждено исключениеSecurityException
.
Класс Thread
сам по себе реализует Runnable
, и, поэтому, вместо представления требуемого для запуска кода в виде объекта Runnable
и его передачи конструктору, возможно создание наследника класса Thread
и соответствующее переопределение метода run
. Однако, более предпочтительным подходом является определение Runnable
в качестве отдельного класса и передача его экземпляра в конструктор Thread
. Изоляция кода внутри отдельного класса освобождает от проблем связанных с потенциальным взаимодействием synchronized
методов и блоков, используемых в реализции Runnable
, с методами используемыми классом Thread
. В более общем смысле, такое разделение обеспечивает независимый контроль над источником действий и контекстом в котором они выполняются - один и тот же Runnable
может передаваться в потоки, созданные различными способами, так же как и другим легковесным исполнителям (executors). Также, стоит отметить, что наследование от Thread
исключает возможность расширения другого класса.
Объекты класса Thread
, также, имеют атрибут обозначающий статус потока-демона (daemon status), который не может быть задан через конструкторы, но может быть установлен до запуска потока. Метод Thread::setDaemon
обозначает, что процесс JVM может завершиться резко прервав поток в том случае, если все не демон-потоки завершили своё выполнение. Метод Thread::isDaemon
возвращает статус потока-демона. Использование демонов довольно ограниченно т.к., обычно, даже фоновым потокам часто требуется выполнить какие-то действия при завершении программы.
Запуск потоков
Результатом вызова метода start
у объекта класса Thread
является запуск метода run
как независимого действия. Вновь созданный поток не владеет блокировками синхронизации захваченными вызывающим потоком.
Поток завершается при выходе из метода run
как обычным возвратом, так и из-за возбуждения непроверяемого исключения (т.е. RuntimeException
, Error
или одного из наследников). Потоки не являются перезапускаемыми (restartable) даже после их завершения. Вызов Thread::start
более одного раза приведет к возбуждению исключения InvalidThreadStateException
.
Метод Thread::isAlive
вернет true
в случае если поток был запущен, но не завершен. Также, он вернет true
в случае когда поток просто блокирован каким-то образом. Различные реализации JVM отличаются тем когда именно isAlive
возвращает false
для отмененных потоков. Нет методов для определения того запускался ли поток, метод isAlive
которого возвращает false
. Также, поток не может прямо определить запустивший его поток, хотя он может определить идентификаторы других потоков в его ThreadGroup
.
Приоритеты
Для возможности реализации JVM под различные аппаратные платформы и операционные системы язык программирования Java не дает каких-либо гарантий о диспетчеризации (scheduling) или честном выборе потоков (fairness), и даже не гарантирует, что потоки будут делать прогресс при выполнении (forward progress). Но, потоки поддерживают методы работы с приоритетом, который может повлиять на работу планировщиков:
- Каждый поток имеет приоритет в пределах от
Thread.MIN_PRIORITY
доThread.MAX_PRIORITY
(1
и10
соответственно). - По-умолчанию новому потоку задается приоритет равный приоритету создавшего его потока. Начальный поток, ассоциированный с
main
, по-умолчанию имеет приоритет равныйThread.NORM_PRIORITY
(5
). - Текущий приоритет потока можно получить с помощью метода
Thread::getPriority
. - Приоритет потока может быть динамически изменен с помощью метода
Thread::setPriority
. Максимально возможный приоритет потока ограничается в егоThreadGroup
.
При наличии большего числа готовых к работе потоков, чем доступных ЦПУ планировщик, в общем случае, склонен к выбору для запуска потоков с бОльшим приоритетом. Точная политика может и будет отличаться для различных платформ. Например, некоторые реализации JVM всегда выбирают поток с наибольшим текущим приоритетом. Некоторые реализации отображают десять возможных приоритетов потоков на меньшее число, поддерживаемых системой, категорий так, что потоки с различными приоритетами будут учитываться одинаково. А некоторые смешивают заданный приоритет со схемой учета возраста потока, или другой политикой планирования, для обеспечения того, что даже низкоприоритетные потоки, в итоге, получат шанс быть выполнеными. Также, задание приоритетов может, но не обязательно, повлиять на диспетчеризацию в отношении других программ, запущенных на той же компьютерной системе.
Приоритеты не имеют какого-либо другого влияния на семантику или корректность. В особенности, приоритет не может использоваться в качестве замены использованию блокировок. Приоритеты могут использоваться только для отражения относительной важности или срочности различных потоков там где это было бы уместно принять к учету при интенсивном соперничестве между потоками за шанс быть выполнеными. Но, программы должны проектироваться так, чтобы работать корректно (хотя, возможно, и не так отзывчиво) даже если Thread::setPriority
реализован просто как заглушка (no-op). То же самое относится и к Thread::yield
.
Следующая таблица представляет собой набор общих соглашений для связывания категорий задач с приоритетом. В большинстве многопоточных приложений относительно небольшое число потоков действительно работают в конкретный момент времени (другие блокированы каким-то образом), и в этом случае манипуляция приоритетами не имеет большого смысла. В других случаях изменения в приоритетах все-таки могут сыграть небольшую роль в конечной настройке многопоточной системы.
Диапазон приоритетов | Использование |
---|---|
10 | Управление критическими ситуациями |
7-9 | Интерактивная, событийно-ориентированная система |
4-6 | IO-операции |
2-3 | Фоновые вычисления |
1 | Запуск только в том случае, если больше некому |
Управляющие методы
Для организации взаимодействия между потоками доступны несколько методов:
-
Каждый объект
Thread
имеет ассоциированный с ним булевый статус прерванности (interruption status). Вызовt.interrupt
для некоторого потокаt
устанавливает его статус прерванности вtrue
, за исключением случаев когда этот поток вовлечен вObject::wait
,Thread::sleep
, илиThread::join
. В этом случаеinterrupt
приведет к тому, что эти действия (вt
) вызовут исключениеInterruptedException
, а статус прерванностиt
установится вfalse
. -
Состояние прерванности любого потока может быть получено с помощью метода
isInterrupted
. Этот метод вернетtrue
если поток был прерван вызовом методаinterrupt
, и статус, после этого, не был сброшен ни потоком через вызовThread.interrupted
, ни возбуждением исключения в ходеwait
,sleep
, илиjoin
. -
Вызов
t.join()
для потокаt
переводит вызывающий поток (caller) в состояние ожидания завершения целевого потокаt
- вызовt.join()
вернет управление когдаt.isAlive()
возвратитfalse
. Версия с аргументом таймаута в миллисекундах вернет управление даже если поток не завершился в указанный период времени. Из-за того как определенisAlive
не имеет смысла вызыватьjoin
у не запущенного потока. По схожим причинам не стоит вызыватьjoin
у потока созданного кем-то другим.
Изначально класс Thread
поддерживал дополнительные управляющие методы: suspend
, resume
, stop
, и destroy
. Методы suspend
, resume
, и stop
были объявлены устаревшими. Метод destroy
не имел реализации и, вероятно, никогда не будет. Эффект вызова методов suspend
и resume
может быть получен более безопасным и надежным способом используя техники ожидания (waiting) и уведомления (notification).
Статические методы
Некоторые методы класса Thread
могут быть применены только к текущему потоку (т.е. к потоку в котором происходит вызов методов Thread
). Для обеспечения данного условия такие методы объявлены статическими (static
).
-
Метод
Thread::currentThread
возвращает ссылку на текущий объектThread
. Эта ссылка может использоваться для вызова других (не статических) методов. Например,Thread.currentThread().getPriority()
вернет приоритет вызвавшего этот метод потока. -
Вызов
Thread::interrupted
сбрасывает статус отмены текущего потока и возвращает предыдущий статус. Таким образом, статус отмены потока не может быть сброшен из других потоков. -
Вызов
Thread::sleep(msecs: long)
приостановит выполнение текущего потока на, по крайней мере,msec
миллисекунд. -
Thread::yield
исключительно вспомогательный метод дающий JVM подсказку о том, что в случае если есть какие-либо ожидающие запуска потока, то вместо данного потока планировщик может запустить один или более из них. JVM может интерпретировать эту подсказку любым образом.
Несмотря на отсутствие гарантий yield
может быть эффективен на некоторых реализациях JVM для однопроцессорных систем, которые не используют вытесняющую многозадачность с квантованием времени (time-sliced pre-emptive scheduling). В этом случае потоки перепланируются (rescheduled) только при блокировании одного из них (например, из-за I/O операций или вызова sleep
). На таких системах потоки выполняющие длительные неблокирующие операции могут занимать процессор на относительно большой промежуток времени ухудшая, тем самым, отзывчивость приложения. В качестве способа защиты методы, выполняющие неблокирующие операции время которых может превысить допустимый предел отзывчивости для потоков, которые обрабатывают события, или других реактивных потоков, могут вставлять в код yield
(или даже sleep
), и, когда уместно, иметь меньший приоритет. Для минимизации нежелательного воздействия вызов yield
можно делать лишь иногда. Например, в цикле можно укзать следующее:
if (Math.random() < 0.01) Thread.yield();
На реализациях JVM для систем, которые используют вытесняющую многозадачность, особенно многопроцессорных, возможно, и даже желательно, чтобы планировщик просто игнорировал эти подсказки.
ThreadGroups
Каждый поток создается как элемент группы потоков ThreadGroup
(по-умолчанию группы потока вызвавшего конструктор). ThreadGroup
имеют древовидную вложенность. Когда поток создает новую ThreadGroup
она становится дочерней для его группы. Метод Thread::getThreadGroup
возвращает группу потока. Класс ThreadGroup
, в свою очередь, поддерживает такие методы как enumerate
, которые дают возможность узнать какие потоки находятся в группе.
Одной из целей класса ThreadGroup
является поддержка политик безопасности, которые динамически ограничивают доступ к операциям класса Thread
. Например, запрет вызова interrupt
для потока другой группы. Для группы потоков, также, можно задать предельное значение приоритета её потоков.
Обычно ThreadGroup
не используются явно в многопоточных программах. Для целей большинства приложений обычные коллекции являются более предпочтительными для работы с множествами объектов Thread
.
ThreadGroup::uncaughtException
является одним из часто используемых методов класса ThreadGroup
. Этот метод вызывается когда поток группы завершается из-за неперехваченного непроверяемого исключения (например, NullPointerException
). Результатом вызова, обычно, является печать стек-трейса (stack trace).