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

В чому перевага Atomic класів перед synchronized?

Atomic класи та synchronized — два різних підходи до потокобезпечності:

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

Junior рівень

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

Atomic класи та synchronized — два різних підходи до потокобезпечності:

Підхід Принцип Аналогія
synchronized Песимістичний: “Конкуренція буде — блокую одразу” Одна людина входить в кімнату, інші чекають в черзі
Atomic Оптимістичний: “Конкуренція малоймовірна — спробую швидко” Всі намагаються зайти одночасно, хто встиг — той оновив

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

// synchronized — песиміст
public class SynchronizedCounter {
    private int count = 0;

    public synchronized void increment() {
        count++; // Тільки один потік одночасно, інші чекають
    }
}

// Atomic — оптиміст
public class AtomicCounter {
    private AtomicInteger count = new AtomicInteger(0);

    public void increment() {
        count.incrementAndGet(); // Швидка CAS спроба, якщо не вийшло — ще раз
    }
}

Коли що використовувати

Ситуація Вибір Чому
Простий лічильник Atomic Швидше (CAS ~5ns vs synchronized ~1000ns — немає context switch), немає deadlock
Складна операція synchronized Потрібно блокувати кілька полів
Прапорець AtomicBoolean Легше synchronized
Оновлення кількох полів synchronized Atomic тільки для одного поля

Middle рівень

Чому Atomic швидший?

1. Відсутність Context Switch

synchronized (при конкуренції):
  Потік → BLOCKED → ОС паркує → Context Switch → ~1000-5000 ns

Atomic (CAS):
  Потік → RUNNABLE → CAS спроба → ~5-10 ns (без context switch)

При synchronized потік переходить у стан BLOCKED, що потребує звернення до ядра ОС та перемикання контексту — це тисячі тактів CPU.

Atomic використовує Spin-waiting (буквально «крутіння в очікуванні»): потік не засинає, а в циклі повторює CAS-спробу. Це швидше, тому що не передає керування операційній системі (немає context switch).

2. Progress Guarantees (Гарантії прогресу)

Проблема synchronized Atomic
Deadlock Можливий Неможливий (для окремих Atomic-операцій; але бізнес-логіка на основі кількох Atomic все ще може зависнути)
Priority Inversion Можливий Неможливий
Забутий unlock Н/Д (автоматично) Н/Д
// synchronized — можливий deadlock
synchronized(lockA) {
    synchronized(lockB) { // Якщо інший потік зробив навпаки → deadlock
        // ...
    }
}

// Atomic — deadlock неможливий
counter1.incrementAndGet(); // Завжди завершиться
counter2.incrementAndGet(); // Завжди завершиться

Як працює CAS-цикл

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

Коли Atomic ГИРШИЙ, ніж synchronized?

1. Висока конкуренція (High Contention)

100 потоків одночасно роблять incrementAndGet():
  - CAS постійно фейлиться
  - Потоки крутяться в циклах, спалюючи CPU на 100%
  - Прогресу майже немає

В цьому випадку synchronized може бути ефективнішим:
  - Потоки вишиковуються в чергу
  - CPU не витрачається на порожні циклі

2. Складні операції

// Atomic НЕ допоможе атомарно оновити x та y
AtomicInteger x = new AtomicInteger(0);
AtomicInteger y = new AtomicInteger(0);

// НЕ безпечно!
void swap() {
    int temp = x.get();
    x.set(y.get()); // Інший потік може побачити проміжний стан
    y.set(temp);
}

// synchronized — безпечно
synchronized(lock) {
    int temp = x;
    x = y;
    y = temp;
}

3. Тривалі операції

// ПОГАНО: довга операція в CAS-циклі
atomicRef.updateAndGet(current -> {
    // Довгі обчислення — CPU згорить при конкуренції!
    return expensiveTransformation(current);
});

// ГАЗАРД: довга операція в synchronized
synchronized(lock) {
    ref = expensiveTransformation(ref); // Інші потоки чекають, CPU не згорає
}

Senior рівень

Under the Hood: Bus Contention

Навіть коли CAS проходить успішно, він генерує трафік по шині даних:

Потік на Ядрі 1 робить CAS:
  1. lock cmpxchg [memory], new_value
  2. Блокування шини пам'яті
  3. Invalidate кеш-ліній на всіх інших ядрах
  4. Очікування Ack від всіх ядер
  5. Розблокування шини

Багато Atomic-змінних можуть “забити” шину пам’яті сервера.

Lock-free vs Wait-free vs Obstruction-free

Термін Гарантія Приклад
Lock-free Хоча б один потік завершить за скінченний час AtomicInteger
Wait-free Кожен потік завершить за скінченну кількість кроків LongAdder.add()
Obstruction-free Потік завершиться, якщо інші не заважають Деякі STM

synchronized — жоден з перелічених (блокуючий):

  • Потік може бути заблокований нескінченно (deadlock, starvation)

Performance: Точка перелому

К-сть потоків | Atomic (ns/op) | synchronized (ns/op)
───────────────────────────────────────────────────────
1              | 5              | 10
2              | 10             | 15
4              | 20             | 20
8              | 50             | 25
16             | 200            | 30
32             | 1000+          | 40
64             | Spin!          | 50

Цифри приблизні, залежать від CPU/JVM. Вимірюйте на своєму залізі через JMH.

Точка перелому: ~4-8 потоків для простих операцій. Після цього synchronized стає швидшим.

LongAdder як розвиток Atomic ідеї

// Проблема: AtomicInteger при 100 потоках → CAS contention
AtomicInteger counter = new AtomicInteger(0);

// Рішення: LongAdder розподіляє по комірках
LongAdder counter = new LongAdder();
counter.increment(); // Кожен потік у свою комірку
long total = counter.sum(); // Сумує всі комірки

LongAdder внутрішньо:

// Базове значення + масив комірок
volatile long base;
volatile Cell[] cells; // Кожен потік хешується у свою комірку

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

Діагностика

Thread Dumps

jstack <pid>
# Якщо гальмує synchronized — бачимо BLOCKED потоки
"worker-1" BLOCKED (on object monitor)
  - waiting to lock <0x000...>

# Якщо гальмує Atomic — BLOCKED немає, потоки в RUNNABLE
"worker-1" RUNNABLE
  at AtomicInteger.compareAndSet(...)
  // Потік крутиться в CAS-циклі

-XX:+PrintAssembly

# synchronized → важкі виклики ОС
call runtime_monitor_enter

# Atomic → одна інструкція
lock cmpxchg dword ptr [rax], rcx

Best Practices

  1. Atomic для простих операцій при низькій/середній конкуренції
  2. synchronized для складних операцій або при високій конкуренції
  3. LongAdder для лічильників при екстремальному навантаженні
  4. Уникайте Atomic для довгих обчислень — CAS-цикл спалить CPU
  5. Моніторьте: немає BLOCKED в дампі, але система гальмує → шукайте гарячі Atomic
  6. Back-off: Thread.onSpinWait() для підказки процесору при CAS retry

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

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

  • Atomic = оптимістичний підхід (спробуй швидко, retry при конфлікті), synchronized = песимістичний (одразу блокую)
  • Atomic використовує CAS-цикл (spin-waiting): потік не засинає, а повторює спробу — немає context switch
  • synchronized переводить потік у BLOCKED (~1000-5000 ns), Atomic залишається в RUNNABLE (~5-10 ns)
  • Точка перелому: ~4-8 потоків для простих операцій — після цього synchronized може стати швидшим
  • Atomic гарантує lock-free прогрес (хоча б один потік завершиться), synchronized може deadlock-нутись
  • Atomic НЕ допомагає для складних операцій (оновлення 2+ полів) — тут synchronized необхідний
  • При високій конкуренції CAS постійно фейлиться, потоки спалюють CPU на 100% — LongAdder або synchronized краще
  • LongAdder розвиває ідею Atomic: розподіляє лічильник по комірках (per-thread), сумує при читанні

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

  • Чому Atomic швидший при низькій конкуренції? — Немає context switch та звернення до ОС, одна інструкція lock cmpxchg
  • Що таке lock-free vs wait-free? — Lock-free: хоча б один потік завершиться; wait-free: кожен потік завершить за скінченну кількість кроків (LongAdder.add)
  • Що відбувається на рівні CPU при CAS? — Блокування шини пам’яті → invalidate кеш-ліній на всіх ядрах → очікування Ack → розблокування
  • Коли synchronized кращий за Atomic? — Висока конкуренція (8+ потоків), складні операції, тривалі обчислення

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

  • “Atomic завжди швидший за synchronized” — ні, точка перелому ~4-8 потоків
  • “Atomic використовує блокування” — ні, це lock-free CAS-цикл
  • “З Atomic неможливий deadlock в будь-якому коді” — окремі Atomic-операції безпечні, але бізнес-логіка на кількох Atomic все ще може зависнути

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

  • [[10. Як працюють AtomicInteger, AtomicLong]]
  • [[12. Що таке пул потоків (Thread Pool)]]
  • [[18. Що таке deadlock (взаємне блокування)]]