JavaRush /Java блог /Java Developer /Многопоточность в Java: суть, «плюсы» и частые ловушки
Автор
Александр Мяделец
Руководитель команды разработчиков в CodeGym

Многопоточность в Java: суть, «плюсы» и частые ловушки

Статья из группы Java Developer
Привет! Прежде всего, поздравляю: ты дошел до темы Многопоточность в Java! Это серьезное достижение, позади — немалый путь. Но приготовься: это одна из самых трудных тем в курсе. И дело не в том, что здесь используются сложные классы или много методов: наоборот, и двух десятков не наберется. Скорее в том, что нужно немного изменить мышление. Раньше твои программы выполнялись последовательно. Одни строчки кода шли после других, одни методы — за другими, и в целом все было понятно. Сначала посчитать что-нибудь, потом вывести результат на консоль, потом завершить программу. Чтобы понять многопоточность, лучше мыслить с точки зрения параллельности. Начнем с чего-нибудь совсем простого :)Многопоточность в Java: суть, «плюсы» и частые ловушки - 1Представь, что твоя семья переезжает из одного дома в другой. Важная часть переезда — собрать книги. Книг у вас накопилось много, и надо сложить их в коробки. Сейчас свободен только ты. Мама готовит еду, брат собирает одежду, а сестра ушла в магазин. В одиночку ты худо-бедно справляешься, и, рано или поздно, даже сам выполнишь задание, но времени понадобится очень много. Впрочем, через 20 минут из магазина вернется твоя сестра, и других дел у нее нет. Так что она может присоединиться к тебе. Задача не менялась: сложить книги в коробки. Только выполняется в два раза быстрее. Почему? Потому что работа делается параллельно. Два разных «потока» (ты и твоя сестра) одновременно выполняют одну и ту же задачу и, если ничего не изменится, разница по времени будет очень большой по сравнению с ситуацией, в которой ты бы делал все один. Если брат скоро справится со своей задачей, он может помочь вам, и дело пойдет еще быстрее.

Проблемы, которые решает многопоточность в Java

По сути, многопоточность Java была придумана, чтобы решить две главные задачи:
  1. Одновременно выполнять несколько действий.

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

    Можно привести и более «программистский» пример. Представь, что у тебя есть программа с пользовательским интерфейсом. При нажатии кнопки «Продолжить» внутри программы должны произойти какие-то вычисления, а пользователь должен увидеть следующий экран интерфейса. Если эти действия осуществляются последовательно, после нажатия кнопки «Продолжить» программа просто зависнет. Пользователь будет видеть все тот же экран с кнопкой «Продолжить», пока все внутренние вычисления не будут выполнены, и программа не дойдет до части, где начнется отрисовка интерфейса.

    Что ж, подождем пару минут!

    Многопоточность в Java: суть, «плюсы» и частые ловушки - 3

    А еще мы можем переделать нашу программу, или, как говорят программисты, «распараллелить». Пусть нужные вычисления выполняются в одном потоке, а отрисовка интерфейса — в другом. У большинства компьютеров хватит на это ресурсов. В таком случае программа не будет «тупить», и пользователь будет спокойно переходить между экранами интерфейса не заботясь о том, что происходит внутри. Одно другому не мешает :)

  2. Ускорить вычисления.

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

Но, как ты уже читал в лекции, современные системы очень умны, и даже на одном вычислительном ядре они способны реализовать параллельность, или псевдопараллельность, когда задачи выполняются попеременно. Давай перейдем от общих вещей к конкретным и познакомимся с главным классом в библиотеке Java, относящимся к многопоточности, — java.lang.Thread. Собственно говоря, потоки в Java представляются экземплярами класса Thread. То есть чтобы создать и запустить выполнение 10 потоков, понадобится 10 объектов этого класса. Напишем самый простой пример:

public class MyFirstThread extends Thread {

   @Override
   public void run() {
       System.out.println("I'm Thread! My name is " + getName());
   }
}
Чтобы формировать и запускать потоки, нам нужно создать класс, унаследовать его от класса java.lang.Thread и переопределить в нем метод run(). Последнее — очень важно. Именно в методе run() мы прописываем ту логику, которую наш поток должен выполнить. Теперь, если мы создадим экземпляр MyFirstThread и запустим его, метод run() выведет в консоль строку с его именем: метод getName() выводит «системное» имя потока, которое присваивается автоматически. Хотя, собственно, почему «если»? Давай создадим и проверим!

public class Main {

   public static void main(String[] args) {

       for (int i = 0; i < 10; i++) {

           MyFirstThread thread = new MyFirstThread();
           thread.start();
       }
   }
}
Вывод в консоль: I'm Thread! My name is Thread-2 I'm Thread! My name is Thread-1 I'm Thread! My name is Thread-0 I'm Thread! My name is Thread-3 I'm Thread! My name is Thread-6 I'm Thread! My name is Thread-7 I'm Thread! My name is Thread-4 I'm Thread! My name is Thread-5 I'm Thread! My name is Thread-9 I'm Thread! My name is Thread-8 Создаем 10 потоков (объектов) MyFirstThread, который наследуется от Thread и запускаем их, вызывая у объекта метод start(). После вызова метода start() начинает работу его метод run(), и выполняется та логика, которая была в нем написана. Обрати внимание: имена потоков идут не по порядку. Это довольно странно, почему они не выполнялись по очереди: Thread-0, Thread-1, Thread-2 и так далее? Это как раз пример того, когда стандартное, «последовательное» мышление не подойдет. Дело в том, что мы в данном случае только отдаем команды на создание и запуск 10 потоков. В каком порядке их запускать — решает планировщик потоков: особый механизм внутри операционной системы. Как именно он устроен и по какому принципу принимает решения — тема очень сложная, и сейчас не будем в нее погружаться. Главное запомни, что последовательность выполнения потоков программист контролировать не может. Чтобы осознать серьезность ситуации, попробуй запустить метод main() из примера выше еще пару раз. Второй вывод в консоль: I'm Thread! My name is Thread-0 I'm Thread! My name is Thread-4 I'm Thread! My name is Thread-3 I'm Thread! My name is Thread-2 I'm Thread! My name is Thread-1 I'm Thread! My name is Thread-5 I'm Thread! My name is Thread-6 I'm Thread! My name is Thread-8 I'm Thread! My name is Thread-9 I'm Thread! My name is Thread-7 Третий вывод в консоль: I'm Thread! My name is Thread-0 I'm Thread! My name is Thread-3 I'm Thread! My name is Thread-1 I'm Thread! My name is Thread-2 I'm Thread! My name is Thread-6 I'm Thread! My name is Thread-4 I'm Thread! My name is Thread-9 I'm Thread! My name is Thread-5 I'm Thread! My name is Thread-7 I'm Thread! My name is Thread-8

Проблемы, которые создает многопоточность

На примере с книгами ты увидел, что многопоточность решает достаточно важные задачи, и ее использование ускоряет работу наших программ. Во многих случаях — в разы. Но многопоточность недаром считается сложной темой. Ведь при неправильном использовании она создает проблемы вместо того, чтобы решать их. Говоря «создавать проблемы» я не имею в виду что-то абстрактное. Есть две конкретные проблемы, которые может вызвать использование многопоточности — взаимная блокировка (deadlock) и состояние гонки (race condition). Deadlock — ситуация, при которой несколько потоков находятся в состоянии ожидания ресурсов, занятых друг другом, и ни один из них не может продолжать выполнение. Мы еще поговорим о нем в следующих лекциях, пока достаточно этого примера: Многопоточность в Java: суть, «плюсы» и частые ловушки - 4 Представь, что поток-1 работает с каким-то Объектом-1, а поток-2 работает с Объектом-2. При этом программа написана так:
  1. Поток-1 перестанет работать с Объектом-1 и переключится на Объект-2, как только Поток-2 перестанет работать с Объектом 2 и переключится на Объект-1.
  2. Поток-2 перестанет работать с Объектом-2 и переключится на Объект-1, как только Поток-1 перестанет работать с Объектом 1 и переключится на Объект-2.
Даже не обладая глубокими знаниями в многопоточности ты легко поймешь, что ничего из этого не получится. Потоки никогда не поменяются местами и будут ждать друг друга вечно. Ошибка кажется очевидной, но на самом деле это не так. Допустить ее в программе можно запросто. Мы рассмотрим примеры кода, вызывающего deadlock, в следующих лекциях. Кстати, на Quora есть отличный пример из реальной жизни, объясняющий что такое deadlock. «В некоторых штатах Индии вам не продадут землю сельскохозяйственного назначения, если вы не зарегистрированы как фермер. При этом вас не зарегистрируют в качестве фермера, если вы не владеете сельскохозяйственными землями». Здорово, что тут сказать! :) Теперь про race condition — состояние гонки. Многопоточность в Java: суть, «плюсы» и частые ловушки - 5Состояние гонки — ошибка проектирования многопоточной системы или приложения, при которой работа системы или приложения зависит от того, в каком порядке выполняются части кода. Вспомни пример с запуском потоков:

public class MyFirstThread extends Thread {

   @Override
   public void run() {
       System.out.println("Выполнен поток " + getName());
   }
}

public class Main {

   public static void main(String[] args) {

       for (int i = 0; i < 10; i++) {

           MyFirstThread thread = new MyFirstThread();
           thread.start();
       }
   }
}
A теперь представь, что программа отвечает за работу робота, который готовит еду! Поток-0 достает яйца из холодильника. Поток-1 включает плиту. Поток-2 достает сковородку и ставит на плиту. Поток-3 зажигает огонь на плите. Поток-4 выливает на сковороду масла. Поток-5 разбивает яйца и выливает их на сковороду. Поток-6 выбрасывает скорлупу в мусорное ведро. Поток-7 снимает готовую яичницу с огня. Поток-8 выкладывает яичницу в тарелку. Поток-9 моет посуду. Посмотри на результаты работы нашей программы: Выполнен поток Thread-0 Выполнен поток Thread-2 Выполнен поток Thread-1 Выполнен поток Thread-4 Выполнен поток Thread-9 Выполнен поток Thread-5 Выполнен поток Thread-8 Выполнен поток Thread-7 Выполнен поток Thread-3 Выполнен поток Thread-6 Веселый получился сценарий? :) А все потому, что работа нашей программы зависит от порядка выполнения потоков. При малейшем нарушении последовательности наша кухня превращается в ад, а сошедший с ума робот крушит все вокруг себя. Это тоже распространенная проблема в многопоточном программировании, о которой ты еще не раз услышишь. В завершение лекции, хочу посоветовать тебе книгу, посвященную многопоточности.
Многопоточность в Java: суть, «плюсы» и частые ловушки - 6
«Java Concurrency in Practice» написана еще в 2006 году, но не утратила актуальность. Она посвящена многопоточному программированию на Java, начиная от основ и заканчивая списком самых распространенных ошибок и антипаттернов. Если когда-нибудь решишь стать гуру многопоточного программирования, эта книга обязательна к прочтению. Увидимся на следующих лекциях! :)
Комментарии (181)
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ
Light_Day :) Уровень 26
7 июня 2024
спасибо за статью!
Andrew Karev Уровень 51
28 апреля 2024
Типичный пример deadlock: вас не возьмут на работу, пока у вас не появится опыт работы. У вас не появится опыт работы, пока вас не возьмут на работу. 🙃
Nik Уровень 41
2 марта 2024
Java Concurrency in Practice для тех кто знает английский, в переводе ее читать невозможно, везде эту книгу советуют, но никто ее сам не читал) Вообще надо переделывать перевод и покупать ее не стоит точно
Fed0d Уровень 28
13 февраля 2024
А ещё есть ливлоки, что значительно хуже дедлоков; голодания
Максим Li Уровень 40
19 ноября 2023
Ок!
Arturka_tx Уровень 30
9 октября 2023
Deadblock: Ты не можешь устроиться на работу, так как на работу берут только с опытом; Ты не можешь получить опыт работы, из-за того что не работаешь;
2 августа 2023
но если добавить небольшую задержку, то потоки упорядочатся ------------------------------------------------------------------------------------------------------------------- for (int i = 0; i < 10; i++) { MyFirstThread thread = new MyFirstThread(); thread.start(); Thread.sleep(1); } -------------------------------------------------------------------------------------------------------------------- I'm Thread! My name is Thread-0 I'm Thread! My name is Thread-1 I'm Thread! My name is Thread-2 I'm Thread! My name is Thread-3 I'm Thread! My name is Thread-4 I'm Thread! My name is Thread-5 I'm Thread! My name is Thread-6 I'm Thread! My name is Thread-7 I'm Thread! My name is Thread-8 I'm Thread! My name is Thread-9
No Name Уровень 32
30 июня 2023
+ статья в копилке
Ислам Уровень 33
22 июня 2023
Nice
Олег Уровень 111 Expert
11 мая 2023
Может кто захочет пожарить яичницу :)

public class MyFirstThread extends Thread {
    @Override
    public void run() {
        switch (getName()) {
            case "Thread-0" -> System.out.println("Поток-1 достает яйца из холодильника");
            case "Thread-1" -> System.out.println("Поток-2 включает плиту");
            case "Thread-2" -> System.out.println("Поток-3 достает сковородку и ставит на плиту.");
            case "Thread-3" -> System.out.println("Поток-4 зажигает огонь на плите");
            case "Thread-4" -> System.out.println("Поток-5 выливает на сковороду масла");
            case "Thread-5" -> System.out.println("Поток-6 разбивает яйца и выливает их на сковороду");
            case "Thread-6" -> System.out.println("Поток-7 выбрасывает скорлупу в мусорное ведро");
            case "Thread-7" -> System.out.println("Поток-8 снимает готовую яичницу с огня");
            case "Thread-8" -> System.out.println("Поток-9 выкладывает яичницу в тарелку");
            case "Thread-9" -> System.out.println("Поток-10 моет посуду");
        }
        //System.out.println("I'm Thread! My name is " + getName());
    }

    public static void main(String[] args) {
        for (int i = 1; i < 11; i++) {
            MyFirstThread thread = new MyFirstThread();
            thread.start();
        }
    }
}