Thread java что это
Многопоточное программирование
Большинство языков программирования поддерживают такую важную функциональность как многопоточность, и Java в этом плане не исключение. При помощи многопоточности мы можем выделить в приложении несколько потоков, которые будут выполнять различные задачи одновременно. Если у нас, допустим, графическое приложение, которое посылает запрос к какому-нибудь серверу или считывает и обрабатывает огромный файл, то без многопоточности у нас бы блокировался графический интерфейс на время выполнения задачи. А благодаря потокам мы можем выделить отправку запроса или любую другую задачу, которая может долго обрабатываться, в отдельный поток. Поэтому большинство реальных приложений, которые многим из нас приходится использовать, практически не мыслимы без многопоточности.
Класс Thread
С помощью статического метода Thread.currentThread() мы можем получить текущий поток выполнения:
Для управления потоком класс Thread предоставляет еще ряд методов. Наиболее используемые из них:
getName() : возвращает имя потока
setName(String name) : устанавливает имя потока
getPriority() : возвращает приоритет потока
isAlive() : возвращает true, если поток активен
isInterrupted() : возвращает true, если поток был прерван
join() : ожидает завершение потока
run() : определяет точку входа в поток
sleep() : приостанавливает поток на заданное количество миллисекунд
start() : запускает поток, вызывая его метод run()
Мы можем вывести всю информацию о потоке:
Недостатки при использовании потоков
Далее мы рассмотрим, как создавать и использовать потоки. Это довольно легко. Однако при создании многопоточного приложения нам следует учитывать ряд обстоятельств, которые негативно могут сказаться на работе приложения.
На некоторых платформах запуск новых потоков может замедлить работу приложения. Что может иметь большое значение, если нам критичная производительность приложения.
Для каждого потока создается свой собственный стек в памяти, куда помещаются все локальные переменные и ряд других данных, связанных с выполнением потока. Соответственно, чем больше потоков создается, тем больше памяти используется. При этом надо помнить, в любой системе размеры используемой памяти ограничены. Кроме того, во многих системах может быть ограничение на количество потоков. Но даже если такого ограничения нет, то в любом случае имеется естественное ограничение в виде максимальной скорости процессора.
Многопоточность в Java: суть, «плюсы» и частые ловушки
Проблемы, которые решает многопоточность в Java
Одновременно выполнять несколько действий.
В примере выше разные потоки (т.е. члены семьи) параллельно выполняли несколько действий: мыли посуду, ходили в магазин, складывали вещи.
Можно привести и более «программистский» пример. Представь, что у тебя есть программа с пользовательским интерфейсом. При нажатии кнопки «Продолжить» внутри программы должны произойти какие-то вычисления, а пользователь должен увидеть следующий экран интерфейса. Если эти действия осуществляются последовательно, после нажатия кнопки «Продолжить» программа просто зависнет. Пользователь будет видеть все тот же экран с кнопкой «Продолжить», пока все внутренние вычисления не будут выполнены, и программа не дойдет до части, где начнется отрисовка интерфейса.
Что ж, подождем пару минут!
А еще мы можем переделать нашу программу, или, как говорят программисты, «распараллелить». Пусть нужные вычисления выполняются в одном потоке, а отрисовка интерфейса — в другом. У большинства компьютеров хватит на это ресурсов. В таком случае программа не будет «тупить», и пользователь будет спокойно переходить между экранами интерфейса не заботясь о том, что происходит внутри. Одно другому не мешает 🙂
Ускорить вычисления.
Тут все намного проще. Если наш процессор имеет несколько ядер, а большинство процессоров сейчас многоядерные, список наших задач могут параллельно решать несколько ядер. Очевидно, что если нам нужно решить 1000 задач и каждая из них решается за секунду, одно ядро справится со списком за 1000 секунд, два ядра — за 500 секунд, три — за 333 с небольшим секунды и так далее.
Многопоточность в Java: работа с потоками и полезные методы класса Thread
Многопоточность в Java — это одновременное выполнение двух или более потоков для максимального использования центрального процессора (CPU — central processing unit). Каждый поток работает параллельно и не требует отдельной области памяти. К тому же, переключение контекста между потоками занимает меньше времени.
Процессы в Java: определение и функции
Что такое потоки
Поток — наименьшее составляющее процесса. Потоки могут выполняться параллельно друг с другом. Их также часто называют легковесными процессами. Они используют адресное пространство процесса и делят его с другими потоками.
Потоки могут контролироваться друг друга и общаться посредством методов wait(), notify(), notifyAll().
Состояния потоков
Потоки могут пребывать в нескольких состояниях:
Способы запуска потоков
Приложение, создающее экземпляр класса Thread, должно предоставить код, который будет работать в этом потоке. Существует два способа, чтобы добиться этого:
Обратите внимание, что оба примера вызывают Thread.start, чтобы запустить новый поток.
Какой из способов выбрать? Первый — с использованием объекта Runnable — более общий, потому что этот объект может превратить отличный от Thread класс в подкласс. Этот способ более гибкий и может использоваться для высокоуровневых API управления потоками.
Второй способ больше подходит для простых приложений, но есть условие: класс задачи должен быть потомком Thread.
Завершение процесса и потоки-демоны
В Java процесс завершается тогда, когда завершаются все его основные и дочерние потоки.
Потоки-демоны — это низкоприоритетные потоки, работающие в фоновом режиме для выполнения таких задач, как сбор «мусора»: они освобождают память неиспользованных объектов и очищают кэш. Большинство потоков JVM (Java Virtual Machine) являются потоками-демонами.
Свойства потоков-демонов:
Чтобы установить, является ли поток демоном, используется метод boolean isDaemon(). Если да, то он возвращает значение true, если нет, то — то значение false.
Завершение потоков
Завершение потока Java требует подготовки кода реализации потока. Класс Java Thread содержит метод stop(), но он помечен как deprecated. Оригинальный метод stop() не дает никаких гарантий относительно состояния, в котором поток остановили. То есть, все объекты Java, к которым у потока был доступ во время выполнения, останутся в неизвестном состоянии. Если другие потоки в приложении имели доступ к тем же объектам, то они могут неожиданно «сломаться».
Вместо вызова метода stop() нужно реализовать код потока, чтобы его остановить. Приведем пример класса с реализацией Runnable, который содержит дополнительный метод doStop(), посылающий Runnable сигнал остановиться. Runnable проверит его и остановит, когда будет готов.
Обратите внимание на методы doStop() и keepRunning(). Вызов doStop() происходит не из потока, выполняющего метод run() в MyRunnable.
Метод keepRunning() вызывается внутренней потоком, выполняющим метод run() MyRunnable. Поскольку метод doStop() не вызван, метод keepRunning() возвратит значение true, то есть поток, выполняющий метод run(), продолжит работать.
В примере сначала создается MyRunnable, а затем передается потоку и запускает его. Поток, выполняющий метод main() (главный поток), засыпает на 10 секунд и потом вызывает метод doStop() экземпляра класса MyRunnable. Впоследствии поток, выполняющий метод MyRunnable, остановится, потому что после того, как вызван doStop(), keepRunning() возвратит false.
Обратите внимание, если для реализация Runnable нужен не только метод run() (а например, еще метод stop() или pause()), реализацию Runnable больше нельзя будет создать с помощью лямбда-выражений. Понадобится кастомный класс или интерфейс, расширяющий Runnable, который содержит дополнительные методы и реализуется анонимным классом.
Метод Thread.sleep()
Поток может остановиться сам, вызвав статический метод Thread.sleep(). Thread.sleep() принимает в качестве параметра количество миллисекунд. Метод sleep() попытается заснуть на это количество времени перед возобновлениям выполнения. Thread sleep() не гарантирует абсолютной точности.
Приведем пример остановки потока Java на 10 секунд (10 тысяч миллисекунд) с помощью вызова метода Thread sleep():
Поток, выполняющий код, уснет примерно на 10 секунд.
Метод yield()
Предотвратить выполнение потока можно методом yield(): предположим, существует три потока t1, t2, and t3. Поток t1 выполняется процессором, а потоки t2 и t3 находятся в состоянии Ready/Runnable. Время выполнения для потока t1 — 5 часов, а для t2 – 5 минут.
Поскольку t1 закончит свое выполнение через 5 часов, t2 придется ждать все это время, чтобы закончить 5-минутную задачу. В таких случаях, когда один поток требует слишком много времени, чтобы завершить свое выполнение, нужен способ приостановить выполнение длинного потока в промежутке, если какая-то важная задача не завершена. Тут и поможет yield ().
По сути, yield() означает, что поток не выполняет ничего особо важного, и если другие потоки или процессы требуют запуска, то их можно запустить.
Использование метода yield() :
Синтаксис:
Результат:
Метод join()
Метод join() экземпляра класса Thread используется для объединения начала выполнения одного потока с завершением выполнения другого потока. Это необходимо, чтобы один поток не начал выполняться до того, как завершится другой поток. Если метод join() вызывается на Thread, то выполняющийся в этот момент поток блокируется до момента, пока Thread не закончит выполнение.
Метод join() ждет не более указанного количества миллисекунд, пока поток умрет. Тайм-аут 0 (ноль) означает «ждать вечно».
Синтаксис:
Например:
Результат:
Из примера видно, что как только поток t1 завершает выполнение задачи, потоки t2 и t3 начинают выполнять свои задачи.
Приоритеты потоков
У каждого потока есть приоритет. Приоритет обозначается числом от 1 до 10. В большинстве случаев планировщик распределяет потоки относительно их приоритета (другими словами — происходит приоритетное планирование). Но это не гарантированно, поскольку он зависит от того, какое планирование выберет JVM.
Три константы, которые определяются в классе Thread:
1. public static int MIN_PRIORITY (значение равно 1);
2. public static int NORM_PRIORITY (дефолтный приоритет потока);
3. public static int MAX_PRIORITY (значение равно 10).
Пример приоритета потока:
Результат:
Некоторые полезные методы класса Thread
Имя потока – ассоциированная с ним строка, которая в некоторых случаях помогает понять, какой поток выполняет действие.
Многопоточность в Java. Лекция 2: потоки, свойства потоков, блокировки
Темную силу чувствую я.
Даешь парсек за три года.
Вводную статью о многопоточности в Java читайте здесь! В ее продолжении мы рассмотрим основы многопоточных программ: создание, запуск и свойства потока, синхронизацию потоков. Далее поговорим об использовании ключевого слова synchronized, volatile переменных и отношении happens-before.
2.1 Средства для работы с многопоточностью в Java и модели многопоточных программ
В первой версии Java инструментов для работы с многопоточностью было немного. Основные средства: класс Thread, интерфейс Runnable, ключевое слово synchronized и методы для синхронизации wait(), notify() и notifyAll() в классе Object. В версию Java 1.5 уже был включен пакет java.util.concurrent, в котором появилось много новых классов и интерфейсов. Также в версии Java 1.8 добавили класс CompletableFuture, который позволяет строить цепочки из асинхронных задач и комбинировать их.
Существуют несколько подходов (моделей) в многопоточном программировании:
Сейчас процессоры хорошо поддерживают концепцию потоков. Например, akka (фрэймворк для работы с многопоточностью, портированный на разные языки программирования: Java, Scala, C#) написан на основе потоков и блокировок.
Способы организации многопоточности в программах:
2.2 Свойства потоков, запуск потоков, присоединение других потоков
Все методы программы выполняются в каком-либо потоке. Поток, который вызывает метод main, является главным потоком приложения и имеет имя main.
В Java поток представлен классом Thread. Создать и запустить поток можно двумя способами:
1) Создать наследника от класса Thread и переопределить метод run().
Листинг 1:
public class MyThread extends Thread <
public void run() <
long sum = 0;
for (int i = 0; i
System.out.println(“Hello!”);
>
Thread t = new Thread(r);
Для запуска потока необходимо использовать метод Thread.start(). Если вызвать метод run(), то он выполнится в вызывающем потоке:
Листинг 3:
Thread t = new Thread(r);
t.run(); //код r выполняется в текущем потоке
t.start(); //код r выполняется в новом потоке
Не следует запускать поток из конструктора класса. Некоторые фреймворки, такие как Spring, создают динамические подклассы для поддержки перехвата методов. В конечном счете, мы получим два потока, запущенных из двух экземпляров.
Объект текущего потока можно получить, вызвав статический метод: Thread.currentThread().
Имена потокам можно задавать через метод setName() или через параметр конструктора. Рекомендуется давать потокам осмысленные имена, это пригодится при отладке. Не рекомендуется давать потокам одинаковые имена, хотя имена потоков не валидируются JVM.
Стандартный формат имен потоков, которые были созданы одиночно — thread-N, где N порядковый номер потока. Для пула потоков, стандартное наименование — pool-N-thread-M, где N обозначает последовательный номер пула (каждый раз, когда вы создаете новый пул, глобальный счетчик N увеличивается), а M — порядковый номер потока в пуле.
У потоков есть приоритет, который можно задать целым числом от 1 до 10. Чем больше число, тем выше приоритет потока. Поток main имеет приоритет 5. А приоритет новых потоков равен приоритету потока-родителя, его можно изменить при помощи метода setPriority(int). Поток с большим приоритетом будет иметь больше процессорного времени на выполнение. Если два потока имеют одинаковый приоритет, то решение о том, какой из них будет выполняться первым, зависит от алгоритма планировщика: (Round-Robin, First Come First Serve).
Есть несколько констант для приоритета потоков:
Листинг 4:
public class Main <
public static void main(String[] args) <
System.out.println(Thread.currentThread().getName());
Thread.currentThread().setPriority(8);
Thread thread = new Thread() <
public void run() <
Thread.currentThread().setName(«My name»);
System.out.println(Thread.currentThread().getName());
System.out.println(Thread.currentThread().getPriority());
>
>;
thread.start();
>
>
В Java есть такое понятие, как поток-демон. Работа JVM заканчивается, когда закончил выполняться последний поток не-демон, несмотря на работающие потоки-демоны. Для работы с этим свойством существуют два метода: setDaemon() и isDaemon().
Класс ThreadGroup. Все потоки находятся в группах, представленных экземплярами класса ThreadGroup. Группа указывается при создании потока. Если группа не была указана, то поток помещается в ту же группу, в которой находится поток-родитель. Методы activeCount() и enumerate() возвращают, соответственно, количество и полный список всех активных потоков в группе.
Нестатический метод join() позволяет одному потоку дождаться выполнения другого. Если текущий поток t1 вызывает у другого потока t2h2t2.join(), то поток th2 останавливается до тех пор, пока поток t2 не завершит свою работу. Вызвать метод join() можно также и с аргументом, указывающим лимит времени ожидания (в миллисекундах или в миллисекундах с нано секундами). Если целевой поток t2 не закончит работу за указанный период времени, метод join() все равно вернет управление инициатору t1.
2.3 Остановка и прерывание потоков
Для остановки потока в Java версии 1 использовался метод stop(). Однако в версии Java 1.1 этот метод сделали deprecated, потому что использование метода stop() не гарантирует корректного завершения работы потока и стабильной работы программы в целом. Поэтому при написании программ использовать его настоятельно не рекомендуется.
Вместо метода stop() следует использовать метод interrupt(). В отличие от метода stop(), который принудительно останавливал поток, метод interrupt() предлагает потоку остановить свое выполнение путем установки флага interrupted в true внутри потока. Этот флаг отображает статус прерывания и имеет начальное значение false. Когда поток прерывается другим потоком, происходит одно из двух:
Есть три метода для работы с прерыванием потока:
Листинг 5:
Существуют два вида операций: блокирующие и неблокирующие. Неблокирующие операции не приостанавливают выполнения потока. К блокирующим операциям можно отнести вызовы методов sleep(), wait(), join() и, например, некоторые методы класса Socket. Если поток был прерван, пока он выполнял неблокирующие вычисления, они не будут прерваны незамедлительно. Однако поток уже отмечен как прерванный, поэтому любая следующая блокирующая операция немедленно прервется и выбросит InterruptedException.
Для обработки прерывания в потоке, который не использует блокирующие операции, следует добавить проверку флага interrupted пример в листинге 6.
Листинг 6:
public void run() <
while (Thread.currentThread().isInterrupted()) <
someHeavyComputations();
>
>
Когда в сигнатуре метода есть InterruptedException, это еще раз напоминает программисту, что этот метод блокирующий. InterruptedException сигнализирует о том, что работу потока хотят завершить. При этом не просят сделать это немедленно.
Первый способ обработки InterruptedException — объявление этого исключения в вышестоящем методе. Также при перехвате метода InterruptedException можно произвести какие-то действия (например, очистку ресурсов или переменных) и повторно пробросить InterruptedException.
Во втором случае, когда InterruptedException объявить невозможно, при генерации и перехвате InterruptedException флаг interrupted устанавливается в false, и вызывающие методы не увидят, что было совершено прерывание потока. Однако можно восстановить флаг прерывания, вызвав Thread.currentThread().interrupt() при обработке прерывания.
Также восстановление флага interrupted может быть полезным, когда первый поток имеет ссылку на второй поток, и первый хочет узнать состояние флага второго.
Стоит внимательно следить за обработкой этого исключения когда код выполняется в threadpool. InterruptedException может быть «интересен» не только коду, но и потоку, который выполняет этот код.
Листинг 7:
try <
Object o = queue.take();
> catch InterruptedException e) <
>
Этот код некорректен, потому что поглощает (swallows) прерывание. Если этот код выполняется в tread pool, то воркер (thread pool worker) tread pool`а должен завершить исполнение, но этого не произойдёт, потому что исключение будет поглощено, и флаг будет сброшен.
Корректный код будет выглядеть так:
Листинг 8:
try <
Object o = queue.take();
> catch InterruptedException e) <
Thread.currentThread().interrupt();
>
В блоке catch происходит перехват исключения и установка флага в true.
Не стоит поглощать исключение просто так (код в листинге 7), также не стоит только записывать в лог при обработке InterruptedException. Потому что, когда лог будет прочитан, приложение может полностью прийти в неработоспособное состояние.
2.4 Синхронизация между потоками
Если два потока будут выполнять код, который изменяет одну и ту же переменную, значение в переменной будет иметь непредсказуемое значение.
Классический пример такого поведения: два потока инкрементируют одно значение. Так как операция инкремента не выполняется за одну инструкцию процессора, то два потока изменят значение переменной произвольным образом — это называется race condition. Блоки кода, в которых может возникнуть race condition, называются критическими секциями. Чтобы избежать такой ситуации в Java предусмотрены способы синхронизации потоков.
Простейший способ синхронизации — концепция «монитора» и ключевое слово synchronized. Изначально эта концепция была введена в языке Pascal. В Java такого класса «монитор», нет, однако у каждого объекта типа Object есть свой собственный «монитор». Так как у всех классов общий родитель — Object, все они имеют свой собственный «монитор».
Концепция «монитор» внутри себя содержит 4 поля:
Рис 1. Внутреннее устройство концепции «монитора»
Blocked set, как и wait set, представляет собой неупорядоченное множество, не допускающее дубликатов. Т. е. в wait set или blocked set один и тот же поток не может быть записан два раза.
Поля монитора невозможно получить через рефлексию. У каждого объекта есть методы wait(), notify() и notifyAll(), которые этот объект унаследовал от класса Object. Использование ключевого слова synchronized гарантирует, что блоки кода будут выполняться только одним потоком в каждую конкретную единицу времени.
Есть два варианта использования ключевого слова synchronized:
Рассмотрим первую ситуацию: поток попадает в synchronized блок, выполняет критическую секцию и выходит из блока синхронизации. Ключевое слово synchronized всегда используется с объектом монитор. Сперва проверяются переменные locked и owner. Если эти поля false и null, соответственно, они заполняются. Поле locked принимает значение true, а в поле owner записывается ссылка на захватывающий поток. Как только это произошло, считается, что поток выполнил код, который соответствует открывающей фигурной скобке synchronized блока, и поток занял эту блокировку. После того как поток выполнил код, который соответствует закрывающейся фигурной скобке блока синхронизации, переменные locked и owner в мониторе очищаются.
Рассмотрим ситуацию, когда поток пытается захватить уже занятый монитор. Сначала проверяется, что переменная locked == true, затем сравнивается переменная owner. Если переменная owner не равна тому потоку, который хочет захватить монитор, то второй поток блокируется и попадает в blocked set монитора. Если сравнение переменных owner дает результат true, это значит, что один и тот же поток пытается захватить монитор повторно — в этом случае поток не блокируется. Такое поведение называется реентернабельностью. Пример такой ситуации — рекурсивные методы. После того, как блокировка освободилась, другой поток покидает blocked set и захватывает монитор. В blocked set может находится множество потоков. В этом случае выбирается произвольный поток, который далее может захватить монитор.
Листинг 9:
public class SomeClass <
private final Object PRIVATE_LOCK_OBJECT = new Object();
public synchronized void firstMethod() <
//some code
>
public void theSameAsFirstMethod() <
synchronized(this) <
//some code
>
>
public void theBestMethodUsingSynchr() <
synchronised(PRIVATE_LOCK_OBJECT) <
//some code
>
>
public static void synchronizedOnStaticMethod() <
synchronized(SomeClass.class) <
//some code
>
>
public static synchronized void synchronizedOnStaticMethod() <
//some code
>
>
Когда метод объявляется с ключевым словом synchronized, это эквивалентно коду, когда всё его тело обернуто в synchronized блок и блокировкой служит объект this. Когда статический метод используется с ключевым словом synchronized, это эквивалентно тому, когда в качестве блокировки используется объект SomeClass.class. Однако самый лучший способ — объявить private final константу, по которой и производится синхронизация. Стоит заметить, что конструкция с использованием ключевого слова synchronized — синтаксическая и проверяется компилятором. Т. е. всегда должна быть открывающая фигурная скобка и соответствующая ей закрывающая фигурная скобка synchronized блока. Synchronized блоки могут быть вложенными друг в друга (см. Листинг 10).
Листинг 10:
final Object LOCK = new Object();
synchronized(LOCK) <
synchronized(LOCK) <
synchronized(LOCK) <
>
>
>
Как показано в Листинге 10, можно несколько раз захватить монитор на одном и том же объекте. Нет способа определить, сколько раз был захвачен монитор, и не стоит строить такую логику в программе. Освобождение монитора происходит после выхода из верхнего synchronized блока. В Листинге 11 показан еще один вариант вложенных синхронизаций.
Листинг 11:
Object LOCK_A = new Object();
Object LOCK_B = new Object();
Object LOCK_C = new Object();
synchronized(LOCK_A) <
synchronized(LOCK B) <
synchronized(LOCK_C) <
>
>
>
В Листинге 11 сначала захватываются мониторы LOCK_A, затем LOCK_B и LOCK_С, а освобождаются мониторы в обратном порядке.
Еще одна ситуация, в которой используется ключевое слово synchronized — использование методов wait(), notify() и notifyAll(). При использовании этих методов необходимо всегда захватывать монитор объекта, на котором будут вызываться эти методы. Если не захватывать монитор, будет сгенерировано IllegalMonitorStateException (см. Листинг 12).
Листинг 12:
public class MainClass <
public static void main(String [] args) throws InterruptedException <
final Object lock = new Object();
lock.wait(); //будет сгенерирован IllegalMonitorStateException
>
>
Листинг 13:
public class MainClass <
private static final Object LOCK = new Object();
public static void main(String [] args) throws InterruptedException <
synchronized(LOCK) <
LOCK.wait();
>
>
>
В Листинге 13 поток main захватывает монитор объекта LOCK и вызывает метод wait() на LOCK. После вызова этого метода поток main попадает в wait set монитора LOCK. При этом монитор LOCK ОСВОБОЖДАЕТСЯ, т. е. очищается поле owner, а поле locked принимает значение false. Такое поведение гарантирует, что если какой-то другой поток захочет ожидать какого-то события на этом объекте, то он может захватить монитор LOCK и попасть в wait set.
Для того чтобы потоки, которые находятся в wait set, продолжили свое выполнение, другой поток должен захватить монитор LOCK и на LOCK вызвать методы notify() или notifyAll(). После вызова метода notify() из wait set выбирается произвольный поток и переводится в blocked set. Если был вызван метод notifyAll(), то все потоки из wait set переводятся в blocked set. Это происходит потому, что монитор LOCK занят тем потоком, который вызвал метод notify или notifyAll(). После того как этот поток выйдет из synchronized блока, нотифицированные потоки будут по одному захватывать монитор и продолжать выполнение. Методы wait(), notify() и notifyAll() используются для ожидания выполнения какого-то условия, а не для передачи данных.
Из состояния wait можно выйти несколькими способами:
Иногда поток, вызвавший метод wait на каком-то объекте блокировки, может случайно проснуться. Эта ситуация называется spurious wakeup. Случайные пробуждения случаются крайне редко (такого почти не бывает) но чтобы гарантированно избежать этого эффекта, необходимо вызывать метод wait() в цикле.
Листинг 14:
Есть два случая, когда поток может попасть в blocked set:
Рассмотрим, почему объект блокировки необходимо всегда делать закрытой неизменяемой переменной в классе private final Object obj = new Object(). Считается плохим стилем, если объект синхронизации виден снаружи класса.
Листинг 15:
class X <
public synchronized void method1() <
>
>
public class TestX <
public void someMethod(X x) <
synchronized(x) <
while(true);
>
>
>
В таком коде никакой поток не сможет вызвать метод method1() у объекта x. Все потоки, которые попытаются вызвать метод method1() у объекта x, будет заблокированы. Еще один некорректный пример в Листинге 16.
Листинг 16:
public class TestX <
public void someMethod(X x) <
synchronized(x) <
while(true) <
x.wait();
>
>
>
>
Если у объекта x будут вызывать x.notify(), цикл в Листинге 16 будет поглощать все вызовы метода notify(), т. е. поток, который выполняет код, будет всегда в wait set. Чтоб избежать таких ошибок, следует использовать private final объект-блокировку, как в одном из примеров выше. Также не следует использовать объект-блокировку для хранения какой либо информации. Это нарушает принцип single responsibility и усложняет чтение и понимание программы.
2.5 Состояния потока
У потоков есть следующие состояния:
Рис 2. Схема переходов потока из одного состояния в другое
Состояния потоков представлены в перечислении Thread.State.
2.6 Ключевое слово volatile
Ключевое слово volatile указывает, что взаимодействие с переменной в памяти должно происходить минуя кэши процессора, т. е. напрямую.
В многопоточном приложении, когда потоки используют не volatile переменные, они могут скопировать значение переменных в кэш процессора для улучшения производительности. Если в процессоре несколько ядер и каждый поток выполняется на отдельном ядре процессора, одна и та же переменная может иметь разное значение на каждом ядре процессора. В результате будет несколько копии одной и той же переменной: копии в кэше каждого ядра процессора и копия переменной в основной памяти. При использовании не volatile переменных нельзя знать наверняка, когда JVM читает значение переменной из главной памяти и когда записывается значение переменной в главную память. Это может привести к проблемам. Предположим, есть два потока, имеющих доступ к общему объекту, у которого есть счетчик (См. Листинг 17).
Листинг 17:
public class SharedObject <
public int counter = 0;
>
Предположим, что только первый поток инкрементирует переменную и оба потока могут читать переменную. Если переменная counter не volatile, то нет никакой гарантии, когда переменная будет записана в основную память, чтоб вновь измененное значение переменной увидел второй поток. Эта проблема решается путем объявления переменной counter как volatile (см. Листинг 18).
Листинг 18:
public class SharedObject <
public volatile int counter = 0;
>
бъявление переменной как volatile гарантирует, что любое чтение и любая запись в эту переменную сразу будет попадать в главную память. Объявления переменной counter как volatile достаточно, когда один поток изменяет переменную, а другой поток читает ее значение. Если два потока изменяют общую переменную, то использования ключевого слова volatile недостаточно — будет race condition. Ключевое слово volatile гарантирует следующее:
Листинг 19:
public class MyClass <
private int years;
private int months
private volatile int days;
public void update(int years, int months, int days) <
this.years = years;
this.months = months;
this.days = days;
>
>
При записи значения в volatile переменную days гарантируется, что запись остальных переменных years и months тоже будет произведена в главную память. Чтение можно выполнить следующим способом (см. Листинг 20).
Листинг 20:
public class MyClass <
private int years;
private int months
private volatile int days;
public int totalDays() <
int total = this.days;
total += months * 30;
total += years * 365;
return total;
>
public void update(int years, int months, int days) <
this.years = years;
this.months = months;
this.days = days;
>
>
В Листинге 20 в методе totalDays сначала производится чтение volatile переменной days, а затем производится чтение остальных переменных. Это чтение производится с главной памяти программы.
JVM оставляет за собой право переупорядочить инструкции для увеличения производительности, не меняя при этом семантики программы. Пример в Листинге 21.
Листинг 21:
int a = 1;
int b = 2;
a++;
b++;
//changes to
int a = 1;
a++;
int b = 2;
b++;
Рассмотрим модифицированный код из Листинга 22.
Листинг 22:
public void update(int years, int months, int days) <
this.days = days;
this.months = months;
this.years = years;
>
В Листинге 22 изменен порядок записи в volatile переменную и в обычные переменные по сравнению с примером из листинга 20. В Java есть решение проблемы перестановки инструкций, которое будет рассмотрено в следующем пункте.
В программах, которые используют многопоточность, встречаются ситуации, когда использование ключевого слова volatile недостаточно для корректной работы программы в целом. Например, есть два потока, которые одновременно изменяют общий счетчик. Необходимо прочитать значение из переменной, увеличить значение переменной, а затем записать значение в общую память. Предположим, что два потока прочитали одно и то же значение, допустим, равное единице. Каждый поток увеличил значение на 1, и первый поток записал значение 2 в главную память, а затем и второй поток записал значение 2 в общую память. Однако после записи второго потока в общую память значение должно быть 3. Такая логика приведет к race condition и некорректному поведению программы. В этом случае надо использовать ключевое слово synchronized, либо атомарные переменные, которые будут рассмотрены в последующей статье.
Также следует помнить, что чтение и запись в volatile переменные происходит дольше, чем в обычные переменные, потому что запись в кэш ядра процессора происходит намного быстрее чем в оперативную память.
2.7 Отношение happens-before
Отношение happens-before гарантирует, что результаты операции в одном потоке будут видны в другом действии в другом потоке. Отношение happens-before определяет частичное упорядочение всех действий внутри программы. Чтобы гарантировать, что поток, выполняющий действие Y, может видеть результаты действия X (независимо от того, происходят ли X и Y в разных потоках), между X и Y должно существовать отношение happens-before. При отсутствии отношения happens-before между двумя действиями JVM может переставить операции как угодно, это происходит за счёт оптимизации компилятора JVM.
Рис 3. Отношение happens-before
Отношение happens-before это не только перераспределение действий во времени, но и гарантия отсутствия перестановок чтения, а также записи в память. Если отношения happens-before не будет, два потока, которые читают и пишут в одно и тоже пространство памяти, могут быть последовательны в терминах времени, но не смогут последовательно увидеть изменения друг друга.
Отношение happens-before возможно в следующих случаях.
Рис 4. Отношение happens-before в одном потоке.
Рис 5. Отношение happens-before при захвате и отображения монитора.
Рис 6. Отношение happens-before при запуске потока.
Рис 7. Отношение happens-before при использовании метода join.
2.8 Заключение
В этой статье мы рассмотрели основы многопоточных программ: создание и запуск потока, свойства потока, синхронизация потоков, состояния, в которых может находится поток. Приведенные примеры демонстрируют, как корректно использовать ключевое слово synchronized и к каким последствиям может привести их неправильное использование. В конце рассказали о volatile переменных и отношении happens-before. Такие знания должны стать хорошей основой для дальнейшего изучения, понимания и написания многопоточных программ.