synchronized и volatile
Вопрос-крючок: «Может ли volatile заменить synchronized?»
synchronized — блокировка, которая гарантирует, что кусок кода одновременно выполняет только один поток, и делает изменения переменных видимыми другим потокам. volatile — модификатор поля, который гарантирует ТОЛЬКО видимость изменений между потоками, но не защищает от одновременного изменения.
Это вопрос-ловушка. Кандидат, который отвечает «да, volatile — это облегчённая версия synchronized», почти наверняка не понимает, что происходит на самом деле. Разберёмся, чем они отличаются и почему путать их — серьёзная ошибка.
Что гарантирует synchronized
Ключевое слово synchronized можно повесить на метод или на блок кода. Оно даёт два разных, но связанных эффекта: во-первых, взаимное исключение (только один поток одновременно может находиться внутри), во-вторых — видимость изменений (когда поток выходит из synchronized-блока, все его изменения переменных «публикуются» и становятся видны следующему потоку, который войдёт в блокировку).
class Counter {
private int value = 0;
public synchronized void increment() {
value++;
}
public synchronized int get() {
return value;
}
}Здесь synchronized гарантирует, что операция value++ (на самом деле это три шага: прочитать, увеличить, записать) не будет прервана другим потоком посередине. Пока один поток внутри increment(), второй поток, который тоже хочет вызвать increment() или get() на этом же объекте, будет ждать снаружи.
Что гарантирует volatile
volatile — это модификатор поля, а не блокировка. Он решает совсем другую проблему: видимость. Без volatile у каждого потока в теории может быть своя «локальная копия» значения переменной (из-за кеширования в регистрах процессора или оптимизаций компилятора), и поток может годами не замечать, что другой поток изменил значение.
class Flag {
private volatile boolean running = true;
public void stop() {
running = false;
}
public void work() {
while (running) {
// полезная работа
}
System.out.println("Остановлено");
}
}Здесь volatile гарантирует, что как только один поток вызовет stop(), другой поток в цикле while (running) увидит новое значение false и выйдет из цикла — а не будет крутиться вечно, глядя на устаревшую закешированную копию.
Почему volatile НЕ заменяет synchronized
Ключевая проблема: volatile не даёт взаимного исключения. Он ничего не знает про операции «прочитать-изменить-записать» (read-modify-write) — он только гарантирует, что чтение всегда возвращает самое свежее записанное значение. Но если два потока одновременно выполняют value++ над volatile-полем, это всё равно сломается.
class BrokenCounter {
private volatile int value = 0;
public void increment() {
value++; // НЕ атомарно, даже с volatile!
}
}value++ — это на самом деле три отдельных шага: прочитать текущее значение, прибавить единицу, записать обратно. volatile гарантирует, что каждый из этих трёх шагов видит актуальные данные, но не гарантирует, что между шагом «прочитать» и шагом «записать» не вклинится другой поток. Если оба потока прочитают одно и то же значение до того, как кто-то из них успеет записать своё — один инкремент потеряется. Об этом подробно поговорим в следующем уроке про race condition.
Когда volatile действительно достаточно
Есть случаи, где volatile — правильный и достаточный инструмент, без всякого synchronized: когда переменная не участвует в составных операциях, а просто читается и записывается целиком, как флаг running выше. Типичный пример — флаг остановки потока или ссылка на объект, которую один поток публикует, а другие только читают.
Как это работает под капотом
И synchronized, и volatile работают через так называемые барьеры памяти (memory barriers) — команды, которые запрещают процессору и компилятору переупорядочивать операции чтения и записи определённым образом и заставляют сбрасывать закешированные значения в основную память. Разница в масштабе: volatile ставит такой барьер вокруг одного конкретного поля при каждом обращении к нему, а synchronized ставит барьер вокруг целого блока кода и вдобавок гарантирует, что внутрь блока одновременно попадёт только один поток.
Частые ошибки на собеседовании
- Утверждают, что volatile делает операцию
value++потокобезопасной — это не так, инкремент остаётся составным и незащищённым от одновременного доступа. - Считают volatile «более лёгкой заменой» synchronized — на деле у них разное назначение: один даёт видимость, другой — видимость плюс взаимное исключение.
- Не могут привести пример, где volatile самодостаточен (флаг остановки, публикация готового объекта) — путают его исключительно с«недоделанной блокировкой».
- Забывают, что synchronized-методы блокируются по объекту (
this) или по классу (для static-методов) — два потока, вызывающих synchronized-методы на РАЗНЫХ объектах, друг друга не блокируют.
Шпаргалка
- synchronized = взаимное исключение + видимость изменений между потоками.
- volatile = только видимость, без защиты от одновременного изменения.
- volatile безопасен для простых флагов и публикации ссылок, но не спасает составные операции вроде
counter++. - Оба механизма используют барьеры памяти, но synchronized дополнительно ограничивает доступ к блоку кода одним потоком за раз.