← назад к разделу

Две нити выполняют один и тот же код — и видят разные значения одной переменной. Это не ошибка компилятора и не баг среды выполнения: так устроено современное железо. Java Memory Model (JMM) — это набор правил, который определяет, когда запись в одном потоке становится видна другому.

Почему потоки могут видеть «старые» данные

Современный процессор не обращается к оперативной памяти при каждой операции — это слишком медленно. У каждого ядра есть собственный кэш (L1, L2), и поток работает именно с ним.

Представьте ситуацию:

  1. Поток A записывает running = false в свой кэш.
  2. Поток B читает running — но из своего кэша, где всё ещё лежит true.
  3. Флаг изменён, а поток B об этом не знает.

Кроме кэшей есть ещё одна проблема — переупорядочивание. Компилятор и процессор могут менять порядок операций ради оптимизации, если это не нарушает поведения в рамках одного потока. Для одного потока результат одинаков, но другой поток может увидеть операции в неожиданном порядке.

// Поток A
object = new SomeObject(); // (1) выделить память, (2) записать поля, (3) присвоить ссылку
ready = true;

// Поток B (без синхронизации)
if (ready) {
    object.doWork(); // object может ещё не быть полностью инициализирован!
}

Именно для таких случаев и существует JMM.

happens-before: гарантия видимости

happens-before — это отношение между операциями: если операция X happens-before операции Y, то все записи, сделанные в X, гарантированно видны Y.

Это не означает, что X выполняется раньше по часам. Это гарантия видимости: JVM обязана обеспечить, что Y увидит актуальные данные.

Короткая формула: happens-before = «ты увидишь всё, что я сделал до этой точки».

JMM устанавливает несколько встроенных правил happens-before:

  • Запись volatile-переменной happens-before её последующего чтения в любом потоке.
  • Выход из synchronized-блока happens-before входа в тот же монитор из другого потока.
  • Thread.start() happens-before любых операций внутри запущенного потока.
  • Все операции потока happens-before Thread.join() из другого потока.
  • Конструктор объекта happens-before финализатора этого объекта.
diagram

Если между двумя операциями нет отношения happens-before — нет и гарантии видимости. Программа может работать правильно на вашей машине и ломаться на другой с иной архитектурой процессора.

volatile: что гарантирует и чего не гарантирует

Ключевое слово volatile говорит JVM: «не кэшируй эту переменную, читай и пиши напрямую в общую память, и устанавливай happens-before при каждом доступе».

Классический пример — флаг остановки потока:

public class Worker implements Runnable {
    private volatile boolean running = true; // видимость гарантирована

    public void stop() {
        running = false; // запись видна другим потокам
    }

    @Override
    public void run() {
        while (running) { // каждый раз читаем актуальное значение
            doWork();
        }
    }
}

Без volatile JVM вправе однажды прочитать running в регистр и больше не обращаться к памяти. Поток никогда не увидит изменение.

Чего volatile не гарантирует — атомарности составных операций.

Инкремент counter++ — это три операции: прочитать, прибавить единицу, записать. volatile делает каждую из них видимой, но не делает всю тройку неделимой:

private volatile int counter = 0;

// В двух потоках одновременно:
counter++; // НЕБЕЗОПАСНО: между чтением и записью другой поток может вмешаться

Для атомарного инкремента нужен AtomicInteger или synchronized. volatile — только для случаев, где один поток пишет, а другие только читают, или когда нужна гарантия видимости для флагов и одиночных присваиваний.

final-поля: безопасная публикация

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

public class Config {
    public final String host;
    public final int port;

    public Config(String host, int port) {
        this.host = host; // гарантированно видно после конструктора
        this.port = port;
    }
}

Это работает, только если ссылка на объект не «утекает» из конструктора до его завершения. Передача this во внешний код внутри конструктора — распространённая ошибка, которая нарушает эту гарантию.

Неизменяемые (immutable) объекты потокобезопасны именно потому, что все их поля final и инициализируются в конструкторе.

«Работает на моей машине»: почему это опасно

Проблемы видимости почти никогда не воспроизводятся в режиме отладки. Причины:

  • x86-архитектура (большинство ноутбуков) имеет более строгую модель памяти, чем требует JMM. Многие ошибки видимости проявляются только на ARM или PowerPC.
  • JIT-компилятор в режиме интерпретации (первые запуски) не делает агрессивных оптимизаций — после прогрева поведение меняется.
  • Отладчик вставляет точки останова, которые создают барьеры памяти, скрывая гонки.

Код без явных гарантий happens-before технически некорректен, даже если годами работает без видимых ошибок.

Коротко

  • Потоки работают с кэшами процессора, а не напрямую с оперативной памятью — записи одного потока не обязаны сразу становиться видны другому.
  • Компилятор и процессор переупорядочивают операции — для одного потока всё корректно, но другой поток может увидеть другой порядок.
  • happens-before — отношение, гарантирующее видимость: всё, что сделано до happens-before-события, видно всем, кто находится после него.
  • volatile гарантирует видимость и happens-before при каждом доступе, но не атомарность составных операций вроде инкремента.
  • final-поля безопасно публикуются без явной синхронизации — при условии, что this не утекает из конструктора.
  • «Работает на моей машине» не означает корректности: другая архитектура или прогретый JIT могут проявить ошибку.

Что почитать дальше

  • Гонки данных — что именно происходит, когда happens-before нарушен, и как это выглядит на практике.
  • synchronized и мониторы — как блоки синхронизации создают happens-before и защищают составные операции.
  • Атомарные операции — AtomicInteger, AtomicReference и CAS как замена volatile там, где нужна атомарность.