Вопрос 1 · Раздел 9

В чём разница между synchronized и volatile?

synchronized и volatile — это два ключевых слова в Java для работы с многопоточностью. Они решают разные задачи:

Версии по языкам: English Russian Ukrainian

Junior уровень

Базовое понимание

synchronized и volatile — это два ключевых слова в Java для работы с многопоточностью. Они решают разные задачи:

Характеристика volatile synchronized
Что делает Гарантирует видимость (запись одного потока сразу видна всем читающим, а не лежит в локальном кэше ядра) Гарантирует видимость + атомарность (составная операция выполняется целиком, без вмешательства других потоков)
Где применяется Только к полям (переменным) К методам или блокам кода
Блокировка Нет Да (захват монитора объекта)
Deadlock Невозможен Возможен

Пример volatile

public class Worker {
    private volatile boolean running = true;

    public void stop() {
        running = false; // Все потоки сразу увидят это значение
    }

    public void doWork() {
        while (running) {
            // Работаем...
        }
    }
}

Пример synchronized

public class Counter {
    private int count = 0;

    public synchronized void increment() {
        count++; // Атомарная операция — только один поток может выполнять
    }

    public synchronized int getCount() {
        return count;
    }
}

Когда что использовать

  • volatile — для флагов (stop flag, ready flag), когда один поток пишет, другие читают
  • synchronized — когда нужно атомарно прочитать-изменить-записать (счётчики, составные операции)

Когда volatile НЕ подходит

  1. Составные операции (increment, check-then-act): volatile count++ НЕ атомарен — используйте AtomicInteger
  2. Группы переменных: если нужно атомарно обновить два поля — volatile не поможет, нужен synchronized
  3. Зависимые переменные: если значение B зависит от A — volatile не гарантирует атомарность пары (A, B)

Middle уровень

Java Memory Model (JMM)

Оба механизма работают в рамках Java Memory Model (JMM), определённой в JSR-133.

Как работает volatile

При записи в volatile переменную JVM вставляет Memory Barriers:

Memory Barrier (барьер памяти / memory fence) — инструкция процессора, запрещающая переупорядочивание операций чтения/записи вокруг барьера. Без барьера процессор может переставить инструкции для оптимизации, что приведёт к некорректному поведению в многопоточной среде.

Типы барьеров в JVM:

  • LoadLoad: запретить переупорядочивание двух чтений
  • StoreStore: запретить переупорядочивание двух записей
  • LoadStore: чтение не может быть переставлено после записи
  • StoreLoad: запись не может быть переставлена после чтения (самый дорогой)

Где “Load” = чтение из памяти, “Store” = запись в память.

  • Store-Store барьер — гарантирует, что все обычные записи до volatile будут сброшены в память
  • Store-Load барьер — гарантирует, что последующие чтения увидят актуальные данные
// Без volatile — проблема видимости
int x = 0;
boolean ready = false; // без volatile

// Поток 1:
x = 42;
ready = true;

// Поток 2:
if (ready) {
    System.out.println(x); // Может вывести 0! (x ещё не виден)
}
// С volatile — гарантия видимости
int x = 0;
volatile boolean ready = false;

// Поток 1:
x = 42;
ready = true; // Store-Load барьер — x гарантированно записан

// Поток 2:
if (ready) {
    System.out.println(x); // Всегда выведет 42!
}

Happens-Before гарантия

Запись в volatile happens-before последующему чтению этой же переменной. Это означает, что все изменения, сделанные до записи в volatile, будут видны потоку, который читает эту переменную.

Как работает synchronized

synchronized обеспечивает:

  1. Взаимное исключение (Mutual Exclusion) — только один поток выполняет код одновременно
  2. Видимость (Visibility) — при входе в synchronized блок кэш инвалидируется, при выходе — данные сбрасываются в память
  3. Атомарность (Atomicity) — составные операции выполняются как единое целое
// Проблема: volatile НЕ обеспечивает атомарность
volatile int counter = 0;
counter++; // НЕ безопасно! Это 3 операции: read → modify → write

// Решение: synchronized обеспечивает атомарность
synchronized(lock) {
    counter++; // Безопасно — только один поток выполняет
}

volatile vs AtomicBoolean: volatile гарантирует только видимость. AtomicBoolean гарантирует видимость + атомарность через CAS. Для простого флага (running = true) volatile достаточно. Для if (!flag.compareAndSet(false, true)) нужен AtomicBoolean.

Double-Checked Locking паттерн

Классический пример, где volatile критически важен:

public class Singleton {
    // ОБЯЗАТЕЛЬНО volatile! Без него возможен частично инициализированный объект
    private static volatile Singleton instance;

    public static Singleton getInstance() {
        if (instance == null) {                  // Первая проверка (без блокировки)
            synchronized (Singleton.class) {
                if (instance == null) {          // Вторая проверка (с блокировкой)
                    instance = new Singleton();  // Создание объекта
                }
            }
        }
        return instance;
    }
}

Без volatile возможна ситуация, когда:

  1. Поток A начинает создавать instance (выделяет память, но конструктор не завершён)
  2. JVM переупорядочивает: сначала присваивает ссылку, потом вызывает конструктор
  3. Поток B видит instance != null и получает частично инициализированный объект

Senior уровень

Under the Hood: Реализация на уровне CPU

volatile на уровне процессора

На архитектуре x86 запись в volatile поле транслируется в инструкцию с префиксом lock, который:

Префикс lock — x86-инструкция, блокирующая кэш-линию на время операции. Другие ядра не могут читать/писать эту кэш-линию, пока операция не завершена.

  • Блокирует кэш-линию на время записи
  • Посылает сигнал инвалидации другим ядрам (протокол MESI)
  • Запрещает переупорядочивание инструкций через барьер

Протокол MESI (Modified, Exclusive, Shared, Invalid) — протокол когерентности кэшей многоядерных CPU. Когда ядро записывает данные (Modified), оно рассылает сигнал другим ядрам — их копии становятся Invalid и должны быть перечитаны из RAM.

# Обычная запись (без volatile):
mov [rax], 1

# Volatile запись (с memory barrier):
lock or dword ptr [rax], 0  # Store barrier
mov [rax], 1
lock or dword ptr [rax], 0  # Load barrier

synchronized на уровне байт-кода

synchronized(obj) { /* code */ }

Байт-код:

aload_1         // Загружаем obj
dup
astore_2
monitorenter    // Захват монитора
/* code */
aload_2
monitorexit     // Освобождение монитора

Важно: компилятор генерирует два monitorexit — один для нормального выхода, второй для обработки исключения (скрытый finally).

Reordering и Memory Barriers

Процессоры и JIT-компилятор переупорядочивают инструкции для оптимизации. volatile накладывает ограничения:

Тип барьера Запрещает
LoadLoad Чтение после чтения не может быть перемещено вверх
StoreStore Запись после записи не может быть перемещена вверх
LoadStore Запись после чтения не может быть перемещена вверх
StoreLoad Чтение после записи не может быть перемещено вверх (самый дорогой)

volatile запись = StoreStore + StoreLoad барьеры volatile чтение = LoadLoad + LoadStore барьеры

Производительность и Highload

Cache Invalidation и False Sharing

public class Counters {
    public volatile int counter1; // Могут быть в одной кэш-линии
    public volatile int counter2; // Запись в counter1 инвалидирует counter2!
}

Решение — @Contended (Java 8+):

public class ContendedCounters {
    @Contended
    public volatile int counter1;

    @Contended
    public volatile int counter2; // Теперь в разных кэш-линиях
}

Адаптивный спиннинг (Adaptive Spinning)

Современная JVM не отправляет поток в BLOCKED сразу. Сначала поток выполняет spin-wait — пустой цикл, ожидая освобождения монитора. Если спиннинг оказался успешным — экономим на context switch. Если нет — паркуем поток через ОС.

Сравнительная таблица (Advanced)

Характеристика volatile synchronized
Тип гарантии Видимость + упорядочивание Видимость + атомарность + исключение
Уровень Переменная (поле) Метод или блок кода
Блокировка Нет (Lock-free) Да (Blocking)
Кэш CPU Отключает кэширование переменной Сбрасывает весь локальный кэш при входе/выходе
Deadlock Невозможен Возможен
Overhead Низкий (memory barrier) Средний/высокий (зависит от contention)
Реентерабельность Н/Д Да

Диагностика

# jstack покажет BLOCKED потоки для synchronized
jstack <pid> | grep "BLOCKED"

# Для volatile проблем — только через анализ поведения
# JCStress для стресс-тестирования

Best Practices

  1. volatile — для флагов и публикации immutable объектов
  2. synchronized — для составных операций (read-modify-write)
  3. Atomic* — для простых счётчиков (CAS быстрее synchronized)
  4. Private lock — используйте private final Object lock вместо synchronized(this)
  5. Избегайте volatile long/double без необходимости атомарности (на 32-битных JVM)

🎯 Шпаргалка для интервью

Обязательно знать:

  • volatile гарантирует только видимость, synchronized — видимость + атомарность + mutual exclusion
  • volatile применяется только к полям, synchronized — к методам и блокам кода
  • volatile НЕ защищает составные операции (count++ не атомарен даже с volatile)
  • volatile использует memory barriers (StoreStore, StoreLoad), synchronized — захват монитора
  • Deadlock невозможен с volatile, но возможен с synchronized
  • На x86 volatile запись транслируется в lock префикс и инвалидацию кэш-линий (MESI)
  • Double-Checked Locking требует volatile для корректной публикации Singleton
  • volatile — lock-free, overhead ниже чем у synchronized при низкой конкуренции

Частые уточняющие вопросы:

  • Можно ли использовать volatile для счётчика? — Нет, count++ = read-modify-write (3 операции), используйте AtomicInteger
  • Что произойдёт без volatile в Double-Checked Locking? — JVM может переупорядочить: ссылка присвоена до завершения конструктора → частично инициализированный объект
  • Какие memory barriers вставляет volatile? — Запись: StoreStore + StoreLoad; Чтение: LoadLoad + LoadStore
  • Когда volatile предпочтительнее synchronized? — Для простых флагов (stop, ready), где один поток пишет, другие читают

Красные флаги (НЕ говорить):

  • “Volatile делает операции атомарными” — нет, это про AtomicInteger/CAS
  • “Synchronized и volatile взаимозаменяемы” — нет, они решают разные задачи
  • “Volatile защищает группу переменных” — нет, только одну переменную
  • “Synchronized нужен для видимости, volatile для атомарности” — ровно наоборот

Связанные темы:

  • [[2. Что такое happens-before relationship]] — volatile запись happens-before чтению
  • [[3. Что такое visibility problem]] — volatile решает проблему видимости
  • [[8. Что такое Atomic классы]] — lock-free альтернатива для счётчиков
  • [[9. Что такое CAS (Compare-And-Swap)]] — основа работы Atomic-классов