🧵 Розділ 9 · Питання #8

Що таке Atomic класи

На відміну від блокувань (synchronized, ReentrantLock), які переводять конкуруючі потоки в стан очікування (BLOCKED або WAITING) через системні виклики ядра ОС, Atomic-класи спи...


🟢 Junior Level

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

Atomic класи — це спеціалізований набір класів із пакета java.util.concurrent.atomic, призначений для виконання атомарних, потокобезпечних операцій над окремими змінними та посиланнями без використання блокувань (lock-free).

На відміну від блокувань (synchronized, ReentrantLock), які переводять конкуруючі потоки в стан очікування (BLOCKED або WAITING) через системні виклики ядра ОС, Atomic-класи спираються на:

  1. Ключове слово volatile для гарантії негайної видимості змін усіма потоками (JMM Happens-Before).
  2. Апаратні процесорні інструкції CAS (Compare-And-Swap), що дозволяють процесору атомарно перевірити та оновити комірку пам’яті в один крок.

Якщо під час оновлення значення встиг змінити інший потік, операція не блокується, а повторюється в оптимістичному циклі (CAS retry loop) до досягнення успіху.


Проблема складених операцій (i++)

Класична операція інкременту count++ не є атомарною. На рівні машинних команд вона складається з трьох окремих фаз:

  1. Читання: завантажити поточне значення з пам’яті в регістр процесора (read).
  2. Модифікація: збільшити значення в регістрі на одиницю (modify).
  3. Запис: зберегти нове значення назад у пам’ять (write).

При паралельному виконанні двома потоками виникає стан гонки (Race Condition), що призводить до втрати значень. AtomicInteger вирішує цю проблему на рівні однієї процесорної інструкції:

// Небезпечно: втрата оновлень
private int unsafeCount = 0;
public void addUnsafe() { unsafeCount++; }

// Потокобезпечно: Lock-Free
private final AtomicInteger safeCount = new AtomicInteger(0);
public void addSafe() { safeCount.incrementAndGet(); }

Класифікація Atomic-класів у Java

Категорія Класи Призначення
Скалярні примітиви AtomicBoolean, AtomicInteger, AtomicLong Атомарна арифметика та прапорці стану
Посилальні типи AtomicReference<V> Атомарна заміна посилань на об’єкти
З контролем версій AtomicStampedReference<V>, AtomicMarkableReference<V> Вирішення проблеми ABA (додавання мітки версії/прапорця)
Масиви AtomicIntegerArray, AtomicLongArray, AtomicReferenceArray<E> Поелементні атомарні операції в масивах
Field Updaters AtomicIntegerFieldUpdater<T>, AtomicReferenceFieldUpdater<T,V> CAS над звичайними volatile-полями для економії пам’яті
Масштабовані суматори LongAdder, LongAccumulator, DoubleAdder Високонавантажені лічильники (висока конкуренція)

Приклад на Java 21

import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;

public class AtomicDemo {
    // 1. Атомарний прапорець для однократної ініціалізації
    private final AtomicBoolean initialized = new AtomicBoolean(false);

    // 2. Атомарний лічильник
    private final AtomicInteger requestCounter = new AtomicInteger(0);

    // 3. Атомарна заміна імутабельного стану
    public record AppConfig(String host, int port) {}
    private final AtomicReference<AppConfig> config = 
            new AtomicReference<>(new AppConfig("localhost", 8080));

    public void initOnce() {
        // Гарантовано виконається рівно одним потоком
        if (initialized.compareAndSet(false, true)) {
            System.out.println("Система успішно ініціалізована");
        }
    }

    public void handleRequest() {
        requestCounter.incrementAndGet(); // Атомарний інкремент
    }

    public void updateConfig(String newHost, int newPort) {
        // Функціональне оновлення значення через CAS-цикл
        config.updateAndGet(old -> new AppConfig(newHost, newPort));
    }
}

🟡 Middle Level

Механіка CAS (Compare-And-Swap) та CAS-цикл

Операція CAS приймає три аргументи:

  1. Адреса комірки пам’яті ($V$).
  2. Очікуване старе значення ($A$).
  3. Нове значення ($B$).

Семантика: значення $B$ записується в комірку $V$ тільки в тому разі, якщо поточне значення у $V$ точно дорівнює $A$. Уся операція виконується на апаратному рівні як єдина неподільна транзакція.

Внутрішній цикл оптимістичного блокування:

Якщо CAS повертає false (інший потік встиг записати своє значення раніше), викликаючий потік не переходить у сон, а наново зчитує актуальне значення і повторює спробу:

public final int getAndAddInt(Object o, long offset, int delta) {
    int current;
    do {
        current = getIntVolatile(o, offset); // 1. Читаємо актуальне значення
    } while (!weakCompareAndSetInt(o, offset, current, current + delta)); // 2. Намагаємося записати
    return current;
}

Архітектурна еволюція: Unsafe проти VarHandle (Java 9+)

Історично всі Atomic-класи реалізовувалися через низькорівневий внутрішній API sun.misc.Unsafe, що працював безпосередньо з сирими адресами пам’яті.

Починаючи з Java 9 (JEP 193) Atomic-класи переписано на типобезпечний механізм java.lang.invoke.VarHandle:

  • Забезпечує прямий доступ до полів з різними режимами доступу моделі пам’яті (JMM Access Modes).
  • Повна оптимізація JIT C2-компілятором до прямих асемблерних інструкцій без накладних витрат рефлексії.

Режими доступу до пам’яті в Atomic-класах

Сучасні Atomic-класи надають методи з різною суворістю бар’єрів пам’яті:

AtomicInteger atom = new AtomicInteger(0);

// 1. Volatile доступ: повний бар'єр пам'яті (StoreLoad)
atom.set(42); 

// 2. Release / Ordered доступ: StoreStore бар'єр, без дорогого StoreLoad!
atom.lazySet(42); 

// 3. Повний двонаправлений бар'єр: Acquire + Release
atom.compareAndSet(42, 100); 

// 4. Ослаблений CAS: може хибно завершитися невдачею (Spurious Failure)
atom.weakCompareAndSetVolatile(100, 200);

Навіщо потрібен lazySet()?

Метод lazySet() встановлює значення із семантикою Ordered/Release. Він гарантує, що всі попередні записи потоку скинуться в пам’ять до цього запису (бар’єр StoreStore), але не виставляє найповільніший бар’єр StoreLoad. Значення стане видимим для інших потоків з невеликою затримкою (eventual visibility), що прискорює запис у кілька разів. Типовий сценарій — занулення посилань у кільцевих буферах (Ring Buffer / LMAX Disruptor).


🔴 Senior Level

Апаратний рівень: CAS на сучасних архітектурах

Реалізація атомарності фундаментально залежить від архітектури центрального процесора:

  1. Архітектура x86-64 (CISC):
    • Використовує одну інструкцію LOCK CMPXCHG.
    • Апаратний префікс LOCK переводить контролер шини та кешу процесора в режим ексклюзивного володіння кеш-рядком (через протокол когерентності кешів MESI/MOESI). Кеш-рядок тимчасово блокується на час виконання операції, гарантуючи неподільність.
  2. Архітектури ARM64 та RISC-V (RISC):
    • Не мають єдиної інструкції CAS.
    • Використовують пару інструкцій LL/SC (Load-Linked / Store-Conditional):
      • LDXR (Load Exclusive Register) — завантажує значення і ставить апаратну мітку на рядок кешу.
      • STXR (Store Exclusive Register) — записує значення лише в тому разі, якщо з моменту LDXR цей рядок кешу не зазнавав модифікації іншим ядром процесора.

Проблема ABA та її усунення

Класична вразливість алгоритмів на базі CAS у lock-free структурах даних (наприклад, неблокуючий стек Трайбера):

1. Потік 1 зчитує зі стека вершину 'A', яка вказує на 'B'.
2. Потік 1 засинає перед виконанням CAS.
3. Потік 2 вилучає 'A', вилучає 'B', видаляє їх.
4. Потік 2 виділяє новий вузол пам'яті, який випадково отримує ту саму адресу 'A', і кладе в стек.
5. Потік 1 прокидається і виконує CAS(A, B).
   Значення за адресою збігається ('A' == 'A')!
   CAS завершується успіхом, але стек зруйновано, оскільки 'B' вказує в невалідну пам'ять!

Рішення: AtomicStampedReference

Пов’язує посилання на об’єкт із цілочисельною міткою версії (штампом, stamp):

// Зберігає пару: посилання + номер ревізії
AtomicStampedReference<Node> stackTop = new AtomicStampedReference<>(initialNode, 0);

int[] stampHolder = new int[1];
Node currentTop = stackTop.get(stampHolder);
int currentStamp = stampHolder[0];

// CAS завершиться успіхом ТІЛЬКИ якщо збігаються І посилання, І номер версії:
boolean success = stackTop.compareAndSet(
    currentTop, 
    newTop, 
    currentStamp, 
    currentStamp + 1
);

Проблема високої конкуренції: Чому AtomicLong поступається LongAdder

За наявності десятків і сотень активно пишучих потоків продуктивність AtomicLong катастрофічно деградує:

  • Усі процесорні ядра намагаються виконати LOCK CMPXCHG над одним і тим самим рядком кешу пам’яті L1/L2.
  • Виникає Cache Bouncing (Cache Ping-Pong): рядок кешу безперервно інвалідується та пересилається між ядрами через між’ядерну шину (QPI/UPI), завантажуючи шину на 100%.
  • Потоки витрачають такти CPU на нескінченні холості оберти в CAS-циклах.
                    Cache Line Bouncing в AtomicLong
        CPU Core 1               CPU Core 2               CPU Core 3
     +--------------+         +--------------+         +--------------+
     | LOCK CMPXCHG |         | LOCK CMPXCHG |         | LOCK CMPXCHG |
     +-------+------+         +-------+------+         +-------+------+
             |                        |                        |
             +------------> [ Cache Line: value ] <------------+
                     (Постійна інвалідація кешів)

Архітектура LongAdder (Striped64):

LongAdder усуває конкуренцію за єдину точку пам’яті:

  1. За відсутності конкуренції потоки оновлюють єдине базове поле volatile long base.
  2. При виникненні першого збою CAS ініціалізується масив комірок: volatile Cell[] cells.
  3. Кожен потік хешується за своїм ідентифікатором (Thread.currentThread().threadLocalRandomProbe) в окрему незалежну комірку Cell.
  4. Клас Cell позначений внутрішньою анотацією @jdk.internal.vm.annotation.Contended, що додає 128 байтів порожнього простору (padding) навколо поля. Це виключає False Sharing (хибне розділення), розносячи комірки за різними рядками кешу процесора.
  5. Метод sum() підсумовує значення з base та всіх комірок Cell.

Економія пам’яті: AtomicIntegerFieldUpdater

Екземпляр AtomicInteger займає 16 байтів (при стиснутих вказівниках) або 24 байти нативної купи як окремий Java-об’єкт.

Якщо в системі створюються мільйони екземплярів сутностей (наприклад, ігрових сесій або мережевих з’єднань Connection), використання об’єктів AtomicInteger призведе до сотень мегабайтів оверхеду:

// Неефективно: 1_000_000 об'єктів * 24 байти = 24 МБ оверхеду на порожні обгортки
public class Connection {
    private final AtomicInteger state = new AtomicInteger(0);
}

// Ефективно: звичайний примітив int (4 байти) + один статичний updater
public class OptimizedConnection {
    private static final AtomicIntegerFieldUpdater<OptimizedConnection> STATE_UPDATER =
            AtomicIntegerFieldUpdater.newUpdater(OptimizedConnection.class, "state");

    // Поле ЗОБОВ'ЯЗАНЕ бути volatile і не-private!
    volatile int state = 0;

    public void connect() {
        STATE_UPDATER.compareAndSet(this, 0, 1);
    }
}

🎯 Шпаргалка для інтерв’ю

1. 30-секундна квінтесенція (Elevator Pitch)

Atomic-класи забезпечують неблокуючу (lock-free) потокобезпечність над змінними та посиланнями через комбінацію семантики volatile та апаратних інструкцій CAS (Compare-And-Swap). Вони виключають важкі блокування операційної системи та перемикання контексту. При високій конкуренції на запис (метрики, лічильники подій) скалярні класи (AtomicLong) слід замінювати на LongAdder, який усуває інвалідацію рядків кешу завдяки розділенню стану на незалежні масиви із захистом від False Sharing.


2. 4 каверзних запитання з глибокими відповідями

Запитання 1: У чому різниця між set(), lazySet() та compareAndSet() в AtomicInteger на рівні бар’єрів пам’яті?

Відповідь:

  • set(v): встановлює значення з повною volatile-семантикою. Компілятор вставляє бар’єри StoreStore перед записом і найдорожчий бар’єр StoreLoad після нього, змушуючи процесор негайно скинути запис зі Store Buffer у видимість усіх інших ядер.
  • lazySet(v): встановлює значення із семантикою Release. Генерує лише бар’єр StoreStore (всі попередні записи завершуються до цього), але опускає бар’єр StoreLoad. Запис виконується з мінімальними затримками CPU, але потоки, що читають, побачать зміну не миттєво, а з невеликою затримкою (eventual consistency).
  • compareAndSet(expect, update): повний двонаправлений бар’єр (Acquire + Release). Жодні операції читання/запису не можуть бути переміщені компілятором або процесором ні до, ні після виклику CAS.

Запитання 2: Що таке ABA-проблема, в яких сценаріях вона критична, і чи небезпечна вона для звичайного AtomicInteger?

Відповідь: ABA-проблема виникає, коли змінна послідовно змінює значення з $A$ на $B$, а потім знову на $A$. Потік, що перевіряє змінну через CAS(A, C), вважає, що стан не змінювався, хоча логічний стан системи здійснив перехід.

  • Для звичайних числових лічильників (AtomicInteger) ABA-проблема абсолютно нешкідлива, оскільки число $A$ завжди математично дорівнює числу $A$.
  • Проблема критична в lock-free алгоритмах управління пам’яттю (стеки, черги, списки з перевикористанням вузлів/Node reuse). Якщо вузол $A$ було вилучено, звільнено та повторно виділено алокатором за тією ж адресою пам’яті, посилання на наступний елемент $B$ усередині старого вказівника вже невалідне. Рішенням є AtomicStampedReference, що версіонує кожну транзакцію.

Запитання 3: Чому виклик LongAdder.sum() під час активного запису не гарантує точний математичний зріз (snapshot)?

Відповідь: Метод LongAdder.sum() не захоплює глобального блокування і не зупиняє потоки, що пишуть. Він послідовно проходить по полю base та елементах масиву Cell[], накопичуючи суму. Якщо під час підсумовування паралельний потік переніс інкремент з однієї комірки в іншу або збільшив уже прочитану комірку, результат обчислення sum() може не відображати точний зріз стану на єдиний момент часу. LongAdder спроектований для високошвидкісного збору метрик, де важлива пропускна здатність, а не атомарність моментального знімка стану.

Запитання 4: Як атомарно оновити два взаємопов’язані поля без блокувань (synchronized/Lock)?

Відповідь: Використовувати два окремих AtomicInteger не можна — це створить стан гонки між оновленням першого та другого полів.
Правильний lock-free патерн:

  1. Об’єднати обидва поля в імутабельний об’єкт даних (у Java 21 ідеально підходить record).
  2. Зберігати посилання на цей об’єкт в AtomicReference<ImmutableState>.
  3. Здійснювати атомарну заміну посилання методом updateAndGet(): ```java public record Coordinate(int x, int y) {} private final AtomicReference pos = new AtomicReference<>(new Coordinate(0, 0));

public void move(int dx, int dy) { pos.updateAndGet(curr -> new Coordinate(curr.x() + dx, curr.y() + dy)); } ```


3. Типові помилки та Red Flags

❌ “AtomicInteger гарантує безпеку кількох послідовних операцій: if (atom.get() == 0) { atom.set(1); }.”
Правильно: Це класична помилка «Check-Then-Act». Між викликом get() та викликом set() інший потік може змінити значення. Для атомарної перевірки та запису зобов’язаний використовуватися метод compareAndSet().

❌ “Atomic-класи завжди працюють швидше, ніж synchronized.”
Правильно: При низькій конкуренції synchronized оптимізується JVM до легковажного лока і практично безкоштовний. При екстремально високій конкуренції на запис AtomicLong може витрачати до 100% CPU на холості спроби CAS (сache thrashing), працюючи навіть гірше блокувань із паркуванням.

❌ “Для поля в AtomicIntegerFieldUpdater модифікатор volatile необов’язковий.”
Правильно: Поле, що передається в AtomicIntegerFieldUpdater, зобов’язане мати модифікатор volatile і не може бути private (повинно бути доступним updater-класу), інакше при створенні updater буде викинуто IllegalArgumentException.


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