Синхронизация и volatile в Java
Поток крутит 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, остальные разбираются в тренажёре.
- Что гарантирует ключевое слово volatile?A)Полную атомарность операций над полем, включая инкремент и другие составные действияB)Взаимное исключение: пока один поток пишет volatile-поле, другие потоки блокируются на чтенииC)Видимость и упорядоченность записей/чтений, но не атомарность составных операцийD)Кэширование значения поля в регистре потока ради ускорения повторных чтений одного значения
показать ответ и разбор
+C)Видимость и упорядоченность записей/чтений, но не атомарность составных операций// разбор: volatile обеспечивает ВИДИМОСТЬ: запись в volatile-поле сразу видна другим потокам (нет кэширования в регистре), и запрещает переупорядочивание вокруг него (happens-before). Но атомарности составных операций он НЕ даёт: counter++ (прочитать-увеличить-записать) остаётся гонкой. volatile хорош для флагов (boolean running) и публикации ссылок, а для счётчиков нужны AtomicInteger или synchronized.
- Почему 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.
- Что такое deadlock и как его чаще всего предотвращают?A)Взаимная блокировка на ресурсах; лечится единым порядком захвата локовB)Это когда поток слишком долго держит один лок; лечится увеличением таймаута ожидания для всех потоковC)Это переполнение пула потоков; предотвращается простым добавлением новых потоков в пул при нехваткеD)Это гонка за процессор между потоками; устраняется повышением приоритета одного из участников
показать ответ и разбор
+A)Взаимная блокировка на ресурсах; лечится единым порядком захвата локов// разбор: Deadlock: поток A держит лок 1 и ждёт лок 2, а поток B держит лок 2 и ждёт лок 1 — оба зависают навсегда (циклическое ожидание). Классическая профилактика — захватывать локи всегда в ОДНОМ глобальном порядке (разрывает цикл), а также использовать tryLock с таймаутом, уменьшать область блокировок и избегать вложенных локов. Родственные беды — livelock и starvation.
- Как 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.
- Почему поток может НЕ увидеть изменение обычного поля, сделанное другим потоком?A)Потому что каждый поток работает с полной копией всех объектов и синхронизирует их лишь при завершенииB)Потому что JVM обновляет поля объектов не чаще одного раза в секунду по внутреннему таймеруC)Без happens-before значение может кэшироваться/переупорядочиваться (модель памяти)D)Такого встречается редко: изменение поля сразу видно всем потокам без дополнительных условий
показать ответ и разбор
+C)Без happens-before значение может кэшироваться/переупорядочиваться (модель памяти)// разбор: Java Memory Model допускает, что компилятор/процессор кэшируют значения в регистрах и переупорядочивают операции ради скорости. Без установленного отношения happens-before (через synchronized, volatile, Lock, Atomic, финальные поля и т.п.) поток может читать УСТАРЕВШЕЕ значение чужой записи — сколько угодно долго. Поэтому «просто пишем и читаем поле из разных потоков» — уже баг видимости, даже без гонки за значение.
дальше
Теорию прочитали. Навык ставится повторением
В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.