Потоки в Java: что это такое и как они работают
Выжимаем максимум из процессора и заставляем программы на Java выполнять несколько задач одновременно.
Иллюстрация: Merry Mary для Skillbox Media
В многоядерных процессорах все ядра параллельно выполняют свои наборы машинных инструкций. Поэтому современные компьютеры довольно быстро справляются со сложными вычислительными задачами.
Но и одноядерную систему можно настроить так, чтобы она работала над несколькими наборами инструкций как бы одновременно — то есть переключалась между ними очень быстро и незаметно для пользователя. При этом за каждую подзадачу будет отвечать своё «виртуальное» ядро, или поток (его ещё называют thread, то есть «нить»). Это и есть многопоточность. Разберёмся, что это такое и как её настроить.
Что такое поток в Java
Представьте работника лаборатории, которому выдали список дел. Он может выполнять каждое строго как написано: закончив первое дело, переходить ко второму, потом к третьему — и так до самого конца. А может решать несколько задач параллельно: например, загрузить компоненты в миксер, а пока идёт их перемешивание, делать навески для следующей загрузки или писать отчёт о результатах вчерашней работы. Возможно, при таком подходе дело пойдёт значительно быстрее.
В программировании то же самое: можно разбить большое задание на подзадачи и распределить их между потоками. Это такие абстрактные сущности, которые последовательно выполняют инструкции программы. Потоки протекают в процессе, а процесс, простыми словами, — это любая запущенная программа.
Любой процесс имеет минимум один поток, который называют главным. Он запускается в первую очередь, а остальные идут параллельно. Например, при запуске программы на Java процесс — это её среда исполнения (JRE).
Обычные программы на Java работают синхронно: строчки кода выполняются одна за другой в главном потоке. Но можно создать несколько тредов и управлять ими. Допустим, один может просто ждать, пока выполнится другой, а может в это время что-то вычислять.
Кроме «виртуальных» тредов, есть аппаратные, о которых говорилось в начале статьи. Они представляют собой «среды исполнения» программных тредов вашего кода. Когда код на Java заточен под несколько тредов, система задействует столько же реальных потоков процессора.
А если программа использует больше тредов, чем есть ядер у компьютера? Вам не стоит беспокоиться: за это отвечает планировщик ОС, который сам распределит ресурсы в угоду производительности.
Как работают потоки в Java
Чтобы увидеть, как работает поток, выполните в режиме отладки следующий код, расставив брейкпоинты на каждой строчке:
Команды будут выполняться последовательно: сначала на экране появится слово Hello, затем пробел, а в конце — World. Это обычное поведение программы, но его можно изменить с помощью класса Thread и интерфейса Runnable из стандартной библиотеки java.lang.
Посмотрим, что покажет программа, если воспользоваться методом Thread.currentThread(), который возвращает ссылку на текущую нить:
В квадратных скобках первым параметром указано имя потока main, о котором говорилось выше. Второй параметр — это приоритет (по умолчанию он равен 5), третий — имя группы потоков.
Отдельно имя можно получить с помощью метода getName():
Как создать свой поток в Java
Для этого есть класс Thread — это поток, только на уровне кода. Создать их можно сколько угодно, но одновременно будет выполняться столько, сколько поддерживает ваша система.
Интерфейс Runnable — это задача, которую выполняет поток, то есть код. Интерфейс содержит основной метод run() — в нём и находится точка входа и логика исполняемого потока.
Создать поток в Java можно двумя способами.
Первый способ:
- Определить класс — наследник класса Thread и переопределить метод run().
- Создать экземпляр своего класса и вызвать метод start().
Обратите внимание: если на экземпляре класса Thread вместо метода start() вызвать run(), то код, написанный для другого потока, отлично выполнится, но выполнит его тот же тред, который и вызвал этот метод, а новый запущен не будет! Поэтому нужно пользоваться методом start().
Второй способ:
- Реализовать интерфейс Runnable и метод run().
- Создать экземпляр Thread и передать в конструктор свой Runnable (экземпляр класса, реализующий этот интерфейс).
Второй вариант лучше — он более гибкий. Например, если бы MyThread уже наследовал какой-либо класс, то было бы невозможно пойти первым путём, так как Java не поддерживает множественное наследование.
Практика: изучаем потоки на котиках
Напишем небольшую консольную игру, в которой будут драться… коты. Обещаем: в процессе написания программы ни один кот не пострадает, зато вы увидите, как «нити» конкурируют между собой.
В коде будут новые ключевые слова и класс, которых вы раньше, скорее всего, не встречали:
- synchronized перед методом означает, что он синхронизирован. Поток, вызвавший синхронизированный метод, запрещает другим нитям к нему обращаться, пока сам не выйдет из метода.
- volatile нужен, когда одну переменную используют разные потоки, во избежание некорректных результатов.
- Класс CopyOnWriteArrayList — это тот же ArrayList, только потокобезопасный, то есть оптимизированный под использование нескольких потоков. Он находится в библиотеке java.util.concurrent.
Программа состоит из двух классов — CatFightsConsole и Cat. Вначале создаётся N боевых «котов», а когда они запускаются, они начинают драться друг с другом. Каждый кот стремится первым вызвать метод Cat.attack(), чтобы атаковать случайного из оставшихся в живых конкурентов и отнять у него жизнь. Когда жизнь кота становится равна 0, его поток завершает свою работу. Бой идёт до последнего кота.
Класс CatFightsConsole содержит метод main(String[] args), в котором производится начальная настройка программы: создание и настройка объектов, запуск потоков. Далее главный поток натыкается на метод join(), который говорит ему остановиться на этой строчке, пока не завершится поток, на котором был вызван метод. Когда все «кототреды» завершат свою работу, в консоль выведется сообщение о последнем коте — победителе.
У класса Cat есть имя (String name), количество жизней (int life), личный поток и статический список cats со ссылками на все объекты Cat. Он реализует интерфейс Runnable, а значит, основной цикл работы потока происходит в методе run().
При запуске потока, пока объектов Cat более одного и пока у них есть жизни, вызывается синхронизированный статический метод Cat.attack(), который декрементирует переменную life с помощью метода decrementLife() у второго переданного в него объекта. Если после этого значение life равно нулю, то у этого же объекта вызывается метод getThread(), а на нём interrupt() — функция, прерывающая работу потока.
Изучите код и запустите его на своём компьютере. Из-за постоянной гонки потоков результаты будут различаться при каждом запуске программы.
Посмотрите, как поведут себя потоки, если дать «котам» 1 или 100 000 жизней. Проверьте, не происходит ли ошибок в вычислениях здоровья и успешно ли завершаются треды.
Также в качестве практики вы можете модернизировать код и поиграться с потоками. Например, добавить новых котов, изменить величину урона или добавить новый класс и подумать, как реализовать механизм их взаимодействия.
Состояния потока и время его жизни
Поток, как и любой другой объект, имеет цикл жизни: он рождается, живёт (готов к выполнению или выполняется), спит (находится в ожидании), и умирает (завершает свою работу).
Получить текущее состояние потока позволяет метод getState(). Он возвращает одно из значений перечисления State, которое содержит набор из 6 констант:
- NEW — новый, только что созданный поток. Это состояние присваивается, когда выделяется память для объекта.
- RUNNABLE — вызывая метод start(), поток становится готовым к выполнению, а затем выполняемым.
- BLOCKED / WAITING / TIME_WAITING — данные состояния означают, что поток находится в ожидании своего выполнения.
- TERMINATED — после завершения работы поток уничтожается.
В следующем примере на экран выводится несколько состояний потока. Внимательно изучите код и комментарии к нему:
Как говорилось ранее, когда запускается новый поток, старый продолжает работу. Вызвав метод join(), главный поток перейдёт на время в состояние WAITING, а затем снова станет RUNNABLE. По завершении работы программы все потоки перейдут в состояние TERMINATED.
Есть ещё метод isAlive(), который позволяет узнать, жив поток или нет. Он возвращает логическое значение true или false.
Что в итоге
В этой статье мы познакомились с понятием многопоточности в Java, прошлись по базовым терминам, узнали, что такое поток, как его создать и в каких состояниях он может пребывать, а также увидели, как работает несколько тредов одновременно, на примере консольного приложения. Повторим основные моменты:
- Потоки — это виртуальные сущности, которые последовательно выполняют код. Они протекают в процессах, где процесс — это программа, которая выполняется.
- Поток можно создать двумя способами: унаследовать класс Thread или реализовать интерфейс Runnable.
- Вся логика нового треда выполняется в методе run(), а запускается он методом start().
- Поток имеет свой жизненный цикл и шесть состояний, описанных в перечислении State.
- State — это свойство класса Thread, которое содержит состояния потока, а получить его можно с помощью метода getState().
- Метод join() переводит в ожидание текущий поток, а interrupt() прерывает его работу.
Многопоточность — важная тема в программировании. Все современные системы используют много потоков для увеличения производительности, а также выполнения нескольких задач одновременно. Поэтому важно понимать, как многопоточность работает.