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

Как работают AtomicInteger, AtomicLong

Их работа базируется на двух ключевых принципах:


🟢 Junior Level

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

AtomicInteger и AtomicLong — это потокобезопасные классы-обёртки над примитивными числовыми типами int и long из пакета java.util.concurrent.atomic. Они обеспечивают атомарное (неделимое) чтение, запись и математическую модификацию значений без использования тяжеловесных блокировок (lock-free).

Их работа базируется на двух ключевых принципах:

  1. Значение переменной хранится в поле с модификатором volatile, гарантируя немедленную видимость изменений всеми потоками процессора (Java Memory Model Happens-Before).
  2. Модификации выполняются через оптимистичные циклы с процессорными инструкциями CAS (Compare-And-Swap), исключая перевод потоков в состояние BLOCKED и системные вызовы ядра операционной системы.

Проблема атомарности примитивов

Обычные операции инкремента count++ или count += 5 состоят из трёх раздельных машинных команд:

  1. Загрузка значения из памяти в регистр CPU (read).
  2. Модификация в регистре (modify).
  3. Сохранение регистра обратно в память (write).

Если два потока выполняют count++ одновременно, они могут прочитать одно и то же начальное значение и перезаписать результат друг друга, потеряв один инкремент (Race Condition).


Основные методы и их применение на Java 21

import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;

public class AtomicUsageDemo {
    private final AtomicInteger counter = new AtomicInteger(0);
    private final AtomicLong globalIdSeq = new AtomicLong(1000L);

    public void demoOperations() {
        // 1. Инкремент и декремент
        int next = counter.incrementAndGet(); // Префиксный инкремент: ++i (вернёт 1)
        int prev = counter.getAndIncrement(); // Постфиксный инкремент: i++ (вернёт 1, станет 2)
        counter.decrementAndGet();            // Префиксный декремент: --i (вернёт 1)

        // 2. Арифметические операции
        counter.addAndGet(10);                // i += 10 (вернёт 11)
        int oldVal = counter.getAndAdd(5);    // вернёт 11, значение станет 16

        // 3. Атомарное сравнение с заменой (CAS)
        boolean updated = counter.compareAndSet(16, 100); // true, если было 16

        // 4. Функциональное атомарное обновление (Java 8+)
        // Применяет чистую лямбда-функцию в цикле CAS
        int doubled = counter.updateAndGet(x -> x * 2); 

        // 5. Аккумуляция с бинарным оператором
        int maxVal = counter.accumulateAndGet(250, Math::max);
    }
}

🟡 Middle Level

Внутреннее устройство: volatile + VarHandle (Java 9+)

Исторически до Java 9 классы AtomicInteger и AtomicLong использовали низкоуровневый sun.misc.Unsafe. Начиная с Java 9, они переведены на типобезопасный механизм java.lang.invoke.VarHandle.

Фрагмент внутренней архитектуры AtomicInteger в HotSpot JVM:

public class AtomicInteger extends Number implements java.io.Serializable {
    // 1. Хранилище значения — всегда volatile!
    private volatile int value;

    // 2. Дескриптор доступа к полю в оперативной памяти
    private static final VarHandle VALUE;

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

    public final int incrementAndGet() {
        // Транслируется напрямую в ассемблер lock xadd на x86
        return (int) VALUE.getAndAdd(this, 1) + 1;
    }
}

Неатомарность 64-битных long на 32-битных JVM

Спецификация языка Java (JLS §17.7 Non-Atomic Treatment of double and long) гласит:

Операции чтения и записи обычных (non-volatile) переменных типов long и double могут рассматриваться виртуальной машиной как две раздельные 32-битные операции.

Проблема «разорванного чтения» (Word Tearing):

На 32-битной архитектуре процессор не может за один такт записать 64 бита. Запись long разбивается на запись младших 32 бит и старших 32 бит:

Поток 1 пишет: 0x00000001_00000000L
Поток 2 пишет: 0x00000000_FFFFFFFFL

Поток 3 может прочитать половину от Потока 1 и половину от Потока 2:
Результат: 0x00000001_FFFFFFFFL (повреждённые данные!)

AtomicLong полностью решает эту проблему:

  • Даже на 32-битных JVM чтение и запись гарантированно атомарны благодаря volatile семантике и использованию специальных инструкций процессора (например, CMPXCHG8B на x86-32).

Режимы записи: set() против lazySet()

AtomicInteger предоставляет разные способы записи значения с разной стоимостью для шины процессора:

Метод Семантика JMM Процессорные барьеры Видимость для других ядер
set(v) volatile store StoreStore + StoreLoad Мгновенная видимость всем потокам
lazySet(v) ordered / release store Только StoreStore Отложенная видимость (eventual), без задержки CPU на StoreLoad
compareAndSet(e, u) Full Fence (CAS) Двунаправленный барьер (StoreLoad) Мгновенно, атомарно при совпадении
AtomicInteger flag = new AtomicInteger(0);

// lazySet идеален при освобождении ресурсов или сбросе флагов в lock-free очередях:
// Мы гарантируем порядок инструкций, но не платим за сброс Store Buffer в шину
flag.lazySet(1);

🔴 Senior Level

Аппаратная трансляция: lock xadd против CAS-цикла

При компиляции JIT-компилятор C2 оптимизирует методы AtomicInteger:

1. Операция getAndAdd / incrementAndGet:

Вместо дорогого цикла с проверкой и повтором JIT компилирует incrementAndGet() на архитектуре x86 в одну атомарную ассемблерную инструкцию:

; rcx = указатель на поле value
; eax = delta (например, 1)
lock xadd [rcx], eax
; Значение в памяти атомарно увеличивается, а в eax возвращается старое значение

Инструкция LOCK XADD выполняется за фиксированное число тактов и не требует цикла повторов, что делает её значительно быстрее классического CAS-цикла при отсутствии коллизий.

2. Операция updateAndGet:

Использует классический оптимистичный спин-цикл с вызовом пользовательской функции:

public final int updateAndGet(IntUnaryOperator updateFunction) {
    int prev = get(), next = 0;
    for (;;) {
        next = updateFunction.applyAsInt(prev);
        if (compareAndSet(prev, next))
            return next;
        prev = get(); // Повторное чтение при неудачном CAS
    }
}

[!CAUTION] Функция в updateAndGet обязана быть чистой (Side-Effect Free)!
При конкуренции функция может быть вызвана несколько раз для одной и той же операции. Если поместить в неё сетевой запрос или инкремент внешнего счётчика, возникнет трудноуловимый дефект многократного исполнения.


Деградация при высокой конкуренции: переход на LongAdder

Когда число параллельно пишущих потоков превышает количество ядер процессора:

  • Инструкции lock xadd и lock cmpxchg непрерывно блокируют одну и ту же 64-байтную кеш-линию L1/L2.
  • Возникает Cache Bouncing между сокетами CPU по шине когерентности кешей.
  • Пропускная способность (Throughput) AtomicLong падает по экспоненте.
// При 100+ потоках на запись:
AtomicLong badCounter = new AtomicLong(); // Падение скорости, 100% CPU

// Архитектурное решение:
java.util.concurrent.atomic.LongAdder goodCounter = new java.util.concurrent.atomic.LongAdder();
goodCounter.increment(); // Распределяет запись по массиву Cell с защитой от False Sharing
long total = goodCounter.sum();

Паттерн ID Range Allocation (Генерация ID под нагрузкой)

Прямое использование AtomicLong.incrementAndGet() в качестве распределённого генератора идентификаторов при 100 000+ RPS создаёт узкое горлышко масштабируемости.

Решение: Пакетное выделение диапазонов (Range Allocation)

public class BatchIdGenerator {
    private final AtomicLong globalSequence = new AtomicLong(1);
    private static final long BATCH_SIZE = 10_000;

    // Каждый рабочий поток держит свой локальный диапазон
    private static final ThreadLocal<LocalRange> localRange = 
            ThreadLocal.withInitial(() -> new LocalRange(0, 0));

    private record LocalRange(long current, long max) {}

    public long nextId() {
        LocalRange range = localRange.get();
        if (range.current < range.max) {
            localRange.set(new LocalRange(range.current + 1, range.max));
            return range.current;
        }

        // Локальный диапазон исчерпан: обращаемся к глобальному AtomicLong один раз на 10 000 ID!
        long newStart = globalSequence.getAndAdd(BATCH_SIZE);
        localRange.set(new LocalRange(newStart + 1, newStart + BATCH_SIZE));
        return newStart;
    }
}

Такой подход снижает количество обращений к разделяемой памяти в $10\,000$ раз, полностью устраняя задержки на шине процессора.


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

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

AtomicInteger и AtomicLong — потокобезопасные неблокирующие (lock-free) обёртки над числовыми примитивами. Они гарантируют атомарность составных операций инкремента и модификации за счёт volatile-видимости и аппаратных процессорных инструкций (lock xadd и lock cmpxchg на x86). На 32-битных JVM AtomicLong исключает проблему повреждения данных при чтении 64-битных значений (Word Tearing). При экстремальной конкуренции на запись скалярные счётчики должны заменяться на шардированный LongAdder.


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

Вопрос 1: Почему в 32-битной JVM чтение обычного long может вернуть повреждённое значение («разорванное чтение»), а AtomicLong гарантирует целостность?

Ответ: Спецификация JLS (§17.7) разрешает JVM на 32-битных системах выполнять чтение и запись обычных long и double как две независимые 32-битные операции (младшие и старшие 4 байта). Если поток $A$ записал первую половину, а поток $B$ параллельно прочитал переменную целиком, поток $B$ получит 64-битное число, состоящее из половины старого и половины нового значений («Word Tearing»). В AtomicLong внутреннее поле объявлено как volatile, что обязывает виртуальную машину использовать специальные атомарные инструкции (например, CMPXCHG8B или операции FPU/SSE-регистров), гарантирующие неделимость 64 бит даже на 32-битных архитектурах.

Вопрос 2: В чём разница в машинном коде между incrementAndGet() и updateAndGet(x -> x + 1)?

Ответ:

  • incrementAndGet() оптимизируется компилятором JIT C2 в одну атомарную процессорную инструкцию lock xadd (на архитектуре x86). Процессор выполняет сложение и возврат старого значения аппаратно за один такт без программного цикла повторов.
  • updateAndGet(fn) вынужден вызывать переданный функциональный интерфейс IntUnaryOperator и поэтому транслируется в классический CAS-цикл (do-while с инструкцией lock cmpxchg). При наличии конкуренции вызов updateAndGet выполняет повторные попытки с многократным вызовом лямбда-выражения.

Вопрос 3: Почему вызов метода с побочными эффектами (side effects) внутри updateAndGet() или accumulateAndGet() недопустим?

Ответ: Внутри updateAndGet() используется оптимистичный цикл повторных попыток CAS. Если параллельный поток успел обновить значение раньше, CAS завершается неудачей, и переданная лямбда-функция вызывается повторно с новым базовым значением. Если лямбда совершает побочные эффекты (например, отправляет сообщение в очередь, делает HTTP-запрос или изменяет внешние коллекции), эти действия выполнятся непредсказуемое количество раз, приводя к дублированию операций и нарушению целостности системы.

Вопрос 4: Как работает и какую проблему решает метод lazySet() в AtomicInteger?

Ответ: Метод lazySet() устанавливает значение с семантикой Ordered Store (Release Barrier). Компилятор вставляет барьер памяти StoreStore, гарантируя, что все предшествующие записи потока завершатся до установки нового значения, но не генерирует дорогостоящий барьер StoreLoad. За счёт этого процессор не ждёт опустошения буфера записи (Store Buffer) в системную шину памяти, выполняя операцию в несколько раз быстрее set(). Обратная сторона — другие процессорные ядра увидят новое значение с небольшой задержкой (eventual consistency). Это идеальная оптимизация для очистки ссылок в неблокирующих очередях и кольцевых буферах.


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

❌ “AtomicInteger защищает цепочку из нескольких атомарных операций: if (atom.compareAndSet(0, 1)) { atom.incrementAndGet(); }.”
Правильно: Каждая операция в AtomicInteger атомарна сама по себе, но их комбинация не атомарна. Между вызовом compareAndSet и incrementAndGet состояние может быть изменено третьим потоком. Для атомарности составных действий требуется использовать updateAndGet либо явные блокировки.

❌ “Для поля в AtomicIntegerFieldUpdater можно опустить модификатор volatile.”
Правильно: Поле обязано быть объявлено как volatile int и иметь модификаторы доступа, позволяющие обращаться к нему из updater-класса (не private, если updater находится в другом контексте).

❌ “AtomicLong всегда масштабируется лучше, чем LongAdder.”
Правильно: При активной конкуренции на запись более 4-8 потоков AtomicLong упирается в инвалидацию кешей (Cache Bouncing). LongAdder распределяет запись по нескольким ячейкам и работает в десятки раз быстрее.


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