🧵 Раздел 9 · Вопрос #11

В чём преимущество Atomic классов перед synchronized

Главное преимущество Atomic классов перед synchronized заключается в использовании неблокирующего (lock-free) подхода на базе аппаратных инструкций процессора CAS (Compare-And-S...


🟢 Junior Level

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

Главное преимущество Atomic классов перед synchronized заключается в использовании неблокирующего (lock-free) подхода на базе аппаратных инструкций процессора CAS (Compare-And-Swap) вместо тяжеловесной пессимистичной блокировки:

  1. Производительность без конкуренции и при умеренной нагрузке: Atomic-операции выполняются за единицы наносекунд (~3–10 ns), так как не требуют захвата монитора, создания структур ядра ОС и переключения контекста потоков (Context Switch, занимающий ~1000–5000 ns).
  2. Отсутствие взаимных блокировок (Deadlock-Free): отдельные операции над Atomic-переменными в принципе не могут вызвать взаимную блокировку (Deadlock), так как потоки не удерживают эксклюзивные замки.
  3. Устойчивость к сбоям потоков: если поток, выполняющий операцию с Atomic, зависнет, будет вытеснен планировщиком ОС или упадёт с ошибкой, остальные потоки продолжат беспрепятственно работать (гарантия системного прогресса Lock-Free).

Сравнительная таблица: Atomic против synchronized

Характеристика AtomicInteger / AtomicLong synchronized
Модель конкуренции Оптимистичная (Lock-Free / CAS) Пессимистичная (Блокирующая)
Состояние конкурирующего потока RUNNABLE (продолжает вычисления) BLOCKED (паркуется планировщиком ОС)
Переключение контекста ОС ❌ Отсутствует (работа в user-space) ⚠️ Присутствует при эскалации до Fat Lock
Риск Deadlock ❌ Невозможен для одиночных операций ⚠️ Возможен при неправильном порядке захвата
Атомарность нескольких полей ❌ Нет (только одно поле или ссылка) ✅ Да (любая сложная транзакция)
Энергопотребление при сильной конкуренции ⚠️ Высокое (сжигает 100% CPU в spin-loop) ✅ Низкое (спящие потоки не тратят такты CPU)

Примеры на Java 21

import java.util.concurrent.atomic.AtomicInteger;

public class AtomicVsSyncDemo {

    // 1. Подход с Atomic: Lock-free счётчик
    public static class LockFreeCounter {
        private final AtomicInteger count = new AtomicInteger(0);

        public void increment() {
            count.incrementAndGet(); // Атомарная процессорная инструкция (lock xadd)
        }

        public int get() {
            return count.get();
        }
    }

    // 2. Подход с synchronized: пессимистичная блокировка
    public static class SynchronizedCounter {
        private int count = 0;

        public synchronized void increment() {
            count++; // Захват монитора, критическая секция, выход
        }

        public synchronized int get() {
            return count;
        }
    }
}

🟡 Middle Level

Архитектурный выигрыш: User-Space против Kernel-Space

При захвате монитора synchronized в условиях конкуренции виртуальная машина JVM вынуждена эскалировать монитор до тяжеловесного (Heavyweight Lock / Fat Lock):

                       Тяжеловесный synchronized:
[ User Space: Поток ] -> [ Системный вызов futex/mutex ] -> [ Kernel Space: ОС ]
                                                                   │
    [ Context Switch: Сохранение регистров CPU, сброс TLB ] <──────┘
                                    │
                                    v
                       Задержка: ~1000 - 5000 ns
  1. Поток переводится в режим ядра (переход кольца защиты Ring 3 $\to$ Ring 0).
  2. Операционная система сохраняет состояние регистров процессора, вытесняет поток из очереди исполнения и паркует его в WAITING/BLOCKED.
  3. Сбрасывается процессорный буфер ассоциативной трансляции адресов (TLB - Translation Lookaside Buffer), что приводит к промахам кеша инструкций и данных.

Atomic-классы работают исключительно в пространстве пользователя (User-Space):

  • Вызов incrementAndGet() компилируется JIT C2 в прямую машинную инструкцию lock xadd [memory], eax.
  • Поток никогда не переходит в режим ядра и не паркуется операционной системой. Задержка составляет всего ~3–10 ns.

Гарантии системного прогресса: Lock-Free vs Blocking

В параллельных вычислениях математически доказаны уровни гарантий прогресса:

+--------------------------------------------------------------------------+
| Lock-Free (Atomic классы)                                                |
| В любой момент времени хотя бы один поток системы гарантированно         |
| совершает полезный прогресс, даже если другие потоки приостановлены.     |
+--------------------------------------------------------------------------+
                                     vs
+--------------------------------------------------------------------------+
| Blocking (synchronized)                                                  |
| Если поток-владелец блокировки засыпает (GC STW-пауза, Page Fault,      |
| вытеснение планировщиком OS), ВСЕ остальные потоки полностью парализованы|
+--------------------------------------------------------------------------+

Проблема Priority Inversion (Инверсия приоритетов):

В системах с synchronized поток с низким приоритетом может захватить монитор, после чего быть вытеснен потоком со средним приоритетом. В результате высокоприоритетный поток, ожидающий этот же монитор, зависает на неопределённый срок. Atomic-классы принципиально не подвержены классической инверсии приоритетов.


Ограничения: Когда Atomic уступает synchronized

Использовать Atomic-классы повсюду — антипаттерн. Существуют три ключевых сценария, где synchronized предпочтительнее:

1. Составные транзакции над несколькими переменными

Atomic-класс может атомарно изменить только своё собственное значение. Он не может согласованно обновить две независимые переменные:

// ОШИБКА: между шагом 1 и шагом 2 состояние рассогласовано!
accountA.addAndGet(-100);
accountB.addAndGet(100);

// synchronized решает эту задачу тривиально:
synchronized (transferLock) {
    balanceA -= 100;
    balanceB += 100;
}

2. Длительные операции в критической секции

Если внутри критической секции требуется выполнить разбор JSON, обращение к диску или сложный расчёт, оптимистичный CAS-цикл приведет к бесконечным сбоям:

// КАТАСТРОФА ДЛЯ CPU: при 10 потоках сложные вычисления будут
// выполняться впустую десятки раз при каждом провале CAS!
atomicRef.updateAndGet(state -> calculateHeavyAnalytics(state));

🔴 Senior Level

Аппаратный уровень: Cache Bouncing и Bus Saturation

При росте числа активно пишущих потоков до 16, 32 или 64 ядер наступают фундаментальные физические ограничения архитектуры x86/ARM:

                      Точка деградации Atomic (Cache Thrashing)
Поток 1 (Ядро 0) ───┐
Поток 2 (Ядро 1) ───┼──> [ LOCK CMPXCHG ] ──> Шторм сигналов Invalidate по шине UPI
Поток 3 (Ядро 2) ───┘                                     │
                                                          v
                                     [ 100% загрузка шины когерентности ]
                                     [ Потоки крутят 100% CPU без прогресса ]
  1. Каждая операция с префиксом LOCK вызывает широковещательный сигнал инвалидации кеш-линии во всех соседних ядрах процессора по протоколу MESI.
  2. Кеш-линия непрерывно перемещается между ядрами (Cache Bouncing / Cache Ping-Pong).
  3. Происходит насыщение полосы пропускания межпроцессорной шины (Interconnect Bus Saturation).
  4. Парадокс высокой нагрузки: при 64+ конкурирующих потоках на запись обычный synchronized начинает опережать AtomicLong по пропускной способности, так как synchronized паркует ожидающие потоки в ОС, освобождая шину памяти и кеши CPU для полезной работы одного потока.

Решение проблемы конкуренции: Эволюция к LongAdder

Для устранения аппаратных коллизий шины в Java 8 был добавлен класс LongAdder:

                       Архитектура LongAdder (Striped64)
Потоки 1, 2   ──>  [ Cell 0 (value) ] (Кеш-линия 0, 64 байта)
Потоки 3, 4   ──>  [ Cell 1 (value) ] (Кеш-линия 1, 64 байта) ──> sum() суммирует
Потоки 5, 6   ──>  [ Cell 2 (value) ] (Кеш-линия 2, 64 байта)     все ячейки
  • Вместо одной общей переменной LongAdder использует внутренний массив ячеек Cell[].
  • Потоки динамически распределяются по разным ячейкам на основе своего хеша (Thread.currentThread().threadLocalRandomProbe).
  • Ячейки Cell защищены внутренней аннотацией @jdk.internal.vm.annotation.Contended, добавляющей 128 байт пустого пространства вокруг поля для полного исключения False Sharing.

Анализ профилирования: Слепое пятно Thread Dump

Критический нюанс эксплуатации в Production:

  • Если зависает synchronized: в дампе потоков (jstack) легко локализовать проблему: ```text “pool-thread-1” #12 prio=5 tid=… waiting for monitor entry java.lang.Thread.State: BLOCKED (on object monitor)
    • waiting to lock <0x000000076a123456> ``` Сразу виден заблокированный ресурс и поток-владелец.
  • Если деградирует Atomic (CAS Contention): потоки крутятся в спин-цикле и имеют состояние:
    "pool-thread-1" #12 prio=5 tid=... runnable
       java.lang.Thread.State: RUNNABLE
       at java.util.concurrent.atomic.AtomicInteger.incrementAndGet(AtomicInteger.java:234)
    

    Потоки формально находятся в статусе RUNNABLE, утилизация процессора сервером подскакивает до 100%, но полезная работа не выполняется. Диагностировать такую проблему можно только через профилировщики CPU (Async-Profiler, JFR) по горячему следу методов Unsafe.getAndAddInt или VarHandle.compareAndSet.


🎯 Шпаргалка для интервью

1. 30-секундный эссенс (Elevator Pitch)

Atomic-классы предоставляют неблокирующую (lock-free) альтернативу synchronized для атомарных операций над отдельными переменными и ссылками. Их главное преимущество — работа полностью в пространстве пользователя (User-Space) на уровне аппаратных инструкций процессора (lock xadd, lock cmpxchg), что исключает дорогие системные вызовы ядра ОС и переключения контекста потоков. Они исключают возникновение Deadlock для одиночных операций. Однако при экстремальной конкуренции сотен потоков на запись скалярные Atomic-классы деградируют из-за инвалидации кеш-линий процессора, уступая шардированному LongAdder или блокировкам.


2. 4 каверзных вопроса с глубокими ответами

Вопрос 1: Почему в thread dump при зависании приложения на Atomic-классах потоки выглядят как абсолютно здоровые RUNNABLE?

Ответ: С точки зрения планировщика операционной системы и виртуальной машины JVM поток, выполняющий CAS-цикл (while (!compareAndSet(...))), непрерывно исполняет инструкции процессора. Он не вызывает системных блокировок (futex), не уходит в режим ожидания и не передаёт управление планировщику ОС. Поэтому его состояние в дампе отображается как RUNNABLE. Это опасное «слепое пятно» традиционного мониторинга: процессор загружен на 100%, потоки «работают», но система фактически находится в состоянии Live-Lock, бесконечно повторяя неудачные попытки CAS.

Вопрос 2: Что произойдёт с системой, если поток аварийно завершится (например, по OOM или Kill) в середине операции Atomic против середины блока synchronized?

Ответ:

  • В Atomic-классах любая операция неделима на уровне инструкции процессора. Поток либо успел выполнить атомарную инструкцию, либо нет. Внутри JVM нет незавершённого промежуточного состояния, поэтому другие потоки не будут заблокированы и продолжат нормальную работу.
  • В synchronized байткод защищён таблицей исключений, и при штатном вылете Throwable монитор будет освобождён. Однако если поток зависнет в бесконечном цикле, уйдёт в невыходящий I/O или поток будет аварийно остановлен на уровне нативного кода ОС, монитор останется навсегда захваченным, и все остальные потоки зависнут в очереди BLOCKED навсегда.

Вопрос 3: В каком конкретно сценарии synchronized превзойдёт AtomicLong по производительности и энергоэффективности на сервере с 64+ ядрами?

Ответ: При массовой конкуренции на запись сотен параллельных потоков за единую переменную (High Contention Write Storm). В AtomicLong все 64 ядра будут непрерывно выполнять LOCK CMPXCHG, забивая межпроцессорную шину инвалидационными пакетами (Cache Bouncing) и потребляя 100% мощности CPU. В то же время synchronized после нескольких адаптивных оборотов спиннинга принудительно переведёт 63 потока в состояние BLOCKED, припарковав их в ядре ОС. Спящие потоки не обращаются к шине памяти и не жгут процессор, позволяя единственному потоку-владельцу быстро выполнить инкремент без сопротивления.

Вопрос 4: Можно ли построить транзакцию перевода денег между двумя счетами без блокировок, используя только AtomicInteger?

Ответ: Используя два независимых AtomicInteger — нельзя. Операции над двумя разными ячейками памяти невозможно объединить в единый атомарный шаг на уровне процессора. Между списанием со счёта $A$ и зачислением на счёт $B$ сторонний поток сможет прочитать промежуточное несогласованное состояние (деньги исчезли из системы).
Единственный способ реализовать это lock-free — объединить балансы обоих счетов в единый неизменяемый объект данных (например, record AccountsState(int balA, int balB)) и атомарно обновлять ссылку на него через единый AtomicReference<AccountsState> методом updateAndGet().


3. Типичные ошибки и Red Flags

❌ “Atomic-классы всегда работают быстрее, чем synchronized.”
Правильно: При низкой и умеренной конкуренции Atomic существенно быстрее. Но при сильной конкуренции на запись (десятки потоков) наступает деградация из-за постоянных промахов CAS. Для счётчиков под высокой нагрузкой следует использовать LongAdder.

❌ “Использование нескольких Atomic переменных обеспечивает потокобезопасность метода: if (flag.get()) { count.incrementAndGet(); }.”
Правильно: Это ошибка «Check-Then-Act». Каждая операция атомарна по отдельности, но их взаимная последовательность не атомарна. Состояние флага может измениться сразу после проверки.

❌ “AtomicInteger спасает от любых Deadlock в коде приложения.”
Правильно: Одиночные вызовы Atomic действительно не могут вызвать Deadlock. Однако сложные неблокирующие алгоритмы на базе нескольких CAS-циклов при ошибках в логике могут войти в Live-Lock, который с точки зрения системы аналогичен бесконечному зависанию.


4. Связанные вопросы