Как работают AtomicInteger, AtomicLong
Их работа базируется на двух ключевых принципах:
🟢 Junior Level
Краткий ответ для собеседования (30 секунд)
AtomicInteger и AtomicLong — это потокобезопасные классы-обёртки над примитивными числовыми типами int и long из пакета java.util.concurrent.atomic. Они обеспечивают атомарное (неделимое) чтение, запись и математическую модификацию значений без использования тяжеловесных блокировок (lock-free).
Их работа базируется на двух ключевых принципах:
- Значение переменной хранится в поле с модификатором
volatile, гарантируя немедленную видимость изменений всеми потоками процессора (Java Memory Model Happens-Before). - Модификации выполняются через оптимистичные циклы с процессорными инструкциями CAS (
Compare-And-Swap), исключая перевод потоков в состояниеBLOCKEDи системные вызовы ядра операционной системы.
Проблема атомарности примитивов
Обычные операции инкремента count++ или count += 5 состоят из трёх раздельных машинных команд:
- Загрузка значения из памяти в регистр CPU (
read). - Модификация в регистре (
modify). - Сохранение регистра обратно в память (
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 распределяет запись по нескольким ячейкам и работает в десятки раз быстрее.