🧵 Раздел 9 · Вопрос #2

Что такое happens-before relationship

Если действие $A$ связано отношением happens-before с действием $B$ ($A \to B$), то результат выполнения действия $A$ гарантированно виден действию $B$.


🟢 Junior Level

Краткий ответ для собеседования (30 секунд)

Happens-Before — это базовое отношение модели памяти Java (Java Memory Model / JSR-133), определяющее гарантию видимости изменений памяти между несколькими потоками.

Если действие $A$ связано отношением happens-before с действием $B$ ($A \to B$), то результат выполнения действия $A$ гарантированно виден действию $B$.

Критически важное понимание: Отношение happens-before НЕ означает, что действие $A$ обязано физически завершиться раньше действия $B$ по системным часам процессора. JIT-компилятор и процессор имеют право переупорядочивать инструкции (Instruction Reordering), но только до тех пор, пока результат для других потоков неотличим от порядка, заданного отношением happens-before.


Базовый пример на Java 21

public class HappensBeforeBasicsDemo {

    private int data = 0;
    private volatile boolean ready = false;

    // Выполняется Потоком 1:
    public void writer() {
        data = 42;    // Действие A (обычная запись)
        ready = true; // Действие B (volatile-запись)
    }

    // Выполняется Потоком 2:
    public void reader() {
        if (ready) {  // Действие C (volatile-чтение)
            // Действие D: data гарантированно равно 42!
            System.out.println("Данные: " + data);
        }
    }
}

Почему это работает:

  1. $A \to B$ (Program Order Rule: в одном потоке запись data = 42 происходит до ready = true).
  2. $B \to C$ (Volatile Variable Rule: запись в volatile-поле happens-before последующему чтению того же поля).
  3. Из транзитивности ($A \to B$ и $B \to C$) следует $A \to C$: Поток 2 гарантированно увидит значение data = 42.

🟡 Middle Level

8 фундаментальных правил Happens-Before (JSR-133)

Спецификация Java Memory Model определяет следующий набор правил:

# Название правила Формулировка правила
1 Program Order В рамках одного потока каждая операция happens-before любой последующей операции по тексту программы.
2 Monitor Lock Освобождение монитора (monitorexit / выход из synchronized) happens-before последующему захвату (monitorenter) того же самого монитора другим или тем же потоком.
3 Volatile Variable Запись в volatile-поле happens-before каждому последующему чтению этого же самого поля.
4 Thread Start Вызов thread.start() happens-before любому действию внутри запущенного потока thread.run().
5 Thread Termination (Join) Любое действие в потоке happens-before успешному возврату из thread.join() или возврату true из thread.isAlive().
6 Interruption Вызов thread.interrupt() happens-before моменту, когда поток обнаружит флаг прерывания (Thread.interrupted(), isInterrupted() или выброс InterruptedException).
7 Finalizer Завершение конструктора объекта happens-before началу выполнения метода finalize() этого объекта.
8 Transitivity Если $A \to B$ и $B \to C$, то гарантируется $A \to C$.

Техника «Piggybacking» (Примазывание к Happens-Before)

Благодаря правилу транзитивности можно обеспечить видимость целой группы обычных (non-volatile) полей, записав после них всего одну volatile-переменную:

public class PiggybackingDemo {
    private int field1;
    private String field2;
    private double field3;
    private volatile boolean published = false;

    public void publishData() {
        field1 = 100;
        field2 = "Java 21";
        field3 = 3.14;
        published = true; // Запись volatile "проталкивает" все три не-volatile поля в общую память!
    }

    public void consumeData() {
        if (published) { // Чтение volatile подтягивает свежие кэши для всех полей!
            System.out.printf("%d, %s, %.2f%n", field1, field2, field3);
        }
    }
}

🔴 Senior Level

Гарантия final полей: Final Field Freeze

Спецификация JMM содержит специальное правило для final-полей, защищающее от публикации частично инициализированных объектов:

Значения final-полей объекта, инициализированные в конструкторе, считаются «замороженными» (freeze) в момент завершения конструктора. Любой поток, получивший ссылку на объект после завершения конструктора, гарантированно увидит корректные значения всех его final-полей без всякой внешней синхронизации.

public class ImmutableState {
    private final int id;
    private final String payload;

    public ImmutableState(int id, String payload) {
        this.id = id;
        this.payload = payload;
        // В конце конструктора JIT вставляет StoreStore барьер (Freeze action)
    }
}

Фатальное исключение: Утечка ссылки this (Escape Analysis)

Если ссылка на конструируемый объект «утекает» наружу до завершения конструктора (например, передача this в слушатель событий или запуск потока внутри конструктора):

public class BrokenEscape {
    private final int value;
    public static BrokenEscape instance;

    public BrokenEscape() {
        this.value = 42;
        instance = this; // ❌ УТЕЧКА ССЫЛКИ! Гарантия final разрушена!
    }
}

Другой поток может перехватить instance до выполнения «freeze» и прочитать дефолтное значение value = 0.


Архитектуры памяти: x86 TSO против ARM Weak Memory

Реализация отношений happens-before в машинном коде зависит от аппаратной модели процессора:

  1. x86 / x64 (Total Store Order — TSO):
    • Процессор аппаратно гарантирует порядок чтений (LoadLoad), записей (StoreStore) и чтений после записей (LoadStore).
    • Единственное возможное переупорядочивание — чтение может обогнать запись (StoreLoad).
    • Поэтому на x86 барьеры LoadLoad, StoreStore, LoadStore бесплатны (компилируются в обычные MOV), а барьер StoreLoad требует явной инструкции LOCK ADD или MFENCE.
  2. ARM / Apple Silicon / POWER (Weakly Ordered):
    • Процессор имеет право произвольно переставлять любые операции чтения и записи.
    • Для обеспечения happens-before JIT-компилятор вынужден генерировать явные барьеры синхронизации памяти: dmb ishld (LoadLoad/LoadStore) и dmb ish (StoreStore/StoreLoad), что делает многопоточную синхронизацию на ARM архитектурно более дорогой.

Валидация JMM с помощью JCStress

Тестировать соблюдение контрактов happens-before обычными unit-тестами бессмысленно (шанс воспроизвести гонку на x86 крайне мал из-за TSO). Для этого OpenJDK предоставляет специализированный инструмент JCStress (Java Concurrency Stress Kit):

import org.openjdk.jcstress.annotations.*;
import org.openjdk.jcstress.infra.results.II_Result;

@JCStressTest
@Outcome(id = "1, 1", expect = Expect.ACCEPTABLE, desc = "Both seen")
@Outcome(id = "0, 1", expect = Expect.ACCEPTABLE, desc = "Partial update")
@Outcome(id = "1, 0", expect = Expect.ACCEPTABLE, desc = "Reordered read")
@Outcome(id = "0, 0", expect = Expect.ACCEPTABLE_INTERESTING, desc = "Race condition violation")
@State
public class MemoryReorderingTest {
    int x, y;

    @Actor
    public void thread1() {
        x = 1;
        y = 1;
    }

    @Actor
    public void thread2(II_Result r) {
        r.r1 = y;
        r.r2 = x;
    }
}

🎯 Шпаргалка для интервью

30-секундный ответ

«Happens-Before — это формальное отношение частичного порядка в Java Memory Model, гарантирующее, что результат записи действия $A$ будет виден действию $B$. Отношение не фиксирует порядок физического времени, а лишь накладывает ограничения на переупорядочивание инструкций JIT-компилятором и процессором. Ключевые правила: Program Order, Monitor Lock (освобождение монитора перед захватом), Volatile (запись перед чтением), Thread Start/Join, Final Field Freeze и транзитивность. На архитектуре x86 большинство барьеров бесплатны, кроме StoreLoad, а на ARM требуются явные барьеры DMB.»


Сводка 8 правил Happens-Before

Правило Событие-источник Событие-приемник
Program Order Операция выше по коду потока Операция ниже по коду того же потока
Monitor Lock Выход из synchronized(M) Вход в synchronized(M) любого потока
Volatile Запись volatile V = val Чтение volatile V любого потока
Thread Start Вызов t.start() Первая инструкция в методе t.run()
Thread Join Завершение работы потока t Выход из t.join() вызывающего потока
Interruption Вызов t.interrupt() Обнаружение t.isInterrupted()
Finalizer Конец конструктора new Obj() Вызов finalize() сборщиком мусора
Transitivity Если $A \to B$ и $B \to C$ То действие $A$ гарантированно видно в $C$

Топ-4 каверзных вопроса на интервью

1. Означает ли $A \text{ happens-before } B$, что действие $A$ обязательно выполнится раньше действия $B$ по времени?

  • Нет, не означает. Отношение happens-before определяет лишь видимость памяти, а не физический хронометраж. Если действия $A$ и $B$ независимы и не влияют на наблюдаемый результат потоков, JIT-компилятор и CPU могут выполнить $B$ раньше $A$.

2. Что такое техника Piggybacking на отношении happens-before?

  • Это способ гарантировать видимость группы обычных несинхронизированных переменных за счет одной операции с более строгой гарантией. Например, поток записывает пять обычных полей, а шестым шагом записывает volatile boolean ready = true. Благодаря правилу Program Order и правилу транзитивности, любой поток, прочитавший ready == true, гарантированно увидит свежие значения всех пяти обычных полей.

3. В чем суть правила Final Field Freeze и как его можно нарушить?

  • По спецификации JSR-133, значения полей с модификатором final, проинициализированные в конструкторе, гарантированно видны всем потокам после выхода из конструктора без синхронизации. Однако если ссылка на объект «утекает» из конструктора до его завершения (например, присвоение staticHolder = this или передача this в callback), другой поток может прочитать ссылку на еще не «замороженный» объект и увидеть нули вместо финальных полей.

4. Почему барьеры памяти в x86 процессорах работают иначе, чем в ARM?

  • Процессоры x86 реализуют модель Total Store Order (TSO), где переупорядочивание записей и чтений запрещено на аппаратном уровне (кроме StoreLoad). Поэтому барьеры чтений и обычных записей компилируются в простые инструкции MOV без накладных расходов. Архитектура ARM использует слабую модель памяти (Weakly Ordered), где разрешены почти все типы перестановок, требуя вставки реальных барьеров DMB.

Типичные ошибки и Red Flags

  • 🚩 Путаница между понятиями «happens-before» и «выполнилось раньше по часам реального времени».
  • 🚩 Утечка ссылки this из конструктора, разрушающая гарантию final.
  • 🚩 Предположение, что обычные переменные видны между потоками без установления цепочки happens-before.
  • 🚩 Использование Thread.sleep() в надежде, что задержка времени заменит отношение happens-before.

Связанные темы