сеньорчикОткрыть в Telegram
← вся теориятеория к собесу · Многопоточность в Java

Локи и примитивы синхронизации

Ящик инструментов: что под какую задачу

Пакет java.util.concurrent - это ящик готовых примитивов, и вопросы здесь про выбор: когда ReentrantLock вместо synchronized, когда атомарный тип вместо замка, чем защёлка отличается от барьера. Проверяют, что ты не забиваешь все гвозди одним молотком.

Сквозная идея ящика простая: под каждый способ координации свой инструмент. Счётчик - атомарный тип, «дождись N событий» - защёлка, лимит одновременного доступа - семафор, производитель и потребитель - блокирующая очередь.

// Формулировки: «чем ReentrantLock лучше synchronized?», «как работает сравнение с обменом?», «CountDownLatch или CyclicBarrier?»

ReentrantLock: тот же замок, но с ручками

ReentrantLock даёт то же взаимное исключение, что и synchronized, но с управлением. Метод tryLock пробует взять замок и уходит, если не вышло. tryLock с таймаутом позволяет обойти взаимную блокировку отступлением: не дождался - отпусти своё и повтори позже. Метод lockInterruptibly делает ожидание отзывчивым к отмене. Плюс честный режим против голодания и несколько условий ожидания на один замок.

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

// Есть и специализированные варианты. ReadWriteLock пускает много читателей ИЛИ одного писателя - выигрывает там, где чтений сильно больше. StampedLock быстрее и умеет оптимистичное чтение, но не поддерживает повторный вход, поэтому применяется точечно.

lock.lock();
try { balance += x; }
finally { lock.unlock(); }   // всегда finally
tryLock с таймаутом
не дождался - отпусти своё и попробуй снова
повторный вход
поток может взять свой же замок ещё раз

Сравнение с обменом и атомарные типы

Сравнение с обменом - это одна инструкция процессора: «замени значение на новое, если оно всё ещё равно ожидаемому». Подход оптимистичный: не запирать заранее, а попробовать; не вышло, потому что кто-то успел раньше - прочитать заново и повторить. На этом стоит вся конструкция без блокировок.

Атомарные типы оборачивают эту инструкцию в удобные методы: incrementAndGet, compareAndSet, updateAndGet. Потоки при этом не засыпают - нет ни очереди за монитором, ни риска взаимной блокировки на этом участке. В моём замере AtomicInteger давал точные 400 000 при двух потоках по 200 000 инкрементов.

// Грабли ровно одни: get и set по отдельности - это уже НЕ атомарная пара, между ними влезет другой поток. Составное изменение делают через compareAndSet в цикле или через updateAndGet. А под жёсткой конкуренцией за один счётчик повторы начинают жечь процессор, и тогда берут LongAdder: он расщепляет значение по ячейкам и складывает их при чтении.

сравнение с обменом
«замени, если ожидаемое» - основа работы без блокировок
LongAdder
счётчик по ячейкам против борьбы за одну переменную

Координация: защёлка, барьер, семафор, очередь

CountDownLatch - одноразовый счётчик «дождись N событий». Главный поток стоит на await, рабочие отмечаются вызовом countDown. Типичные случаи: старт после инициализации всех подсистем, тест ждёт завершения потоков. Перезарядить её нельзя.

CyclicBarrier - многоразовая точка встречи: N потоков доходят до барьера и продолжают вместе. Подходит для пошаговых алгоритмов, где все обязаны закончить шаг, прежде чем начнётся следующий. Мнемоника короткая: защёлку ждут снаружи, на барьере встречаются сами.

Semaphore - счётчик разрешений: не больше N потоков одновременно внутри участка. Так ограничивают число соединений или защищают внешний сервис от перегруза.

// Блокирующая очередь закрывает схему «производитель и потребитель» целиком: put и take сами блокируются, когда надо. И критично, чтобы ёмкость была ОГРАНИЧЕННОЙ - именно она создаёт обратное давление, притормаживая производителя. Безлимитная очередь вместо давления копит задачи, пока не кончится память.

защёлка и барьер
одноразовое ожидание N событий / многоразовый сбор N потоков
обратное давление
ограниченная очередь тормозит производителя

Как отвечать: «Когда возьмёшь ReentrantLock вместо synchronized?»

Дефолт у меня synchronized: он короче, монитор отпускается сам, и компилятор его хорошо оптимизирует. ReentrantLock беру, когда нужна конкретная возможность. Первая - tryLock с таймаутом, чтобы не зависнуть навсегда и обойти взаимную блокировку отступлением. Вторая - lockInterruptibly, чтобы ожидание реагировало на отмену. Третья - несколько условий на один замок, например «очередь не пуста» и «очередь не полна» по отдельности. Четвёртая - честный режим против голодания. Плата за это - unlock строго в finally, иначе первое исключение оставит замок захваченным и все встанут. И отдельный современный случай: на горячих путях с виртуальными потоками ReentrantLock предпочтительнее, потому что synchronized до Java 24 прибивает виртуальный поток к несущему. Я это мерил: сто виртуальных потоков, каждый со своим замком, спят по 50 миллисекунд - под synchronized прогон занял 2,51 секунды, под ReentrantLock 0,05.

Сильный ответ: назван дефолт и четыре осознанные причины отступить от него, каждая с задачей. Актуальная деталь про виртуальные потоки, да ещё с числами, показывает, что знание не из старых конспектов.

На чём валят

  • Пишут lock без unlock в finally. Первое же исключение оставляет замок навсегда, остальные ждут вечно.
  • Ставят замок вокруг одного счётчика. Атомарный тип проще, быстрее и не даёт взаимной блокировки.
  • Считают get и set атомарной парой. Между ними гонка, нужен compareAndSet или updateAndGet.
  • Путают защёлку и барьер: одноразовое ожидание событий против многоразовой встречи потоков.
  • Берут безлимитную очередь «для надёжности». Вместо обратного давления получают рост очереди до нехватки памяти.

Проверьте себя

Пять вопросов из банка по этой подтеме. Всего их 18, остальные разбираются в тренажёре.

  1. #locks_tools1 / 5
    Зачем нужен ReadWriteLock?
    A)Чтобы читатели и писатели работали строго по очереди по одному, исключая параллельность
    B)Чтобы автоматически кэшировать результаты чтения и не выполнять повторные обращения к данным дважды
    C)Пускать много читателей параллельно, но писателя — эксклюзивно
    D)Чтобы разделить один лок на два независимых, которые не влияют друг на друга при работе
    показать ответ и разбор
    +C)Пускать много читателей параллельно, но писателя — эксклюзивно

    // разбор: ReadWriteLock даёт две блокировки: read-лок могут держать МНОГО потоков одновременно (чтение не меняет данные), а write-лок — эксклюзивен и не пускает ни читателей, ни других писателей. Это ускоряет сценарии «читают часто, пишут редко» по сравнению с обычным mutual-exclusion. Минусы — сложнее и риск голодания писателей; в современных задачах часто выигрывает StampedLock или неизменяемые снимки.

  2. #locks_tools2 / 5
    Для чего используют Semaphore?
    A)Для того чтобы разбудить ровно один ждущий поток из многих, как это делает метод notify()
    B)Для гарантии, что участок кода выполнит строго один поток за всё время жизни приложения
    C)Для передачи значений между потоками по принципу очереди «первым пришёл — первым вышел»
    D)Ограничить число потоков, одновременно работающих с ресурсом
    показать ответ и разбор
    +D)Ограничить число потоков, одновременно работающих с ресурсом

    // разбор: Semaphore держит счётчик разрешений (permits): acquire() берёт разрешение (ждёт, если их нет), release() возвращает. Так ограничивают число потоков, одновременно использующих ресурс — например, не более 10 параллельных обращений к внешнему API или пул из N соединений. Семафор с одним разрешением работает как мьютекс (но без владельца — освободить может любой поток).

  3. #locks_tools3 / 5
    Что делает CountDownLatch?
    A)Даёт потокам дождаться, пока счётчик N событий не обнулится
    B)Периодически сбрасывает счётчик в исходное значение, позволяя переиспользовать барьер много раз подряд
    C)Заставляет ровно N потоков ждать друг друга в одной точке, чтобы затем стартовать одновременно
    D)Ограничивает число одновременно работающих потоков значением N, как это делает обычный семафор
    показать ответ и разбор
    +A)Даёт потокам дождаться, пока счётчик N событий не обнулится

    // разбор: CountDownLatch — одноразовый барьер-счётчик: инициализируется числом N, потоки-ожидающие висят на await(), а рабочие вызывают countDown(); когда счётчик достигает нуля, все ожидающие разблокируются. Удобно «дождаться, пока N задач/сервисов будут готовы». Обнулившийся latch переиспользовать нельзя. Для циклического «N потоков встречаются и повторяют» берут CyclicBarrier.

  4. #locks_tools4 / 5
    Чем CyclicBarrier отличается от CountDownLatch?
    A)CyclicBarrier одноразовый, а CountDownLatch можно сбрасывать и запускать заново сколько угодно раз
    B)N потоков ждут друг друга в точке и барьер переиспользуется
    C)У CyclicBarrier ожидающие и считающие потоки — разные группы, как и у CountDownLatch, отличий нет
    D)CyclicBarrier ограничивает число разрешений на доступ к ресурсу, работая по сути как семафор
    показать ответ и разбор
    +B)N потоков ждут друг друга в точке и барьер переиспользуется

    // разбор: CyclicBarrier синхронизирует ФИКСИРОВАННУЮ группу из N потоков: каждый вызывает await() и ждёт, пока все N не соберутся в этой точке, после чего они одновременно продолжают — и барьер автоматически сбрасывается для следующего раунда (циклический). Опционально запускает barrier-action по срабатыванию. В отличие от одноразового CountDownLatch, где одни потоки countDown, а другие await, тут все участники равноправны и ждут друг друга.

  5. #locks_tools5 / 5
    Зачем ReentrantLock предоставляет объекты Condition (await/signal)?
    A)Чтобы заменить сам лок: Condition захватывает монитор вместо метода lock() у ReentrantLock
    B)Чтобы автоматически освобождать лок по таймауту, если никто не подал сигнал вовремя
    C)Ждать/сигналить по нескольким разным условиям на одном локе
    D)Чтобы ускорить работу лока за счёт того, что signal() не требует предварительного его захвата
    показать ответ и разбор
    +C)Ждать/сигналить по нескольким разным условиям на одном локе

    // разбор: Condition — аналог wait/notify, но для явных локов и гибче: на одном ReentrantLock можно завести НЕСКОЛЬКО условий (например, notFull и notEmpty в ограниченной очереди) и будить именно ту группу ожидающих, которой пора продолжать, — а не «всех подряд» как notifyAll. await()/signal() вызываются, удерживая лок; await освобождает лок на время ожидания. Это точнее и снижает лишние пробуждения.

дальше

Теорию прочитали. Навык ставится повторением

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