☕🧵 Введение в многопоточность в Java. Часть 1. Преимущества и недостатки многопоточности
В чем заключается «магия» многопоточности? Как создать поток и чем он отличается от процесса? Как процессор обрабатывает потоки?
Независимо от того, какой язык вы используете для написания своих программ, по умолчанию все они являются последовательными. То есть все инструкции, которые мы пишем, последовательно выполняются операционной системой. Следующая строка кода не может начать свое выполнение до завершения текущей и ждет, пока текущая строка не завершит свое выполнение. Если в программе есть вызов API к удаленному серверу, то программа блокируется до тех пор, пока не получит ответ к запросу. Если выполнение вызова занимает несколько минут, то нам придётся также ждать ответа программы. Программа в таком случае просто блокируется и не будет отвечать на дальнейшие команды.
Давайте рассмотрим другие распространенные сценарии. Например, вы используете какую-то социальную сеть, где скачиваете видео или изображения и одновременно общаетесь там со своими друзьями. Вы выполняете две разные задачи одновременно, и ваша программа не блокируется, пока она не завершит одну из своих задач. Как? Здесь в игру вступает многопоточность.
Существует множество определений многопоточности:
- Многопоточность — это способность процессора независимо выполнять процессы или потоки. Все программы выполняются потоками. Поток — это легковесный подпроцесс, наименьшая единица обработки.
- Многопоточность — это процесс одновременного выполнения нескольких потоков.
Преимущества многопоточности
- с использованием многопоточности можно разрабатывать более адаптивные приложения: мы можем выполнять несколько операций одновременно, например, скачивание некоторых ресурсов и общение в чате одновременно;
- мы можем добиться лучшего использования ресурсов: по умолчанию программа Java является однопоточной. Может быть несколько ядер процессора, которые можно использовать, применяя многопоточность;
- общая производительность может быть увеличена в несколько раз.
Недостатки многопоточности
- потоки манипулируют данными, расположенными в одной и той же памяти, принадлежат одному и тому же процессу, и необходимо обеспечивать синхронизацию и согласованность данных между потоками;
- довольно сложно проектировать многопоточные приложения и трудно отлаживать в случае ошибок;
- когда потоков много, процессору приходится переключаться между потоками. Этот процесс называется переключением контекста. Переключение между потоками — дорогостоящая операция, поскольку процессор должен сохранять локальные данные одного потока и загружать локальные данные другого потока. В конечном счете общая производительность пострадает, а не улучшится, если будет слишком много потоков.
Многопоточность в Java
Java предоставляет базовый класс для создания потоков — класс Thread
. Существует два способа создания потоков: либо наследование класса Thread
и переопределение метода run()
, либо реализация интерфейса Runnable
и передача его реализации классу Thread
в качестве аргумента конструктора. Давайте рассмотрим пример с использованием обоих способов.
Создание потоков путем наследования класса Thread
В приведенном выше фрагменте кода мы создаем класс Car
, который наследует класс Thread
и переопределяет его метод run().
Внутри метода run()
мы просто выводим модель автомобиля и имя выполняемого потока.
Thread.sleep(1000)
— останавливает этот поток на заданный период времени (в миллисекундах). В main-методе мы создаем два экземпляра (ferrari, bmw)
класса Car
и вызываем метод start()
для каждого из них. Затем выводим какое-нибудь сообщение. По умолчанию всякий раз, когда запускается любая Java-программа, она выполняется основным потоком. Запуск этой программы дает следующий вывод.
Как видим из вывода, вывод сообщения, которое написали в методе main — последняя команда в программе, но она выводится в консоль первой и не ждёт выполнения вызовов методов — ferrari.start()
и bmw.start()
. Это и есть магия многопоточности. Сколько бы времени ни потребовалось для выполнения методов — ferrari.start()
и bmw.start(),
поток main дальше выполняется и не ждёт их завершения.
Создание потоков путем реализации интерфейса Runnable
Приведенный выше фрагмент кода выполняет ту же логику, которую мы обсуждали выше, однако он немного отличается от предыдущего фрагмента кода. В этом случае мы реализовываем интерфейс Runnable
и переопределяем его метод run()
и передаём их конструктору класса Thread
. Один из конструкторов класса Thread
принимает интерфейс Runnable
в качестве одного из своих аргументов. Вывод программы аналогичен
Принимая во внимание два способа создания потоков, второй считается более предпочтительным, поскольку множественное наследование запрещено в Java
, и, унаследовав класс Thread
, мы не сможем унаследовать какой-либо другой класс. Однако, реализовав интерфейс Runnable
, мы сможем унаследовать другой класс. Это небольшое преимущество второго способа.
Что произойдет, если мы вызовем метод run() класса Thread?
Давайте рассмотрим приведенный выше фрагмент кода. В этом примере аналогичным образом мы создали класс Car
, который наследует класс Thread
, переопределили метод run()
, создали два экземпляра класса Car
(ferrari, bmw)
. Вместо вызова метода start()
мы вызвали метод run()
у экземпляров (ferrari, bmw)
. Запуск этой программы дает следующий результат.
При вызове метода run()
, программа выполняется последовательно, в том порядке, в котором мы написали. Вызов метода run()
не создает новый поток, он ведет себя как обычный метод в Java
.
Процессы и потоки
Термины «процесс» и «поток» повсеместно используются, когда речь идет о многопоточности. Давайте подробно рассмотрим эти две концепции.
Процесс — это исполняемая программа, или, другими словами, просто запущенная программа
- когда вы запускаете какое-нибудь приложение или веб-браузер, запускаются разные процессы;
- операционная система назначает каждому процессу отдельные регистры, программные счетчики, память кучи и стека;
- процессы полностью независимы и не имеют общей памяти или данных;
- переключение контекста и связь между процессами занимают больше времени, поскольку они тяжелые, создание новых процессов требует больше ресурсов по сравнению с потоками.
Поток — это легкий процесс
- это единица выполнения внутри данного процесса, один процесс может иметь один и более потоков;
- каждый поток с данным процессом разделяет память и ресурсы, поэтому программисты имеют дело с параллелизмом и синхронизацией;
- создание нового потока требует меньше ресурсов, чем новый процесс, коммуникация между потоками и переключением контекста быстрее по сравнению с переключением контекста и коммуникации процессов.
Многопоточность и алгоритм разделения времени
Представьте, что у вас есть устройство с одним ядром и запущенное приложение, которое требует k
потоков (k > 1
). В этом случае одному процессору приходится обрабатывать k
потоков. Как процессор обрабатывает k
– потоков? Вот где алгоритм time-slicing делает всю магию.
При наличии нескольких потоков время обработки одноядерного процессора распределяется между процессами и потоками. Все потоки планируются процессором случайным образом c помощью thread scheduler
, и каждый поток получает минимальное количество времени для выполнения. Как только выделенное время истекает, другой поток получает свою часть процессорного времени и начинает свою часть выполнения. Этот процесс выделения процессором времени потокам продолжается до тех пор, пока все потоки не завершат свое выполнение. Это называется алгоритмом квантования времени (time slicing algorithm). Под капотом потоки выполняются последовательно, однако они выполняются настолько быстро, что у нас возникает ощущение параллельного выполнения. Это называется simulated or fake concurrency.
.
Это было краткое введение в многопоточность в Java
. В следующих постах мы рассмотрим жизненный цикл потоков, разницу между процессом и потоком, разницу между многопоточностью и параллелизмом и т. д. Продолжение следует.