Java. Concurrency
Java Memory Model
+ happens-before, volatile
Введение в Java Memory Model
Представьте, что вы работаете в большой команде архитекторов над одним проектом. У каждого архитектора есть свой локальный чертёжный стол, где он делает наброски (это кэш процессора). Есть также центральный, главный чертёж на стене (это оперативная память). Когда архитектор заканчивает важную часть, он должен пойти и обновить главный чертёж, чтобы остальные видели его изменения.
А теперь представьте, что архитектор ленив. Он делает пометку на своём столе, но откладывает поход к главному чертежу. В это время другой архитектор смотрит на главный чертёж и не видит этих изменений. В итоге они работают с разными версиями проекта, что приводит к ошибкам и несогласованности.
Java Memory Model (JMM) — это набор правил, который определяет, когда и как изменения, сделанные одним потоком, становятся видны другим потокам. JMM — это своего рода «протокол работы с главным чертежом», который гарантирует, что все члены команды (потоки) работают с актуальной информацией.
Без JMM многопоточное программирование было бы полностью непредсказуемым из-за двух вещей:
  • Кэши процессора
    Каждый поток может работать с локальной копией переменной, не видя изменений в основной памяти.
  • Переупорядочивание инструкций
    Компилятор и процессор для оптимизации могут менять порядок выполнения инструкций, что ломает логику многопоточных программ.
Проблема видимости: кэши и переупорядочивание
Давайте посмотрим на классический пример, который иллюстрирует проблему видимости.
class VisibilityProblem {
// Без 'volatile' нет гарантий, что изменение будет видно другому потоку
private boolean ready = false;
private int number = 0;
private class ReaderThread extends Thread {
    @Override
    public void run() {
        while (!ready) { // ← Поток может никогда не увидеть изменения ready
            // Пустой цикл
        }
        System.out.println(number); // ← Может напечатать 0, а не 42
    }
}

public void start() {
    new ReaderThread().start();
    number = 42;      // ← 1. Пишем number
    ready = true;     // ← 2. Пишем ready
	}
}
Что здесь может пойти не так?
  • Проблема с кэшем
    Поток ReaderThread может постоянно читать значение ready из своего локального кэша, который никогда не обновится, даже когда основной поток запишет true в основную память. В итоге цикл while станет бесконечным.
  • Проблема с переупорядочиванием
    Компилятор или процессор может поменять местами строки number = 42; и ready = true; для оптимизации. В таком случае ReaderThread увидит ready = true, выйдет из цикла и прочитает number, но оно всё ещё будет равно 0, потому что присваивание number = 42 ещё не выполнилось.
Чтобы решить эти проблемы, в Java есть специальные инструменты.
Ключевое слово volatile
volatile — это специальное ключевое слово, которое мы можем добавить к полю класса. Оно даёт JVM две важные инструкции:

  • Гарантия видимости
    Все записи в **volatile**переменную немедленно сбрасываются в основную память. Все чтения происходят напрямую из основной памяти, минуя кэш.
  • Запрет переупорядочивания
    Вокруг операций чтения и записи **volatile**переменной ставятся «барьеры памяти» (memory fences), которые запрещают компилятору и процессору перемещать эти инструкции относительно других операций.
Давайте исправим наш пример:
class SolvedVisibilityProblem {
// Теперь все изменения 'ready' немедленно видны всем потокам
private volatile boolean ready = false;
private int number = 0;
// ... остальной код такой же ...
}
С volatile мы гарантируем, что как только основной поток напишет ready = true, поток ReaderThread обязательно это увидит и завершит цикл.
Важное правило: volatile гарантирует только видимость, но не атомарность. Операция i++ не является атомарной, даже если i объявлена как volatile. Для атомарности составных действий используйте synchronized или классы из пакета java.util.concurrent.atomic.
Связь всех: правило happens-before
Если volatile и synchronized — это инструменты, то happens-before — это фундаментальный закон, который объясняет, почему эти инструменты работают.
Правило happens-before — это гарантия, что если одно действие (A) происходит-до (happens-before) другого действия (B), то результаты действия A гарантированно будут видны при выполнении действия B.
Это как цепочка причин и следствий. Если вы сначала написали письмо (действие А), а потом отправили его (действие Б), то получатель гарантированно увидит текст письма.
Действие, А (запись) ─ happens-before ⟶ Действие Б (чтение) Гарантия: результат, А виден в Б
Ключевые правила happens-before
Вам не нужно знать их все, но несколько основных стоит запомнить:
  • Правило монитора (для synchronized)
    Разблокировка монитора (synchronized блок завершён) happens-before последующей блокировки того же монитора (другой поток вошёл в synchronized блок).
    • Это значит, что все изменения, сделанные внутри synchronized блока, станут видны потоку, который следующим зайдёт в этот же блок.
  • Правило volatile переменной
    Запись в volatile поле happens-before каждого последующего чтения этого же поля.
    • Это и есть та самая магия volatile, которая гарантирует видимость.
  • Правило старта потока
    Вызов Thread.start() happens-before любого действия в запускаемом потоке.
    • Если вы установили значения полей перед вызовом start(), то новый поток увидит эти значения.
  • Правило завершения потока
    Все действия в потоке happens-before успешного возврата из join() в другом потоке.
    • Если поток, А выполнил threadB.join(), то поток, А гарантированно увидит все результаты работы потока B.
Практические примеры и сравнение
Пример: Идиома Double-Checked Locking
Это классический пример, где volatile абсолютно необходим. Цель — лениво создать синглтон, но избежать блокировки при каждом вызове getInstance().
class Singleton {
// ОБЯЗАТЕЛЬНО volatile!
private static volatile Singleton instance;

private Singleton() {}

public static Singleton getInstance() {
    if (instance == null) { // ← Первая проверка (без блокировки)
        synchronized (Singleton.class) {
            if (instance == null) { // ← Вторая проверка (с блокировкой)
                instance = new Singleton(); // ← Опасная строка!
            }
        }
    }
    return instance;
	}
}
Почему volatile здесь критически важен?
Операция instance = new Singleton(); не является атомарной. Она состоит из трёх шагов:
  • Выделить память под объект.
  • Вызвать конструктор для инициализации полей.
  • Присвоить ссылку на объект переменной instance.
Без volatile процессор может переупорядочить шаги 2 и 3. Что произойдет:
  • Поток, А заходит в synchronized, выделяет память и присваивает ссылку переменной instance (шаг 3).
  • В этот момент поток Б вызывает getInstance(). Он видит, что instance уже не null (первая проверка), и возвращает эту ссылку.
  • Проблема: Поток Б получает ссылку на не до конца инициализированный объект! Конструктор ещё не был вызван (шаг 2), потому что поток, А ещё его выполняет.
volatile предотвращает такое переупорядочивание, гарантируя, что ссылка на объект будет присвоена переменной только после полной инициализации объекта.
Сравнение: volatile vs synchronized
Заключение
Поздравляю, вы только что заглянули под капот JVM и разобрались в одной из самых сложных, но и самых важных тем многопоточности!
Ключевые моменты:
  • JMM
    Это правила, которые делают многопоточный код предсказуемым.
  • volatile
    Это лёгкий инструмент для гарантии видимости переменных между потоками. Он не заменяет synchronized.
  • happens-before
    Это фундаментальная концепция, которая объясняет, почему один поток видит результаты работы другого. Это основа, на которой строятся все механизмы синхронизации.
  • Double-Checked Locking
    Это яркий пример того, как без понимания JMM можно написать код, который будет работать 99% времени и сломается в самый неподходящий момент.
Понимание JMM отделяет Senior-разработчика, который просто пишет synchronized, от эксперта, который точно знает, почему volatile нужен в синглтоне и как happens-before влияет на производительность его приложения. Теперь вы на пути к тому, чтобы стать таким экспертом.
Практическое задание
Для закрепления материала выполните практическое задание в проекте practice/practice-9.
Задача: реализуйте классы и методы так, чтобы все unit-тесты в MemoryModelTest. java проходили.
Требования:
  • Используйте volatile для обеспечения видимости изменений между потоками
  • Реализуйте правильную синхронизацию для гарантии happens-before
  • Используйте synchronized для обеспечения видимости и атомарности
  • Правильно реализуйте Double-Checked Locking с volatile
Инструкция:
  • Перейдите в директорию practice/practice-9
  • Запустите тесты: mvn test
  • Реализуйте недостающие классы и методы, чтобы все тесты проходили
  • Не изменяйте сами тесты!
Подсказки:
  • volatile 
    Гарантирует видимость, но не атомарность
  • synchronized
    Гарантирует и видимость, и атомарность для блока кода
  • Thread.start()
    Создает happens-before отношение
  • Thread.join()
    Создает happens-before отношение
  • В Double-Checked Locking volatile критически важен для предотвращения переупорядочивания
  • Помните: volatile не делает операцию i++ атомарной