Две нити выполняют один и тот же код — и видят разные значения одной переменной. Это не ошибка компилятора и не баг среды выполнения: так устроено современное железо. Java Memory Model (JMM) — это набор правил, который определяет, когда запись в одном потоке становится видна другому.
Почему потоки могут видеть «старые» данные
Современный процессор не обращается к оперативной памяти при каждой операции — это слишком медленно. У каждого ядра есть собственный кэш (L1, L2), и поток работает именно с ним.
Представьте ситуацию:
- Поток A записывает
running = falseв свой кэш. - Поток B читает
running— но из своего кэша, где всё ещё лежитtrue. - Флаг изменён, а поток 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 финализатора этого объекта.
Если между двумя операциями нет отношения 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там, где нужна атомарность.