Многопоточность Java. #неОпятьАСнова #javaJunior #javaCore
Эта статья, как и все последующие — моя попытка структурировать полученные знания в процессе изучения Java. В этой статье тезисно собрана вся основная информация по теме и те формулировки, которые показались мне наиболее удачными и понятными.
Это мой конспект, если хотите.
На все источники, откуда черпалась информация, предоставлены ссылки в конце статьи.
Статья будет полезна тем, кто изучает или повторяет основы Java Core.
И тем, кто готовится к собеседованию.
Основные понятия
Процессор компьютера с одним ядром может выполнять только одну команду одновременно.
Как правило ОС не выделяет отдельный процессор под каждый процесс, а значит применяется процедура time slicing (нарезка времени) — процессор постоянно переключается между потоками выполнения. Переключение происходит сотни раз в секунду (тактовая частота), и со стороны кажется, что все потоки работают одновременно –, но это не так.
Пото́к выполне́ния (тред; от англ. thread — нить) — наименьшая единица обработки, исполнение которой может быть назначено ядром операционной системы.
Несколько ядер могут решать список задачь параллельно.
Тогда одновременно будет выполняться более одного потока.
В первом случае программа называются Однопоточной
Во втором, если потоков несколько — Многопоточной
(когда независимо друг от друга выполняются разные части кода)
Основу работы с потоками в Java составляют интерфейс Runnable и класс Thread.
С их помощью можно запускать и останавливать потоки выполнения, менять их свойства, среди которых основные: приоритет и daemon.
@FunctionalInterface
public interface Runnable
public class Thread extends Object implements Runnable
Изначально программа состоит из главного потока — Main Thread
Главный поток запускает метод main()
Дальше по ходу выполнения программы могут быть запущены дочерние треды.
Программа завершается когда Main Thread выполнит метод main()
Функциональный интерфейс Runnable содержит единственный абстрактный метод run()
который запускает новый поток выполнения.
Функциональность отдельного потока заключается в Классе Thread
Чтобы запустить новый поток
Существует два способа создать и запустить новый тред:
Реализовать интерфейс Runnable
Унаследовать класс Thread
class MyThread implements Runnable {
@Override
public void run() {
}
}
class MyThread extends Thread {
@Override
public void run() {
}
}
Runnable — функциональный интерфейс, можно использовать лямбда выражение
Thread thread = new Thread(() -> {
System.out.println("run method body");
});
thread.start();
Наследование класса Thread целесообразно применять когда нужно дополнить функциональность самого класса Thread.
Использование интерфейса Runnable — когда просто нужно одновременно выполнить несколько задач и не требуется вносить изменений в сам механизм многопоточности.
Для запуска новых потоков нужно вызывать метод start()
, а не run()
Метод start()
инициализирует Thread, а затем сам вызывает метод run()
Прямой вызов метода run()
не имеет отношения к многопоточности — в этом случае программа будет выполнена в главном потоке Main Thread.
Thread thread = new Thread(new MyThread());
thread.start();
В каком порядке запускать новые потоки решает планировщик потоков: механизм внутри операционной системы.
Последовательность выполнения потоков контролировать нельзя.
Если поток был запущен и завершился — повторно запустить его не получится.
Остановить поток
Поток нельзя остановить — он может остановиться только сам.
Но можно явно указать, что потоку следует остановиться.
Main Thread завершается вместе с выходом из метода main()
Дочерний поток — завершая выполнение метода run()
Класс Thread содержит скрытое булево поле — флаг прерывания.
Установить флаг можно вызвав метод потока interrupt()
Это укажет, что поток следует прервать, но не прервет его тут же.
Проверить установлен ли флаг, можно двумя способами:
1. Вызвать метод isInterrupted()
объекта потока
2. Вызвать статический метод Thread.interrupted()
class MyThread implements Runnable {
@Override
public void run() {
Thread current = Thread.currentThread();
while (!current.isInterrupted()) {
}
}
}
Метод interrupt()
выставляет флаг прерывания на конкретном потоке, указывая, что ему следует остановиться. Ставит значение флага true
.
Статический метод Thread.interrupted()
возвращает значение флага прерывания для текущего потока. После проверки всегда присваивает значение флага false
и запускает поток.
Метод isInterrupted()
возвращает значение флага прерывания для того объекта, на котором вызван. Не запускает поток.
Приоритет потоков
Для контроля важности и очерёдности работы разных потоков существует приоритет.
Приоритет является одним из ключевых факторов выбора системой потока для выполнения.
Он может иметь числовое значение от 1 до 10.
По умолчанию главному потоку выставляется средний приоритет — 5
Для этого в классе Thread объявлены три константы:
static final int MAX_PRIORITY = 10
static final int NORM_PRIORITY = 5
static final int MIN_PRIORITY = 1
Задать потоку приоритет можно с помощью метода setPriority(int)
Использование памяти
Чем больше потоков создается — тем больше памяти используется.
Во многих системах может быть ограничение на количество потоков.
Даже если такого ограничения нет, в любом случае имеется естественное ограничение в виде максимальной скорости процессора.
Для каждого потока создается свой собственный стек в памяти.
Туда помещаются все локальные переменные и ряд других данных, связанных с выполнением потока.
Возможные ошибки
Использование многопоточности может привести к двум ситуациям:
Deadlock (взаимная блокировка) — несколько потоков находятся в состоянии ожидания ресурсов, занятых друг другом, и ни один из них не может продолжать выполнение.
Race Condition (состояние гонки) — ошибка проектирования многопоточной системы или приложения, при которой работа системы или приложения зависит от того, в каком порядке выполняются части кода.
Не все Race condition потенциально производят Deadlock, однако, Deadlock происходят только в Race condition.
Синхронизация потоков. Блокировка ресурсов
Проблемы с использованием общих ресурсов в многопоточном приложении решаются синхронизацией потоков (блокировкой ресурсов).
Механизм синхронизации обеспечивает последовательный доступ к ресурсам.
Выполнение программы не будет продолжено, пока блокировка ресурса не освободится.
Для блокировки ресурса используетися ключевое слово synchronized
Синхронизированным может быть либо отдельный метод либо блок кода.
public class Test {
public synchronized void test() {
}
}
final полякласса инициализируются в его конструкторе — соответсвенно корректное значение final полей будет видно всем потокам без синхронизации.
static метод — в этом случае синхронизация будет осуществляться по классу где этот метод объявлен.
public static synchronized void test() {
}
Если у объекта один синхронизированный метод статический , а другой синхронизированный метод не статический — они могут одновременно выполняться т.к. монитор (блокировка) для первого — класс, а для второго — объект.
Недостатком использования synchronized
является то, что другие потоки вынуждены ждать, пока нужный объект или метод освободится. Это создает bottle neck (узкое место) в программе, от чего скорость работы может пострадать.
Monitor. Mutex. Semaphore
Семафор — это средство синхронизации доступа к ресурсу.
Ограничивает количество потоков, которые могут войти в заданный участок кода
Использует счетчик потоков, который указывает, сколько потоков одновременно могут получать доступ к общему ресурсу.
Мьютекс — поле для синхронизации потоков. Есть у каждого объекта в Java.
Простейший Семафор, может находиться в одном из двух состояний: true или false.
Монитор — это дополнительная надстройка над Мьютексом.
Блокирует объект именно монитор
Когда один тред заходит внутрь synchronized
блока кода, JVM тут же блокирует Mьютекс синхронизированного объекта.
Больше ни один тред не сможет зайти в этот блок, пока текущий тред его не покинет.
Как только первый поток выйдет из блока synchronized
, Mьютекс автоматически разблокируется и будет свободен для захвата следующим потоком.
Когда Mьютекс занят — новый поток будет ждать, пока он не освободится.
Concurrency. Неблокирующая синхронизация
Когда обрабатывают крупный массив данных на многоядерном процессоре, обычные структуры данных можно оградить мьютексом только целиком, и если потоки постоянно к ним обращаются, работа становится почти что последовательной.
Неблокирующие алгоритмы гарантируют, что такие остановки одного из потоков не приведут к простою остальных.
Неблокирующая синхронизация позволяет полностью избавиться от взаимных блокировок. Разделение доступа между потоками идёт за счёт атомарных операций и разработанных под конкретную задачу механизмов блокировки.
Пакет java.util.concurrent включает в себя несколько небольших стандартизированных расширяемых фреймворков, а также некоторые классы, которые обеспечивают полезную функциональность и в остальном утомительны или сложны в реализации.
Классы и интерфейсы пакета java.util.concurrent
объединены в несколько групп по функциональному признаку:
Collections — набор более эффективно работающих в многопоточной среде коллекций нежели стандартные универсальные коллекции из java.util пакета
Synchronizers — объекты синхронизации, позволяющие управлять и/или ограничивать работу нескольких потоков.
Atomic — набор атомарных классов, позволяющих использовать принцип действия механизма оптимистической блокировки для выполнения атомарных операций.
Queues — объекты создания блокирующих и неблокирующих очередей с поддержкой многопоточности.
Locks — механизмы синхронизации потоков, альтернативы базовым synchronized, wait, notify, notifyAll
Executors — механизмы создания пулов потоков и планирования работы асинхронных задач
Атомарные классы
Операция называется атомарной когда ее можно безопасно выполнить при параллельных вычислениях в нескольких потоках, не используя при этом блокировок.
Блокировка подразумевает пессимистический подход, разрешая только одному потоку выполнять определенный код, связанный с изменением значения некоторой общей переменной.
Можно использовать оптимистический подход — в этом случае блокировки не происходит, и если поток обнаруживает, что значение переменной изменилось другим потоком, то он повторяет операцию снова, но уже с новым значением переменной.
Так работают атомарные классы.
Атомарная операция либо выполняется целиком, либо не выполняется вовсе.
Атомарные классы гарантируют, что определенные операции будут выполняться потокобезопасно, например операции инкремента и декремента, обновления и добавления значения (add).
Когда требуется примитивный тип, выполняющий операции инкремента и декремента, гораздо проще выбрать его среди атомарных классов в пакетеjava.util.concurrent.atomic, чем писать synchronized
блок самому.
Внутри атомарные классы используют сравнение с обменом — атомарную инструкцию, которая работает гораздо быстрее, чем синхронизация с помощью блокировок. Поэтому, если просто нужно изменять одну переменную с помощью нескольких потоков, лучше выбирать атомарные классы.
Сравнение с обменом — атомарная инструкция, сравнивающая значение в памяти с одним из аргументов, и в случае успеха записывающая второй аргумент в память.
Deamon потоки. Фоновые процессы
Daemon потоки позволяют описывать фоновые процессы, которые нужны только для обслуживания основных потоков выполнения и не могут существовать без них.
Для работы с Daemon потоками у класса Thread существуют методы: setDaemon()
isDaemon()
JVM прекращает работу, как только все не Daemon потоки завершаются.
Жизненный цикл потока
Существует четыре состояния жизненного цикла потока:
New
Поток находится в состоянии New, когда создается новый экземпляр объекта класса Thread, но метод start()
не вызывался.
Runnable
Когда для созданного нового объекта Thread был вызван метод start()
.
Такой поток либо ожидает, что планировщик заберет его для выполнения, либо уже запущен.
Non-Runnable (Blocked, Timed-Waiting)
Когда поток временно неактивен, то есть объект класса Thread существует, но не выбран планировщиком для выполнения.
Terminated
Когда поток завершает выполнение своего метода run()
, он переходит в состояние terminated (завершен). На этом этапе выполнение потока завершается.
Переключение потоков
Присоединиться к потоку join()
Выполнение начинает выбранный поток.
Приостановит выполнение текущего потока.
try {
thread1.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
thread2.start();
thread3.start();
Усыпить поток Thread.sleep()
Цель метода — усыпить поток на некоторое время.
Часто используется в дочерних тредах, когда нужно делать какое-то действие постоянно, но не слишком часто. Поток в состоянии сна можно прервать.
Thread.sleep(2000); // пауза на 2 секунды
После того как поток просыпается, он переходит в состояние runnable.
Однако это не значит, что планировщик потоков запустит сразу и именно его.
Переключиться на другую нить Thread.yield()
Аналог Thread.sleep(0)
— работает фактически так же.
Процессор постоянно переключается между тредами. Каждому потоку выделяется небольшой отрезок процессорного времени, называемый квантом. Когда это время истекает — процессор переключается на другой поток и начинает выполнять его.
Вызов метода Thread.yield()
позволяет досрочно завершить квант времени текущего треда. Другими словами: переключает процессор на следующий поток.