Вопрос 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-классы тоже решают проблему видимости