☕🧵 Введение в многопоточность в Java. Часть 1. Преимущества и недостатки многопоточности

В чем заключается «магия» многопоточности? Как создать поток и чем он отличается от процесса? Как процессор обрабатывает потоки?

Жизненный цикл потоков, Thread.join() и потоки-демоны

Независимо от того, какой язык вы используете для написания своих программ, по умолчанию все они являются последовательными. То есть все инструкции, которые мы пишем, последовательно выполняются операционной системой. Следующая строка кода не может начать свое выполнение до завершения текущей и ждет, пока текущая строка не завершит свое выполнение. Если в программе есть вызов API к удаленному серверу, то программа блокируется до тех пор, пока не получит ответ к запросу. Если выполнение вызова занимает несколько минут, то нам придётся также ждать ответа программы. Программа в таком случае просто блокируется и не будет отвечать на дальнейшие команды.

Давайте рассмотрим другие распространенные сценарии. Например, вы используете какую-то социальную сеть, где скачиваете видео или изображения и одновременно общаетесь там со своими друзьями. Вы выполняете две разные задачи одновременно, и ваша программа не блокируется, пока она не завершит одну из своих задач. Как? Здесь в игру вступает многопоточность.

Существует множество определений многопоточности:

  • Многопоточность — это способность процессора независимо выполнять процессы или потоки. Все программы выполняются потоками. Поток — это легковесный подпроцесс, наименьшая единица обработки.
  • Многопоточность — это процесс одновременного выполнения нескольких потоков.

Преимущества многопоточности

  • с использованием многопоточности можно разрабатывать более адаптивные приложения: мы можем выполнять несколько операций одновременно, например, скачивание некоторых ресурсов и общение в чате одновременно;
  • мы можем добиться лучшего использования ресурсов: по умолчанию программа Java является однопоточной. Может быть несколько ядер процессора, которые можно использовать, применяя многопоточность;
  • общая производительность может быть увеличена в несколько раз.

Недостатки многопоточности

  • потоки манипулируют данными, расположенными в одной и той же памяти, принадлежат одному и тому же процессу, и необходимо обеспечивать синхронизацию и согласованность данных между потоками;
  • довольно сложно проектировать многопоточные приложения и трудно отлаживать в случае ошибок;
  • когда потоков много, процессору приходится переключаться между потоками. Этот процесс называется переключением контекста. Переключение между потоками — дорогостоящая операция, поскольку процессор должен сохранять локальные данные одного потока и загружать локальные данные другого потока. В конечном счете общая производительность пострадает, а не улучшится, если будет слишком много потоков.
☕ Подтянуть свои знания по Java вы можете на нашем телеграм-канале «Библиотека Java для собеса»

Многопоточность в Java

Java предоставляет базовый класс для создания потоков — класс Thread. Существует два способа создания потоков: либо наследование класса Thread и переопределение метода run(), либо реализация интерфейса Runnable и передача его реализации классу Thread в качестве аргумента конструктора. Давайте рассмотрим пример с использованием обоих способов.

Создание потоков путем наследования класса Thread

public class App {

    public static void main(String[] args) {
        var ferrari = new Car("Ferrari");
        var bmw = new Car("BMW");
        ferrari.start();
        bmw.start();
        System.out.println("Method continues execution... Main method is executed by thread " + Thread.currentThread().getName());
    }

}

class Car extends Thread {

    private final String model;

    public Car(String model) {
        this.model = model;
    }

    @Override
    public void run() {
        try {
            Thread.sleep(1000);
        } catch (InterruptedException exception) {
            exception.printStackTrace();
        }
        System.out.println("Car " + model + " is being driven by thread " + Thread.currentThread().getName());
    }

}

В приведенном выше фрагменте кода мы создаем класс Car, который наследует класс Thread и переопределяет его метод run(). Внутри метода run() мы просто выводим модель автомобиля и имя выполняемого потока.

Thread.sleep(1000) — останавливает этот поток на заданный период времени (в миллисекундах). В main-методе мы создаем два экземпляра (ferrari, bmw) класса Car и вызываем метод start() для каждого из них. Затем выводим какое-нибудь сообщение. По умолчанию всякий раз, когда запускается любая Java-программа, она выполняется основным потоком. Запуск этой программы дает следующий вывод.

Вывод программы

Как видим из вывода, вывод сообщения, которое написали в методе main — последняя команда в программе, но она выводится в консоль первой и не ждёт выполнения вызовов методов — ferrari.start() и bmw.start(). Это и есть магия многопоточности. Сколько бы времени ни потребовалось для выполнения методов — ferrari.start() и bmw.start(), поток main дальше выполняется и не ждёт их завершения.

Создание потоков путем реализации интерфейса Runnable

public class App {

    public static void main(String[] args) {
        var ferrari = new Car("Ferrari");
        var bmw = new Car("BMW");
        var ferrariThread = new Thread(ferrari, "Ferrari-Thread");
        var bmwThread = new Thread(bmw, "BMW-Thread");
        ferrariThread.start();
        bmwThread.start();
        System.out.println("Method continues execution... Main method is executed by thread " + Thread.currentThread().getName());
    }

}

class Car implements Runnable {

    private final String model;

    public Car(String model) {
        this.model = model;
    }

    @Override
    public void run() {
        try {
            Thread.sleep(1000);
        } catch (InterruptedException exception) {
            exception.printStackTrace();
        }
        System.out.println("Car " + model + " is being driven by thread " + Thread.currentThread().getName());
    }

}

Приведенный выше фрагмент кода выполняет ту же логику, которую мы обсуждали выше, однако он немного отличается от предыдущего фрагмента кода. В этом случае мы реализовываем интерфейс Runnable и переопределяем его метод run() и передаём их конструктору класса Thread. Один из конструкторов класса Thread принимает интерфейс Runnable в качестве одного из своих аргументов. Вывод программы аналогичен

Вывод программы

Принимая во внимание два способа создания потоков, второй считается более предпочтительным, поскольку множественное наследование запрещено в Java, и, унаследовав класс Thread, мы не сможем унаследовать какой-либо другой класс. Однако, реализовав интерфейс Runnable, мы сможем унаследовать другой класс. Это небольшое преимущество второго способа.

🧩☕ Интересные задачи по Java для практики можно найти на нашем телеграм-канале «Библиотека задач по Java»

Что произойдет, если мы вызовем метод run() класса Thread?

public class App {

    public static void main(String[] args) {
        var ferrari = new Car("Ferrari");
        var bmw = new Car("BMW");
        ferrari.run();
        bmw.run();
        System.out.println("Method continues execution... Main method is executed by thread - " + Thread.currentThread().getName());
    }

}

class Car extends Thread {

    private final String model;

    public Car(String model) {
        this.model = model;
    }

    @Override
    public void run() {
        try {
            Thread.sleep(1000);
        } catch (InterruptedException exception) {
            exception.printStackTrace();
        }
        System.out.println("Car " + model + " is being driven by thread " + Thread.currentThread().getName());
    }

}

Давайте рассмотрим приведенный выше фрагмент кода. В этом примере аналогичным образом мы создали класс 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. В следующих постах мы рассмотрим жизненный цикл потоков, разницу между процессом и потоком, разницу между многопоточностью и параллелизмом и т. д. Продолжение следует.

***

Материалы по теме

ЛУЧШИЕ СТАТЬИ ПО ТЕМЕ

admin
16 апреля 2019

ТОП-6 алгоритмов сортировки на Java для новичков

Изучение алгоритмов сортировки на языке Java поможет не изобретать велосипе...
admin
11 января 2019

ТОП-10 лучших книг по Java для программистов

Не имеет значения, хотите вы улучшить скилл или только собираетесь начать и...
admin
05 апреля 2017

6 книг по Java для программистов любого уровня

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