🧵 Розділ 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.

Пов’язані теми