На старте своей карьеры вы вполне можете обойтись без практических навыков параллельного программирования, но рано или поздно перед вами встанет задача, которая потребует от вас таких навыков.
Итак, в этой статье мы поговорим о многопоточности в Java. Тема очень обширна, и я не собираюсь описывать все ее аспекты. Статья предназначена для людей, которые только начинают знакомиться с многопоточностью. В этой статье мы рассмотрим основы многопоточности в Java, основные механизмы синхронизации, такие как ключевые слова volatile и synchronized, а также все важные вопросы условий гонки и блокировки.
Я использовал несколько иной подход, связывая технические примеры с нашей повседневной жизнью, надеюсь, вам понравится. Тема будет раскрыта на примере абстрактной комнаты и людей в ней.
Чтобы максимально упростить материал, я намеренно опущу некоторые нюансы реализации и иерархии многопоточности в Java, усложняющие понимание предмета. Если вы ищете подробный обзор с техническими терминами и языком, эта статья не для вас.
Contents
Содержание
Что такое процессы и потоки
Прежде чем перейти к многопоточности, давайте разберемся, что такое процессы и потоки.
Процесс — это экземпляр работающей программы, проще говоря, когда вы запускаете программу на своем компьютере, вы порождаете процесс. Он имеет собственное адресное пространство памяти и один или несколько потоков.
Поток представляет собой последовательность инструкций, выполняемых в процессе. Потоки совместно используют адресное пространство памяти процесса, что позволяет им работать параллельно.
Создание потоков и управление ими
Каждый раз, когда вы запускаете свою программу, т.е. порождаете процесс, 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("Этот код выполняется в главном потоке");
}
}
Чтобы объяснить более простым языком, что такое потоки и как они взаимодействуют, давайте абстрагируем код и представим, что ваша программа (процесс) — это комната, а потоки — это люди в этой комнате. В комнате есть различные элементы (объекты), с которыми люди могут взаимодействовать. Когда вы пускаете в комнату нескольких человек (создаете новые темы), они получают доступ к одним и тем же предметам.
Однако, когда несколько человек пытаются одновременно взаимодействовать с одним и тем же элементом, могут возникать конфликты, такие как условия гонки и тупиковые ситуации.
Состояние гонки
Состояние гонки (Состояние гонки) — ситуация, при которой два или более потока одновременно обращаются к одним и тем же данным или ресурсам, и результаты их операций зависят от порядка, в котором выполняются операции. Это может привести к непредсказуемым и нежелательным результатам, таким как неправильные значения или ошибки программы. Из-за состояния гонки данные или ресурсы могут быть повреждены или использованы не по назначению.
Рассмотрим «состояние гонки» в нашем абстрактном примере. Представьте, что в комнате стоит стол, а на нем большой стакан с водой. В одной комнате два человека, говорят Саша и Петя. И вот Саша решил сделать глоток воды из этого стакана, чуть позже еще глоток, потом еще. В реальной комнате это выглядело бы примерно так, каждый раз, когда Саша подходил к этому стакану, брал его, делал глоток и ставил, он возвращался на место. Но компьютер не часть, в нем есть всякие механизмы оптимизации. Например, вместо того, чтобы заставлять Сашу приходить и уходить каждый раз, когда он хочет сделать глоток воды, он создаст копию стакана для Саши, когда тот впервые придет за стаканом.
Так вот, у Саши уже есть свой стакан (копия стакана, который остался на столе) и ему не нужно больше каждый раз ходить за стаканом, он просто садится на свое место и делает три глотка воды. Но, несмотря на то, что у Саши есть копия стакана, а оригинал остается на столе, он знает, что должен вернуть его на место и заменить оригинал своей копией, этот процесс называется синхронизацией и нужен для поддержания текущее состояние уровня воды в стакане (значение переменной).
Пока Саша пил из своего экземпляра стакана, Петя тоже захотел сделать глоток воды, для чего подошел к столику, где еще стоит полный стакан воды, потому что Петя еще не вернул свой стакан (синхр. еще не было) и, соответственно, получил свою копию полного стакана, затем вернулся на свое место и сделал два глотка. Пока Петя пил из своей копии стакана, Саша уже допил и взял свой стакан за него и соответственно заменил оригинальный стакан на этот (синхронизация сделана), соответственно количество воды в стакане на столе уменьшилось на 3 глотками. Через некоторое время Петя тоже допил и взял себе его стакан и, соответственно, заменил стакан, лежавший на столе, на него. В результате объем воды в стакане на столе равен полному стакану минус два глотка Пети, а глотки Саши потрачены впустую. Таково состояние гонки, и ее результаты могут быть абсолютно непредсказуемыми.
Для борьбы с этим явлением Java предлагает различные механизмы, самый простой из которых — использование ключевого слова volatile.
Ключевое слово изменчивый используется для обозначения переменных, которые могут быть изменены несколькими потоками. Это гарантирует, что изменения, внесенные в переменную, будут видны другим потокам.
Проще говоря, если вернуться к примеру с Сашей и Петей, то всякий раз, когда кто-то из них захочет сделать глоток воды, он подойдет к стакану, чтобы взять его, и сразу же после глотка поставит обратно, но в данном случае нет нет оптимизации, но зато объем воды в стакане всегда актуален и это позволяет избежать состояния гонки.
Ниже приведен пример кода, с помощью которого вы можете наблюдать за результатами «состояния гонки». При каждом запуске этого кода результаты будут непредсказуемыми, хотя ожидалось увидеть число 2000. Проблема легко решается добавлением ключевого слова volatile в объявление переменной частный изменчивый весь объем = 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());
}
}
Тупик
Тупик (Взаимоблокировка) — это ситуация, когда два или более потока ждут друг друга в цикле, чтобы освободить ресурсы или завершить какую-либо операцию. Следовательно, ни один поток не может продолжать свое выполнение, так как каждый из них блокирует ресурсы, необходимые для завершения работы другого потока. Это приводит к остановке программы и невозможности продолжения работы до тех пор, пока ситуация взаимоблокировки не будет разрешена вручную.
Вернемся в ту же комнату, где нас ждут Саша и Петя, и представим, что в одной комнате стоят два чемодана, в первом с различными инструментами вроде плоскогубцев, молотка, всяких гаечных ключей и т. д., а в во-вторых у нас крепеж, ну типа гаек, шурупов, гвоздей, шурупов и т.д. Саша решил что-то починить в спальне и ему понадобились эти два чемодана. И при этом он хочет быть уверенным, что никто ничего из него не возьмет, пока он работает, для этого он запирает нужные ему чемоданы на ключи. Поэтому каждый раз, когда он что-то берет или кладет в чемодан, он открывает его и запирает.
Этот метод в Java называется закупорка, это как ключ, который в любой момент времени может держать в руках только один человек. Когда кто-то владеет ключом (блокируя доступ), другие люди должны ждать своей очереди. Блокировка гарантирует, что критический раздел кода будет выполняться только одним потоком за раз. Одним из таких ключей в языке Java является ключевое слово synchronized.
Ключевое слово синхронизированный обеспечивает атомарность операций и позволяет избежать конфликтов между потоками. Это слово может применяться как ко всему методу, так и к отдельному участку кода.
class MyClass {
int counter;
public synchronized void doSomething() {
counter++;
}
}
class MyClass {
int counter;
public void doSomething() {
synchronized(this) {
counter++;
}
}
}
Мы рассмотрели концепцию синхронизации и можем продолжить рассмотрение того, что такое «взаимная блокировка».
Возвращаясь к Саше, он сначала взял ключ от ящика с инструментами и запер его, но заодно Петя тоже решил сделать ремонт и тоже решил запереть чемодан, но ему пришлось сначала запереть чемодан стяжками. Потом Пете нужен чемодан с инструментами, он подошел к этому чемодану и видит, что он уже заперт Сашей, “ну, если такая вещь, я подожду, пока Саша с ней закончит”, – подумал Петя. При этом Саше нужен чемодан с застежками, но он также видит, что чемодан уже заперт Петей, в итоге Саша тоже решил подождать. Мораль в том, что ждать будут вечно, и происходит эта самая взаимная блокировка.
Заключение
Мы рассмотрели только небольшую, но очень важную часть многопоточности в Java. Теперь, когда вы изучили основы и вас это не оттолкнуло, пришло время погрузиться и ознакомиться с другими, более продвинутыми механизмами синхронизации. Они включают в себя очереди, флаги и семафоры, которые позволяют координировать доступ и взаимодействие между потоками. Для достижения максимальной производительности в многопоточных приложениях недостаточно знать и использовать только ключевые слова. Также важно правильно управлять доступом к общим данным и избегать конфликтов между потоками. Удачи!