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

Многопоточность #

1. Что такое многопоточность? #

Возможность программы выполнять несколько блоков одновременно


Что такое поток? Поток (thread) - это независимая единица исполнения инструкций в рамках одной программы. Потоки позволяют выполнять несколько задач одновременно, используя возможности многоядерных процессоров

Основные характеристики потока:

  1. Поток выполняется в рамках одного процесса и использует его память
  2. У каждого потока есть своё выполнение
  3. Имеет доступ к общей памяти

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

  1. увеличения производительности на многоядерных процессорах
  2. улучшения отзывчивости программ (например, в GUI приложения можно выполнять долгие операции в фоновом потоке, чтобы интерфейс не зависал)
  3. асинхронной обработки (например, чтение/запись файлов, работа с сетью)


2. Чем процесс отличается от потока? #

Процесс - независимый экземпляр выполняемой программы со своими выделенными системой ресурсами Поток (Thread/нить) - способ выполнения процесса, определяющий последовательность исполнения кода в процессе. Поток всегда создаётся в контексте какого-либо процесса


3. Чем Thread отличается от Runnable? Когда использовать Thread, а когда Runnable? #

Thread - класс, представляющий собой надстройку над физическим потоком/нитью. Используется, когда нужно управлять непосредственно состоянием потока Runnable - функциональный интерфейс, реализации которого представляют собой абстракцию над задачей (код, набор инструкций), которая может быть выполнена внутри потока. Используется как задача для исполнения, может быть передана нескольким потокам, более гибок


4. Что такое монитор? Как реализован в Java? #

Монитор - механизм синхронизации в Java. Каждый объект, наследующийся от Object неявно имеет монитор. Когда какой-либо поток захватывает монитор (через блок synchronized), то остальные потоки не могут взаимодействовать с этим объектом пока монитор не будет освобождён захватившим потоком


5. Что такое синхронизация? Какие способы синхронизации существуют в Java? #

Синхронизация (потоков) - это механизм координации доступа нескольких потоков с целью предотвратить Race Condition и обеспечить согласованность памяти. Достигается через блокировки и области видимости

Системная синхронизация В Java оператор synchronized(obj) привязывает блок кода к определённому потоку, захватывая монитор объекта obj и исключая доступ к нему других потоков в этот момент. Если этот поток должен ожидать выполнения каких-то условий, то он должен вызвать у obj метод wait() - работа потока приостанавливается (он “засыпает”), он отпускает захваченный монитор. Другой поток может вызвать на этом же объекте метод notify(), предварительно захватив его монитор, в результате чего ждущий на obj поток «просыпается» и продолжает своё выполнение

ВАЖНО:

  • методы wait() и notify() не синхронизированы, поэтому их можно вызвать только внутри synchronized-блока. В противном случае в рантайме будет выброшено IllegalMonitorException: current thread is not owner
  • после вызова notify() спящий поток “просыпается” не ранее, чем поток, вызвавший notify() , выйдет из блока synchronized (т.е. только после того, как им будет отпущен монитор)
public class Counter {
    private int count;
    public synchronized void increment() { 
        count++;
    }
}

эквивалентно

public class Counter {
    private int count;
    public void increment() { 
		    synchronized (this) {
	        count++;
        }
    }
}

Особенности syncronized в зависимости от места объявления:

  • блок кода - необходимо явно указывать объект синхронизации в круглых скобках
  • нестатический метод - синхронизация на экземпляре (this)
  • статический метод - синхронизация на объекте класса (ClassName.class), т.е. синхронизация происходит на уровне класса и распространяется на все экземпляры

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

public static void main(String[] args) {
        SimpleThread simpleThread = new SimpleThread();
        simpleThread.start();
        try {
            simpleThread.join(); //До тех пор, пока этот поток не завершит свою работу - мы не пойдем дальше
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        System.out.println(Thread.currentThread().getName());
    }

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

Статья на хабре про виды синхронизаторов java.util.concurrent


6. Что такое FutureTask? #

Future - это интерфейс, отражающий результат асинхронного выполнения задачи, предоставляет методы для работы с асинхронными результатами. Используется в комбинации с ExecutorService или другими асинхронными API

FutureTask - это базовая реализация интерфейса Future. Позволяет выполнять задачу асинхронно, а затем получить её результат или обработать исключения. Если задача ещё не закончена, попытка получить результат приостановит (заблокирует) выполнение, пока задача не завершится Реализует интерфейс RunnableFuture, который объединяет функциональность интерфейсов Runnable и Future


7. Что такое CompletableFuture? #

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

Сравнение FutureTaskFuture и CompletableFuture

ОсобенностьFutureTaskCompletableFuture
Асинхронное выполнениеДаДа
Получение результатаget()Неблокирующие методы (thenApply)
Отмена задачиДаДа
Композиция задачНетДа (thenCombineallOfanyOf)
Функциональный стильНетДа
СложностьПростаяРасширенная функциональность
  1. Future - интерфейс для асинхронных операций, с базовыми методами (getcancel)
  2. FutureTask - комбинация Runnable и Future, используется для выполнения асинхронных задач вручную
  3. CompletableFuture - современный и мощный инструмент для асинхронного программирования с поддержкой композиции и функционального стиля


8. Что такое ExecutorService? #

ExecutorService - это интерфейс, определяющий сервис для работы с асинхронными задачами, т.е. контракт для сервиса, который управляет пулом асинхронных задач.


9. Как работать с коллекциями в многопоточном программировании? #

  • Превратить обычную коллекцию в синхронизированную, с помощью Collections.synchronized() List<String> list = Collections.synchronizedList(new ArrayList<>())
  • Использование Concurrent-коллекций
    • CopyOnWriteArrayList, CopyOnWriteArraySet
    • Неблокирующие хеш-таблицы (ConcurrentHashMap, ConcurrentSkipListMap, ConcurrentSkipListSet)
    • Неблокирующие очереди (ConcurrentLinkedQueue и ConcurrentLinkedDeque)

Почему мы должны использовать потокобезопасные коллекции? Когда несколько потоков одновременно обращаются к одной и той же коллекции, возможно возникновение состояния гонки (race condition), приводящих к следующим проблемам:

  1. искажение данных - одновременная модификация может нарушить целостность данных
  2. исключения - например, ConcurrentModificationException, когда один поток перебирает коллекцию, а другой изменяет её структуру
  3. непредсказуемое поведение - результат работы программы становится нестабильным

CopyOnWriteCollections. Что это? Как происходит запись? Это потокобезопасные коллекции (CopyOnWriteArrayListCopyOnWriteArraySet). Как происходит запись:

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

ConcurrentHashMap. Что это? Как происходит запись? Это потокобезопасная реализация хеш-таблицы. Как происходит запись:

  • хеширование ключа - сначала вычисляется хеш-код ключа, что бы определить, в какой бакет хеш-таблицы попадет этот элемент
  • блокировка бакета - блокируется только тот бакет, в который будет добавлен элемент (если бакет пустой, то применяется CAS-вставка без блокировки)
  • добавление элемента - элемент добавляется, блокировка с бакета снимается

Прим.: на самом деле блокируется не бакет (т.к. это вообще абстракция), а первый его элемент Прим.2: до Java 8 ConcurrentHashMap делил внутреннюю таблицу на сегменты (Segment[]). Каждый сегмент представлял собой небольшую хэш-таблицу с отдельной блокировкой (ReentrantLock). Когда поток записывал данные, блокировался только сегмент, содержащий нужный ключ. Это позволяло нескольким потокам одновременно работать с разными сегментами. Устаревший механизм сегментов усложнял архитектуру и ограничивал параллелизм, поэтому был упразднён.


10. SynchronizedCollections. Что это? За счет чего достигается потокобезопасность? #

Это специальные коллекции в Java, которые обеспечивают потокобезопасность за счет того, что все методы помечены synchronized


11. Что такое Deadlock #

Это ситуация, когда два или более потока блокируются навсегда, ожидая ресурсы, которые уже захвачены другими потоками (Thread A ожидает Thread B, а Thread B ожидает Thread A)


12. Что такое Livelock #

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


13. Что такое Race condition #

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


14. Atomic vs Volatile. Что это и когда использовать? #

Atomic классы (AtomicInteger, AtomicLong, и т.д.) предоставляют возможность совершать неатомарные операции с примитивами (инкремент, декремент, сравнение и замена) атомарно. Они используют механизмы Compare-And-Swap (CAS) для обеспечения безопасности операции над переменными в многопоточной среде без явной синхронизации. Могут повышать быстродействие за счёт отсутствия явной синхронизации и реализации CAS на уровне архитектуры процессора

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

Когда что использовать?

  • volatile - когда достаточно гарантировать только видимость изменений (например, для флагов завершения или состояния)
  • Atomic - когда требуется атомарность (инкремент, сравнение и установка значений)

Сравнение volatile и Atomic

ХарактеристикаvolatileAtomic
Гарантия видимостиДаДа
Атомарные операцииНетДа
ИспользованиеДля простого чтения/записиДля сложных операций (инкремент, CAS)
Пример подходящих случаевФлаги (boolean isRunning)Счётчики (AtomicInteger.incrementAndGet)
ПроизводительностьВыше, так как операции прощеЧуть ниже из-за механизмов синхронизации


15. Зачем нужны atomic #

Классы из пакета java.util.concurrent.atomic (например, AtomicInteger, AtomicLong, AtomicReference) обеспечивают безопасные операции над переменными в многопоточной среде без явной синхронизации.
Они полезны для:

  1. Неатомарных операций над примитивами (инкремент, декремент, сравнение и замена) с гарантией атомарности.
  2. Оптимизации производительности — вместо блокировок используется низкоуровневая атомарность через аппаратные команды процессора.


16. Зачем нужны volatile #

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

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

Ограничения volatile:

  1. Не обеспечивает атомарности операций (например, count++ всё равно небезопасно).
  2. Используется для простых флагов или переменных, где достаточно гарантии чтения/записи.


17. Какие бывают состояния у потока? #

Multithreading


18. Collections.synchronizedMap vs ConcurrentHashMap #

ХарактеристикаCollections.synchronizedMapConcurrentHashMap
СинхронизацияСинхронизация всего объекта через блокировкуСинхронизация на уровне отдельных сегментов
ПроизводительностьМедленнее в условиях конкурентности, так как блокировка глобальнаяБыстрее благодаря разделению на сегменты
ИтерацияТребуется вручную синхронизировать блок for или iteratorИтерация безопасна без дополнительной синхронизации
ПрименениеДля простых сценариев с малым количеством потоковДля высоконагруженных многопоточных систем


19. Какими способами можем запустить несколько потоков в Java приложении? Как запустить поток в Java? #

В Java существует несколько способов запустить поток:

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

public class MyThread extends Thread {
    @Override
    public void run() {
        System.out.println("Thread is running");
    }

    public static void main(String[] args) {
        MyThread thread = new MyThread();
        thread.start(); // Запуск потока
    }
}

Реализация интерфейса Runnable Создаётся класс, реализующий интерфейс Runnable. Затем объект этого класса передаётся в конструктор Thread

public class MyRunnable implements Runnable {
    @Override
    public void run() {
        System.out.println("Runnable is running");
    }

    public static void main(String[] args) {
        Thread thread = new Thread(new MyRunnable());
        thread.start(); // Запуск потока
    }
}

Использование анонимного класса или лямбда-выражения Часто используется для краткости, если код в потоке небольшой

public class Main {
    public static void main(String[] args) {
        Thread thread = new Thread(() -> System.out.println("Lambda thread is running"));
        thread.start(); // Запуск потока
    }
}

Использование интерфейса Callable Callable возвращает результат и может выбрасывать исключения. Для выполнения требуется ExecutorService

public class CallableExample {
    public static void main(String[] args) throws Exception {
        ExecutorService executor = Executors.newSingleThreadExecutor();
        Callable<String> task = () -> "Callable result";

        Future<String> future = executor.submit(task);
        System.out.println(future.get()); // Получение результата

        executor.shutdown();
    }
}


20. Что за интерфейсы Runnable и Callable? В чем между ними отличия? #

ХарактеристикаRunnableCallable
Методvoid run()V call()
Возвращаемое значениеОтсутствуетРезультат типа V
Обработка исключенийИсключения необходимо обрабатывать вручнуюМожет выбрасывать проверяемые исключения
ИспользованиеИспользуется с Thread или ExecutorИспользуется с Executor


21. Расскажи про пакет concurrent #

Пакет java.util.concurrent предоставляет набор инструментов для работы с многопоточностью. Основные компоненты:

  1. Executor и ExecutorService - используются для управления потоками и выполнения задач. ExecutorService позволяет управлять пулом потоков
  2. Синхронизированные коллекции - потокобезопасные коллекции, такие как:
    • ConcurrentHashMap
    • CopyOnWriteArrayList
    • BlockingQueue
  3. Locks - более гибкая альтернатива синхронизированным блокам:
    • ReentrantLock - предоставляет явное управление блокировками + реентрабельный
    • ReadWriteLock - разделяет блокировки чтения и записи
  4. Синхронизаторы - объекты для координации потоков:
    • Semaphore - ограничение количества одновременно выполняющихся потоков
    • CountDownLatch - дословно «защёлка с обратным отсчетом» - остановка потоков, пока внутренний счётчик не станет равен нулю
    • CyclicBarrier - синхронизация нескольких потоков (обратен семафору, останавливает выполнение, пока в точку не придёт определённое количество потоков)
    • Exchanger - обмен данными между двумя потоками
  5. Future и CompletableFuture
    • Future - используется для получения результата выполнения асинхронной задачи
    • CompletableFuture - удобный способ построения асинхронных цепочек из результатов, поддерживает декларативный стиль программирования


22. Способы решения задач для многопоточного доступа #

  1. Синхронизация (synchronized) Использование синхронизированных методов или блоков для контроля доступа к общим ресурсам.
  2. Использование Lock - более гибкий контроль, чем synchronized, позволяет разблокировать вручную
  3. Потокобезопасные коллекции
  4. Синхронизаторы
    • использование CountDownLatch для ожидания завершения потоков
    • использование Semaphore для ограничения одновременного доступа
  5. Atomic-классы - обеспечивают атомарные операции над переменными
  6. Пул потоков - управление многопоточностью через ExecutorService позволяет избежать избыточного создания потоков


23. Чем конкурентность отличается от параллелизма? #

КритерийКонкурентность (Concurrency)Параллелизм (Parallelism)
ОпределениеСпособность выполнять несколько задач одновременно, переключаясь между ними.Одновременное выполнение нескольких задач на разных процессорах/ядрах.
ЦельМаксимально эффективно использовать ресурсы.Ускорение выполнения задачи путем разделения её на части.
ВзаимодействиеМожет быть на одном ядре за счет переключения между задачами.Требует нескольких ядер или процессоров.
ПримерОперационная система, выполняющая множество программ.Большие вычисления, разделенные на потоки, работающие одновременно.


24. Что такое CAS операции? (Compare And Swap) #

CAS (Compare And Swap) — атомарная операция, которая используется для управления многопоточным доступом к данным без использования блокировок

  1. Принцип работы:
    • Проверяется, соответствует ли текущее значение ожидаемому
    • Если да, то происходит обновление значения
    • Если нет, операция повторяется
  2. Этапы:
    • Сравнение текущего значения с ожидаемым
    • Обновление значения, если они совпадают
  3. Особенности:
    • Обеспечивает высокую производительность, так как не требует блокировок
    • Используется в Atomic классах и потокобезопасных коллекциях (ConcurrentHashMap)

Пример работы CAS

AtomicInteger atomicInteger = new AtomicInteger(0);
int expectedValue = 0;
int newValue = 1;

// CAS-операция
boolean result = atomicInteger.compareAndSet(expectedValue, newValue);
System.out.println(result); // true, если значение обновлено


25. Асинхронность vs многопоточность #

КритерийАсинхронностьМногопоточность
ОпределениеСпособ выполнения задач без блокировки текущего потокаОдновременное выполнение задач в нескольких потоках
Использование потоковПоток может не использоваться, задачи выполняются позжеВсегда использует несколько потоков
ПримерАсинхронное чтение файла: выполнение задачи продолжается при готовности результатаВычисления на нескольких потоках одновременно
Области примененияСетевые запросы, работа с I/O (например, базы данных)Распараллеливание вычислительных задач


26. Зачем нужны пулы потоков? #

Пулы потоков (Thread Pools) — это группа потоков, которые переиспользуются для выполнения задач. Они предоставляются классом Executors и реализуют интерфейс ExecutorService

Основные причины использования:

  1. Управление ресурсами:
    • избегают создания большого количества потоков, которые могут привести к недостатку памяти
    • ограничивают максимальное количество одновременно выполняемых потоков
  2. Повышение производительности - потоки переиспользуются, что уменьшает затраты на их создание и уничтожение
  3. Контроль задач - позволяют планировать задачи, добавлять их в очередь или ограничивать выполнение
ExecutorService executor = Executors.newFixedThreadPool(2);
executor.submit(() -> System.out.println("Task 1"));
executor.submit(() -> System.out.println("Task 2"));
executor.shutdown();


27. Что значит потокобезопасность? #

Потокобезопасность означает, что объект или метод корректно работает при одновременном доступе из нескольких потоков. Без потокобезопасности возможны race conditions, что приводит к некорректной работе программы.


28. Области памяти в многопоточности? #

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

  1. Heap (куча) - общая память для всех потоков, здесь хранятся объекты
  2. Stack (стек потока) - отдельная область для каждого потока, где хранятся локальные переменные и вызовы методов а также ссылки на обьекты
  3. Метаспейс (Metaspace) - область для хранения информации о классах
  4. Рабочая память потока (Thread Working Memory) - кэш каждого потока, который может отличаться от данных в основной памяти

Пример проблемы:
Если один поток изменяет объект в Heap, а другой читает старые данные из Thread Working Memory, может произойти рассинхронизация (data race).

Как избежать?

  • использовать volatile, чтобы изменения в одной нити были видны другим
  • применять synchronized и Lock для атомарных операций


29. Executors.newCachedThreadPool() #

Executors.newCachedThreadPool() создает гибкий пул потоков:

  • без ограничения количества потоков (создает новые при необходимости)
  • потоки переиспользуются при их простое (idle timeout ≈ 60 сек)
  • подходит для кратковременных и легковесных задач

Минусы:

  • может создать слишком много потоков и перегрузить систему


30. Что такое happens-before? #

Концепция “happens before” описывает отношения между различными операциями в многопоточной программе. В Java она определяется в спецификации Java Memory Model (JMM). Основные правила

  1. Программный порядок. В одном потоке все операции выполняются в том порядке, в котором они написаны в коде. Это означает, что если в одном потоке сначала выполняется операция A, а затем операция B, то операция A всегда будет завершена до начала операции B.
  2. Блокировка монитора. Разблокировка монитора в одном потоке происходит до последующей блокировки того же монитора в другом потоке. Это правило гарантирует, что критические секции кода, защищенные синхронизацией, выполняются последовательно и не пересекаются.
  3. Взаимодействие через volatile переменные. Запись в volatile переменную происходит до последующего чтения этой переменной другим потоком. Это обеспечивает видимость изменений, сделанных одним потоком, для других потоков.
  4. Создание и завершение потоков. Создание потока происходит до начала его выполнения, а завершение потока происходит до того, как другой поток обнаружит его завершение. Это правило помогает управлять жизненным циклом потоков и их взаимодействием.
  5. Передача данных через join(). Вызов метода Thread.join() на одном потоке происходит до завершения этого потока. Это позволяет одному потоку дождаться завершения другого потока перед продолжением выполнения.

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


31. ForkJoinPool vs FixedThreadPool #

Фреймворк Fork/Join, представленный в JDK 7, - это набор классов и интерфейсов, позволяющих использовать преимущества многопроцессорной архитектуры современных компьютеров. Он разработан для выполнения задач, которые можно рекурсивно разбить на маленькие подзадачи, которые можно решать параллельно

  • Этап Fork - большая задача разделяется на несколько меньших подзадач, которые в свою очередь также разбиваются на меньшие. И так до тех пор, пока задача не становится тривиальной и решаемой последовательным способом
  • Этап Join - далее (опционально) идет процесс «свертки» - решения подзадач некоторым образом объединяются, пока не получится решение всей задачи

Еще одно преимущество этого фреймворка заключается в том, что он использует work- stealing алгоритм: потоки, которые завершили выполнение собственных подзадач, могут «украсть» подзадачи у других потоков, которые всё ещё заняты

import java.util.concurrent.*;

class SumTask extends RecursiveTask<Integer> {
    private final int[] array;
    private final int start, end;

    SumTask(int[] array, int start, int end) {
        this.array = array;
        this.start = start;
        this.end = end;
    }

    @Override
    protected Integer compute() {
        if (end - start <= 2) {
            return array[start] + array[end - 1]; // Базовый случай
        }

        int mid = (start + end) / 2;
        SumTask leftTask = new SumTask(array, start, mid);
        SumTask rightTask = new SumTask(array, mid, end);

        leftTask.fork();
        return rightTask.compute() + leftTask.join();
    }
}

public class ForkJoinExample {
    public static void main(String[] args) {
        ForkJoinPool pool = new ForkJoinPool();
        int[] numbers = {1, 2, 3, 4, 5, 6, 7, 8};

        int sum = pool.invoke(new SumTask(numbers, 0, numbers.length));
        System.out.println("Сумма: " + sum); // 36
    }
}
СвойствоForkJoinPoolFixedThreadPool
Размер пулаАвтоматически настраиваетсяФиксированное число потоков
Поддержка work-stealing?ДаНет
Когда использовать?Рекурсивно разделяемые задачи (RecursiveTask)Независимые (RunnableCallable)