Для чего нужна многопоточность java

Многопоточность в Java

Здравствуйте! В этой статье я вкратце расскажу вам о процессах, потоках, и об основах многопоточного программирования на языке Java. Для чего нужна многопоточность java. Смотреть фото Для чего нужна многопоточность java. Смотреть картинку Для чего нужна многопоточность java. Картинка про Для чего нужна многопоточность java. Фото Для чего нужна многопоточность java
Наиболее очевидная область применения многопоточности – это программирование интерфейсов. Многопоточность незаменима тогда, когда необходимо, чтобы графический интерфейс продолжал отзываться на действия пользователя во время выполнения некоторой обработки информации. Например, поток, отвечающий за интерфейс, может ждать завершения другого потока, загружающего файл из интернета, и в это время выводить некоторую анимацию или обновлять прогресс-бар. Кроме того он может остановить поток загружающий файл, если была нажата кнопка «отмена».

Еще одна популярная и, пожалуй, одна из самых хардкорных областей применения многопоточности – игры. В играх различные потоки могут отвечать за работу с сетью, анимацию, расчет физики и т.п.

Давайте начнем. Сначала о процессах.

Процессы

Процесс — это совокупность кода и данных, разделяющих общее виртуальное адресное пространство. Чаще всего одна программа состоит из одного процесса, но бывают и исключения (например, браузер Chrome создает отдельный процесс для каждой вкладки, что дает ему некоторые преимущества, вроде независимости вкладок друг от друга). Процессы изолированы друг от друга, поэтому прямой доступ к памяти чужого процесса невозможен (взаимодействие между процессами осуществляется с помощью специальных средств).

Для каждого процесса ОС создает так называемое «виртуальное адресное пространство», к которому процесс имеет прямой доступ. Это пространство принадлежит процессу, содержит только его данные и находится в полном его распоряжении. Операционная система же отвечает за то, как виртуальное пространство процесса проецируется на физическую память.

Схема этого взаимодействия представлена на картинке. Операционная система оперирует так называемыми страницами памяти, которые представляют собой просто область определенного фиксированного размера. Если процессу становится недостаточно памяти, система выделяет ему дополнительные страницы из физической памяти. Страницы виртуальной памяти могут проецироваться на физическую память в произвольном порядке.

Для чего нужна многопоточность java. Смотреть фото Для чего нужна многопоточность java. Смотреть картинку Для чего нужна многопоточность java. Картинка про Для чего нужна многопоточность java. Фото Для чего нужна многопоточность java

При запуске программы операционная система создает процесс, загружая в его адресное пространство код и данные программы, а затем запускает главный поток созданного процесса.

Потоки

Один поток – это одна единица исполнения кода. Каждый поток последовательно выполняет инструкции процесса, которому он принадлежит, параллельно с другими потоками этого процесса.

Следует отдельно обговорить фразу «параллельно с другими потоками». Известно, что на одно ядро процессора, в каждый момент времени, приходится одна единица исполнения. То есть одноядерный процессор может обрабатывать команды только последовательно, по одной за раз (в упрощенном случае). Однако запуск нескольких параллельных потоков возможен и в системах с одноядерными процессорами. В этом случае система будет периодически переключаться между потоками, поочередно давая выполняться то одному, то другому потоку. Такая схема называется псевдо-параллелизмом. Система запоминает состояние (контекст) каждого потока, перед тем как переключиться на другой поток, и восстанавливает его по возвращению к выполнению потока. В контекст потока входят такие параметры, как стек, набор значений регистров процессора, адрес исполняемой команды и прочее…

Проще говоря, при псевдопараллельном выполнении потоков процессор мечется между выполнением нескольких потоков, выполняя по очереди часть каждого из них.

Вот как это выглядит:

Для чего нужна многопоточность java. Смотреть фото Для чего нужна многопоточность java. Смотреть картинку Для чего нужна многопоточность java. Картинка про Для чего нужна многопоточность java. Фото Для чего нужна многопоточность java

Цветные квадраты на рисунке – это инструкции процессора (зеленые – инструкции главного потока, синие – побочного). Выполнение идет слева направо. После запуска побочного потока его инструкции начинают выполняться вперемешку с инструкциями главного потока. Кол-во выполняемых инструкций за каждый подход не определено.

То, что инструкции параллельных потоков выполняются вперемешку, в некоторых случаях может привести к конфликтам доступа к данным. Проблемам взаимодействия потоков будет посвящена следующая статья, а пока о том, как запускаются потоки в Java…

Запуск потоков

Каждый процесс имеет хотя бы один выполняющийся поток. Тот поток, с которого начинается выполнение программы, называется главным. В языке Java, после создания процесса, выполнение главного потока начинается с метода main(). Затем, по мере необходимости, в заданных программистом местах, и при выполнении заданных им же условий, запускаются другие, побочные потоки.

В языке Java поток представляется в виде объекта-потомка класса Thread. Этот класс инкапсулирует стандартные механизмы работы с потоком.

Запустить новый поток можно двумя способами:

Способ 1

Создать объект класса Thread, передав ему в конструкторе нечто, реализующее интерфейс Runnable. Этот интерфейс содержит метод run(), который будет выполняться в новом потоке. Поток закончит выполнение, когда завершится его метод run().

Для пущего укорочения кода можно передать в конструктор класса Thread объект безымянного внутреннего класса, реализующего интерфейс Runnable:

Способ 2

Создать потомка класса Thread и переопределить его метод run():

В приведённом выше примере в методе main() создается и запускается еще один поток. Важно отметить, что после вызова метода mSecondThread.start() главный поток продолжает своё выполнение, не дожидаясь пока порожденный им поток завершится. И те инструкции, которые идут после вызова метода start(), будут выполнены параллельно с инструкциями потока mSecondThread.

Для демонстрации параллельной работы потоков давайте рассмотрим программу, в которой два потока спорят на предмет философского вопроса «что было раньше, яйцо или курица?». Главный поток уверен, что первой была курица, о чем он и будет сообщать каждую секунду. Второй же поток раз в секунду будет опровергать своего оппонента. Всего спор продлится 5 секунд. Победит тот поток, который последним изречет свой ответ на этот, без сомнения, животрепещущий философский вопрос. В примере используются средства, о которых пока не было сказано (isAlive() sleep() и join()). К ним даны комментарии, а более подробно они будут разобраны дальше.

В приведенном примере два потока параллельно в течении 5 секунд выводят информацию на консоль. Точно предсказать, какой поток закончит высказываться последним, невозможно. Можно попытаться, и можно даже угадать, но есть большая вероятность того, что та же программа при следующем запуске будет иметь другого «победителя». Это происходит из-за так называемого «асинхронного выполнения кода». Асинхронность означает то, что нельзя утверждать, что какая-либо инструкция одного потока, выполнится раньше или позже инструкции другого. Или, другими словами, параллельные потоки независимы друг от друга, за исключением тех случаев, когда программист сам описывает зависимости между потоками с помощью предусмотренных для этого средств языка.

Теперь немного о завершении процессов…

Завершение процесса и демоны

В Java процесс завершается тогда, когда завершается последний его поток. Даже если метод main() уже завершился, но еще выполняются порожденные им потоки, система будет ждать их завершения.

Однако это правило не относится к особому виду потоков – демонам. Если завершился последний обычный поток процесса, и остались только потоки-демоны, то они будут принудительно завершены и выполнение процесса закончится. Чаще всего потоки-демоны используются для выполнения фоновых задач, обслуживающих процесс в течение его жизни.

Объявить поток демоном достаточно просто — нужно перед запуском потока вызвать его метод setDaemon(true) ;
Проверить, является ли поток демоном, можно вызвав его метод boolean isDaemon() ;

Завершение потоков

В Java существуют (существовали) средства для принудительного завершения потока. В частности метод Thread.stop() завершает поток незамедлительно после своего выполнения. Однако этот метод, а также Thread.suspend(), приостанавливающий поток, и Thread.resume(), продолжающий выполнение потока, были объявлены устаревшими и их использование отныне крайне нежелательно. Дело в том что поток может быть «убит» во время выполнения операции, обрыв которой на полуслове оставит некоторый объект в неправильном состоянии, что приведет к появлению трудноотлавливаемой и случайным образом возникающей ошибке.

Вместо принудительного завершения потока применяется схема, в которой каждый поток сам ответственен за своё завершение. Поток может остановиться либо тогда, когда он закончит выполнение метода run(), (main() — для главного потока) либо по сигналу из другого потока. Причем как реагировать на такой сигнал — дело, опять же, самого потока. Получив его, поток может выполнить некоторые операции и завершить выполнение, а может и вовсе его проигнорировать и продолжить выполняться. Описание реакции на сигнал завершения потока лежит на плечах программиста.

Java имеет встроенный механизм оповещения потока, который называется Interruption (прерывание, вмешательство), и скоро мы его рассмотрим, но сначала посмотрите на следующую программку:

Incremenator — поток, который каждую секунду прибавляет или вычитает единицу из значения статической переменной Program.mValue. Incremenator содержит два закрытых поля – mIsIncrement и mFinish. То, какое действие выполняется, определяется булевой переменной mIsIncrement — если оно равно true, то выполняется прибавление единицы, иначе — вычитание. А завершение потока происходит, когда значение mFinish становится равно true.

Взаимодействовать с потоком можно с помощью метода changeAction() (для смены вычитания на сложение и наоборот) и метода finish() (для завершения потока).

В объявлении переменных mIsIncrement и mFinish было использовано ключевое слово volatile (изменчивый, не постоянный). Его необходимо использовать для переменных, которые используются разными потоками. Это связано с тем, что значение переменной, объявленной без volatile, может кэшироваться отдельно для каждого потока, и значение из этого кэша может различаться для каждого из них. Объявление переменной с ключевым словом volatile отключает для неё такое кэширование и все запросы к переменной будут направляться непосредственно в память.

В этом примере показано, каким образом можно организовать взаимодействие между потоками. Однако есть одна проблема при таком подходе к завершению потока — Incremenator проверяет значение поля mFinish раз в секунду, поэтому может пройти до секунды времени между тем, когда будет выполнен метод finish(), и фактическим завершения потока. Было бы замечательно, если бы при получении сигнала извне, метод sleep() возвращал выполнение и поток незамедлительно начинал своё завершение. Для выполнения такого сценария существует встроенное средство оповещения потока, которое называется Interruption (прерывание, вмешательство).

Interruption

Класс Thread содержит в себе скрытое булево поле, подобное полю mFinish в программе Incremenator, которое называется флагом прерывания. Установить этот флаг можно вызвав метод interrupt() потока. Проверить же, установлен ли этот флаг, можно двумя способами. Первый способ — вызвать метод bool isInterrupted() объекта потока, второй — вызвать статический метод bool Thread.interrupted(). Первый метод возвращает состояние флага прерывания и оставляет этот флаг нетронутым. Второй метод возвращает состояние флага и сбрасывает его. Заметьте что Thread.interrupted() — статический метод класса Thread, и его вызов возвращает значение флага прерывания того потока, из которого он был вызван. Поэтому этот метод вызывается только изнутри потока и позволяет потоку проверить своё состояние прерывания.

Итак, вернемся к нашей программе. Механизм прерывания позволит нам решить проблему с засыпанием потока. У методов, приостанавливающих выполнение потока, таких как sleep(), wait() и join() есть одна особенность — если во время их выполнения будет вызван метод interrupt() этого потока, они, не дожидаясь конца времени ожидания, сгенерируют исключение InterruptedException.

Переделаем программу Incremenator – теперь вместо завершения потока с помощью метода finish() будем использовать стандартный метод interrupt(). А вместо проверки флага mFinish будем вызывать метод bool Thread.interrupted();
Так будет выглядеть класс Incremenator после добавления поддержки прерываний:

Как видите, мы избавились от метода finish() и реализовали тот же механизм завершения потока с помощью встроенной системы прерываний. В этой реализации мы получили одно преимущество — метод sleep() вернет управление (сгенерирует исключение) незамедлительно после прерывания потока.

Заметьте что методы sleep() и join() обёрнуты в конструкции try-catch. Это необходимое условие работы этих методов. Вызывающий их код должен перехватывать исключение InterruptedException, которое они бросают при прерывании во время ожидания.

С запуском и завершением потоков разобрались, дальше я расскажу о методах, использующихся при работе с потоками.

Метод Thread.sleep()

Thread.sleep() — статический метод класса Thread, который приостанавливает выполнение потока, в котором он был вызван. Во время выполнения метода sleep() система перестает выделять потоку процессорное время, распределяя его между другими потоками. Метод sleep() может выполняться либо заданное кол-во времени (миллисекунды или наносекунды) либо до тех пор пока он не будет остановлен прерыванием (в этом случае он сгенерирует исключение InterruptedException).

Несмотря на то, что метод sleep() может принимать в качестве времени ожидания наносекунды, не стоит принимать это всерьез. Во многих системах время ожидания все равно округляется до миллисекунд а то и до их десятков.

Метод yield()

Статический метод Thread.yield() заставляет процессор переключиться на обработку других потоков системы. Метод может быть полезным, например, когда поток ожидает наступления какого-либо события и необходимо чтобы проверка его наступления происходила как можно чаще. В этом случае можно поместить проверку события и метод Thread.yield() в цикл:

Метод join()

В Java предусмотрен механизм, позволяющий одному потоку ждать завершения выполнения другого. Для этого используется метод join(). Например, чтобы главный поток подождал завершения побочного потока myThready, необходимо выполнить инструкцию myThready.join() в главном потоке. Как только поток myThready завершится, метод join() вернет управление, и главный поток сможет продолжить выполнение.

Метод join() имеет перегруженную версию, которая получает в качестве параметра время ожидания. В этом случае join() возвращает управление либо когда завершится ожидаемый поток, либо когда закончится время ожидания. Подобно методу Thread.sleep() метод join может ждать в течение миллисекунд и наносекунд – аргументы те же.

С помощью задания времени ожидания потока можно, например, выполнять обновление анимированной картинки пока главный (или любой другой) поток ждёт завершения побочного потока, выполняющего ресурсоёмкие операции:

В этом примере поток brain (мозг) думает над чем-то, и предполагается, что это занимает у него длительное время. Главный поток ждет его четверть секунды и, в случае, если этого времени на раздумье не хватило, обновляет «индикатор раздумий» (некоторая анимированная картинка). В итоге, во время раздумий, пользователь наблюдает на экране индикатор мыслительного процесса, что дает ему знать, что электронные мозги чем то заняты.

Приоритеты потоков

Каждый поток в системе имеет свой приоритет. Приоритет – это некоторое число в объекте потока, более высокое значение которого означает больший приоритет. Система в первую очередь выполняет потоки с большим приоритетом, а потоки с меньшим приоритетом получают процессорное время только тогда, когда их более привилегированные собратья простаивают.

Работать с приоритетами потока можно с помощью двух функций:

void setPriority(int priority) – устанавливает приоритет потока.
Возможные значения priority — MIN_PRIORITY, NORM_PRIORITY и MAX_PRIORITY.

int getPriority() – получает приоритет потока.

Некоторые полезные методы класса Thread

Это практически всё. Напоследок приведу несколько полезных методов работы с потоками.

boolean isAlive() — возвращает true если myThready() выполняется и false если поток еще не был запущен или был завершен.

setName(String threadName) – Задает имя потока.
String getName() – Получает имя потока.
Имя потока – ассоциированная с ним строка, которая в некоторых случаях помогает понять, какой поток выполняет некоторое действие. Иногда это бывает полезным.

static Thread Thread.currentThread() — статический метод, возвращающий объект потока, в котором он был вызван.

long getId() – возвращает идентификатор потока. Идентификатор – уникальное число, присвоенное потоку.

Заключение

Отмечу, что в статье рассказано далеко не про все нюансы многопоточного программирования. И коду, приведенному в примерах, для полной корректности не хватает некоторых нюансов. В частности, в примерах не используется синхронизация. Синхронизация потоков — тема, не изучив которую, программировать правильные многопоточные приложения не получится. Почитать о ней вы можете, например, в книге «Java Concurrency in Practice» или здесь (всё на английском).

В статье были рассмотрены основные средства работы с потоками в Java. Если эта статья окажется полезной, то в следующей я расскажу о проблемах совместного доступа потоков к ресурсам и о методах их решения.

Источник

Многопоточность в Java. Основы

Преимущества многопоточности
Не смотря на множество проблем, многопоточность очень полезна в пользовательских интерфейсах. Пока один поток занимается обработкой данных, второй преспокойно отрисовывает графику. В результате мы видим на экране плавную анимацию и можем взаимодействовать с элементами интерфейса, не опасаясь, что всё зависнет.

Также многопоточность позволяет улучшить скорость обработки данных: пока один поток подготавливает данные, например, выкачивая их из интернета мелкими порциями, второй и третий потоки могут их обрабатывать, а четвёртый записывать результат на диск.

Если же у нас многоядерный процессор, то потоки позволят существенно улучшить производительность, переложив работу на другие ядра.

Что ж, достаточно теории, перейдём к практике.

Первый вариант, это расширение класса Thread.

Как видно, во втором случае приходится чуть больше писать, но на самом деле преимущество всё-таки у этого способа, потому что мы можем и расширить класс, и реализовать интерфейс Runnable (например, class CustomRunnable extends JPanel implements Runnable ). Первый же способ удобен в том случае, если надо переопределить или добавить новые методы в класс Thread.

Если новый класс для потока создавать накладно, то можно сделать всё в анонимном классе.

Эти два способа хорошо подходят для случаев, когда кода в методе run очень мало.

В Java 8 появились лямбда-выражения, а вместе с ними и ещё более короткие способы создания потока:

Можно ещё побаловаться с Reflection API, но это уж как-нибудь сами.

Вообще, поток в Java работает до тех пор, пока выполняется код в его методе run. Если в run будет простая операция, скажем, вывод текста в консоль, то поток сразу же завершится после этой операции. Если же мы в потоке записываем данные в цикле в 100 файлов, то поток будет работать до тех пор, пока не выполнит свою задачу.

И, к слову, если поток работает в фоне и вы закрываете программу, то процесс этой программы всё равно останется висеть в фоне, пока не завершатся все потоки. Чтобы по окончанию программы гарантированно получить завершение потоков, нужно сделать из потока демона Для чего нужна многопоточность java. Смотреть фото Для чего нужна многопоточность java. Смотреть картинку Для чего нужна многопоточность java. Картинка про Для чего нужна многопоточность java. Фото Для чего нужна многопоточность java. Звучит забавно, но так и есть:

С учётом исключения InterruptedException, можно переписать первый пример:

При исключении мы покинем цикл while и поток завершится. Оба метода runnable.stop() и thread.interrupt() теперь успешно завершают поток. Обратите внимание на ключевое слово volatile для поля isRunning, я объясню его значение чуть позже.

Проблема доступа к общим ресурсам
Как я говорил ранее, при работе с потоками может возникнуть множество проблем, поэтому важно уметь проектировать приложение так, чтобы этих проблем избежать. К счастью, в Java есть огромное множество решений, с которыми я вас постепенно буду знакомить.

Подвох вот в чём. В первом случае изменение поля происходит за одну инструкцию putstatic. Во втором, между getstatic и putstatic аж две инструкции. Ничто не мешает другому потоку, пока выполняется сложение, перезаписать значение поля counter.

Детальнее обо всём этом можно прочесть в статье: Модель памяти в примерах и не только / Хабрахабр

Для объявления блоков синхронизации в Java служит ключевое слово synchronized :

Теперь, чтобы решить нашу проблему со счётчиком, достаточно добавить неатомарную операцию в блок синхронизации.

На самом деле в этом коде тоже есть проблема, попробуйте найти её после прочтения раздела. Или читайте комментарии к статье.

Таким образом, получается синхронизированная работа потоков.

Блоки синхронизации следует расставлять с умом. Если бы мы сделали вот так:

мы бы тоже получили верный результат 5000, вот только работал бы у нас только один поток:
— Создаём два потока и запускаем их.
— Допустим, первый поток запустился быстрее и вошёл в блок синхронизации. Второй пока запускается.
— Первый поток теперь в цикле while. Второй поток встретил блок synchonized и не получил разрешение войти.
— Первый поток работает. Второй ждёт.
— Спустя некоторое количество времени, первый поток увеличил счётчик до 5000 и вышел из циклов и блока синхронизации. Второму потоку разрешается войти внутрь.
— Первый поток завершил работу. Второй поток проверил, что условие Counter.get() уже не выполняется и не вошёл в цикл while. Покинул блок синхронизации и завершился.

То есть мы заходим в блок синхронизации, монитор. Возвращаем значение и выходим из монитора. Если во время критической секции произошло исключение, мы также выходим из монитора.

Теперь нигде не нужно добавлять блок синхронизации.
О других классах, упрощающих работу с многопоточностью, я постараюсь рассказать в следующей статье.

Синхронизация. Пример проектирования многопоточного приложения
В довершение статьи, хочу показать пример небольшого приложения и важность правильного проектирования потоков.
Допустим, у нас есть 20 файлов с данными. Нам нужно наиболее эффективно прочитать эти файлы и вывести данные на экран. Данные, по мере чтения, будут добавляться в список и уже оттуда выводиться на экран.

Есть класс, отвечающий за панель рисования, туда мы будем добавлять объекты по мере чтения:

▌ Вариант 1. Без многопоточности

Теперь ошибок нет, но обработка 20 файлов занимает порядка пяти минут. Причём, первые файлы читаются быстро, а потом работа замедляется. Попробуйте понять, почему так происходит.

Имея 20 потоков логично предположить, что работа пройдёт в 20 раз быстрее. Так бы оно и было, имей наш процессор хотя бы 20 ядер. В противном случае мы лишь создадим дополнительную нагрузку и все наши данные могут отрисовываться не очень плавно.

Тем не менее, время работы теперь: 40 секунд. Это долго. Та же проблема, что замедляла работу в первом варианте, замедляет всё и сейчас. Должен быть способ избавиться от synchronized блоков и такой способ есть.

▌ Вариант 3. Использование синхронизированного списка

Для того, чтобы сделать из обычного списка синхронизированный, вызываем метод

Время работы теперь чуть менее 4 секунд!

▌ Вариант 4. Ограничение количества потоков

Мы можем ограничить количество потоков, создав пул:

Теперь можно создать хоть сотню потоков, выполняться в один момент времени будут не более maxThreads потоков.

Ограничим пул пятью потоками

Теперь время исполнения увеличилось до 11 секунд, но зато отрисовка проходит плавнее и мы можем быть уверены, что будь у нас сотня файлов, система не получит большую нагрузку.

Лучшей практикой, является ограничение пула потоков по количеству процессоров в системе:

▌ Вариант 5. java.util.concurrent

Особых изменений в скорости работы при нескольких потоках мы не получим, но если запустить всё в одном потоке, как в первом случае, то вместо пяти минут мы получим 55 секунд!

Как можно понять из названия, его магия в том, что на каждую операцию добавления в список, создаётся новый массив. Это не очень хорошо в плане памяти, зато потокобезопасно. Подробнее разберёмся в следующей статье.

Наглядная работа всех вариантов представлена в этой анимации:
Для чего нужна многопоточность java. Смотреть фото Для чего нужна многопоточность java. Смотреть картинку Для чего нужна многопоточность java. Картинка про Для чего нужна многопоточность java. Фото Для чего нужна многопоточность java
Версия побольше

Источник

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *