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

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

Звичайна операція count++ не потокобезпечна:

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

Junior рівень

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

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

Lock-free = немає м’ютекса, немає блокування потоку. Використовується CAS-цикл: читаємо значення, обчислюємо нове, намагаємося записати через CAS. Якщо інший потік змінив значення — CAS повертає false, повторюємо цикл.

Навіщо вони потрібні?

Звичайна операція count++ не потокобезпечна:

// НЕ безпечно!
public class UnsafeCounter {
    private int count = 0;

    public void increment() {
        count++; // 3 операції: read → modify → write
        // Два потоки можуть прочитати однакове значення!
    }
}

Рішення з Atomic

// Безпечно!
public class SafeCounter {
    private AtomicInteger count = new AtomicInteger(0);

    public void increment() {
        count.incrementAndGet(); // Атомарна операція
    }
}

Основні Atomic класи

Клас Тип Приклад використання
AtomicBoolean boolean Прапорець стану
AtomicInteger int Лічильник
AtomicLong long Лічильник (64-бітний)
AtomicReference<T> Об’єкт Потокобезпечне посилання
AtomicIntegerArray int[] Масив лічильників
AtomicLongAdder long Високонавантажений лічильник

Приклади використання

// AtomicInteger
AtomicInteger counter = new AtomicInteger(0);
counter.incrementAndGet();   // ++i (повертає нове значення)
counter.getAndIncrement();   // i++ (повертає старе значення)
counter.addAndGet(5);        // i += 5
counter.compareAndSet(5, 10);// Якщо i==5, встановити 10

// AtomicBoolean
AtomicBoolean flag = new AtomicBoolean(false);
flag.set(true);
boolean oldValue = flag.getAndSet(false);

// AtomicReference
AtomicReference<String> ref = new AtomicReference<>("initial");
ref.compareAndSet("initial", "updated");

Механізм: CAS + volatile

Atomic класи працюють на двох стовпах:

  1. volatile поле — гарантує видимість змін
  2. CAS (Compare-And-Swap) — атомарне оновлення
// Спрощена реалізація AtomicInteger
public class AtomicInteger {
    private volatile int value; // volatile для видимості

    public final int incrementAndGet() {
        for (;;) { // Нескінченний цикл
            int current = get();         // 1. Читаємо
            int next = current + 1;      // 2. Обчислюємо
            if (compareAndSet(current, next)) { // 3. CAS — оновлюємо
                return next;             // Успіх!
            }
            // CAS не вдався — хтось інший оновив, пробуємо знову
        }
    }
}

Middle рівень

CAS (Compare-And-Swap) детально

CAS — це інструкція процесора, яка атомарно:

  1. Порівнює значення за адресою з очікуваним
  2. Якщо дорівнює — записує нове
  3. Якщо не дорівнює — нічого не робить
// Псевдокод CAS
boolean CAS(memoryAddress, expectedValue, newValue) {
    if (*memoryAddress == expectedValue) {
        *memoryAddress = newValue;
        return true;
    }
    return false;
}

На x86 це інструкція LOCK CMPXCHG.

CMPXCHG (CoMPare and eXCHanGe) — x86-інструкція для CAS-операції. На ARM — ldxr/stxr, на RISC-V — lr.w/sc.w (Load Reserved / Store Conditional).

Оновлення посилань

public class AtomicReferenceDemo {
    private AtomicReference<List<String>> list = new AtomicReference<>(new ArrayList<>());

    public void addItem(String item) {
        list.updateAndGet(currentList -> {
            // Створюємо нову копію (immutable update)
            List<String> newList = new ArrayList<>(currentList);
            newList.add(item);
            return newList;
        });
    }
}

VarHandle (Java 9+)

Починаючи з Java 9, Atomic-класи переписані з використанням VarHandle:

VarHandle (Java 9, JEP 193) — заміна sun.misc.Unsafe для атомарних операцій. Безпечніша (немає прямого доступу до пам’яті), але з тими ж можливостями. API Atomic-класів залишився зворотно сумісним — код міняти не потрібно.

// Замість sun.misc.Unsafe — типізований та безпечний API
public class AtomicInteger {
    private static final VarHandle VALUE;
    private volatile int value;

    static {
        try {
            MethodHandles.Lookup l = MethodHandles.lookup();
            VALUE = l.findVarHandle(AtomicInteger.class, "value", int.class);
        } catch (ReflectiveOperationException e) {
            throw new Error(e);
        }
    }
}

Різні рівні гарантій пам’яті

AtomicLong counter = new AtomicLong(0);

// Різні методи запису:
counter.set(100);          // Volatile запис — повна видимість
counter.lazySet(100);      // Ослаблена видимість: не ставить StoreLoad barrier, запис може стати видимий із затримкою. Швидше!
counter.compareAndSet(0, 100); // CAS з повним бар'єром
Метод Гарантія Швидкість
set(v) Повна видимість одразу Стандарт
lazySet(v) Видимість eventual Швидше
compareAndSet Повний бар’єр Стандарт
weakCompareAndSet Може повернути false spuriously (навіть якщо значення збігається) на деяких архітектурах. Це не баг — дозволено специфікацією для оптимізації на CPU зі слабкою моделлю пам’яті (ARM, RISC-V). Швидше

LongAdder для високої конкуренції

При сотнях потоків, що оновлюють один лічильник, AtomicLong починає гальмувати (CAS постійно фейлиться).

// ПОГАНО при високій конкуренції:
AtomicLong counter = new AtomicLong(0);
// Кожен потік постійно "промахується" в CAS

// ГАЗАРД при високій конкуренції:
LongAdder counter = new LongAdder();
counter.increment(); // Розподіляє по комірках
long total = counter.sum(); // Сумує всі комірки

Як працює LongAdder:

  • Замість однієї змінної — масив комірок (Cells)
  • Різні потоки оновлюють різні комірки
  • При sum() значення сумуються

Коли Atomic класи НЕ підходять

  1. Складні операції над кількома полями: потрібно атомарно оновити два поля — AtomicInteger не допоможе, потрібен synchronized або AtomicReference з immutable-об’єктом
  2. Висока конкуренція (100+ потоків): CAS-цикл марнує CPU — краще LongAdder
  3. Довгі обчислення між read та write: якщо між читанням та записом проходить багато часу — CAS буде постійно фейлитися, краще Lock

Senior рівень

Under the Hood: Реалізація CAS

На рівні x86 процесора:

; LOCK CMPXCHG — атомарна операція
; rax = expected, rcx = newValue, [memory] = actual value

lock cmpxchg [memory], rcx
; Якщо [memory] == rax:
;   [memory] = rcx
;   ZF = 1 (success)
; Інакше:
;   rax = [memory]
;   ZF = 0 (failure)

Префікс lock блокує шину пам’яті на час операції, забезпечуючи атомарність на багатопроцесорних системах.

ABA Проблема

Класична проблема CAS:

Потік 1: прочитав значення A
Потік 2: змінив A → B → A (значення повернулось!)
Потік 1: CAS(A, new_value) — УСПІХ! Але стан змінювався!

Рішення: AtomicStampedReference

// Зберігає значення + версію (штамп)
AtomicStampedReference<String> ref = new AtomicStampedReference<>("A", 0);

int[] stampHolder = new int[1];
String value = ref.get(stampHolder);
int stamp = stampHolder[0];

// CAS з перевіркою версії
boolean success = ref.compareAndSet("A", "B", stamp, stamp + 1);

AtomicMarkableReference

// Зберігає значення + boolean прапорець
AtomicMarkableReference<Node> ref = new AtomicMarkableReference<>(node, false);

// Позначити вузол як "видалений"
ref.compareAndSet(node, node, false, true);

LongAdder внутрішній устрій

// Спрощено
public class LongAdder {
    // Базове значення (використовується при низькій конкуренції)
    volatile long base;

    // Масив комірок (використовується при високій конкуренції)
    volatile Cell[] cells;

    static final class Cell {
        @Contended // Padding для уникнення False Sharing
        volatile long value;
    }

    public void add(long x) {
        Cell[] as; long b, v; int m; Cell a;
        if ((as = cells) != null || !casBase(b = base, b + x)) {
            // Базовий CAS не вдався — використовуємо Cell
            // Кожен потік хешується у свою комірку
            // ...
        }
    }

    public long sum() {
        long sum = base;
        for (Cell a : cells) {
            if (a != null) sum += a.value;
        }
        return sum;
    }
}

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

Бенчмарк (приблизний, 100 потоків)

Операція Час на операцію Примітка
synchronized ~5000 ns Context switching
AtomicLong ~100 ns CAS contention
LongAdder ~10 ns Розподілені комірки

False Sharing

// Проблема: масив Atomic елементів в одній кеш-лінії
AtomicInteger[] counters = new AtomicInteger[16];
// counters[0] та counters[1] можуть бути в одній кеш-лінії
// Запис у counters[0] інвалідує counters[1]

Рішення — @Contended всередині LongAdder:

@jdk.internal.vm.annotation.Contended
volatile long value; // 128 байт padding навколо

AtomicIntegerFieldUpdater — економія пам’яті

Якщо у вас мільйони об’єктів і в кожному потрібен атомарний лічильник:

// ПОГАНО: мільйони додаткових об'єктів AtomicInteger
class User {
    AtomicInteger loginAttempts = new AtomicInteger(0); // +24 байти на об'єкт
}

// ГАЗАРД: updater працює зі звичайним int полем
class User {
    volatile int loginAttempts = 0; // 4 байти

    private static final AtomicIntegerFieldUpdater<User> UPDATER =
        AtomicIntegerFieldUpdater.newUpdater(User.class, "loginAttempts");

    public void increment() {
        UPDATER.incrementAndGet(this);
    }
}

Діагностика

CAS Failures в профайлері

# Java Flight Recorder — дивіться contention events
java -XX:StartFlightRecording=filename=rec.jfr MyApp

Якщо багато часу в incrementAndGet — висока contention.

Bytecode аналіз

javap -c -p AtomicInteger.class

Інструкції getAndAddInt — нативні, транслюються в lock cmpxchg.

Best Practices

  1. Atomic* для простих лічильників та прапорців при помірній конкуренції
  2. LongAdder для високонавантажених лічильників (метрики, статистика)
  3. AtomicReference з updateAndGet для immutable updates
  4. AtomicStampedReference коли важлива ABA проблема
  5. synchronized для складних операцій (кілька полів одночасно)
  6. @Contended для масивів атомарних лічильників (потребує JVM прапорця)
  7. Уникайте AtomicLong як ID-генератора при 1000+ RPS — використовуйте range allocation

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

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

  • Atomic-класи забезпечують lock-free потокобезпечність через CAS + volatile
  • incrementAndGet = спін-цикл: read → modify → CAS retry until success
  • На x86 CAS = lock cmpxchg інструкція з префіксом lock (блокування шини пам’яті)
  • Java 9+ переписані на VarHandle (безпечна заміна sun.misc.Unsafe, JEP 193)
  • ABA проблема: значення змінилося A→B→A, CAS «не помітить»; рішення — AtomicStampedReference
  • LongAdder вирішує contention при 100+ потоках: розподіляє по Cell[] комірках, сумує при sum()
  • lazySet() — ослаблена видимість (eventual), швидше ніж set() — не ставить StoreLoad barrier

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

  • Чому AtomicLong гальмує при високій конкуренції? — CAS постійно фейлиться, потоки крутять спін-цикл, марнуючи CPU
  • Чим LongAdder відрізняється від AtomicLong? — LongAdder: масив комірок, кожен потік пише у свою; AtomicLong: одна змінна, всі конкурують
  • Що таке ABA проблема? — Потік 1 прочитав A, Потік 2 змінив A→B→A, Потік 1: CAS(A) — успіх, але стан змінювався
  • Коли використовувати AtomicReference? — Для атомарної заміни immutable об’єктів (наприклад, оновлення конфігурації)

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

  • “Atomic-класи використовують блокування” — ні, це lock-free через CAS
  • “CAS атомарний на будь-якій архітектурі” — на 32-бітних JVM AtomicLong може використовувати внутрішнє блокування
  • “lazySet() гарантує негайну видимість” — ні, eventual consistency, без StoreLoad бар’єру
  • “LongAdder завжди кращий за AtomicLong” — ні, LongAdder швидший тільки при високій конкуренції; для звичайних випадків AtomicLong

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

  • [[9. Що таке CAS (Compare-And-Swap)]] — основа роботи всіх Atomic-класів
  • [[1. В чому різниця між synchronized та volatile]] — volatile для видимості + CAS для атомарності
  • [[3. Що таке visibility problem]] — volatile поле всередині Atomic забезпечує видимість
  • [[7. Що таке reentrant lock]] — AQS (основа ReentrantLock) теж використовує CAS