Питання 2 · Розділ 9

Що таке happens-before relationship?

Тут: x = 10 happens-before ready = true (program order rule: в ОДНОМУ потоці операція раніше в коді видна операції пізніше в коді), а ready = true happens-before читання ready в...

Мовні версії: English Russian Ukrainian

Junior рівень

Базове розуміння

Happens-before — це гарантія Java про видимість результатів між потоками.

Важливо: Happens-before НЕ означає, що дія A виконається раніше B за часом. Потоки МОЖУТЬ виконуватися в будь-якому порядку. HB лише гарантує: якщо A happens-before B, то результат A буде видимий в B — начебто A відбулося раніше.

Простий приклад

// Потік 1:
int x = 10;             // Дія A
ready = true;           // Дія B (volatile)

// Потік 2:
if (ready) {            // Дія C
    System.out.println(x); // Завжди виведе 10!
}

Тут: x = 10 happens-before ready = true (program order rule: в ОДНОМУ потоці операція раніше в коді видна операції пізніше в коді), а ready = true happens-before читання ready в іншому потоці (volatile правило). За транзитивністю: x = 10 happens-before читання в потоці 2.

Чому це важливо?

Без happens-before компілятор та процесор можуть переупорядкувати інструкції:

// БЕЗ happens-before гарантій:
int x = 10;
boolean flag = false; // без volatile

// Потік 1:           // Потік 2:
x = 10;               if (flag) {
flag = true;              System.out.println(x); // Може бути 0!
// Процесор може поміняти порядок              }

Middle рівень

8 правил Happens-Before (JSR-133)

Java Memory Model визначає 8 правил:

# Правило Опис
1 Program Order В одному потоці кожна дія HB дії, розташованій нижче в коді
2 Monitor Lock Звільнення монітора (вихід з synchronized) HB наступному захопленню того ж монітора
3 Volatile Variable Запис у volatile поле HB наступному читанню того ж поля
4 Thread Start Виклик Thread.start() HB будь-якій дії в запущеному потоці
5 Thread Termination Будь-яка дія в потоці HB завершенню цього потоку (коли join() повертається)
6 Interruption Виклик interrupt() HB виявленню переривання (isInterrupted() або InterruptedException)
7 Finalizer Завершення конструктора HB початку finalize() методу
8 Transitivity Якщо A HB B і B HB C, то A HB C

Приклади для кожного правила

1. Program Order Rule

int a = 1;    // HB
int b = a + 1; // HB
int c = b + 1; // HB — порядок гарантований в одному потоці

2. Monitor Lock Rule

synchronized(lock) {
    x = 42;  // Запис всередині synchronized
} // Звільнення монітора

// В іншому потоці:
synchronized(lock) {
    System.out.println(x); // Бачить 42 — захоплення того ж монітора
}

3. Volatile Variable Rule

volatile boolean flag = false;
int data = 0;

// Потік 1:
data = 100;     // HB (program order)
flag = true;    // Volatile запис

// Потік 2:
if (flag) {     // Volatile читання — HB від запису
    System.out.println(data); // Завжди 100!
}

4. Thread Start Rule

int sharedData = 50;
Thread t = new Thread(() -> {
    System.out.println(sharedData); // Завжди бачить 50!
});
sharedData = 100;  // HB (program order) перед start()
t.start();         // start() HB всім діям в потоці t
// HB гарантує видимість ТІЛЬКИ того, що happened-before start()
// sharedData = 100 (після start()) — НЕ видна потоку t
// sharedData = 50 (до start()) — видна потоку t

5. Thread Termination Rule

Thread t = new Thread(() -> {
    result = compute(); // Дія в потоці
});
t.start();
t.join(); // join() повертається — HB
System.out.println(result); // Бачить результат!

Piggybacking (Наїзд на спині)

Завдяки транзитивності, можна гарантує видимость групи звичайних змінних через одну volatile:

int x = 0, y = 0, z = 0;
volatile boolean published = false;

// Потік-писар:
x = 10;
y = 20;
z = 30;
published = true; // Volatile запис — всі попередні записи "їдуть з нею"

// Потік-читач:
if (published) { // Volatile читання — "підчіплює" всі попередні записи
    System.out.println(x + y + z); // Завжди 60!
}

Senior рівень

Under the Hood: Memory Fences

Кожне HB відношення реалізується через Memory Fences (бар’єри пам’яті) на рівні процесора:

Memory Fence (бар’єр пам’яті) — те саме, що Memory Barrier. Забороняє процесору переупорядковувати операції навколо бар’єру.

Тип бар’єру Що забороняє Коли вставляється
LoadLoad Читання не може бути переміщене вгору перед бар’єром volatile читання
StoreStore Запис не може бути переміщений вгору перед бар’єром volatile запис
LoadStore Запис не може бути переміщений вгору перед бар’єром volatile читання
StoreLoad Читання не може бути переміщений вгору перед бар’єром (найдорожчий) volatile запис

На x86 архітектурі:

  • LoadLoad, LoadStore — безкоштовно (x86 не переупорядковує читання)
  • StoreStore — безкоштовно (x86 не переупорядковує записи)
  • StoreLoad — вимагає інструкцію mfence (Memory Fence) на x86. Найдорожчий бар’єр, забороняє БУДЬ-ЯКЕ переупорядковування навколо нього.

CPU Reordering та Out-of-Order Execution

Процесори виконують інструкції не за порядком для оптимізації:

// Початковий код:
A = 1;       // Інструкція 1
B = 2;       // Інструкція 2
flag = true; // Інструкція 3 (volatile)

// Процесор може виконати:
flag = true; // Спочатку — дешевше
A = 1;       // Потім
B = 2;       // Потім

Без volatile бар’єру інший потік може побачити flag = true, але A та B ще не записані.

Продуктивність та Highload

Ціна Memory Barriers

// Бенчмарк (приблизний):
volatile запис:  ~10-50 ns (залежить від архітектури)
звичайний запис:   ~1 ns
synchronized:     ~50-500 ns (залежить від contention)

Чим більше volatile змінних — тим більше “затичок” в конвеєрі CPU.

False Sharing з volatile

public class VolatilePair {
    public volatile long v1; // В одній кеш-лінії (64 байти)
    public volatile long v2; // Запис v1 інвалідує v2!
}

Рішення — @Contended:

public class ContendedPair {
    @Contended
    public volatile long v1; // В різних кеш-лініях

    @Contended
    public volatile long v2;
}

Запуск з -XX:-RestrictContended для Java 8+.

Діагностика

JCStress (Java Concurrency Stress)

Єдиний інструмент для емпіричної перевірки HB:

@JCStressTest
@Outcome(id = "0", expect = ACCEPTABLE, desc = "Not published")
@Outcome(id = "42", expect = ACCEPTABLE, desc = "Published correctly")
@Outcome(id = "0", expect = FORBIDDEN, desc = "Visibility bug!")
public class HBTest {
    int data = 0;
    volatile boolean ready = false;

    @Actor
    public void actor1() {
        data = 42;
        ready = true;
    }

    @Actor
    public void actor2(IntResult2 r) {
        if (ready) {
            r.r1 = data;
        }
    }
}

FindBugs/SpotBugs

Виявляє потенційні порушення JMM:

// SpotBugs знайде це:
public class Bug {
    private int data;        // Не volatile
    private boolean ready;   // Не volatile

    public void write() {
        data = 42;
        ready = true; // HB немає! Інший потік може не побачити data
    }
}

Best Practices

  1. Використовуйте volatile для прапорців та простих publication патернів
  2. Використовуйте synchronized для складних операцій
  3. Використовуйте Atomic* для lock-free лічильників
  4. final поля автоматично мають HB після конструктора
  5. Уникайте ручного керування HB через Unsafe/VarHandle без крайньої необхідності
  6. Тестуйте багатопоточний код через JCStress, а не тільки через звичайні тести

🎯 Шпаргалка для співбесіди

Обов’язково знати:

  • Happens-before — це гарантія видимості, а не хронологічного порядку виконання
  • 8 правил JMM: Program Order, Monitor Lock, Volatile Variable, Thread Start, Thread Termination, Interruption, Finalizer, Transitivity
  • Завдяки транзитивності можна «причіпити» видимість звичайних змінних до одної volatile (piggybacking)
  • На x86 StoreLoad бар’єр вимагає mfence — найдорожчий з усіх бар’єрів
  • Без happens-before процесор та JIT можуть переупорядкувати інструкції
  • final поля автоматично отримують HB-гарантію після завершення конструктора
  • HB гарантує видимість результатів: якщо A HB B, результат A буде видимий в B

Часті уточнюючі запитання:

  • Happens-before означає, що A виконається раніше B за часом? — Ні, тільки що результат A буде видимий в B
  • Як гарантує видимість кількох звичайних змінних? — Записати їх, потім записати volatile-прапорець (piggybacking через транзитивність)
  • Яке правило HB покриває synchronized? — Monitor Lock: звільнення монітора HB наступному захопленню того ж монітора
  • Чим HB відрізняється від memory barrier? — HB — це гарантія на рівні JMM, memory barrier — реалізація на рівні CPU

Червоні прапори (НЕ говорити):

  • “Happens-before означає, що операції виконуються за порядком” — ні, це про видимість
  • “Достатньо volatile для всіх змінних” — ні, одна volatile «тягне» за собою всі попередні записи
  • “Thread.start() не має відношення до HB” — має: start() HB всім діям в запущеному потоці
  • “На x86 всі бар’єри дорогі” — LoadLoad та StoreStore на x86 безкоштовні

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

  • [[1. В чому різниця між synchronized та volatile]] — volatile правило як одне з HB-правил
  • [[3. Що таке visibility problem]] — HB вирішує проблему видимості на рівні JMM
  • [[4. Що таке монітор (monitor) в Java]] — Monitor Lock rule пов’язаний з synchronized
  • [[5. Як працює synchronized на рівні монітора]] — memory barriers при вході/виході