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

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

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

Оглавление

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

  2. Как запустить новый поток

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

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

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

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

  7. Daemon потоки

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

  9. Deadlock и Race condition

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

  11. Monitor. Mutex. Semaphore

  12. Concurrency

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

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

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

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

Как правило ОС не выделяет отдельный процессор под каждый процесс, а значит применяется процедура 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ьютекс занят – новый поток будет ждать, пока он не освободится.

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 блок самому.

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

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

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

Комментарии (5)


  1. Bakuard
    14.10.2022 08:22
    +7

    Несколько замечаний из тех, что сразу бросаются в глаза:

    "Программа завершается когда Main Thread выполнит метод main()" - неверно. Программа написанная на Java завершится, как только завершится последний её поток не являющийся демоном. Метод main() при этом может быть уже давным-давно завершен.

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


    1. zahaand Автор
      14.10.2022 12:02

      Спасибо! Дополнил формулировки:
      "Программа завершается, когда Main Thread выполнит метод main() и все дочерние не daemon треды выполнят свои методы run()"

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


    1. Kirillchug
      14.10.2022 14:19
      -1

      Так вроде бы при запуске java-процесс, метод main() начинает свою работу, а дальше уже от него начинают создаваться потоки, т.е. создаётся древовидная структура. Т.к. метод main() это корень, то он не может закончиться, пока его дочерние потоки не завершаться, иначе наступит момент неопределённость для дочерних потоков.
      Поэтому, если я правильно понял "... как только завершится последний её поток не являющийся демоном. Метод main() при этом может быть уже давным-давно завершен", то всё же, наверно, получается метод main(), скорее всего, НЕ может быть завершённым при присутствии каких-либо других потоков не демонов, потому что они его дочерние потоки. Он может завершить свою основную работу, но он тогда просто перейдёт в режим Pending и будет ожидать завершения своих дочерних потоков. Но он не будет же Terminated.
      Поэтому метод main() первый запустился и последний завершиться из-за древовидной структуры.


      1. Bakuard
        14.10.2022 15:10

        Потоки - это независимые друг от друга сущности. Не знаю, откуда Вы взяли информацию об иерархии (скорее всего путаете с другим языком).
        Код ниже выведет в консоль: main -> TERMINATED

        public class Main {
        
            public static void main(String[] args) throws Exception {
                Thread main = Thread.currentThread();
        
                Thread child = new Thread(() -> {
                    try {
                        Thread.sleep(2000);
                        System.out.println(main.getName() + " -> " + main.getState());
                    } catch(Exception e) {
                        e.printStackTrace();
                    }
                });
                child.start();
            }
        
        }

        И ещё момент - у Java потоков нет состояния Pending (в смысле - вообще нет).


      1. Lewigh
        14.10.2022 20:49
        +1

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

        Вы немного путаете. Когда вы запускаете программу на Java - вы запускаете JVM а уже JVM, в дальнейшем когда будет готова, запускает Вашу программу посредством вызова main(). Это просто главная точка входа для пользовательского приложения, не более.