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

Що таке visibility problem?

Кожен потік може зберігати копії змінних в:

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

Junior рівень

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

Visibility Problem (проблема видимості) — це ситуація, коли один потік змінює змінну, але інший потік продовжує бачити старе значення.

Чому: кожне CPU-ядро має свій кеш (L1/L2). Потік читає змінну з кешу свого ядра. Коли інший потік на іншому ядрі змінює цю змінну, оновлення спочатку залишається в кеші того ядра і не одразу стає видно всім.

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

public class VisibilityDemo {
    private boolean flag = false; // БЕЗ volatile

    public void setFlag() {
        flag = true; // Потік 1 змінює
    }

    public void checkFlag() {
        while (!flag) {
            // Потік 2 може НІКОЛИ не вийти з циклу!
            // Він бачить свою локальну копію flag = false
        }
        System.out.println("Flag is true");
    }
}

Чому так відбувається?

Кожен потік може зберігати копії змінних в:

  • Реєстрах процесора — найшвидші, але повністю локальні
  • Кеші процесора (L1/L2/L3) — швидкі, але не завжди синхронізуються
  • Основній пам’яті (RAM) — спільна, але повільна

Рішення

Використовуйте volatile:

public class VisibilityDemo {
    private volatile boolean flag = false; // З volatile — завжди бачить актуальне. volatile вставляє memory barrier: при записі — скидає кеш-лінію в RAM (Store barrier), при читанні — інвалідує кеш-лінію та перечитує з RAM (Load barrier).

    public void setFlag() {
        flag = true; // Всі потоки одразу побачать це значення
    }

    public void checkFlag() {
        while (!flag) {
            // Тепер потік 2 гарантовано побачить зміну
        }
        System.out.println("Flag is true");
    }
}

Коли виникає visibility problem?

Ситуація Виникає? Рішення
Звичайна змінна, кілька потоків Так volatile / synchronized
volatile змінна Ні
synchronized блок Ні
final поле (після конструктора) Ні
AtomicInteger Ні

Middle рівень

Фізична причина (Hardware Level)

Сучасні процесори мають складну ієрархію пам’яті:

Реєстри CPU → L1 Cache (32-64KB) → L2 Cache (256KB-1MB) → L3 Cache (8-64MB) → RAM
   ~1 цикл        ~4 цикли             ~10 циклів            ~40 циклів         ~100 циклів

Що відбувається при записі

  1. Потік на Ядрі 1 змінює змінну → записує в Store Buffer

Store Buffer — буфер записів процесора. Коли ядро записує дані, вони спочатку потрапляють у Store Buffer, а не одразу в кеш/RAM. Це прискорює запис (процесор не чекає), але створює visibility problem.

  1. Дані потрапляють в L1 кеш Ядра 1
  2. Ядро 2 читає змінну → бере зі свого L1 кешу (старе значення!)
  3. Протокол MESI синхронізує кеши, але це відбувається асинхронно

Протокол MESI

Протокол MESI (Modified, Exclusive, Shared, Invalid) — протокол когерентності кешів багатоядерних CPU. Коли ядро записує дані (Modified), воно розсилає сигнал іншим ядрам — їхні копії стають Invalid і мають бути перечитані з RAM.

Стан Значення
Modified (M) Дані змінені в цьому кеші, потрібно скинути в RAM
Exclusive (E) Дані тільки в цьому кеші, ідентичні RAM
Shared (S) Дані є в кількох кешах, всі ідентичні RAM
Invalid (I) Дані неактуальні (хтось інший змінив)

Коли потік пише в volatile, він посилає сигнал всім ядрам перевести їхні копії в статус Invalid.

JIT-оптимізації, що погіршують проблему

Hoisting (Винесення за цикл)

Hoisting (винесення) — JIT-компілятор виносить читання змінної з циклу в змінну-реєстр, щоб не читати з RAM щоразу. Без volatile JIT “не знає” що змінну може змінити інший потік.

// Початковий код:
while (!flag) {
    doSomething(); // flag не міняється всередині циклу
}

// JIT може оптимізувати:
if (!flag) {
    while (true) {
        doSomething(); // Нескінченний цикл, навіть якщо flag зміниться!
    }
}

Register Allocation

Змінна може бути закешована в реєстрі процесора, який взагалі не бере участі в системі когерентності кешів.

Способи вирішення

1. volatile

volatile boolean flag = false;

Гарантує:

  • Читання завжди з основної пам’яті
  • Запис завжди в основну пам’ять
  • Заборона переупорядковування (memory barriers)

2. synchronized

synchronized(lock) {
    while (!flag) {
        // ...
    }
}

При вході в synchronized — кеш інвалідується При виході — дані скидаються в RAM

3. final поля

public class Config {
    public final String value; // Гарантія видимості після конструктора

    public Config(String value) {
        this.value = value;
    }
}

4. Atomic-класи

AtomicBoolean flag = new AtomicBoolean(false);
// Всередині: volatile + CAS

Коли volatile НЕ вирішує visibility problem

  1. Складні операції: volatile count++ — читання та запис не атомарні
  2. Групи змінних: volatile на x та y не гарантує, що інший потік побачить узгоджену пару (x, y)
  3. Залежні обчислення: результат залежить від кількох змінних, кожна volatile — але їхня комбінація може бути неузгодженою

Senior рівень

Under the Hood: Cache Coherence Protocol

На рівні x86 архітектури використовується протокол MESIF (розширений MESI):

Подія Дія
Запис у кеш-лінію Перевід у Modified, посилання Invalidate всім іншим ядрам
Читання кеш-лінії в Shared Можна читати без обмежень
Читання кеш-лінії в Invalid Запит даних з RAM або іншого кеша
Запис у Modified кеш-лінію Локальний запис, інші ядра не повідомляються до конфлікту

Bus Traffic та Cache Invalidation

Коли ядро 1 пише у volatile змінну:

  1. Посилається Invalidate повідомлення по шині (Ring Bus / Mesh)
  2. Всі інші ядра перевіряють свої кеши
  3. Ядра з цією кеш-лінією відповідають Ack та переводять в Invalid
  4. Тільки після всіх Ack запис вважається завершеним

Це займає сотні циклів CPU — саме тому volatile запис дорожчий за читання.

False Sharing — проблема продуктивності

public class FalseSharingDemo {
    // Обидва volatile в одній кеш-лінії (64 байти)
    public volatile long counter1 = 0;
    public volatile long counter2 = 0;
}

Коли потік 1 пише в counter1, кеш-лінія інвалідується для потоку 2, навіть якщо він працює з counter2.

Рішення: @Contended

public class ContendedDemo {
    @Contended
    public volatile long counter1 = 0;

    @Contended
    public volatile long counter2 = 0;
}

@Contended додає padding (128 байт) навколо змінної, щоб вона займала окрему кеш-лінію.

Вимагає -XX:-RestrictContended для використання в користувацькому коді (Java 8+).

Ручний Padding (для старих Java)

public class PaddedCounter {
    // Padding до змінної
    public long p1, p2, p3, p4, p5, p6, p7;
    public volatile long value = 0;
    // Padding після змінної
    public long q1, q2, q3, q4, q5, q6, q7;
}

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

Бенчмарк (приблизний)

Операція Час Примітка
Звичайне читання ~1 ns З кешу
Volatile читання ~5-10 ns З бар’єрами
Звичайний запис ~1 ns В кеш
Volatile запис ~50-100 ns З Invalidate
synchronized (немає contention) ~10-20 ns Thin lock
synchronized (contention) ~1000+ ns Context switch

Write Barriers

Використання volatile сповільнює запис сильніше, ніж читання:

  • Читання: тільки бар’єр (LoadLoad + LoadStore)
  • Запис: бар’єр + Invalidate інших кешів + очікування Ack

Діагностика

-XX:+PrintAssembly

Дозволяє побачити реальні інструкції процесора:

java -XX:+PrintAssembly -XX:+UnlockDiagnosticVMOptions

Шукайте інструкцію lock перед записом — це Memory Barrier на x86:

lock or dword ptr [rsp], 0  # StoreLoad barrier
mov [rax], 1                # volatile запис

Flaky Tests

Проблеми видимості — головна причина тестів, які:

  • Проходять локально (1-2 ядра)
  • Падають на CI-сервері (8+ ядер)
  • Падають “іноді” (race condition timing)

Java Memory Model Stress Testing

@JCStressTest
public class VisibilityStressTest {
    int x = 0;
    volatile boolean ready = false;

    @Actor
    public void writer() {
        x = 42;
        ready = true;
    }

    @Actor
    public void reader(IntResult2 r) {
        if (ready) {
            r.r1 = x; // Має бути завжди 42
        }
    }
}

Best Practices

  1. Завжди використовуйте volatile для прапорців, що читаються з різних потоків
  2. Уникайте зберігання спільного стану у звичайних полях
  3. final поля — безкоштовний спосіб забезпечити видимість immutable даних
  4. Остерігайтеся False Sharing для часто оновлюваних volatile полів
  5. Тестуйте на багатопроцесорних системах, не тільки на локальній машині
  6. Використовуйте JCStress для стрес-тестування багатопоточного коду

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

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

  • Visibility problem: один потік змінює змінну, інший бачить старе значення з кешу ядра
  • Причина: ієрархія пам’яті (реєстри → L1/L2/L3 кеш → RAM), кожне ядро має свій кеш
  • Протокол MESI (Modified, Exclusive, Shared, Invalid) забезпечує когерентність кешів
  • JIT-оптимізація hoisting може винести читання з циклу → нескінченний цикл без volatile
  • 4 рішення: volatile, synchronized, final поля, Atomic-класи
  • Volatile НЕ вирішує проблему для складних операцій та груп змінних
  • False Sharing: дві volatile змінні в одній кеш-лінії — запис в одну інвалідує іншу

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

  • Чому тест проходить локально, але падає на CI? — Локально 1-2 ядра (менше кеш-проблем), CI — 8+ ядер
  • Що таке Store Buffer? — Буфер записів процесора; запис спочатку потрапляє туди, а не в RAM — причина visibility problem
  • Як JIT погіршує проблему? — Hoisting: виносить читання змінної з циклу в регістр, ігноруючи зміни інших потоків
  • Що робить @Contended? — Додає padding (128 байт) навколо volatile, щоб уникнути false sharing

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

  • “Volatile вирішує всі проблеми багатопоточності” — ні, тільки видимість одної змінної
  • “Кеш процесора завжди синхронізований” — ні, MESI працює асинхронно
  • “Synchronized повільніший за volatile для простого прапорця” — ні, volatile дешевше для простих випадків
  • “Final поля не мають відношення до видимості” — мають: фіналізовані поля видимі після конструктора

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

  • [[1. В чому різниця між synchronized та volatile]] — volatile як рішення visibility problem
  • [[2. Що таке happens-before relationship]] — JMM-рівень гарантій видимості
  • [[4. Що таке монітор (monitor) в Java]] — synchronized та інвалідація кешу при вході
  • [[8. Що таке Atomic класи]] — Atomic-класи теж вирішують проблему видимості