Два потока выполняют один и тот же код — и видят разные значения одной переменной. Это не ошибка компилятора и не баг среды выполнения: так устроено современное железо. Java Memory Model (JMM) — это набор правил, который определяет, когда запись в одном потоке становится видна другому.
Сверху обычное поле: запись до памяти доходит, но цикл сверяется с копией, которую JIT положил в регистр, и не кончается никогда. Снизу то же поле объявлено volatile: запись выталкивается барьером, каждый виток идёт в память — поток B видит false и выходит.
Почему потоки могут видеть «старые» данные
Поток A записывает running = false, а поток B крутится в цикле так, будто там по-прежнему true, — и может не узнать об изменении никогда.
Современный процессор не обращается к оперативной памяти при каждой операции — это слишком медленно. У каждого ядра есть собственный кэш (L1, L2), и почти вся работа с данными идёт через него. Только кэши ядер железо держит согласованными между собой: само по себе кэширование чужую запись не прячет. Слои, которые её прячут, стоят по обе стороны от кэша.
Почему так выходит? Причин две. Первая: запись сначала попадает в буфер записи внутри ядра и до общей памяти доходит не мгновенно. Вторая, куда более коварная: компилятор JIT видит цикл, в котором running никто не меняет, и честно оптимизирует — читает поле один раз, кладёт в регистр и дальше проверяет уже его. Тогда никакая запись из другого потока цикл не остановит в принципе.
Кроме видимости есть ещё одна проблема — переупорядочивание. Компилятор и процессор меняют порядок операций ради оптимизации: в рамках одного потока результат тот же, а другой поток увидит операции в неожиданном порядке.
// Поток A
object = new SomeObject(); // (1) выделить память, (2) записать поля, (3) присвоить ссылку
ready = true;
// Поток B (без синхронизации)
if (ready) {
object.doWork(); // object может ещё не быть полностью инициализирован!
}
Чинится это одним словом: объявить ready как volatile. Почему одного флага достаточно, хотя сломанным выглядит object: запись volatile-переменной ставит барьер, который запрещает переносить через неё предыдущие записи, а чтение того же поля — барьер для последующих чтений. То есть если поток B увидел ready == true, он обязан увидеть и всё, что поток A записал до этой строки, включая заполненные поля объекта. Это и есть правило happens-before из следующего раздела, применённое к публикации: один volatile-флаг делает безопасной публикацию сколь угодно большого объекта.
happens-before: гарантия видимости
happens-before — это отношение между операциями: если операция X happens-before операции Y, то все записи, сделанные в X, гарантированно видны Y.
Это не про порядок по часам, а гарантия видимости: JVM обязана обеспечить, что Y увидит актуальные данные.
Короткая формула: happens-before = «ты увидишь всё, что я сделал до этой точки».
JMM устанавливает несколько встроенных правил happens-before:
- Запись
volatile-переменной happens-before её последующего чтения в любом потоке. - Выход из
synchronized-блока happens-before входа в тот же монитор из другого потока. Thread.start()happens-before любых операций внутри запущенного потока.- Все операции потока happens-before
Thread.join()из другого потока. Thread.interrupt()happens-before момента, когда прерванный поток об этом узнал: увидел выставленный флаг или поймалInterruptedException.- Отношение транзитивно: если X happens-before Y, а Y happens-before Z, то X happens-before Z.
Если между двумя операциями нет отношения happens-before — нет и гарантии видимости. Программа может работать правильно на вашей машине и ломаться на другой с иной архитектурой процессора.
Есть и отдельная гарантия, которой в списке нет и которая ломается как раз без синхронизации: атомарность 64-битных записей. Запись обычного (не volatile) long или double спецификация разрешает разбить на две 32-битные половины. Соседний поток тогда может прочитать половину от старого значения и половину от нового — то есть число, которого никто никогда не записывал. Это единственное место модели памяти, где появляются значения «из воздуха», и лечится оно тем же volatile (у него 64-битные чтение и запись атомарны всегда) или любым замком.
volatile: что гарантирует и чего не гарантирует
Ключевое слово volatile запрещает JVM две конкретные вещи, а не оптимизации вообще: держать значение поля в регистре и переставлять обращения к нему местами с соседними операциями. Каждое чтение — настоящее чтение из памяти, а не из регистра; каждая запись доходит до других потоков; вокруг обращений расставляются барьеры. Всё остальное JIT оптимизирует как обычно — и код вокруг volatile-поля в том числе.
Классический пример — флаг остановки. Запустим два одинаковых цикла: один проверяет обычное поле, другой — volatile.
живой пример
public class VisibilityDemo {
static boolean plain = true;
static volatile boolean guarded = true;
public static void main(String[] args) throws InterruptedException {
System.out.println("обычное поле: остановился = " + spin(false));
System.out.println("volatile: остановился = " + spin(true));
}
static boolean spin(boolean useVolatile) throws InterruptedException {
Thread worker = new Thread(() -> {
if (useVolatile) { while (guarded) { } } else { while (plain) { } }
});
worker.setDaemon(true);
worker.start();
Thread.sleep(300);
if (useVolatile) guarded = false; else plain = false;
worker.join(2000);
return !worker.isAlive();
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Вывод — обычное поле: остановился = false и volatile: остановился = true. За 300 миллисекунд, что крутился цикл, JIT успел положить обычное поле в регистр: запись до памяти дошла, но оттуда её больше не читают, и join(2000) истёк впустую. С volatile цикл вышел сразу.
Обещания в первой строке при этом нет: на другой машине поток может и остановиться. Гарантии не даёт ни один исход — в этом и беда.
Ещё деталь про сам пример: первый поток так и остался крутиться. Записи он не видит, остановить его нечем, и до конца программы он будет занимать ядро. Поэтому запускать пример стоит там, где ядер хотя бы четыре: на одном-двух он смажет второй замер.
Где volatile не помогает
Чего volatile не гарантирует — атомарности составных операций.
Инкремент counter++ — это три операции: прочитать, прибавить единицу, записать. volatile делает каждую из них видимой, но не делает всю тройку неделимой:
живой пример
public class CounterDemo {
static volatile int counter = 0;
public static void main(String[] args) throws InterruptedException {
Runnable job = () -> { for (int i = 0; i < 200_000; i++) counter++; };
Thread a = new Thread(job), b = new Thread(job);
a.start(); b.start();
a.join(); b.join();
System.out.println("ждали 400000, получили " + counter);
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Печатает что-нибудь вроде ждали 400000, получили 236844, и каждый запуск даёт своё число: оба потока прочитали одно значение и записали один и тот же результат.
Для атомарного инкремента нужен AtomicInteger или synchronized. volatile — для флагов и одиночных присваиваний: один поток пишет, остальные читают.
Атомики при этом не отдельный мир, а надстройка над тем же механизмом: AtomicInteger.get() это ровно volatile-чтение, set() — volatile-запись, с теми же барьерами и тем же happens-before. Разница только в одном методе, compareAndSet, которого у поля нет: он добавляет к видимости ещё и атомарность «проверить и заменить». Поэтому AtomicInteger можно читать как «volatile-поле плюс CAS», и любое правило видимости из этой статьи к нему применимо без оговорок; подробности в статье про атомики.
final-поля: безопасная публикация
Объект сконструировали в одном потоке и отдали другому без синхронизации, и второй поток увидел его недостроенным: ссылка уже есть, а в поле ещё значение по умолчанию. От этой беды спасают final-поля: значения, записанные в них в конструкторе, видны любому потоку, получившему ссылку на объект, даже без явной синхронизации. В списке happens-before выше этой гарантии нет, она устроена отдельно.
Работает это до тех пор, пока ссылка не «утекла» из конструктора раньше времени. Передачу this во внешний код видно даже без второго потока:
живой пример
public class EscapeDemo {
static Config leaked;
static class Config {
final String host;
Config(String host) {
leaked = this; // так не надо: this ушёл наружу
System.out.println("внутри конструктора host = " + leaked.host);
this.host = host;
}
}
public static void main(String[] args) {
new Config("db.internal");
System.out.println("после конструктора host = " + leaked.host);
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Печатает внутри конструктора host = null, а потом после конструктора host = db.internal: получивший ссылку раньше срока увидел недостроенный объект. В одном потоке это заметно сразу, в многопоточном коде — через раз: гарантия final-полей на сбежавшую ссылку не распространяется.
Неизменяемые (immutable) объекты потокобезопасны именно потому, что все их поля final и инициализируются в конструкторе, а this из него не выпускают.
Одна ссылка и две точки времени: на шаге 2 ссылка ушла наружу, на шаге 3 поле ещё пустое, и только шаг 4 достраивает объект.
Способы безопасной публикации
final-поля — частный случай общего вопроса: как отдать объект другому потоку так, чтобы тот увидел его целиком, а не наполовину собранным. Это называют безопасной публикацией, и способов ровно пять; любой из них достаточен.
- Статическое поле, заполненное при загрузке класса.
static final Config CONFIG = new Config();— JVM гарантирует, что класс загрузится один раз и все увидят готовый объект. - Ссылка в
volatile-поле или вAtomicReference. Запись ссылки ставит барьер, и читатель, увидевший её, видит и содержимое объекта. - Все поля объекта
final. Гарантия из предыдущего раздела; работает, покаthisне сбежал из конструктора. - Ссылка записана под замком и читается под тем же замком. Обычное правило happens-before для монитора.
- Объект положен в потокобезопасную коллекцию.
ConcurrentHashMap,BlockingQueueи родственники обещают, что положенное одним потоком безопасно достаётся другим: публикация встроена в саму коллекцию.
Небезопасно всё остальное, и прежде всего — присваивание обычного поля без синхронизации, то самое ready = true из начала статьи. Отсюда практическое правило: объект, который передают в другой поток, либо неизменяем (все поля final), либо публикуется одним из пяти способов выше. «Передал через обычное поле, потом положил флажок» — не способ.
«Работает на моей машине»: почему это опасно
Проблемы видимости почти никогда не воспроизводятся в режиме отладки. Причины:
- Архитектура процессора. x86 гарантирует больше, чем требует JMM, — часть ошибок там не проявляется вовсе. На ARM (ноутбуки Apple Silicon, немалая доля облачных серверов) модель памяти слабее, и та же ошибка вылезает.
- JIT-компилятор в режиме интерпретации (первые запуски) не делает агрессивных оптимизаций — после прогрева поведение меняется.
- Отладчик вставляет точки останова, которые создают барьеры памяти, скрывая гонки.
Код без явных гарантий happens-before технически некорректен, даже если годами работает без видимых ошибок.
Глубже: ленивая инициализация без гонки: holder, двойная проверка, computeIfAbsentрасширенное
Дорогой объект хочется создать при первом обращении, а не на старте. Первая версия пишется за минуту и содержит гонку:
private Parser parser;
Parser parser() {
if (parser == null) parser = new Parser(); // два потока увидят null и создадут два парсера
return parser;
}
Второй поток может увидеть и хуже, чем два парсера: ссылку на объект, конструктор которого не дописал поля, потому что без happens-before порядок записи полей и записи ссылки не гарантирован. Это и называется небезопасной публикацией.
Три способа сделать правильно, от простого к гибкому. Статический инициализатор, private static final Parser PARSER = new Parser();, создаётся при загрузке класса, JVM гарантирует единственность и видимость, лениво ровно настолько, насколько ленива загрузка класса. Идиома holder даёт настоящую ленивость той же ценой:
class ParserHolder { static final Parser INSTANCE = new Parser(); } // вложенный класс грузится при первом обращении
Parser parser() { return ParserHolder.INSTANCE; }
Двойная проверка нужна, когда экземпляр не статический:
private volatile Parser parser;
Parser parser() {
Parser local = parser;
if (local == null) {
synchronized (this) {
local = parser;
if (local == null) parser = local = new Parser();
}
}
return local;
}
volatile здесь не украшение: без него первая проверка может увидеть недостроенный объект. А если ленивых объектов много, по одному на ключ, всё это заменяет одна строка: cache.computeIfAbsent(key, k -> new Parser(k)) на ConcurrentHashMap, где атомарность обещает сама карта. Задача «Ленивая инициализация под гонкой» в тренажёре ждёт любого из этих ответов и не примет первый вариант.
Коротко
- Записи одного потока не обязаны сразу становиться видны другому: мешают буфер записи и оптимизации JIT.
- Компилятор и процессор переупорядочивают операции: для одного потока порядок верный, для соседнего — нет.
- happens-before — отношение, гарантирующее видимость: всё, сделанное до такого события, видно всем, кто после.
volatileдаёт видимость и happens-before при каждом доступе, но не атомарность составных операций вроде инкремента.final-поля публикуются безопасно — при условии, чтоthisне утекает из конструктора.- «Работает на моей машине» не означает корректности: другая архитектура или прогретый JIT проявят ошибку.
- Ленивая инициализация без гонки: статический инициализатор, holder-класс, двойная проверка с
volatileилиcomputeIfAbsent; проверкаif (x == null) x = newнебезопасна. - Небезопасную публикацию из примера с
readyчинит одинvolatile-флаг: его барьеры вытаскивают и все записи, сделанные до него. - Способов безопасной публикации пять: статическое поле,
volatile/AtomicReference, все поляfinal, запись и чтение под одним замком, потокобезопасная коллекция. - Обычные
longиdoubleпишутся неатомарно и могут читаться половинками;AtomicInteger.get()/set()это те же volatile-чтение и запись плюсcompareAndSet.
Что почитать дальше
- Гонки данных — что именно происходит, когда happens-before нарушен, и как это выглядит на практике.
- synchronized и мониторы — как блоки синхронизации создают happens-before и защищают составные операции.
- Атомарные операции —
AtomicInteger,AtomicReferenceи CAS как заменаvolatileтам, где нужна атомарность. - Типичные ошибки многопоточности — с чего начинаются зависания и потерянные обновления в реальном коде.