Перейти к содержимому

Java Memory Model: visibility, ordering и happens-before

Разбираемся, зачем Java Memory Model нужна в многопоточном коде, откуда берутся проблемы видимости и data race и какие гарантии дают volatile, synchronized, start() и join().

Посмотрим на код:

public class StopExample {

    private static boolean stopped;

    public static void main(String[] args) throws InterruptedException {

        Thread worker = new Thread(() -> {
            while (!stopped) {
                // Работаем
            }

            System.out.println("Остановились");
        });

        worker.start();

        Thread.sleep(1_000);
        stopped = true;

        worker.join();
    }
}

Поток main через секунду записывает:

stopped = true;

Значит, worker должен увидеть новое значение и выйти из цикла. Выглядит очевидно, но Java такой гарантии здесь не даёт: программа может завершиться, а может продолжить крутиться в цикле, особенно после того, как JIT-компилятор успеет оптимизировать горячий код.

Причина не в том, что Java “иногда плохо синхронизирует память”. Синхронизации здесь вообще нет — и вот с этого места начинается Java Memory Model.

Сначала немного спустимся вниз

Java-приложение работает внутри процесса операционной системы. Часто говорят, что процессу выделяется собственный кусок оперативной памяти. Для первого приближения этого достаточно, но физически картина сложнее: процесс работает с виртуальным адресным пространством, а операционная система отображает его страницы на физическую память. Эти страницы вовсе не обязаны лежать рядом в RAM.

Внутри процесса работает JVM. Упрощённо нас сейчас интересуют две области памяти:

Java process
└── JVM
    ├── Heap
    ├── Thread 1 stack
    ├── Thread 2 stack
    ├── Thread 3 stack
    └── ...
У потоков собственные стеки, но они могут работать с одними объектами в общей heap-памяти.
У потоков собственные стеки, но они могут работать с одними объектами в общей heap-памяти.

Heap общий

Логически именно в heap находятся экземпляры классов и массивы. Например:

Order order = new Order();

Объект Order относится к общей памяти JVM. Если ссылки на него получили несколько потоков, они могут одновременно читать и изменять его поля. Именно здесь многопоточность перестаёт быть просто способом выполнить две задачи одновременно.

order.setStatus(COMPLETED);

Если другой поток в этот же момент работает с order, уже нужно думать, что именно он может увидеть.

Stack у каждого потока свой

У каждого потока есть собственный JVM stack. Когда вызывается метод, в stack появляется новый frame:

Thread stack

┌──────────────────────────┐
│ OrderService.process()   │
│                          │
│ order                    │
│ amount = 100             │
└──────────────────────────┘
┌──────────────────────────┐
│ Controller.handle()      │
└──────────────────────────┘

Когда метод завершился, frame снимается со stack. Часто это объясняют фразой:

примитивы находятся в stack, а объекты — в heap.

Как учебная модель это работает, но буквально она неверна. Локальная переменная:

int amount = 100;

находится во frame текущего метода.

Но если int является полем объекта:

class Order {
    private int amount;
}

то значение является частью состояния объекта.

А локальная переменная:

Order order;

содержит ссылку, тогда как сам объект находится в общей памяти. Плюс JIT вообще может положить значение в регистр, устранить объект с помощью escape analysis или выполнить другие оптимизации.

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

Поток тоже требует памяти

Для каждого обычного платформенного потока JVM создаёт собственный stack. Можно встретить утверждение:

Java выделяет по 1 МБ на поток.

На популярных конфигурациях JVM это действительно может быть значением по умолчанию, но считать 1 МБ частью спецификации Java нельзя. Размер зависит от JVM, операционной системы и архитектуры.

Его можно изменить:

java -Xss512k Application

В рабочих сервисах проблема обычно обнаруживается не потому, что кто-то решил посчитать память одного Thread, а когда thread dump внезапно показывает несколько сотен потоков. HTTP-сервер, Kafka, scheduler, клиенты внешних систем, собственные ExecutorService — каждый компонент понемногу добавляет свои пулы.

В какой-то момент оказывается, что значительная часть потоков вообще не работает: они ждут соединение из пула, ответ внешней системы или возможность взять блокировку. Уменьшить -Xss иногда полезно, но если сервису неожиданно понадобилась тысяча платформенных потоков, я бы сначала разбирался именно с этой тысячей.

В конечном итоге код попадает на процессор

Stack и heap сами ничего не выполняют. Когда поток находится в состоянии RUNNABLE и планировщик предоставляет ему процессорное время, его инструкции выполняются процессором.

Допустим, есть один процессор с одним ядром и два готовых потока:

Thread A
Thread B

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

Операционная система некоторое время выполняет Thread A, затем переключается на Thread B:

A A A A | B B B | A A | B B B B | ...

Это time slicing. При переключении нужно сохранить состояние одного потока и восстановить состояние другого — такое переключение называют context switch. Приложение выглядит многопоточным, хотя в каждый конкретный момент инструкции выполняет только один поток.

Если ядер несколько

На двух ядрах ситуация уже другая:

Core 1 -> Thread A
Core 2 -> Thread B

Два потока действительно могут исполняться параллельно. Отсюда легко сделать следующий вывод:

Если у меня 16 ядер, значит 16 потоков ускорят программу в 16 раз.

Обычно нет. Часть программы остаётся последовательной, потоки синхронизируются, ждут память, конкурируют за блокировки и другие ресурсы. Если задача CPU-bound, количество доступных процессоров действительно задаёт естественную границу полезного параллелизма. Если задача в основном ждёт сеть или базу, потоков может быть больше ядер.

На одном из интеграционных сервисов я встречал классическую ситуацию: размер рабочего пула постепенно увеличивали в надежде получить большую пропускную способность. Сначала это действительно помогало, потом рост остановился: потоков уже оказалось значительно больше, чем соединений в HTTP connection pool. Большинство новых потоков не выполняли запросы, а просто стояли в очереди за соединением. Добавили параллелизма — получили больше ожидающих.

А между ядром и RAM ещё несколько уровней памяти

Процессор работает значительно быстрее оперативной памяти. Если каждую операцию чтения выполнять непосредственно из RAM, большая часть производительности процессора терялась бы на ожидании. Поэтому между ядрами и оперативной памятью находятся кэши. Сильно упрощённо:

Core

L1

L2

L3

RAM

L1 обычно самый маленький и быстрый, L2 больше, а L3 ещё крупнее и часто используется несколькими ядрами. Точная архитектура зависит от конкретного процессора, поэтому эту схему стоит воспринимать как удобное приближение.

Упрощённая иерархия памяти процессора: регистры, кэши L1, L2, L3 и RAM.
Упрощённая иерархия памяти процессора: регистры, кэши L1, L2, L3 и оперативная память.

Эта схема помогает интуитивно представить одну из причин проблем видимости. Допустим, два потока работают на разных ядрах:

Core 1                Core 2
   ↓                     ↓
value = 100           value = 100

Первый поток меняет:

value = 101;

но второй какое-то время продолжает работать со старым значением. Такая физическая модель помогает понять проблему, но для Java-кода она ещё не описывает всех возможных причин.

Где здесь появляется Java Memory Model

Между нашим .java и выполнением кода есть несколько уровней, на которых возможны оптимизации:

Java source

javac

bytecode

JIT compiler

machine code

CPU

Компилятор может изменить порядок независимых операций, JIT — убрать повторное чтение значения, а процессор — использовать регистры и выполнять инструкции не точно в том порядке, в котором они написаны в Java. Всё это нормально, пока наблюдаемое поведение программы соответствует правилам Java Memory Model.

JMM не привязана к конкретной реализации процессора. Она описывает какие результаты разрешено наблюдать Java-программе при взаимодействии нескольких потоков.

Вернёмся к stopped

Посмотрим ещё раз:

private static boolean stopped;

Один поток выполняет:

while (!stopped) {
}

а другой:

stopped = true;

С точки зрения одного потока цикл выглядит примерно так:

if (!stopped) {
    while (true) {
    }
}

Если внутри цикла ничего не меняет stopped, JIT может решить, что нет смысла перечитывать это поле снова и снова. В однопоточном коде такая оптимизация ничего бы не изменила, но здесь поле меняется другим потоком. Наш код при этом не создал между потоками никаких отношений, которые гарантировали бы worker наблюдение новой записи.

Исправление:

private static volatile boolean stopped;

Теперь запись:

stopped = true;

и последующее чтение stopped другим потоком получают специальные гарантии видимости и порядка.

public class StopExample {

    private static volatile boolean stopped;

    public static void main(String[] args)
        throws InterruptedException {

        Thread worker = new Thread(() -> {
            while (!stopped) {
                // Работаем
            }

            System.out.println("Остановились");
        });

        worker.start();

        Thread.sleep(1_000);
        stopped = true;

        worker.join();
    }
}

Для простого флага остановки volatile подходит хорошо. Но дальше начинается нюанс.

volatile не превращает операции в атомарные

Возьмём счётчик:

private static volatile int counter;

И два потока:

counter++;

Можно ожидать, что volatile всё исправил, но это не так: counter++ — не одна операция.

Упрощённо:

read counter
add 1
write counter

Теперь пусть исходное значение равно 100.

Thread A             Thread B

read 100
                     read 100

add 1
                     add 1

write 101
                     write 101

Оба потока увеличили счётчик.

Результат:

101

Одно увеличение потерялось — это lost update.

volatile гарантирует свойства видимости и порядка вокруг обращения к переменной, но не превращает последовательность read + modify + write в одну неделимую операцию.

Для такого счётчика можно использовать:

AtomicInteger counter = new AtomicInteger();
counter.incrementAndGet();

или синхронизировать изменение:

synchronized (lock) {
    counter++;
}

В зависимости от задачи может подойти и LongAdder.

Data race и race condition — не одно и то же

Термины часто используют как синонимы, хотя это немного разные вещи. Data race в контексте Java возникает, когда несколько потоков выполняют конфликтующие обращения к одной переменной, хотя бы одно из них является записью, а между этими действиями нет необходимого отношения happens-before. Наш:

counter++;

без синхронизации — хороший пример.

Race condition — понятие шире: программа содержит её, если правильность результата зависит от взаимного порядка событий. Например:

if (!map.containsKey(id)) {
    map.put(id, createValue());
}

Даже если методы containsKey() и put() сами по себе потокобезопасны, вся последовательность check -> act не обязательно атомарна: другой поток может вклиниться между проверкой и записью. В реальном приложении это часто выглядит ещё неприятнее.

Например, один поток получает успешный ответ от внешней системы:

PROCESSING -> COMPLETED

а параллельный scheduler решает, что запрос завис:

PROCESSING -> FAILED

Оба успевают прочитать:

PROCESSING

и оба считают свой переход допустимым, поэтому итоговый статус определяется тем, кто последним записал значение в базу.

В одной JVM проблему иногда можно закрыть локальной синхронизацией. Если приложение запущено в нескольких экземплярах, synchronized уже ничего не решает: конкурентный поток вполне может находиться в другой JVM.

Тогда приходится опускать гарантию ниже — например, использовать optimistic locking, условный UPDATE или другой механизм координации через общее хранилище.

Что такое happens-before

В центре Java Memory Model находится отношение happens-before. Название немного сбивает с толку: это не просто

операция A физически произошла раньше операции B.

Например:

data = 42;
ready = true;

Эти строки действительно написаны одна после другой, но если другой поток читает ready и data без синхронизации, одного порядка строк исходного кода недостаточно для корректного межпоточного взаимодействия.

Happens-before означает более сильную гарантию:

результаты действия A должны быть доступны действию B в соответствии с правилами JMM.

Рассмотрим пример:

class SharedData {
    int value;
    boolean ready;
}

Первый поток:

shared.value = 42;
shared.ready = true;

Второй:

if (shared.ready) {
    System.out.println(shared.value);
}

Логика кажется простой:

value = 42
ready = true

Значит, если второй поток увидел:

ready == true

он должен увидеть и:

value == 42

Без синхронизации полагаться на это нельзя.

Теперь сделаем ready volatile:

class SharedData {
    int value;
    volatile boolean ready;
}

Запись:

shared.ready = true;

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

А запись:

shared.value = 42;

произошла до записи ready в первом потоке.

Получаем цепочку:

value = 42

ready = true
    ↓ happens-before
read ready == true

read value

Второй поток должен увидеть корректное значение value.

volatile связывает не только значение самого флага: операции до volatile write становятся видимыми после соответствующего volatile read.
volatile связывает не только значение самого флага: операции до volatile write становятся видимыми после соответствующего volatile read.

Здесь мы уже рассуждаем на уровне гарантий JMM: важен не физический путь значения через память, а связь между действиями потоков.

Какие действия создают happens-before

Для повседневного Java-кода полезно помнить несколько основных правил.

Порядок внутри одного потока

Если в одном потоке написано:

x = 10;
y = 20;

операции этого потока должны наблюдаться им в соответствии с программным порядком.

Это не означает, что процессор буквально обязан выполнить машинные инструкции именно в таком порядке. Оптимизации разрешены, если не меняют допустимое поведение программы.

synchronized

Если один поток выходит из:

synchronized (lock) {
    data = 42;
}

а другой после этого захватывает тот же монитор:

synchronized (lock) {
    System.out.println(data);
}

освобождение монитора happens-before последующего успешного захвата того же монитора.

Поэтому synchronized даёт не только взаимное исключение, но и необходимые гарантии видимости. Это иногда забывают, воспринимая synchronized исключительно как “не пустить второй поток внутрь блока”.

volatile

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

volatile boolean ready;

поэтому часто используется как простой механизм публикации состояния или передачи флага между потоками.

Thread.start()

Код:

value = 42;
Thread worker = new Thread(
    () -> System.out.println(value));
worker.start();

получает важную гарантию.

Действия потока, вызвавшего start(), выполненные до start(), happens-before действиям запущенного потока.

То есть здесь worker увидит корректно опубликованное значение value.

Thread.join()

В обратную сторону работает join().

worker.start();
worker.join();
System.out.println(result);

Все действия worker до его завершения happens-before успешному возврату из join().

Поэтому после:

worker.join();

можно читать результат работы завершившегося потока без добавления отдельного volatile только ради этого перехода.

Это хороший пример того, почему JMM оказывается вокруг нас даже тогда, когда в коде вообще нет слова volatile.

synchronized — не просто блокировка

Возьмём:

class Counter {

    private int value;

    public synchronized void increment() {
        value++;
    }

    public synchronized int get() {
        return value;
    }
}

Здесь synchronized решает сразу две разные задачи. Первая — атомарность критической секции: два потока не будут одновременно выполнять increment() на одном объекте. Вторая — видимость: изменения, сделанные потоком до освобождения монитора, становятся доступны потоку, который после этого захватит тот же монитор.

Именно сочетание этих свойств делает synchronized гораздо сильнее обычного запрета “двум потокам нельзя выполнять этот код одновременно”.

Почему код без data race сильно проще

Java Memory Model даёт очень важное свойство правильно синхронизированным программам. Если программа не содержит data race, разработчик может рассуждать о её поведении примерно так, будто действия всех потоков складываются в некоторый последовательный порядок, сохраняющий порядок каждого отдельного потока. Это огромное упрощение, потому что реальная платформа в этот момент может:

  • переупорядочивать инструкции;
  • использовать несколько уровней cache;
  • держать значения в регистрах;
  • выполнять спекулятивные операции;
  • работать на архитектуре, о которой автор Java-кода вообще никогда не думал.

А программа всё равно должна вести себя в рамках правил JMM. Именно поэтому писать собственную синхронизацию, исходя из предположения

на x86 эта запись вроде должна быть атомарной,

— обычно плохая идея. Сегодня сервис работает на x86, завтра — на ARM, а после обновления JDK JIT может по-другому оптимизировать горячий метод. Гораздо надёжнее рассуждать на уровне гарантий языка.

JMM не делает код потокобезопасным автоматически

Java Memory Model не делает обычный код потокобезопасным сама по себе. JMM определяет правила игры, а выбрать подходящий способ синхронизации должен код.

Есть общее изменяемое состояние:

sharedState

Нужно ответить на три вопроса.

Первый: несколько потоков могут одновременно его изменять? Если да, нужна гарантия атомарности операции или протокола изменения.

Второй: один поток должен увидеть изменения другого? Если да, между операциями должна появиться необходимая связь видимости.

Третий: имеет ли значение порядок нескольких операций?

Например:

data = prepareData();
ready = true;

Если другой поток использует ready как сигнал, этот порядок должен быть корректно опубликован.

После этого уже выбирается инструмент:

volatile
synchronized
Lock
Atomic*
ConcurrentHashMap
BlockingQueue
CountDownLatch
CompletableFuture
...

У каждого из них свои гарантии. Не существует универсальной аннотации:

@ThreadSafePlease

хотя иногда очень хочется.

Как я обычно ищу такие ошибки

Проблемы JMM редко выглядят в production как:

Java Memory Model violation at line 53

Обычно симптомы гораздо менее дружелюбные: задача иногда не останавливается, статус один раз из нескольких тысяч остаётся старым, флаг initialized уже выставлен, а связанные данные ещё выглядят незаполненными, счётчик под нагрузкой начинает терять значения. Иногда ошибка вообще исчезает после добавления логирования.

Последний вариант особенно прекрасен: добавили println, тайминги изменились — проблема пропала. Убрали логирование — снова появилась.

Поэтому при подозрении на гонку я сначала раскладываю взаимодействие потоков по коду:

какое состояние общее?
кто его пишет?
кто его читает?
что связывает эти операции?
где здесь happens-before?

Если на последний вопрос ответа нет, проблема обычно становится заметно интереснее.

Какую модель держать в голове

Для начала достаточно такой картины. Java-приложение работает внутри процесса, у потоков есть собственные stacks, но они могут работать с одними объектами в общей heap-памяти. Выполняются потоки на процессоре, а несколько ядер позволяют действительно исполнять несколько потоков параллельно.

Реальная платформа использует регистры, кэши и различные оптимизации компилятора и процессора. Java Memory Model позволяет не привязывать корректность к этим деталям: она определяет, какие результаты многопоточная Java-программа имеет право наблюдать. Главным инструментом рассуждения здесь становится happens-before.

Если несколько потоков работают с одним изменяемым состоянием, я обычно начинаю именно с вопроса:

Что здесь создаёт happens-before?

Если ответ — “ничего, но оно вроде всегда работает”, то статья про JMM внезапно перестаёт быть теорией.