Многопоточность в Java: от основ до java.util.concurrent

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

1. Введение в многопоточность: класс Thread, интерфейс Runnable и жизненный цикл потока

Введение в многопоточность: класс Thread, интерфейс Runnable и жизненный цикл потока

Добро пожаловать в курс «Многопоточность в Java: от основ до java.util.concurrent». Это первая статья, в которой мы заложим фундамент для понимания того, как работают современные приложения. Мы разберем, чем поток отличается от процесса, как создать свой первый поток в Java и через какие стадии он проходит за время своей жизни.

Зачем нам нужна многопоточность?

Представьте, что вы готовите ужин. Вам нужно нарезать овощи, сварить суп и запечь мясо. Если вы будете делать это строго последовательно (сначала нарезали всё, потом поставили вариться суп и ждете, пока он сварится, и только потом ставите мясо), ужин будет готов к полуночи. Это — однопоточный подход.

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

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

  • Повысить отзывчивость интерфейса. Если тяжелая задача (например, загрузка файла) выполняется в отдельном потоке, пользовательский интерфейс не «зависает».
  • Эффективно использовать ресурсы CPU. Современные процессоры имеют много ядер. Однопоточная программа использует только одно ядро, оставляя остальные бездействовать.
  • Обрабатывать множество запросов. Это критично для серверов, которые должны отвечать тысячам пользователей одновременно.
  • Процесс и Поток: в чем разница?

    Прежде чем писать код, важно разграничить два понятия: процесс и поток (thread).

    Процесс

    Процесс — это экземпляр запущенной программы. Он имеет свое собственное адресное пространство в памяти, свои дескрипторы файлов и другие ресурсы. Процессы изолированы друг от друга. Если один процесс «упадет» с ошибкой, это обычно не влияет на другие процессы.

    Поток

    Поток (или нить) — это легковесный процесс. Потоки существуют внутри процесса. Ключевая особенность: все потоки одного процесса делят общую память (кучу/Heap). Это делает обмен данными между ними быстрым, но создает почву для серьезных проблем синхронизации, о которых мы будем говорить в следующих статьях.

    !Визуализация различия между процессом и потоками, работающими в общем пространстве памяти

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

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

    Способ 1: Наследование от класса Thread

    Класс java.lang.Thread — это основной класс, представляющий поток. Чтобы создать свой поток, нужно создать класс-наследник и переопределить метод run().

    Способ 2: Реализация интерфейса Runnable

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

    Что выбрать: Thread или Runnable?

    В 99% случаев следует выбирать реализацию интерфейса Runnable. И вот почему:

  • Java не поддерживает множественное наследование классов. Если вы наследуетесь от Thread, вы больше не сможете наследоваться ни от какого другого класса. Реализуя Runnable, вы сохраняете гибкость.
  • Разделение логики и исполнения. Runnable представляет собой задачу (что нужно сделать), а Threadмеханизм исполнения (как это сделать). Это соответствует принципам чистого кода.
  • Использование пулов потоков. В будущем мы будем использовать ExecutorService, который принимает именно задачи (Runnable или Callable), а не объекты Thread.
  • Главная ошибка новичка: start() vs run()

    Обратите внимание на код запуска выше. Мы вызываем метод start(), хотя логика написана в методе run(). Почему?

    thread.start(): Создает новый системный поток, выделяет ему стек и вызывает метод run() уже в этом новом* потоке. Это и есть многопоточность. thread.run(): Просто выполняет метод run() в текущем* потоке, как обычный вызов метода. Никакого нового потока не создается, параллелизма нет.

    > Никогда не вызывайте метод run() напрямую, если хотите запустить код в новом потоке.

    Жизненный цикл потока (Thread Lifecycle)

    Поток не просто «работает» или «не работает». Он проходит через несколько состояний, описанных в перечислении Thread.State.

    !Диаграмма переходов состояний жизненного цикла потока в Java

    Разберем эти состояния подробнее:

  • NEW (Новый)
  • Поток создан (объект Thread существует), но метод start() еще не вызван. Это просто объект в памяти.

  • RUNNABLE (Готов/Выполняется)
  • После вызова start() поток переходит в это состояние. Важно понимать: в Java состояние RUNNABLE означает, что поток либо выполняется прямо сейчас, либо готов к выполнению и ждет, пока планировщик операционной системы выделит ему процессорное время.

  • BLOCKED (Заблокирован)
  • Поток временно неактивен, так как пытается войти в защищенную (synchronized) секцию кода, которая занята другим потоком. Он «висит» и ждет освобождения блокировки.

  • WAITING (Ожидание)
  • Поток ждет действий от другого потока бесконечно долго. Это происходит при вызове методов Object.wait(), Thread.join() или LockSupport.park().

  • TIMED_WAITING (Ожидание с таймером)
  • То же самое, что и WAITING, но с ограничением по времени. Например, Thread.sleep(1000) или obj.wait(500).

  • TERMINATED (Завершен)
  • Метод run() завершил выполнение (успешно или с исключением). Поток уничтожен, его нельзя запустить повторно.

    Базовые методы управления потоком

    Рассмотрим два фундаментальных метода, которые влияют на состояния потока.

    Thread.sleep(long millis)

    Этот статический метод переводит текущий поток в состояние TIMED_WAITING на указанное количество миллисекунд. Он не освобождает захваченные ресурсы (мониторы), а просто «засыпает».

    join()

    Метод join() позволяет одному потоку ждать завершения другого. Если вы вызовете t.join() в главном потоке, то главный поток перейдет в состояние WAITING и будет ждать, пока поток t не перейдет в состояние TERMINATED.

    Заключение

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

    В следующей статье мы углубимся в самую сложную тему многопоточности — синхронизацию ресурсов и проблему Race Condition.

    2. Обеспечение безопасности потоков: ключевое слово synchronized, volatile и мониторы объектов

    Обеспечение безопасности потоков: ключевое слово synchronized, volatile и мониторы объектов

    В предыдущей статье мы научились создавать и запускать потоки. Мы выяснили, что главная особенность потоков — это общая память. Это их суперсила, позволяющая быстро обмениваться данными, но это же и их «ахиллесова пята».

    Сегодня мы разберем самую частую причину ошибок в многопоточных программах — состояние гонки (Race Condition), и изучим инструменты, которые Java предоставляет для защиты данных: ключевые слова synchronized и volatile.

    Проблема: Состояние гонки (Race Condition)

    Представьте, что у нас есть общий счетчик, который увеличивают два потока. Казалось бы, что может быть проще операции count++?

    Если мы запустим два потока, каждый из которых вызовет increment() по 1000 раз, мы ожидаем увидеть в итоге 2000. Однако на практике мы можем получить 1950, 1800 или любое другое число меньше 2000. Почему?

    Дело в том, что операция count++ не является атомарной (неделимой). Для процессора это три отдельных действия:

  • Read: Считать текущее значение count из памяти в регистр процессора.
  • Modify: Увеличить значение в регистре на 1.
  • Write: Записать новое значение из регистра обратно в память.
  • Когда два потока работают одновременно, может произойти следующее наложение:

    !Схема возникновения состояния гонки (Race Condition) при инкрементации переменной

    В итоге одно увеличение «потерялось». Это и есть Race Condition — ошибка проектирования, при которой работа системы зависит от того, в каком порядке выполняются части кода.

    Критическая секция и Мониторы

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

    В Java для защиты критических секций используется механизм мониторов.

    Что такое монитор?

    Каждый объект в Java (абсолютно каждый, наследник Object) имеет ассоциированный с ним монитор (или мьютекс). Монитор работает как замок с одним ключом.

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

    !Визуализация работы монитора объекта и блокировки

    Ключевое слово synchronized

    В Java ключевое слово synchronized — это самый простой способ работы с мониторами. Оно гарантирует, что в один момент времени только один поток может выполнять данный участок кода.

    1. Синхронизированные методы

    Мы можем добавить synchronized в объявление метода:

    Что здесь является монитором? * Для экземплярных методов (void method()) монитором выступает сам объект, у которого вызван метод (this). * Для статических методов (static void method()) монитором выступает объект класса (ClassName.class).

    Теперь, если Поток А вызовет increment(), он захватит монитор текущего объекта Counter. Поток Б, попытавшись вызвать increment() у того же объекта, будет ждать, пока Поток А не закончит.

    2. Синхронизированные блоки

    Иногда синхронизировать весь метод — это слишком дорого. Если метод содержит 100 строк кода, а с общим ресурсом мы работаем только в двух, нет смысла блокировать остальные 98. Для этого используют синхронизированные блоки:

    В скобках после synchronized мы явно указываем объект-монитор, по которому будем синхронизироваться. Это может быть this или любой другой объект.

    > Важно: Чтобы синхронизация работала, все потоки должны использовать один и тот же объект-монитор. Если каждый поток будет блокироваться на своем собственном объекте (new Object()), синхронизации не произойдет.

    Реентерабельность (Reentrancy)

    Мониторы в Java — реентерабельные (возвратные). Это значит, что если поток уже захватил монитор объекта, он может входить в другие synchronized блоки или методы, защищенные тем же самым монитором, без блокировки.

    Если бы мониторы не были реентерабельными, вызов methodB() внутри methodA() привел бы к вечному ожиданию самого себя (deadlock).

    Проблема видимости памяти и volatile

    Помимо атомарности (неделимости операций), в многопоточности есть проблема видимости (visibility).

    Современные процессоры имеют кэши (L1, L2, L3). Для ускорения работы потоки часто копируют переменные из основной памяти (RAM) в кэш процессора. Если один поток изменит переменную в своем кэше, другой поток может не увидеть этого изменения, так как он читает старое значение из своего кэша.

    Ключевое слово volatile

    Ключевое слово volatile (летучий) ставится перед объявлением переменной. Оно говорит компилятору и процессору:

    > «Не кэшируй эту переменную! Всегда читай её из основной памяти и пиши сразу в основную память».

    Без volatile поток, выполняющий цикл while, мог бы бесконечно крутиться, не замечая, что другой поток изменил isRunning на false.

    volatile vs synchronized

    Это частый вопрос на собеседованиях. В чем разница?

    | Характеристика | synchronized | volatile | | :--- | :--- | :--- | | Атомарность | Гарантирует (операции внутри блока неделимы) | НЕ гарантирует (только чтение/запись переменных) | | Видимость | Гарантирует (сброс кэшей при выходе из монитора) | Гарантирует (чтение/запись напрямую в RAM) | | Применение | Для сложных операций (инкремент, проверка и действие) | Только для простых флагов или ссылок |

    > Внимание: volatile не спасает от Race Condition при инкременте (count++). Операция count++ — это чтение + изменение + запись. volatile гарантирует, что мы считаем свежее значение, но не запрещает другому потоку вклиниться между чтением и записью.

    Взаимная блокировка (Deadlock)

    Использование synchronized несет риски. Самый страшный из них — Deadlock.

    Это ситуация, когда Поток 1 держит Монитор А и ждет Монитор Б, а Поток 2 держит Монитор Б и ждет Монитор А. Они будут ждать друг друга вечно.

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

    Заключение

    Сегодня мы разобрали основы безопасности потоков:

  • Race Condition возникает, когда несколько потоков меняют данные одновременно.
  • Монитор — это механизм защиты объекта, работающий как замок.
  • synchronized обеспечивает и атомарность, и видимость, позволяя выполнять код только одному потоку за раз.
  • volatile обеспечивает только видимость, отключая кэширование переменной процессором.
  • В следующей статье мы рассмотрим методы wait() и notify(), которые позволяют потокам не просто блокировать друг друга, а общаться и координировать свои действия.

    3. Взаимодействие между потоками: методы wait, notify, notifyAll и проблемы взаимных блокировок

    Взаимодействие между потоками: методы wait, notify, notifyAll и проблемы взаимных блокировок

    Добро пожаловать в третью часть курса «Многопоточность в Java: от основ до java.util.concurrent». В предыдущих статьях мы научились создавать потоки и защищать данные от одновременного доступа с помощью synchronized. Мы создали «замки» (мониторы), которые не пускают чужаков в критическую секцию.

    Но что, если потокам нужно не просто не мешать друг другу, а сотрудничать? Представьте кухню ресторана. Повар (Producer) готовит блюдо и ставит его на раздачу. Официант (Consumer) забирает блюдо и несет клиенту. Официант не может забрать блюдо, пока повар его не приготовил. А повар не может поставить новое блюдо, если раздача переполнена.

    Им нужно общаться: «Блюдо готово!» или «Место освободилось!».

    Сегодня мы разберем, как организовать такое общение с помощью методов wait, notify и notifyAll, и как не попасть в ловушку взаимной блокировки (Deadlock).

    Проблема активного ожидания (Busy Wait)

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

    Это называется активным ожиданием (Busy Wait). Поток постоянно проверяет условие, загружая процессор на 100% бесполезной работой. Это как если бы официант каждые полсекунды заглядывал на кухню и кричал: «Готово? Готово? Готово?», мешая всем работать.

    Нам нужен механизм, который позволит официанту сесть на стул и уснуть, пока повар не разбудит его фразой «Готово!».

    Методы wait, notify и notifyAll

    В Java этот механизм встроен в класс java.lang.Object. Да, именно в Object, а не в Thread. Это часто сбивает с толку, но логика здесь железная: эти методы работают с монитором объекта.

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

    1. wait()

    Метод wait() делает две важные вещи:
  • Освобождает монитор (блокировку) объекта.
  • Переводит текущий поток в состояние WAITING (сон).
  • Поток будет спать до тех пор, пока другой поток не вызовет notify() или notifyAll() на том же самом объекте.

    > Важно: Метод wait() можно вызывать только внутри synchronized блока или метода. Если вызвать его без захваченного монитора, вы получите ошибку IllegalMonitorStateException.

    2. notify()

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

    3. notifyAll()

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

    Реализация паттерна Producer-Consumer

    Давайте реализуем правильное взаимодействие на примере склада (Store), куда Производитель кладет товары, а Потребитель забирает.

    Обратите внимание на ключевые моменты:

  • Методы put и get являются synchronized. Это обязательно.
  • Когда поток вызывает wait(), он отпускает монитор this. Если бы он этого не делал, другой поток никогда не смог бы войти в свой метод (ведь монитор занят) и вызвать notify().
  • После пробуждения поток продолжает выполнение со следующей строки после wait().
  • Почему while, а не if?

    Вы могли заметить, что проверка условия обернута в цикл while, а не в if:

    Почему так? Существует явление, называемое ложным пробуждением (Spurious Wakeup). В редких случаях операционная система может разбудить поток без вызова notify(). Кроме того, если мы используем notifyAll(), может проснуться несколько потоков, но товар заберет только первый. Остальные должны снова проверить условие и, если товара нет, опять уйти в сон.

    > Всегда проверяйте условие ожидания в цикле while.

    notify() против notifyAll()

    Что лучше использовать?

    * notify() эффективнее, так как будит только один поток (меньше переключений контекста). * notifyAll() надежнее. Если у вас сложная логика и несколько условий ожидания, notify() может разбудить «не того» (например, разбудить другого производителя, когда нужно разбудить потребителя), и система зависнет. notifyAll() гарантирует, что нужный поток точно проснется.

    Рекомендация: используйте notifyAll(), если не уверены на 100%, что notify() безопасен в вашем случае.

    Взаимная блокировка (Deadlock)

    Когда потоков становится много, и они используют несколько мониторов, возникает риск Deadlock.

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

    !Визуализация классической ситуации Deadlock: два потока блокируют друг друга, ожидая ресурсы.

    Пример кода с Deadlock

    Если запустить methodA в одном потоке и methodB в другом:

  • Поток А захватывает lock1.
  • Поток Б захватывает lock2.
  • Поток А пытается захватить lock2 и засыпает (ждет, пока Б его отпустит).
  • Поток Б пытается захватить lock1 и засыпает (ждет, пока А его отпустит).
  • Результат: программа висит вечно.

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

    Существует несколько стратегий, но самая надежная и простая — порядок блокировок (Lock Ordering).

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

    В примере выше, если бы methodB тоже сначала захватывал lock1, а потом lock2, проблемы бы не возникло. Поток Б просто ждал бы на входе в первый блок synchronized, пока Поток А не закончит всю работу.

    Заключение

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

  • Методы wait(), notify() и notifyAll() позволяют потокам координировать свои действия, не тратя процессорное время впустую.
  • wait() всегда должен вызываться внутри цикла while для защиты от ложных пробуждений.
  • Эти методы работают только внутри синхронизированного контекста.
  • Deadlock — это опасная ловушка, которой можно избежать, соблюдая строгий порядок захвата ресурсов.
  • В следующей статье мы перейдем от низкоуровневых примитивов (synchronized, wait, notify) к современным инструментам из пакета java.util.concurrent, которые делают жизнь разработчика намного проще.

    4. Пакет java.util.concurrent: пулы потоков, интерфейсы ExecutorService, Callable и Future

    Пакет java.util.concurrent: пулы потоков, интерфейсы ExecutorService, Callable и Future

    Добро пожаловать в четвертую часть курса «Многопоточность в Java: от основ до java.util.concurrent». В предыдущих статьях мы работали с потоками на низком уровне: создавали их вручную через new Thread(), синхронизировали блоки кода с помощью synchronized и управляли ожиданием через wait() и notify().

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

    Начиная с Java 5, нам доступен пакет java.util.concurrent (JUC) — мощный набор инструментов, который берет на себя всю грязную работу по управлению потоками. Сегодня мы разберем его сердце: пулы потоков и интерфейсы ExecutorService, Callable и Future.

    Проблема ручного создания потоков

    Представьте, что вы владелец курьерской службы. У вас есть 1000 заказов (задач). Если вы будете нанимать нового курьера для каждого заказа, а после доставки сразу его увольнять, ваш бизнес разорится на оформлении документов и поиске сотрудников. Это аналогия подхода new Thread(runnable).start().

    Недостатки ручного создания:

  • Накладные расходы: Создание и уничтожение потока требует ресурсов процессора и памяти.
  • Отсутствие контроля: Если придет 10 000 запросов, вы создадите 10 000 потоков, что приведет к OutOfMemoryError.
  • Сложность получения результата: Как мы помним, метод run() ничего не возвращает (void). Чтобы получить результат вычислений из потока, приходится придумывать обходные пути.
  • Решение — Пул потоков (Thread Pool).

    Что такое Пул потоков?

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

    !Визуализация работы пула потоков: задачи из очереди распределяются между свободными потоками, которые переиспользуются.

    Основные преимущества: * Производительность: Потоки уже созданы и готовы к работе. * Контроль ресурсов: Мы можем ограничить количество одновременно работающих потоков (например, не больше 10), чтобы не перегрузить систему.

    Интерфейс ExecutorService

    В Java работа с пулами осуществляется через интерфейс ExecutorService. Это высокоуровневая абстракция, которая говорит: «Вот тебе задача, выполни её, а как и в каком потоке — решай сам».

    Чтобы создать экземпляр ExecutorService, обычно используют фабричный класс Executors.

    Основные типы пулов

  • FixedThreadPool (Фиксированный пул)
  • Создает пул с фиксированным количеством потоков (в примере — 5). Если все потоки заняты, новые задачи встают в очередь. Идеально подходит для стабильной, предсказуемой нагрузки.

  • CachedThreadPool (Кэширующий пул)
  • Создает новые потоки по мере необходимости. Если поток простаивает 60 секунд, он уничтожается. Подходит для большого количества кратковременных задач, но опасен при резком скачке нагрузки (может создать слишком много потоков).

  • SingleThreadExecutor (Однопоточный исполнитель)
  • Пул, содержащий всего один поток. Гарантирует, что задачи будут выполняться последовательно, одна за другой. Это безопасная альтернатива синхронизации, если вам нужно упорядочить доступ к ресурсу.

    Завершение работы пула

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

    * executor.shutdown(): Пул перестает принимать новые задачи, но доделывает те, что уже запущены или стоят в очереди. * executor.shutdownNow(): Пытается остановить все активные задачи (через прерывание) и возвращает список задач, которые не успели запуститься.

    Callable и Future: когда нужен результат

    Интерфейс Runnable, который мы использовали ранее, имеет существенный недостаток: метод run() не возвращает значения и не может выбросить проверяемое исключение (checked exception).

    Для решения этой проблемы в JUC появился интерфейс Callable.

    Интерфейс Callable

    Это «старший брат» Runnable. Он параметризован (Generic) и может возвращать результат.

    Обратите внимание: метод call() возвращает тип V и может выбрасывать Exception. Это позволяет нам писать код в привычном стиле, возвращая значения и обрабатывая ошибки.

    Интерфейс Future

    Когда мы отправляем Callable в ExecutorService, результат вычисления появится не мгновенно. Поток может работать секунду, минуту или час. Как нам получить этот результат?

    Здесь на сцену выходит Future (Будущее). Это объект-заглушка, «квитанция», которую вы получаете сразу после отправки задачи. По этой квитанции можно будет получить результат, когда он будет готов.

    Аналогия: Вы заказываете пиццу. Вам дают чек с номером (Future). Пицца еще не готова, но чек у вас уже есть. Вы можете заниматься своими делами, а периодически проверять, готов ли заказ.

    Пример использования Callable и Future

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

    Методы интерфейса Future

  • get(): Самый важный метод. Блокирует текущий поток до тех пор, пока задача не завершится. Может выбросить:
  • * InterruptedException: если текущий поток прервали во время ожидания. * ExecutionException: если внутри задачи (call()) произошло исключение. Оно будет обернуто в ExecutionException.
  • get(long timeout, TimeUnit unit): То же самое, но ждет не вечно, а указанное время. Если не дождался — выбрасывает TimeoutException. Это позволяет не «завешивать» программу намертво.
  • isDone(): Проверяет, завершилась ли задача. Возвращает true или false. Не блокирует поток.
  • cancel(boolean mayInterruptIfRunning): Пытается отменить выполнение задачи. Если задача еще в очереди — она удаляется. Если уже работает — параметр true попытается прервать поток.
  • Сравнение подходов

    Давайте подведем итог, чем новый подход лучше старого.

    | Характеристика | Thread + Runnable | ExecutorService + Callable + Future | | :--- | :--- | :--- | | Создание потоков | Ручное (new Thread()) | Автоматическое (пул потоков) | | Ресурсы | Высокие накладные расходы | Переиспользование потоков | | Возврат значения | Невозможно (нужны «костыли») | Легко через return в call() | | Обработка исключений | Только внутри run() | Пробрасываются в future.get() | | Управление группой | Сложно | Легко (invokeAll, shutdown) |

    Заключение

    Пакет java.util.concurrent перевернул мир Java-разработки. Использование ExecutorService вместо ручного создания потоков стало стандартом индустрии. Это безопаснее, удобнее и производительнее.

    Мы научились:

  • Создавать пулы потоков, чтобы экономить ресурсы системы.
  • Использовать Callable для задач, которые должны вернуть результат.
  • Работать с Future для получения асинхронных результатов и обработки исключений.
  • В следующей статье мы рассмотрим коллекции для многопоточной работы (Concurrent Collections), такие как ConcurrentHashMap и CopyOnWriteArrayList, которые позволяют безопасно работать с данными без явного использования synchronized.

    5. Продвинутая синхронизация: интерфейс Lock, атомарные переменные и потокобезопасные коллекции

    Продвинутая синхронизация: интерфейс Lock, атомарные переменные и потокобезопасные коллекции

    Добро пожаловать в пятую часть курса «Многопоточность в Java: от основ до java.util.concurrent». В предыдущих статьях мы прошли путь от создания простых потоков до использования пулов ExecutorService. Мы научились защищать данные с помощью synchronized и координировать потоки через wait/notify.

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

    В этой статье мы рассмотрим инструменты из пакета java.util.concurrent, которые дают нам гибкость, недоступную при использовании обычных мониторов: интерфейс Lock, атомарные переменные и специализированные коллекции.

    Интерфейс Lock: гибкая альтернатива synchronized

    Ключевое слово synchronized удобно своей простотой: вы входите в блок, и JVM сама захватывает монитор, а при выходе — освобождает. Но эта простота ограничивает:

  • Нет возможности прервать ожидание. Если поток встал в очередь к synchronized блоку, его нельзя «отменить» или прервать.
  • Нет тайм-аута. Поток будет ждать вечно.
  • Только один тип блокировки. Блокировка всегда эксклюзивная (и для чтения, и для записи).
  • Интерфейс java.util.concurrent.locks.Lock решает эти проблемы. Самая популярная его реализация — ReentrantLock.

    Основные методы Lock

    Работа с Lock требует явного управления: мы сами должны взять замок и сами его вернуть.

    > Критически важно: Всегда вызывайте unlock() внутри блока finally. Если внутри защищенного кода произойдет исключение, а unlock() не будет вызван, замок останется заблокированным навечно, и программа зависнет.

    Попытка захвата: tryLock()

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

    Разделение чтения и записи: ReadWriteLock

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

    Для таких случаев существует ReadWriteLock и его реализация ReentrantReadWriteLock. Он поддерживает пару замков: один для чтения, другой для записи.

    !Иллюстрация принципа ReadWriteLock: множество потоков могут читать одновременно, но запись требует эксклюзивного доступа.

    Правила просты: * Read Lock: Могут захватить несколько потоков одновременно, если никто в данный момент не пишет. * Write Lock: Может захватить только один поток, и только если никто в данный момент не читает и не пишет.

    Атомарные переменные: синхронизация без блокировок

    В статье про synchronized мы видели, что операция count++ не является атомарной. Она состоит из трех шагов: чтение, изменение, запись. Чтобы защитить её, мы использовали тяжеловесные мониторы.

    Но для простых операций над числами и ссылками в Java есть более быстрый инструмент — пакет java.util.concurrent.atomic. Самые популярные классы: AtomicInteger, AtomicLong, AtomicBoolean, AtomicReference.

    Как это работает?

    Атомарные переменные используют процессорные инструкции низкого уровня (CAS — Compare-And-Swap). Это механизм оптимистичной блокировки.

    Алгоритм CAS работает так:

  • Запоминаем текущее значение переменной.
  • Вычисляем новое значение.
  • Просим процессор: «Запиши новое значение, только если текущее значение в памяти всё еще равно тому, что мы запомнили на шаге 1».
  • Если значение успело измениться другим потоком, операция не выполняется, и мы пробуем снова с шага 1.
  • !Визуализация алгоритма Compare-And-Swap: поток пытается обновить значение, и повторяет попытку, если данные были изменены другим потоком.

    Пример использования AtomicInteger

    Этот код работает быстрее, чем synchronized, так как не требует переключения контекста потоков и блокировки операционной системы при отсутствии высокой конкуренции.

    Потокобезопасные коллекции (Concurrent Collections)

    До появления java.util.concurrent (до Java 5), если нам нужен был потокобезопасный список или карта, мы использовали Vector, Hashtable или обертки Collections.synchronizedList(). Все они работали по принципу: «Один замок на весь объект». Если один поток читал первый элемент списка, другой не мог добавить элемент в конец списка.

    Современные коллекции решают эту проблему умнее.

    ConcurrentHashMap

    Это, пожалуй, самая важная коллекция в многопоточности. В отличие от Hashtable или Collections.synchronizedMap, она не блокирует всю карту при каждой операции.

    В старых версиях Java использовалась техника Segment Locking (блокировка сегментов): карта делилась на 16 частей, и блокировалась только та часть, в которую шла запись. В Java 8+ механизм стал еще сложнее и эффективнее, используя CAS и блокировку только конкретного узла (корзины) при коллизиях.

    Результат: Чтение (get) вообще не блокируется, а запись (put) блокирует минимально возможную часть данных.

    CopyOnWriteArrayList

    Интересная реализация списка List. Как следует из названия, при каждом изменении (add, set, remove) создается новая копия внутреннего массива.

    * Плюсы: Идеально для сценария, где много чтений и очень мало записей (например, список подписчиков на события). Чтение происходит мгновенно без блокировок, так как мы читаем неизменяемый массив. * Минусы: Очень дорогостоящая запись. Не подходит для часто меняющихся данных.

    Блокирующие очереди (BlockingQueue)

    Помните паттерн Producer-Consumer, который мы реализовывали через wait/notify? Интерфейс BlockingQueue делает всю эту работу за нас.

    Реализации, такие как ArrayBlockingQueue или LinkedBlockingQueue, имеют методы put() и take(): * put(E e): кладет элемент. Если очередь полна, поток засыпает и ждет места. * take(): берет элемент. Если очередь пуста, поток засыпает и ждет появления элемента.

    Никаких synchronized, wait или notify. Все скрыто внутри очереди.

    Заключение

    Мы рассмотрели продвинутые инструменты синхронизации:

  • Lock дает гибкость: возможность попытки захвата (tryLock), разделение на чтение/запись (ReadWriteLock) и явное управление блокировкой.
  • Атомарные переменные (AtomicInteger и др.) позволяют выполнять простые операции над данными очень быстро, используя возможности процессора (CAS) без тяжелых блокировок.
  • Потокобезопасные коллекции (ConcurrentHashMap, BlockingQueue) берут на себя всю сложность синхронизации данных, позволяя нам сосредоточиться на бизнес-логике.
  • В следующей, заключительной статье курса, мы подведем итоги и рассмотрим лучшие практики и частые ошибки при работе с многопоточностью, чтобы ваш код был не только рабочим, но и надежным.