Введение в многопоточность в Java очень простым языком: Процессы, Потоки и Основы синхронизации
На старте вашей карьеры вы вполне можете обойтись без практических навыков в параллельном программировании, но рано или поздно перед вами встанет задача, требующая от вас таких навыков.
Итак, в данной статье мы поговорим о многопоточности в Java. Тема очень обширная, и я не ставлю целью описать все ее аспекты. Статья рассчитана на людей, только начинающих свое знакомство с многопоточностью. В данной статье мы рассмотрим основу многопоточности Java, такие базовые механизмы синхронизации как ключевые слова volatile и synchronized и очень важную проблематику “Состояние гонки” и “Взаимная блокировка”.
Я выбрал немного необычный подход, связав технические примеры с нашей повседневной жизнью, надеюсь вам понравится. Тема будет раскрыта на примере абстрактной комнаты и людей в находящихся в ней.
Дабы максимально упростить материал, я намеренно буду опускать некоторые нюансы реализации и иерархии многопоточности в Java, усложняющие понимание темы. Если вы рассчитываете на подробный обзор с техническими терминами и формулировками, то данная статья вам не подойдет.
Что такое процессы и потоки
Прежде чем перейти к многопоточности, давайте разберемся, что такое процессы и потоки.
Процесс — это экземпляр выполняющейся программы, простыми словами при запуске любой программы на вашем компьютере, вы порождаете процесс. Он имеет свое собственное адресное пространство памяти и один или несколько потоков.
Поток — это последовательность инструкций, выполняющаяся внутри процесса. Потоки делят адресное пространство памяти процесса, что позволяет им работать параллельно.
Создание и управление потоками
Каждый раз когда вы запускаете вашу программу, т.е. порождаете процесс, JVM (виртуальная машина) создает для вас так называемы главный поток (main thread) в котором ваш код будет исполняться.
Из своего главного потока вы можете создавать множество других потоков которые будут исполняться параллельно вашему главному потоку.
В Java создание и управление потоками осуществляется с использованием класса Thread. Чтобы создать поток, необходимо унаследоваться от класса Thread и переопределить его метод run(), в котором указывается код, который будет выполняться в потоке. Затем создается экземпляр класса Thread и вызывается метод start(), чтобы запустить поток.
class MyThread extends Thread < public void run() < System.out.println("Этот код выполняется в потоке"); >> public class Main < public static void main(String[] args) < MyThread thread = new MyThread(); thread.start(); System.out.println("Этот код выполняется в главном потоке"); >>
Чтобы объяснить более простым языком что такое потоки и как они взаимодействуют, давайте абстрагируемся от кода и представим что ваша программа (процесс) — это комната, а потоки — это люди, находящиеся в этой комнате. В комнате есть различные предметы (объекты), с которыми люди могут взаимодействовать. Когда вы пускаете несколько людей в комнату (создаете новые потоки), они получают доступ к тем же самым предметам.
Однако, когда несколько людей одновременно пытаются взаимодействовать с одним и тем же предметом, могут возникать конфликты, такие как “Состояние гонки” (Race condition) и “Взаимная блокировка” (Deadlocks).
Состояние гонки
Состояние гонки (Race condition) — это ситуация, когда два или более потока одновременно обращаются к общим данным или ресурсам, и результаты их операций зависят от того, в каком порядке выполняются операции. Это может привести к непредсказуемым и нежелательным результатам, таким как неправильные значения или ошибки в программе. В результате состояния гонки данные или ресурсы могут быть повреждены или использованы неправильно.
Давайте рассмотрим “состояние гонки” в рамках нашего абстрактного примера. Представьте что в комнате стоит стол, а на нем полный стакан воды. В этой же комнате находятся два человека, скажем Саша и Петя. И вот Саша решил сделать глоток воды из этого стакана, чуть позже еще глоток, а потом и еще. В реальной комнате это выглядело бы примерно так: Саша каждый раз подходил бы к этому стакану, брал бы его, делал глоток и клал бы на место, а потом и сам возвращался на место. Но компьютер это не комната, в нем есть всякие механизмы оптимизации. К примеру, вместо того, чтобы заставлять Сашу ходить туда-сюда каждый раз когда он захотел сделать глоток воды, он создаст копию стакана для Саши когда тот придет за стаканом в первый раз.
Итак, Саша уже имеет свой стакан (копию того стакана, что остался стоять на столе), и ему уже не надо каждый раз ходить за стаканом, он просто сидит на своем месте и делает три глотка воды. Но, несмотря на то что у Саши копия стакана, а оригинал остался стоять на столе, он знает что должен его отнести на место и заменить своей копией оригинал, это процесс называется синхронизация и необходимо для поддержания актуального состояния уровня воды в стакане (значения переменной).
В то время как Саша пил из своей копии стакана, Петя тоже захотел глотнуть воды для чего подошел к столу где стоит все еще полный стакан воды потому как Петя еще не вернул свой стакан (синхронизация еще не произошла) и соответственно получил свою копию полного стакана, после чего вернулся на место и сделал два глотка. В то время как Петя пил из своей копии стакана, Саша уже закончил пить и отнес свой стакан на место и соответственно заменил им оригинальный стакан (совершил синхронизацию), в итоге количество воды в стакане на столе уменьшилось на 3 глотка. Через какое-то время Петя тоже закончил пить и отнес свой стакан на место и соответственно заменил им стакан, стоящий на столе. В итоге объем воды в стакане на столе — это полный стакан минус два глотка Пети, а глотки Саши утеряны. Это и есть состояние гонки и результаты ее могут быть абсолютно не предсказуемыми.
Для борьбы с этим явлением Java предоставляет различные механизмы, базовым является использование ключевого слова volatile.
Ключевое слово volatile используется для обозначения переменных, которые могут быть изменены несколькими потоками. Оно гарантирует, что изменения переменной видны другим потокам.
Проще говоря, если вернуться к примеру с Сашей и Петей, то каждый раз, когда кто-нибудь из них захочет глотнуть воды, то он будет подходить к стакану брать его и после глотка сразу же ставить на место, да в этом случае уже нет оптимизации, но зато объем воды в стакане всегда актуальный и это избавляет нас от состояния гонки.
Ниже приведен пример кода, при помощи которого вы можете наблюдать результаты “состояние гонки”. При каждом запуске данного кода результаты будут непредсказуемы, хотя ожидалось увидеть цифру 2000. Проблема легко решается добавлением ключевого слова volatile в декларации переменной private volatile int volume = 0;
class Scratch < static class GlassOfWater < private int volume = 0; public int getVolume() < return volume; >public void setVolume(int volume) < this.volume = volume; >> public static void main(String[] args) throws InterruptedException < GlassOfWater glassOfWater = new GlassOfWater(); Thread thread1 = new Thread(() -> < for (int i = 0; i < 1000; i++) < glassOfWater.setVolume(glassOfWater.getVolume() + 1); >>); Thread thread2 = new Thread(() -> < for (int i = 0; i < 1000; i++) < glassOfWater.setVolume(glassOfWater.getVolume() + 1); >>); thread1.start(); thread2.start(); thread1.join(); thread2.join(); System.out.println("Volume is " + glassOfWater.getVolume()); > >
Взаимная блокировка
Взаимная блокировка (Deadlock) — это ситуация, когда два или более потока зацикленно ожидают друг друга, чтобы освободить ресурсы или завершить определенные операции. В результате ни один из потоков не может продолжить свое выполнение, так как каждый из них блокирует ресурсы, необходимые для завершения работы другого потока. Это приводит к тому, что программа останавливается и не может продолжить свое выполнение, пока ситуация deadlock не будет разрешена вручную.
Вернемся в ту же комнату, где нас ожидают Саша и Петя, и представим что в этой же комнате есть два чемоданчика, первый с различными инструментами типа плоскогубцы, молоток, всякие ключи и т.д, а во втором у нас крепеж, ну типа гайки, шурупы, гвозди, саморезы и т.д. Саша решил что-то отремонтировать в комнате и ему понадобились оба эти чемоданчика. И в то же время он хочет быть уверенным что никто ничего от туда не возьмет пока он работает, для этого он запереть на ключи нужные ему чемоданчики. Таким образом каждый раз когда он что-то будет брать или класть в чемоданчик он его отпирает и запирает.
Данный прием в Java называется блокировкой, это как ключ, который только один человек может держать в руках в определенный момент времени. Когда кто-то владеет ключом (блокирует доступ), другие люди должны ждать своей очереди. Блокировка гарантирует, что критическая секция кода будет выполняться только одним потоком за раз. Таким ключиком в языке Java является ключевое слово synchronized.
Ключевое слово synchronized обеспечивает атомарность операций и предотвращает конфликты между потоками. Это слово может быть применено как к методу целиком, так и к отдельному участку кода.
class MyClass < int counter; public synchronized void doSomething() < counter++; >> class MyClass < int counter; public void doSomething() < synchronized(this) < counter++; >> >
С понятием synchronized мы разобрались и можем продолжить обзор того что же такое “Взаимная блокировка”.
Вернемся к Саше, сначала он взял ключ от ящика с инструментами и запер его, но в это же время Петя тоже решил заняться ремонтом и так же решил запереть чемоданчик, но первым он запер чемоданчик с крепежом. Далее Пете нужен чемоданчик с инструментами, он пошел к этому чемоданчику и видит что он уже заперт Сашей, “ну что же раз такое дело то подожду пока Саша завершит работу с ним” подумал Петя. В то же время Саше нужен чемоданчик с крепежом, но он так же видит что чемоданчик уже заперт Петей, в итоге Саша так же решил подождать. Мораль в том что они будут ждать до бесконечности, так и возникает эта самая взаимная блокировка.
Заключение
Мы рассмотрели лишь малую, но очень важную часть многопоточности в Java. Теперь, когда вы ознакомились с базовыми понятиями и это не отпугнуло вас, пришло время углубиться и познакомиться с другими, более продвинутыми механизмами синхронизации. Они включают в себя очереди, флаги и семафоры, которые позволяют координировать доступ и взаимодействие между потоками. Чтобы достичь максимальной производительности в многопоточных приложениях, недостаточно знать и использовать только ключевые слова. Важно также правильно управлять доступом к общим данным и предотвращать конфликты между потоками. Удачи!