Как стать автором
Обновить

Многопоточность Java. #неОпятьАСнова #javaJunior #javaCore

Время на прочтение11 мин
Количество просмотров47K

Эта статья, как и все последующие – моя попытка структурировать полученные знания в процессе изучения Java. Здесь тезисно собрана вся основная информация по теме и те формулировки, которые показались мне наиболее удачными и понятными. Это мой конспект, если хотите.

Статья будет полезна тем, кто изучает или повторяет основы Java Core.И тем, кто готовится к техническому интервью.

На источники, откуда черпалась информация, предоставлены ссылки в конце статьи.

Оглавление

  1. Основные понятия

  2. Интерфейс Runnable. Класс Thread

  3. Остановить поток

  4. Жизненный цикл потока

  5. Приоритет потоков

  6. Переключение потоков

  7. Daemon потоки

  8. Использование памяти

  9. Deadlock и Race condition

  10. Синхронизация потоков

  11. Monitor. Mutex. Semaphore

  12. Модификатор volatile

  13. Интерфейсы Callable и Future

  14. Класс CompletableFuture

  15. Concurrency. Неблокирующая синхронизация

  16. Атомарные классы

  17. Список ссылок

Основные понятия

Процессор компьютера с одним ядром может выполнять только одну команду одновременно.

Как правило ОС не выделяет отдельный процессор под каждый процесс, а значит применяется процедура time slicing (нарезка времени) – процессор постоянно переключается между потоками выполнения. Переключение происходит сотни раз в секунду (тактовая частота), и со стороны кажется, что все потоки работают одновременно – но это не так.

Пото́к выполне́ния (тред; от англ. thread — нить) — наименьшая единица обработки, исполнение которой может быть назначено ядром операционной системы. 

Несколько ядер могут решать список задач параллельно.
Тогда одновременно будет выполняться более одного потока.

В первом случае программа называются Однопоточной
Во втором, если потоков несколько – Многопоточной
(когда независимо друг от друга выполняются разные части кода)

Основу работы с потоками в Java составляют интерфейс Runnable и класс Thread.
С их помощью можно запускать и останавливать потоки выполнения, менять их свойства, среди которых основные: приоритет и daemon (фоновые процессы).

@FunctionalInterface
public interface Runnable
public class Thread extends Object implements Runnable

Изначально программа состоит из главного потока – Main Thread
Главный поток запускает метод main()
Дальше по ходу выполнения программы могут быть запущены дочерние треды.
Программа завершается, когда Main Thread выполнит метод main() и все дочерние не daemon треды выполнят свои методы run()

Функциональный интерфейс Runnable содержит единственный абстрактный метод run()в котором будет реализована логика выполнения нового потока.

Функциональность каждого отдельного потока содержит класс Thread

Чтобы запустить новый поток

Существует два способа создать и запустить новый тред:

  1. Реализовать интерфейс Runnable

  2. Унаследовать класс Thread

class MyThread implements Runnable {
    @Override
    public void run() {
    }
}
class MyThread extends Thread {
    @Override
    public void run() {
    }
}

Runnable – функциональный интерфейс, можно использовать лямбда выражение

Thread thread = new Thread(() -> {
    System.out.println("run method body");
});

thread.start();

Наследование класса Thread целесообразно применять когда нужно дополнить функциональность самого класса Thread.

Использование интерфейса Runnable – когда просто нужно одновременно выполнить несколько задач и не требуется вносить изменений в сам механизм многопоточности.

Для запуска новых потоков нужно вызывать метод start(), а не run()
Метод start()заставляет этот поток начать выполнение.
Виртуальная машина Java сама вызывает метод запуска run() этого потока.
Прямой вызов метода run() не имеет отношения к многопоточности – в этом случае программа будет выполнена в главном потоке Main Thread.

Thread thread = new Thread(new MyThread());

thread.start();

В каком порядке запускать новые потоки решает Планировщик потоков – часть JVM, которая решает какой поток должен выполнится в каждый конкретный момент времени и какой поток нужно приостановить.

Последовательность выполнения потоков контролировать нельзя.

Если поток был запущен и завершился – повторно запустить его не получится.

Остановить поток

Поток нельзя остановить – он может остановиться только сам.
Но можно явно указать, что потоку следует остановиться.

Main Thread завершается вместе с выходом из метода main()
Дочерний поток – завершая выполнение метода run()

Класс Thread содержит скрытое булево поле – флаг прерывания.
Установить флаг можно вызвав метод потока interrupt()
Это укажет, что поток следует прервать, но не прервет его тут же.

Проверить установлен ли флаг, можно двумя способами:
1. Вызвать метод isInterrupted() объекта потока
2. Вызвать статический метод Thread.interrupted()

class MyThread implements Runnable {
    @Override
    public void run() {
        Thread current = Thread.currentThread();
        while (!current.isInterrupted()) {
        }
    }
}

Метод interrupt() выставляет флаг прерывания на конкретном потоке, указывая, что ему следует остановиться. Ставит значение флага true.

Статический метод Thread.interrupted() возвращает значение флага прерывания для текущего потока. После проверки всегда присваивает значение флага false и запускает поток.

Метод isInterrupted() возвращает значение флага прерывания для того объекта, на котором вызван. Не запускает поток.

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

Существует четыре состояния жизненного цикла потока:

New
Поток находится в состоянии New, когда создается новый экземпляр объекта класса Thread, но метод start() не вызывался.

Runnable
Когда для созданного нового объекта Thread был вызван метод start().
Такой поток либо ожидает, что планировщик заберет его для выполнения, либо уже запущен.

Non-Runnable (Blocked , Timed-Waiting)
Когда поток временно неактивен, то есть объект класса Thread существует, но не выбран планировщиком для выполнения.

Terminated
Когда поток завершает выполнение своего метода run(), он переходит в состояние terminated (завершен). На этом этапе выполнение потока завершается.

Приоритет потоков

Для контроля важности и очерёдности работы разных потоков существует приоритет.
Приоритет является одним из ключевых факторов выбора системой потока для выполнения.

Он может иметь числовое значение от 1 до 10.
По умолчанию главному потоку выставляется средний приоритет – 5
Для этого в классе Thread объявлены три константы:

static final int MAX_PRIORITY = 10
static final int NORM_PRIORITY = 5
static final int MIN_PRIORITY = 1

Задать потоку приоритет можно с помощью метода setPriority(int)

Переключение потоков

Присоединиться к потоку join()Свое выполнение начнёт выбранный поток.
Выполнение текущего потока будет приостановлено.

try {
    thread1.join();
} catch (InterruptedException e) {
    e.printStackTrace();
}
thread2.start();
thread3.start();

Усыпить поток Thread.sleep()
Цель метода – усыпить поток на некоторое время.
Часто используется в дочерних тредах, когда нужно делать какое-то действие постоянно, но не слишком часто.
Поток в состоянии сна можно прервать.

Thread.sleep(2000); // пауза на 2 секунды

После того как поток просыпается, он переходит в состояние runnable.
Однако это не значит, что Планировщик потоков запустит сразу и именно его.

Пропуск хода Thread.yield()Аналог Thread.sleep(0) – работает фактически так же.

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

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

Deamon потоки. Фоновые процессы

Daemon потоки позволяют описывать фоновые процессы, которые нужны только для обслуживания основных потоков выполнения и не могут существовать без них.

Для работы с Daemon потоками у класса Thread существуют методы:
setDaemon()
isDaemon()

JVM прекращает работу, как только все не Daemon потоки завершаются.

Использование памяти

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

Для каждого потока создается свой собственный стек в памяти.
Туда помещаются все локальные переменные и ряд других данных, связанных с выполнением потока.

Возможные ошибки. Deadlock и Race condition

Использование многопоточности может привести к двум ситуациям:

Deadlock (взаимная блокировка) – несколько потоков находятся в состоянии ожидания ресурсов, занятых друг другом, и ни один из них не может продолжать выполнение.

Race Condition (состояние гонки) – ошибка проектирования многопоточной системы или приложения, при которой работа системы или приложения зависит от того, в каком порядке выполняются части кода.

Не все Race condition потенциально производят Deadlock, однако, Deadlock происходят только в Race condition.

Синхронизация потоков. Блокировка ресурсов

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

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

Для блокировки ресурса используетися ключевое слово synchronized
Синхронизированным может быть либо отдельный метод либо блок кода.

public class Test {
    public synchronized void test() {
    }
}

final поля класса инициализируются в его конструкторе – соответсвенно корректное значение final полей будет видно всем потокам без синхронизации.

static метод – в этом случае синхронизация будет осуществляться по классу где этот метод объявлен.

public static synchronized void test() {
}

Если у объекта один синхронизированный метод статический , а другой синхронизированный метод не статический – они могут одновременно выполняться т.к. монитор (блокировка) для первого – класс, а для второго – объект.

Недостатком использования synchronized является то, что другие потоки вынуждены ждать, пока нужный объект или метод освободится. Это создает bottle neck (узкое место) в программе, от чего скорость работы может пострадать.

Monitor. Mutex. Semaphore

Семафор — это средство синхронизации доступа к ресурсу.
Ограничивает количество потоков, которые могут войти в заданный участок кода
Использует счетчик потоков, который указывает, сколько потоков одновременно могут получать доступ к общему ресурсу.

Мьютекс — поле для синхронизации потоков. Есть у каждого объекта в Java.
Это простейший Семафор, который может находиться в одном из двух состояний: true или false.

Монитор — это дополнительная надстройка над Мьютексом.
Блокирует объект именно монитор

Когда один тред заходит внутрь synchronized блока кода, JVM тут же блокирует Mьютекс синхронизированного объекта.
Больше ни один тред не сможет зайти в этот блок, пока текущий тред его не покинет.
Как только первый поток выйдет из блока synchronized, Mьютекс автоматически разблокируется и будет свободен для захвата следующим потоком.
Когда Mьютекс занят – новый поток будет ждать, пока он не освободится.

Модификатор volatile

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

Также, в отличае от других примитивных типов данных, операции чтения и записи long и double не являются атомарными из-за их большого размера (64 бита).

Эти две проблемы решает модификатор volatile:

  1. Операции чтения и записи volatile переменной являются атомарными.

  2. Переменная не будет помещаться в кэш: результат записи значения в volatile переменную одним потоком будет виден всем другим потокам, которые используют эту переменную для чтения.

public volatile long x;

public volatile double y;

Интерфейс Callable

Чтобы получить результат работы потока в Java 5 был добавлен интерфейс Callable

@FunctionalInterface
public interface Callable<V>

Callable очень похож на интерфейс Runnable
В отличие от Runnable объявляет метод call(), который возвращает результат работы потока. Использует дженерики для указания типа возвращаемого объекта.
Кроме того метод call() бросает Exception.

public class MyCallableClass implements Callable {
    @Override
    public String call() throws Exception {
    }
}

Runnable изначально был разработан для длительного параллельного выполнения.
Callable предназначен для одноразовых задач, которые возвращают один результат.

Интерфейс Future хранит результат асинхронного вычисления.
Содержит следующие методы:

  • Метод get() ожидает завершения вычислений и затем возвращает результат.
    Устанавливает блокировку до тех пор, пока не будет готов результат.

  • Метод cancel() пытается отменить выполнение этой задачи.

  • Методы isDone() и isCancelled() определяют текущее состояние задачи.

Класс CompletableFuture

Класс CompletableFuture - новый класс из Java 8 для асинхронной работы.
Дает возможность комбинировать шаги обработки результата, соединяя их в цепочку.
Как и Future, использует дженерики для указания типа возвращаемого объекта.

public class CompletableFuture<T> extends Object implements Future<T>, CompletionStage<T>

Получить результат выполнения параллельного потока supplyAsync()

CompletableFuture<String> future = CompletableFuture
                .supplyAsync(() -> "Hello world!");

String result = future.get();

Провести операцию над полученным результатом thenApply()

CompletableFuture<String> future = CompletableFuture
                .supplyAsync(() -> "Hello")
                .thenApply(s -> s + " world!");

System.out.println(future.get());

Если значение возвращать не нужно, а нужно только провести промежуточную операцию над результатом – существуют методы thenRun() и thenAccept()
Они возвращают CompletableFuture<Void>

CompletableFuture.supplyAsync(() -> people.getPerson(id))
                .thenAccept(person -> System.out.println(person.getName()));

Concurrency. Неблокирующая синхронизация

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

Неблокирующая синхронизация позволяет полностью избавиться от взаимных блокировок. Разделение доступа между потоками идёт за счёт атомарных операций и разработанных под конкретную задачу механизмов блокировки.

Пакет java.util.concurrent включает в себя несколько небольших стандартизированных расширяемых фреймворков, а также некоторые классы, которые обеспечивают полезную функциональность и в остальном утомительны или сложны в реализации.

Классы и интерфейсы пакета java.util.concurrent объединены в несколько групп по функциональному признаку:

  • Collections – набор более эффективно работающих в многопоточной среде коллекций нежели стандартные универсальные коллекции из java.util пакета

  • Synchronizers – объекты синхронизации, позволяющие управлять и/или ограничивать работу нескольких потоков.

  • Atomic – набор атомарных классов, позволяющих использовать принцип действия механизма оптимистической блокировки для выполнения атомарных операций.

  • Queues – объекты создания блокирующих и неблокирующих очередей с поддержкой многопоточности.

  • Locks – механизмы синхронизации потоков, альтернативы базовым synchronized, wait, notify, notifyAll

  • Executors – механизмы создания пулов потоков и планирования работы асинхронных задач

Атомарные классы. Atomic

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

Блокировка подразумевает пессимистический подход, разрешая только одному потоку выполнять определенный код, связанный с изменением значения некоторой общей переменной.

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

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

Когда требуется примитивный тип, выполняющий операции инкремента и декремента, гораздо проще выбрать его среди атомарных классов в пакете java.util.concurrent.atomic, чем писать synchronized блок самому.

Внутри атомарные классы используют сравнение с обменом – атомарную инструкцию, которая работает гораздо быстрее, чем синхронизация с помощью блокировок. Поэтому, если просто нужно изменять одну переменную с помощью нескольких потоков, лучше выбирать атомарные классы.

Сравнение с обменом  – атомарная инструкция, сравнивающая значение в памяти с одним из аргументов, и в случае успеха записывающая второй аргумент в память.

Список ссылок на источники информации

Теги:
Хабы:
Всего голосов 23: ↑21 и ↓2+19
Комментарии10

Публикации

Истории

Работа

Java разработчик
350 вакансий

Ближайшие события