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

Синхронизация и volatile в Java

synchronized и модель памяти

Поток крутит while по обычному булеву флагу, другой ставит флаг в true. Прошло две секунды - цикл всё ещё крутится, поток жив. Тот же код с volatile завершается сразу. Никаких исключений, никаких признаков поломки: просто одна запись не дошла до другого потока.

Здесь проверяют понимание модели памяти Java - того, что отличает «использую synchronized» от «понимаю, что он гарантирует». Звучит страшно, а сводится к одной идее: запись одного потока видна другому, только если между ними установлено отношение happens-before.

// Формулировки: «что гарантирует volatile?», «зачем wait в цикле?», «как избежать взаимной блокировки?»

Монитор и на чём синхронизироваться

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

На чём синхронизироваться - приватный final-объект, созданный специально для этого. Синхронизация на this опасна тем, что this виден снаружи: чужой код может взять твой замок и устроить взаимную блокировку.

// Отдельная ловушка - синхронизация на строке. Я проверил: литерал «заказ-42» и выражение «заказ-» плюс 42 оказались ОДНИМ объектом, потому что константы вычисляются при компиляции и попадают в общий пул строк. Значит, синхронизация по идентификатору-строке даёт случайный глобальный замок, разделяемый со всем кодом в процессе.

// И про размер: под замком держат только работу с общим состоянием, коротко. Сетевой вызов под замком выстраивает все потоки в очередь, и «многопоточный» сервис начинает работать по одному.

private final Object lock = new Object();
synchronized (lock) { balance += x; }   // своё и коротко
// synchronized ("заказ-" + id) - глобальный замок на весь процесс
монитор
встроенный замок объекта, повторно входим
пул строк
одинаковые константные строки - один объект на процесс

happens-before и безопасная публикация

Модель памяти формализует видимость через отношение happens-before: запись гарантированно видна чтению, если между ними есть такая связь. Создают её выход из синхронизированного блока и вход в него же по тому же монитору, запись и чтение volatile-переменной, а также запуск потока и ожидание его завершения.

Отсюда неочевидное следствие: синхронизировать только запись бесполезно. Читатель без своей стороны связи спокойно видит устаревшее значение - ровно как в замере с флагом. Синхронизация всегда парная.

// volatile - правильный инструмент для флагов и для публикации ссылки на неизменяемый объект. А final-поля безопасно публикуются после конструктора, поэтому неизменяемым объектом можно делиться вообще без синхронизации: состояние не меняется, значит и гонок по нему нет.

happens-before
формальная гарантия, что запись дойдёт до чтения
безопасная публикация
передача объекта другим потокам без гонки по состоянию

wait, notify и взаимная блокировка

Методы wait и notify работают только внутри синхронизированного блока того же монитора: wait отпускает замок и засыпает, notify будит. Железное правило - wait всегда внутри цикла по условию. Причин две: пробуждение без notify формально разрешено, и между notify и фактическим пробуждением условие мог изменить третий поток. Метод notifyAll безопаснее, чем notify.

Взаимная блокировка выглядит так: поток А держит замок 1 и ждёт замок 2, поток Б держит замок 2 и ждёт замок 1. Оба стоят навсегда. Классическое лечение - глобальный порядок захвата: все берут замки в одном и том же порядке, например по возрастанию идентификатора счёта, и цикл ожидания становится невозможен.

// Диагноз ставится по дампу потоков: виртуальная машина сама пишет «Found one Java-level deadlock» и показывает, кто кого ждёт. А главный генератор таких блокировок - вызов чужого кода под своим замком: что этот код захватит внутри, ты не знаешь.

wait в цикле
проверять условие заново после каждого пробуждения
порядок захвата
все берут замки в одном порядке, цикла ожидания нет

Как отвечать: «Что гарантирует volatile и когда его достаточно?»

volatile даёт две гарантии. Первая - видимость: запись одного потока действительно доходит до чтений в других. Вторая - запрет переупорядочения операций вокруг этой переменной. Насколько первая важна, я проверял: цикл по обычному булеву флагу не завершился и через две секунды после того, как другой поток выставил флаг, а с volatile вышел мгновенно. Чего volatile НЕ даёт - атомарности составных операций. Инкремент остаётся гонкой, потому что чтение, увеличение и запись никто не сделал неделимыми; у меня volatile-счётчик терял значения так же, как обычный. Достаточно его там, где один поток пишет, остальные читают, и значение самодостаточно: флаг остановки, публикация ссылки на неизменяемый конфиг. Как только появляется «прочитать и изменить в зависимости от прочитанного», нужен либо атомарный тип со сравнением и обменом, либо замок.

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

На чём валят

  • Синхронизируют только запись. Читатель без своей стороны связи видит устаревшее значение, у меня цикл не вышел вовсе.
  • Ставят volatile на счётчик и считают задачу решённой. Составная операция осталась гонкой.
  • Пишут if вместо while вокруг wait. Пробуждение без notify или третий поток ломают инвариант.
  • Синхронизируются на строке-идентификаторе. Константные строки лежат в общем пуле, и замок становится глобальным.
  • Зовут чужой код под своим замком. Что он захватит внутри, неизвестно, и взаимная блокировка готова.

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

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

  1. #synchronization1 / 5
    Что гарантирует ключевое слово volatile?
    A)Полную атомарность операций над полем, включая инкремент и другие составные действия
    B)Взаимное исключение: пока один поток пишет volatile-поле, другие потоки блокируются на чтении
    C)Видимость и упорядоченность записей/чтений, но не атомарность составных операций
    D)Кэширование значения поля в регистре потока ради ускорения повторных чтений одного значения
    показать ответ и разбор
    +C)Видимость и упорядоченность записей/чтений, но не атомарность составных операций

    // разбор: volatile обеспечивает ВИДИМОСТЬ: запись в volatile-поле сразу видна другим потокам (нет кэширования в регистре), и запрещает переупорядочивание вокруг него (happens-before). Но атомарности составных операций он НЕ даёт: counter++ (прочитать-увеличить-записать) остаётся гонкой. volatile хорош для флагов (boolean running) и публикации ссылок, а для счётчиков нужны AtomicInteger или synchronized.

  2. #synchronization2 / 5
    Почему counter++ небезопасен при конкуренции, даже если counter volatile?
    A)Потому что volatile-поля вообще не получится изменять после инициализации, запись в них запрещена
    B)Потому что оператор ++ у volatile-полей компилятор молча заменяет на обычное присваивание нуля
    C)Он безопасен: volatile делает инкремент атомарным, гонок при таком объявлении counter встречается редко
    D)Это три операции (read-modify-write); между ними вклинивается другой поток
    показать ответ и разбор
    +D)Это три операции (read-modify-write); между ними вклинивается другой поток

    // разбор: counter++ — это три отдельных действия: прочитать текущее значение, прибавить единицу, записать обратно. Два потока могут прочитать одно и то же значение, оба прибавить и записать — один инкремент теряется (lost update). volatile гарантирует лишь свежесть прочитанного/записанного значения, но НЕ атомарность этой тройки. Нужен AtomicInteger.incrementAndGet() (CAS) или synchronized.

  3. #synchronization3 / 5
    Что такое deadlock и как его чаще всего предотвращают?
    A)Взаимная блокировка на ресурсах; лечится единым порядком захвата локов
    B)Это когда поток слишком долго держит один лок; лечится увеличением таймаута ожидания для всех потоков
    C)Это переполнение пула потоков; предотвращается простым добавлением новых потоков в пул при нехватке
    D)Это гонка за процессор между потоками; устраняется повышением приоритета одного из участников
    показать ответ и разбор
    +A)Взаимная блокировка на ресурсах; лечится единым порядком захвата локов

    // разбор: Deadlock: поток A держит лок 1 и ждёт лок 2, а поток B держит лок 2 и ждёт лок 1 — оба зависают навсегда (циклическое ожидание). Классическая профилактика — захватывать локи всегда в ОДНОМ глобальном порядке (разрывает цикл), а также использовать tryLock с таймаутом, уменьшать область блокировок и избегать вложенных локов. Родственные беды — livelock и starvation.

  4. #synchronization4 / 5
    Как AtomicInteger обеспечивает потокобезопасный инкремент без synchronized?
    A)Через внутренний блок synchronized на каждый вызов — это просто более удобная обёртка над ним
    B)Через CAS (compare-and-swap) — атомарную инструкцию процессора в цикле
    C)Через volatile-поле: одной пометки volatile достаточно, чтобы инкремент стал атомарным
    D)Через отдельный поток-координатор, которому все остальные потоки шлют запросы на изменение значения
    показать ответ и разбор
    +B)Через CAS (compare-and-swap) — атомарную инструкцию процессора в цикле

    // разбор: AtomicInteger использует CAS: инструкцию процессора «сравнить-и-обменять» — атомарно записать новое значение, только если текущее совпало с ожидаемым. incrementAndGet крутит цикл: прочитать, посчитать +1, попытаться CAS; если кто-то опередил — повторить. Это lock-free: без блокировок, без усыпления потоков, хорошо масштабируется при умеренной конкуренции (при высокой — растут повторы). Основа java.util.concurrent.atomic.

  5. #synchronization5 / 5
    Почему поток может НЕ увидеть изменение обычного поля, сделанное другим потоком?
    A)Потому что каждый поток работает с полной копией всех объектов и синхронизирует их лишь при завершении
    B)Потому что JVM обновляет поля объектов не чаще одного раза в секунду по внутреннему таймеру
    C)Без happens-before значение может кэшироваться/переупорядочиваться (модель памяти)
    D)Такого встречается редко: изменение поля сразу видно всем потокам без дополнительных условий
    показать ответ и разбор
    +C)Без happens-before значение может кэшироваться/переупорядочиваться (модель памяти)

    // разбор: Java Memory Model допускает, что компилятор/процессор кэшируют значения в регистрах и переупорядочивают операции ради скорости. Без установленного отношения happens-before (через synchronized, volatile, Lock, Atomic, финальные поля и т.п.) поток может читать УСТАРЕВШЕЕ значение чужой записи — сколько угодно долго. Поэтому «просто пишем и читаем поле из разных потоков» — уже баг видимости, даже без гонки за значение.

дальше

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

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