для чего нужна многопоточность java
Многопоточное программирование в Java 8. Часть первая. Параллельное выполнение кода с помощью потоков
Авторизуйтесь
Многопоточное программирование в Java 8. Часть первая. Параллельное выполнение кода с помощью потоков
Добро пожаловать в первую часть руководства по параллельному программированию в Java 8. В этой части мы на простых примерах рассмотрим, как выполнять код параллельно с помощью потоков, задач и сервисов исполнителей.
Впервые Concurrency API был представлен вместе с выходом Java 5 и с тех пор постоянно развивался с каждой новой версией Java. Большую часть примеров можно реализовать на более старых версиях, однако в этой статье я собираюсь использовать лямбда-выражения. Если вы все еще не знакомы с нововведениями Java 8, рекомендую посмотреть мое руководство.
Потоки и задачи
Все современные операционные системы поддерживают параллельное выполнение кода с помощью процессов и потоков. Процесс — это экземпляр программы, который запускается независимо от остальных. Например, когда вы запускаете программу на Java, ОС создает новый процесс, который работает параллельно другим. Внутри процессов мы можем использовать потоки, тем самым выжав из процессора максимум возможностей.
Поскольку интерфейс Runnable функциональный, мы можем использовать лямбда-выражения, которые появились в Java 8. В примере мы создаем задачу, которая выводит имя текущего потока на консоль, и запускаем ее сначала в главном потоке, а затем — в отдельном.
Результат выполнения этого кода может выглядеть так:
Из-за параллельного выполнения мы не можем сказать, будет наш поток запущен до или после вывода «Done!» на экран. Эта особенность делает параллельное программирование сложной задачей в больших приложениях.
18 декабря, Онлайн, Беcплатно
Потоки могут быть приостановлены на некоторое время. Это весьма полезно, если мы хотим сэмулировать долго выполняющуюся задачу. Например, так:
Работать с потоками напрямую неудобно и чревато ошибками. Поэтому в 2004 году в Java 5 добавили Concurrency API. Он находится в пакете java.util.concurrent и содержит большое количество полезных классов и методов для многопоточного программирования. С тех пор Concurrency API непрерывно развивался и развивается.
Давайте теперь подробнее рассмотрим одну из самых важных частей Concurrency API — сервис исполнителей (executor services).
Исполнители
Concurrency API вводит понятие сервиса-исполнителя (ExecutorService) — высокоуровневую замену работе с потоками напрямую. Исполнители выполняют задачи асинхронно и обычно используют пул потоков, так что нам не надо создавать их вручную. Все потоки из пула будут использованы повторно после выполнения задачи, а значит, мы можем создать в приложении столько задач, сколько хотим, используя один исполнитель.
Вот как будет выглядеть наш первый пример с использованием исполнителя:
Класс Executors предоставляет удобные методы-фабрики для создания различных сервисов исполнителей. В данном случае мы использовали исполнитель с одним потоком.
Вот как я предпочитаю останавливать исполнителей:
Исполнитель пытается завершить работу, ожидая завершения запущенных задач в течение определенного времени (5 секунд). По истечении этого времени он останавливается, прерывая все незавершенные задачи.
Callable и Future
Давайте напишем задачу, которая возвращает целое число после секундной паузы:
Callable-задачи также могут быть переданы исполнителям. Но как тогда получить результат, который они возвращают? Поскольку метод submit() не ждет завершения задачи, исполнитель не может вернуть результат задачи напрямую. Вместо этого исполнитель возвращает специальный объект Future, у которого мы сможем запросить результат задачи.
Задачи жестко связаны с сервисом исполнителей, и, если вы его остановите, попытка получить результат задачи выбросит исключение:
Таймауты
Любой вызов метода future.get() блокирует поток до тех пор, пока задача не будет завершена. В наихудшем случае выполнение задачи не завершится никогда, блокируя ваше приложение. Избежать этого можно, передав таймаут:
Выполнение этого кода вызовет TimeoutException :
Вы уже, возможно, догадались, почему было выброшено это исключение: мы указали максимальное время ожидания выполнения задачи в одну секунду, в то время как ее выполнение занимает две.
InvokeAll
InvokeAny
Используем этот метод, чтобы создать несколько задач с разными строками и задержками от одной до трех секунд. Отправка этих задач исполнителю через метод invokeAny() вернет результат задачи с наименьшей задержкой. В данном случае это «task2»:
ForkJoinPool впервые появился в Java 7, и мы рассмотрим его подробнее в следующих частях нашего руководства. А теперь давайте посмотрим на исполнители с планировщиком (scheduled executors).
Исполнители с планировщиком
Мы уже знаем, как отдать задачу исполнителю и получить ее результат. Для того, чтобы периодически запускать задачу, мы можем использовать пул потоков с планировщиком.
ScheduledExecutorService способен запускать задачи один или несколько раз с заданным интервалом.
Этот пример показывает, как заставить исполнитель выполнить задачу через три секунды:
Кроме того, он принимает начальную задержку, которая определяет время до первого запуска.
Обратите внимание, что метод scheduleAtFixedRate() не берет в расчет время выполнения задачи. Так, если вы поставите задачу, которая выполняется две секунды, с интервалом в одну, пул потоков рано или поздно переполнится.
В этом примере мы ставим задачу с задержкой в одну секунду между окончанием выполнения задачи и началом следующей. Начальной задержки нет, и каждая задача выполняется две секунды. Так, задачи будут запускаться на 0, 3, 6, 9 и т. д. секунде. Как видите, метод scheduleWithFixedDelay() весьма полезен, если мы не можем заранее сказать, сколько будет выполняться задача.
Это была первая часть серии статей про многопоточное программирование. Настоятельно рекомендую разобрать вышеприведенные примеры самостоятельно. Все они доступны на GitHub. Можете смело форкать репозиторий и добавлять его в избранное.
Надеюсь, вам понравилась статья. Если у вас возникли какие-либо вопросы, вы можете задать их в твиттере.
Реактивное программирование на Java: как, зачем и стоит ли? Часть I
Идея реактивного программирования появилась сравнительно недавно, лет 10 назад. Что вызвало популярность этого относительно нового подхода и почему сейчас он в тренде, рассказал на конференции РИТ++ 2020 эксперт и тренер Luxoft Training Владимир Сонькин.
В режиме мастер-класса он продемонстрировал, почему так важен неблокирующий ввод-вывод, в чем минусы классической многопоточности, в каких ситуациях нужна реактивность, и что она может дать. А еще описал недостатки реактивного подхода.
В этой статье мы поговорим о том, что такое реактивное программирование, и зачем оно нужно, обсудим подходы и посмотрим примеры.
Почему реактивное программирование получило такую популярность? В какой-то момент перестала расти скорость процессоров, а значит разработчикам уже не приходится рассчитывать на то, что скорость их программ станет увеличиваться сама по себе: теперь их нужно распараллеливать.
На рисунке видно, что график частоты процессоров рос в 90-х, а в начале 2000-х частота резко увеличилась. Оказалось, что это был потолок.
Почему же рост частоты остановился?
Транзисторы начали делать максимально малого размера. PN-переход, который используется в них, получился настолько тоненьким, насколько это вообще возможно. На графике размеров транзисторов внутри процессора мы видим: размер все меньше и меньше, транзисторов в процессоре все больше и больше.
Такая миниатюризация раньше приводила к тому, что росла частота. Поскольку электроны бегут со скоростью света, за счет уменьшения размера, время, которое требовалось электрону, чтобы пробежать весь путь внутри процессора, уменьшалось. Но техпроцессы уткнулись в физический потолок. Пришлось придумывать что-то другое.
Многопоточность
И мы уже знаем, что удалось придумать: начали делать многоядерные процессоры. Вместо того, чтобы опираться на то, что производительность процессора будет расти, стали рассчитывать увеличение их количества. Но для эффективного использования множества процессоров нужна многопоточность.
Тема многопоточности — сложная, но неизбежная в современном мире. Типичный современный компьютер имеет от 4 ядер и множество потоков. В современном мощном сервере может быть и 100 ядер. Если в вашей программе не используется многопоточность, вы не получаете никаких преимуществ. Поэтому все мировые индустрии постепенно двигаются к тому, чтобы задействовать эти возможности.
При этом нас подстерегает множество опасностей. Программировать с учетом многопоточности сложно: синхронизации, гонки, затрудненная отладка и т.д. попортили немало крови разработчикам. К тому же, стоимость такой разработки становится выше.
В Java многопоточность появилась давным-давно, она существует с самой первой версии.
Писать большую систему, используя примитивы многопоточности, мягко говоря, сложно. Сейчас так уже никто не делает. Это все равно, что кодить на Ассемблере.
Во многих случаях эффект, который приносит многопоточность, не улучшает производительность, а ухудшает ее.
Что же с этим делать?
Параллельное программирование во многих ситуациях можно заменить асинхронностью. Посмотрите на иллюстрацию. На левой картинке малыш очень хочет помогать маме в домашних делах. Мама достает из стиральной машинки белье, дает ребенку, и он его укладывает в корзину. Так работает программа на 2 потока: поток-мама и поток-малыш. Теоретически производительность в этом случае должна возрастать: два человека лучше, чем один, ведь мы задействовали два ядра. Но представьте себе такую ситуацию в реальной жизни: мама подает ребенку белье и ждет, пока он его уложит в машинку. Или ребенок ждет белье от мамы. На деле они постоянно мешают друг другу. Плюс, нужно отвести время на передачу белья. Мама быстрее разобралась бы с бельем сама.
Примерно такая же ситуация происходит в компьютере. Поэтому работа с параллельностью совсем не так проста, как кажется. Все синхронизации между потоками выполнения на самом деле занимают кучу времени.
На картинке справа одинокий парень, который купил себе автоматическую машинку. Она стирает, а он в это время может почитать книжку. Здесь однозначно есть преимущество для юноши, потому что он занимается своим делом и при этом не следит за тем, завершилась ли стирка. Когда стирка завершится, он услышит звуковой сигнал и отреагирует на него. То есть параллельность есть, а синхронизации нет. Стало быть нет и траты времени на синхронизацию, сплошная выгода!
Это и есть подход с асинхронностью. Есть отдельный исполнитель, и мы дали ему не часть нашей задачи, а свою собственную. На левой картинке мама и мальчик делают общую задачу, а на правой стиральная машина и парень делают разные, каждый свою. В какой-то момент они соединятся: когда стиральная машина достирает, юноша отложит свою книгу. Но все 1,5 часа, пока белье стиралось, он прекрасно себя чувствовал, читал и ни о чем не думал.
Примеры параллельного и асинхронного подходов
Рассмотрим 2 варианта выполнения потоков: параллельный и асинхронный.
Потоки выполняются параллельно;
Потокам thread 1 и 2 нужно обращаться к одному и тому же общему разделяемому ресурсу. Допустим, это какая-то база данных, и она не позволяет потокам подключаться к ней одновременно. Или позволяет, но это сразу снижает скорость ее работы, поэтому потокам лучше обращаться к ней по очереди. Никакой параллельности здесь нет: потокам приходится работать по очереди. А третий поток ждет ответа от базы данных, и тоже заблокирован — такая система малоэффективна.
Вроде бы параллельность есть, а преимуществ от нее не так много.
Потоки выполняются асинхронно.
Если использовать асинхронность, мы ставим задачу, и она выполняется где-то в другом потоке. Например, другим ядром процессора или другим процессором. Мы поставили задачу и занимаемся другими делами, а потом в какой-то момент, когда эта задача завершится, получим результаты. Это можно проиллюстрировать работой организации. Начальник — поток main — ставит задачу Пете, и говорит: «Как только ты ее выполнишь, передай Коле, а тот после завершения работы над задачей пусть доложит мне. В результате Петя и Коля заняты работой, а начальник может ставить новые задачи другим сотрудникам».
Еще один пример: конкуренция и параллелизм.
Представим себе офис, утро, всем хочется выпить кофе. Concurrency (конкуренция) — это когда выстраивается очередь к одной на всех кофемашине. Люди конкурируют: «Эй, я тут первый стоял!» — «Нет, я!». Они друг другу мешают.
В параллелизме есть две кофемашины и две очереди: каждый стоит в своей. Но все равно сотрудники тратят время на то, чтобы постоять там.
Как найти правильное решение для этого сценария, если использовать асинхронность?
Доставка кофе прямо к столу — хороший вариант, в очереди вы не стоите, но придется нанимать официанта, который будет разносить напитки.
Другой возможный вариант — фиксированный график. Например, один сотрудник подходит за кофе в 11:10, следующий — в 11:20 и т.д. Но это не асинхронность. Будут происходить простои, а значит это не полная загрузка кофемашины. Кто-то не успел к своему времени, а кому-то не хватило 10 минут, чтобы сделать себе кофе, и в итоге весь график сдвигается. А если сделать
большие зазоры, кофемашина будет недогружена. И потом, все хотят прийти в 10 утра и выпить кофе, а это растягивается на 2 часа, и кому-то его чашка достанется только в 12.
Еще один вариант — записывать всех желающих в «виртуальную очередь». Когда кофемашина освободится от предыдущих любителей кофе, человек получает уведомление и подходит к кофемашине без очереди. Сейчас во многих организациях так делают. Например, в интернет-магазинах с самовывозом. Берешь талончик и занимаешься своими делами, а когда приходит время, подходишь и получаешь товар. Вот это и есть асинхронность: никто никого не ждет, все работают и получают свой кофе настолько быстро, насколько возможно. И кофемашина тоже не простаивает.
С асинхронностью разобрались. Но есть еще одна важная проблема: блокирующий ввод-вывод.
Блокирующий ввод-вывод
Традиционный ввод-вывод — блокирующий. А что же такое блокирующий ввод-вывод?
Допустим, вы читаете файл или базу данных. Вызывается метод, который это делает, и он блокирует поток: мы больше ничего не делаем, мы ждем. Например, вы вызвали readFile() и ждете, когда это наконец произойдет. Поток блокируется и не прогрессирует: он находится в ожидании. Но на самом деле процессор не занят.
В этом примере заблокированы потоки:
На чтение файла (blocked on reading file);
На чтение из базы данных (blocked on reading from DB);
На сложных вычислениях (blocked on heavy calculations);
На ответе от клиента (blocked on responding the client).
Эта ситуация сродни той, когда вы пришли в супермаркет, там есть четыре кассы, но система обслуживания касс тормозит. Кассирши сидят, ничего не делают и просто ждут, пока на кассе сработает нажатие на кнопку.
Что делать, если все потоки заблокированы? Как подобные проблемы решаются в супермаркете?
Synchronous I/O
Вариант обычный: синхронный ввод-вывод. Хорошего мало, в этом варианте образуются очереди к кассам.
Что сделать, чтобы возле касс не собирались огромные очереди? Например, можно открыть больше касс, или создать больше потоков.
Больше потоков — больше касс. Это рабочий вариант. Но нагрузка получается неравномерной.
Мы открыли много касс (создали много потоков), и получается, что кто-то простаивает. На самом деле, это не просто простой: когда у нас много потоков, есть дополнительный расход ресурсов. Увеличивается расход памяти. Кроме того, процессору нужно переключаться между потоками.
Чем больше потоков, тем чаще между ними нужно переключаться. Получается, что потоков у нас гораздо больше, чем ядер. Допустим, у нас 4 ядра, а потоков мы насоздавали сотню, потому что все остальные были заблокированы чтением данных. Соответственно, происходит переключение, так называемый context switching, чтобы разные потоки получали свою порцию машинного времени.
Но у такого подхода есть минусы. Context switching не бесплатен. Он занимает время. Плодить неограниченное количество потоков было бы неплохим вариантом в теории. Но на практике мы получаем упадок скорости работы и рост потребляемой памяти.
В Java есть разные подходы, которые позволяют с этим бороться — это блокирующие очереди и пулы потоков (ThreadPool). Можно ограничивать количество потоков, и тогда все остальные клиенты встают в очередь. При старте у нас может быть минимальное количество потоков, потом их количество растет.
Вернемся к примеру магазина: если народа нет, в магазине открыты две кассы, а в час пик работает десять. Но больше мы открыть не можем, потому что тогда пришлось бы арендовать дополнительную площадь и нанимать людей. А это ударит по бизнесу.
Теперь поговорим о более современных подходах: кассах самообслуживания, предзаказах и так далее. А значит, мы подбираемся к асинхронному подходу.
Asynchronous I/O
Асинхронный ввод-вывод известен достаточно давно, ведь асинхронность необходима, когда мы работаем с самым медленным инструментом ввода-вывода. А наиболее медленным девайсом ввода-вывода, с которым постоянно приходится работать, является не консоль или клавиатура, а человек. В системах, которые работают с человеком, асинхронность появилась давным-давно.
Блокирующие интерфейсы использовались когда-то и при работе с человеком. Например, старый DOS’овский интерфейс командной строки. И сейчас существуют такие утилиты, которые задают вопрос, блокируются и больше ничего не делают, а ждут, пока человек ответит. С тех пор, как стали появляться оконные интерфейсы, появился асинхронный ввод-вывод. В настоящее время большинство интерфейсов именно асинхронные.
Как работает асинхронность?
Мы регистрируем функцию-callback, но на сей раз не говорим: «Человек, введи данные, а я буду ждать». Это звучит иначе: «Когда человек введет данные, вызови, пожалуйста, эту функцию — callback». Такой подход используется в любых библиотеках пользовательского интерфейса. Но в JavaScript он был изначально. В 2009 году, когда движок JavaScript стал работать гораздо быстрее, умные ребята решили использовать его на сервере, и сделали инструмент под названием Node.js.
Node.js
Идея Node.js в том, что на серверную часть переносится JavaScript, и весь ввод-вывод становится асинхронным. То есть вместо того, чтобы поток блокировался, например, при обращении к файлу, мы получаем асинхронный ввод-вывод. Обращение к файлу тоже становится асинхронным. Например, если потоку нужно получить содержимое файла, он говорит: «Дайте мне, пожалуйста, содержимое файла, а когда оно будет прочитано, вызовите эту функцию». Мы поставили задачу и занимаемся своими делами.
Такой асинхронный подход оказался весьма действенным, и Node.js быстро набрал популярность.
Как работает Node.js?
На входе есть приемщик — это цикл. JavaScript однопоточный язык. Но это не значит, что там ничего нельзя делать в других потоках. В нем поддерживаются потоки через Web Workers и т.д. Но на входе стоит один поток.
Вычислительные задачи для Node.js обычно очень маленькие. Основная работа идет с вводом-выводом (в базу данных, в файловую систему, в сторонние сервисы и т.д.). Сами вычисления занимают мало времени. Когда данные получили из базы или из файловой системы, вызывается callback, то есть какая-то функция, в которую передаются данные.
Но в этой схеме нет ожидания. Сравним ее с традиционной моделью многопоточного сервера в Java.
What happens in Java?
Здесь есть пул потоков. Сначала обращение попадает в первый поток, потом какой-то поток заблокировался, и мы создали еще один. Он тоже заблокировался, создаем следующий. А блокируются они потому, что обращаются к блокирующим операциям ввода-вывода. Например, поток запросил файл или данные из БД и ждет, когда эти данные придут.
Модель Node.js очень быстро стала популярной. Естественно, в этот момент люди стали переписывать ее на других языках. Node.js в какой-то момент вырвался вперед в нагруженных системах с большим объемом ввода-вывода. Но подходит он не для любых систем. Если у вас много вычислений или небольшое количество запросов, то большого преимущества вы не увидите. Соответственно, в Java стали появляться аналогичные решения, в том числе платформа для работы с асинхронным вводом-выводом Vert.x. Сервер Vert.x построен на таком же принципе, что и Node.js.
Решение Node.js интересное, оно действительно помогает повышать производительность. Когда пришла реактивность, стали применять сервер, который называется Netty. Такой подход оказался очень выгодным.
История многопоточности
Как работает многопоточность в Java? Старая добрая многопоточность в Java — это базовые примитивы многопоточности:
Сложно писать, сложно отлаживать, сложно тестировать.
Многопоточность в 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. Такие знания должны стать хорошей основой для дальнейшего изучения, понимания и написания многопоточных программ.