🧵 Раздел 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 vs 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. Связанные вопросы