Тема 31. Введение в многопоточность - BelyiZ/JavaCourses GitHub Wiki

Материал находится на стадии "Черновик"

Содержание:

  1. Многопоточность в Java
  2. Создание потоков
  3. Жизненный цикл потоков
  4. Список литературы/курсов

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

Многопоточность в Java — это одновременное выполнение двух или более потоков для максимального использования центрального процессора (CPU — central processing unit). Каждый поток работает параллельно и не требует отдельной области памяти. К тому же, переключение контекста между потоками занимает меньше времени.

Использование многопоточности:

  • Лучшее использование одного центрального процессора: если поток ожидает ответ на запрос, отправленный по сети, другой поток в это время может использовать CPU для выполнения других задач. Кроме того, если у компьютера несколько CPU или у CPU несколько исполнительных ядер, многопоточность позволяет приложению использовать эти дополнительные ядра.

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

  • Улучшенный user experience в плане скорости ответа на запрос: например, если нажать на кнопку в графическом интерфейсе, то это действие отправит запрос по сети: здесь важно, какой поток выполняет этот запрос. Если используется тот же поток, который обновляет/уведомляет графический интерфейс, тогда пользователь может столкнуться с зависанием интерфейса, ожидающего ответа на запрос. Но этот запрос может выполнить фоновый поток, чтобы поток в графическом интерфейсе мог в это время реагировать на другие запросы пользователя.

  • Улучшенный user experience в плане справедливости распределения ресурсов: многопоточность позволяет справедливо распределять ресурсы компьютера между пользователями. Представьте сервер, который принимает запросы от клиентов и у него есть только один поток для выполнения этих запросов. Если клиент отправляет запрос, для обработки которого нужно много времени, все остальные запросы вынуждены ждать до тех пор, пока он завершится. Когда каждый клиентский запрос выполняется собственным потоком, ни одна задача не сможет полностью захватить CPU.

Процессы в Java: определение и функции

  • Процесс состоит из кода и данных. Он создается операционной системой при запуске приложения, является достаточно ресурсоемким и обладает собственным виртуальным адресным пространством.
  • Процессы работают независимо друг от друга. Они не имеют прямого доступа к общим данным в других процессах.
  • Операционная система выделяет ресурсы для процесса — память и время на выполнение.
  • Если один из процессов заблокирован, то ни один другой процесс не может выполняться, пока он не будет разблокирован.
  • Для создания нового процесса обычно дублируется родительский процесс.
  • Процесс может контролировать дочерние процессы, но не процессы того же уровня.

Поток — наименьшее составляющее процесса. Потоки могут выполняться параллельно друг с другом. Их также часто называют легковесными процессами. Они используют адресное пространство процесса и делят его с другими потоками.

Потоки могут контролироваться друг друга и общаться посредством методов: wait(), notify(), notifyAll().

Преимущества потоков перед процессами

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

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

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

Создание потоков

Приложение, создающее экземпляр Thread, должно предоставить код, который будет выполняться в отдельном потоке. Есть два способа сделать это:

  1. Предоставить экземпляр класса, реализующего интерфейс java.lang.Runnable. Этот класс имеет один метод run(), который должен содержать код, который будет выполняться в отдельном потоке. Экземпляр класса java.lang.Runnable передается в конструктор класса Thread вот так:
public class HelloRunnable implements Runnable {
    public void run() {
        System.out.println("Hello from a thread!");
    } 
    public static void main(String args[]) {
        (new Thread(new HelloRunnable())).start();
    }

}
  1. Написать подкласс класса Thread. Класс Thread сам реализует интерфейс java.lang.Runnable, но его метод run() ничего не делает. Приложение может унаследовать класс от Thread и переопределить метод run():
public class HelloThread extends Thread {
    public void run() {
        System.out.println("Hello from a thread!");
    }
    public static void main(String args[]) {
        (new HelloThread()).start();
    }

}

Обратим внимание, что оба примера вызывают метод Thread.start() для запуска нового потока. Именно он запускает отдельный поток. Если просто вызывать метод run(), то код будет выполняться в том же потоке, отдельный поток создаваться не будет.

Как выбрать способ: Первый способ, где предоставляется экземпляр класса, реализующего Runnable, более общий, потому что этот объект может превратить отличный от Thread класс в подкласс. Этот способ более гибкий и может использоваться для высокоуровневых API управления потоками. Второй способ проще использовать в простых приложениях, но он ограничен тем, что класс будет наследником Thread.

Жизненный цикл потоков

При выполнении программы объект Thread может находиться в одном из четырех основных состояний: "новый", "работоспособный", "неработоспособный" и "пассивный". При создании потока он получает состояние "новый" (NEW) и не выполняется. Для перевода потока из состояния "новый" в "работоспособный" (RUNNABLE) следует выполнить метод start(), вызывающий метод run().

Поток может находиться в одном из состояний, соответствующих элементам статически вложенного перечисления Thread.State:

  1. New – когда создается экземпляр класса Thread, поток находится в состоянии new. Он пока еще не работает.
  2. Running — поток запущен и процессор начинает его выполнение. Во время выполнения состояние потока также может измениться на Runnable, Dead или Blocked.
  3. Suspended — запущенный поток приостанавливает свою работу, затем можно возобновить его выполнение. Поток начнет работать с того места, где его остановили.
  4. Blocked — поток ожидает высвобождения ресурсов или завершение операции ввода-вывода. Находясь в этом состоянии поток не потребляет процессорное время.
  5. Terminated — поток немедленно завершает свое выполнение. Его работу нельзя возобновить. Причинами завершения потока могут быть ситуации, когда код потока полностью выполнен или во время выполнения потока произошла ошибка (например, ошибка сегментации или необработанного исключения).
  6. Dead — после того, как поток завершил свое выполнение, его состояние меняется на dead, то есть он завершает свой жизненный цикл.

Завершение процесса и потоки-демоны В Java процесс завершается тогда, когда завершаются все его основные и дочерние потоки.

Потоки-демоны — это низкоприоритетные потоки, работающие в фоновом режиме для выполнения таких задач, как сбор "мусора": они освобождают память неиспользованных объектов и очищают кэш. Большинство потоков JVM (Java Virtual Machine) являются потоками-демонами.

Свойства потоков-демонов:

  • Не влияют на закрытие JVM, когда все пользовательские потоки завершили свое исполнение;
  • JVM сама закрывается, когда все пользовательские потоки перестают выполняться;
  • Если JVM обнаружит работающий поток-демон, она завершит его, после чего закроется. JVM не учитывает, работает поток или нет. Чтобы установить, является ли поток демоном, используется метод boolean isDaemon(). Если да, то он возвращает значение true, если нет, то — то значение false.

Завершение потоков

Завершение потока Java требует подготовки кода реализации потока. Класс Java Thread содержит метод stop(), но он помечен как deprecated. Оригинальный метод stop() не дает никаких гарантий относительно состояния, в котором поток остановили. То есть, все объекты Java, к которым у потока был доступ во время выполнения, останутся в неизвестном состоянии. Если другие потоки в приложении имели доступ к тем же объектам, то они могут неожиданно "сломаться".

Вместо вызова метода stop() нужно реализовать код потока, чтобы его остановить. Приведем пример класса с реализацией Runnable, который содержит дополнительный метод doStop(), посылающий Runnable сигнал остановиться. Runnable проверит его и остановит, когда будет готов.

public class MyRunnable implements Runnable {
    private boolean doStop = false;
    public synchronized void doStop() {
        this.doStop = true;
    }
private synchronized boolean keepRunning() {
        return this.doStop == false;
    }
    @Override
    public void run() {
        while(keepRunning()) {
            // keep doing what this thread should do.
            System.out.println("Running");
            try {
                Thread.sleep(3L * 1000L);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

Обратите внимание на методы doStop() и keepRunning(). Вызов doStop() происходит не из потока, выполняющего метод run() в MyRunnable.

Метод keepRunning() вызывается внутренней потоком, выполняющим метод run() MyRunnable. Поскольку метод doStop() не вызван, метод keepRunning() возвратит значение true, то есть поток, выполняющий метод run(), продолжит работать.

Например:

public class MyRunnableMain {
    public static void main(String[] args) {
        MyRunnable myRunnable = new MyRunnable();
        Thread thread = new Thread(myRunnable);
        thread.start();
        try {
            Thread.sleep(10L * 1000L);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        myRunnable.doStop();
    }
}

В примере сначала создается MyRunnable, а затем передается потоку и запускает его. Поток, выполняющий метод main() (главный поток), засыпает на 10 секунд и потом вызывает метод doStop() экземпляра класса MyRunnable. Впоследствии поток, выполняющий метод MyRunnable, остановится, потому что после того, как вызван doStop(), keepRunning() возвратит false.

Если для реализация Runnable нужен не только метод run() (а например, еще методstop()или pause()), реализацию Runnable больше нельзя будет создать с помощью лямбда-выражений. Понадобится кастомный класс или интерфейс, расширяющий Runnable, который содержит дополнительные методы и реализуется анонимным классом.

Метод Thread.sleep()

Поток может остановиться сам, вызвав статический метод Thread.sleep(). Thread.sleep() принимает в качестве параметра количество миллисекунд. Метод sleep() попытается заснуть на это количество времени перед возобновлениям выполнения. Thread sleep() не гарантирует абсолютной точности.

Приведем пример остановки потока Java на 10 секунд (10 тысяч миллисекунд) с помощью вызова метода Thread sleep():

try {
    Thread.sleep(10L * 1000L);
} catch (InterruptedException e) {
    e.printStackTrace();
}

Поток, выполняющий код, уснет примерно на 10 секунд.

Метод yield() Предотвратить выполнение потока можно методом yield(): предположим, существует три потока t1, t2, and t3. Поток t1 выполняется процессором, а потоки t2 и t3 находятся в состоянии Ready/Runnable. Время выполнения для потока t1 — 5 часов, а для t2 – 5 минут.

Поскольку t1 закончит свое выполнение через 5 часов, t2 придется ждать все это время, чтобы закончить 5-минутную задачу. В таких случаях, когда один поток требует слишком много времени, чтобы завершить свое выполнение, нужен способ приостановить выполнение длинного потока в промежутке, если какая-то важная задача не завершена. Тут и поможет yield ().

По сути, yield() означает, что поток не выполняет ничего особо важного, и если другие потоки или процессы требуют запуска, то их можно запустить.

Использование метода yield():

Когда поток вызывает метод java.lang.Thread.yield, он дает планировщику подсказку, что готов приостановить свое выполнение. Планировщик потока вправе это проигнорировать. Если какой-то поток выполняет метод yield(), планировщик потока проверяет, есть ли поток с таким же или высшим приоритетом. Если процессор найдет такой поток, то изменит состояние выполняющегося в данный момент потока на Ready/Runnable и отдаст процессор другому потоку. Если нет — поток продолжит выполняться.

Список литературы/курсов

  1. https://nuancesprog.ru/p/10254/
  2. https://tproger.ru/translations/java8-concurrency-tutorial-1/

Тема 30. Ссылки|Оглавление|Тема 32. Конкуренция