Когда несколько потоков обращаются к одному счётчику, обычное counter++ ломается — не потому что операция неправильная, а потому что она не атомарная. Класс AtomicInteger и его родственники решают эту задачу без блокировок — через аппаратную инструкцию CAS.
Почему counter++ опасен в многопоточном коде
Запись counter++ выглядит как одна операция, но процессор разбивает её на три шага:
- Прочитать текущее значение из памяти.
- Прибавить единицу.
- Записать результат обратно.
Если два потока выполняют эти три шага вперемешку, оба могут прочитать одно и то же значение, увеличить его и записать обратно — итоговый счётчик окажется на единицу меньше, чем нужно. Это классическое состояние гонки (race condition).
Один из способов защититься — поставить synchronized. Но блокировка — дорогая операция: поток, не получивший монитор, засыпает и ждёт пробуждения. Для простых операций над числом это избыточно.
CAS — инструкция «проверь и замени»
CAS (compare-and-swap) — это одна неделимая инструкция процессора, которая делает следующее:
«Если текущее значение по этому адресу равно ожидаемому, замени его на новое. Иначе — ничего не делай, скажи, что не получилось.»
Короткая формула: CAS(адрес, ожидаемое, новое) → true/false
Поскольку инструкция атомарная на уровне железа, никакой другой поток не может вклиниться между проверкой и записью. Не нужен монитор — нет засыпания и пробуждения потоков.
На CAS строится весь пакет java.util.concurrent.atomic.
AtomicInteger, AtomicLong, AtomicReference
Это три самых используемых класса из пакета java.util.concurrent.atomic.
AtomicInteger и AtomicLong — обёртки над int и long с набором атомарных операций:
AtomicInteger counter = new AtomicInteger(0);
// Прибавить 1, вернуть новое значение
int next = counter.incrementAndGet();
// Прибавить 1, вернуть старое значение
int prev = counter.getAndIncrement();
// Прибавить произвольное число
counter.addAndGet(5);
// Ручной CAS: если сейчас 10 — поставить 20
boolean success = counter.compareAndSet(10, 20);
Под капотом каждый вызов использует CAS в цикле: если замена не удалась (другой поток успел раньше), операция повторяется с новым прочитанным значением.
AtomicReference<V> позволяет атомарно менять ссылку на объект. Полезно, когда нужно заменить узел в структуре данных или обновить конфигурацию без блокировки:
AtomicReference<String> config = new AtomicReference<>("v1");
// Заменить "v1" на "v2", только если ещё не изменилось
boolean updated = config.compareAndSet("v1", "v2");
Как выглядит lock-free инкремент внутри
Чтобы понять, как CAS-цикл работает без блокировки, вот упрощённая модель incrementAndGet:
// Так примерно устроено incrementAndGet внутри JDK
int incrementAndGet(AtomicInteger ref) {
while (true) {
int current = ref.get(); // читаем текущее значение
int next = current + 1; // считаем желаемое новое
if (ref.compareAndSet(current, next)) { // пробуем записать
return next; // удалось — возвращаем результат
}
// не удалось — повторяем с новым current
}
}
При низкой конкуренции CAS срабатывает с первой попытки. При высокой — потоки «конкурируют» в этом цикле, не засыпая. Это и есть lock-free: прогресс гарантирован хотя бы для одного из потоков в каждый момент времени.
ABA-проблема
У CAS есть тонкая слабость: он сравнивает только значение, а не «историю» изменений.
Представьте: поток A прочитал значение "X". Пока он думает, поток B поменял "X" → "Y" → снова "X". Когда поток A выполняет CAS с ожидаемым "X", проверка проходит успешно — хотя значение за это время уже дважды менялось.
Для ссылок это может привести к ошибкам в сложных структурах данных (например, в lock-free очередях). Решение — AtomicStampedReference: он хранит пару (ссылка + числовой штамп), и CAS проверяет оба.
AtomicStampedReference<String> ref =
new AtomicStampedReference<>("X", 0);
int[] stamp = new int[1];
String current = ref.get(stamp); // получаем значение и текущий штамп
// CAS по значению И штампу — ABA не пройдёт
ref.compareAndSet(current, "Z", stamp[0], stamp[0] + 1);
В большинстве прикладных задач ABA-проблема не встречается, но в lock-free структурах данных о ней нужно помнить.
LongAdder — когда конкуренция высокая
AtomicLong отлично работает при умеренной нагрузке. Но если десятки потоков непрерывно инкрементируют один и тот же объект, они начинают «биться» за него — CAS-циклы становятся длиннее, ядра процессора греются вхолостую.
LongAdder решает это иначе: он хранит не одно значение, а массив ячеек. Каждый поток, как правило, работает со своей ячейкой, почти не сталкиваясь с другими. Итоговое значение — сумма всех ячеек, которую возвращает sum().
LongAdder hits = new LongAdder();
// В нескольких потоках — конкуренции почти нет
hits.increment();
// Читаем итог — сумма всех ячеек
long total = hits.sum();
Разница на практике: при 16 потоках LongAdder может быть в несколько раз быстрее AtomicLong. Цена — sum() не гарантирует точность в момент вызова (ячейки обновляются независимо), поэтому LongAdder подходит для статистики и счётчиков, но не для логики, где важна точная атомарная точка чтения.
AtomicLong LongAdder
───────────── ───────────────────────
одна ячейка ячейка 1 | ячейка 2 | ...
потоки спорят потоки работают раздельно
sum = точный sum() = приблизительный
Когда выбирать atomics, а когда блокировки
Атомарные переменные хороши, когда:
- Операция простая: инкремент, замена ссылки, флаг.
- Конкуренция умеренная (потоков немного).
- Нужна максимальная производительность на горячем пути.
Блокировки (synchronized, ReentrantLock) предпочтительнее, когда:
- Нужно защитить составную операцию из нескольких шагов.
- Обновляется несколько переменных сразу, и между ними должна быть согласованность.
- Логика сложная — читаемость важнее, чем lock-free оптимизация.
Правило: начинайте с synchronized или ReentrantLock, если не уверены. Atomics вводите там, где измерили узкое место и знаете, что блокировка здесь лишняя.
Коротко
counter++не атомарен — два потока могут потерять обновление.- CAS — аппаратная инструкция «замени, если значение совпадает»; не требует блокировки.
AtomicInteger,AtomicLong,AtomicReferenceстроятся на CAS и дают атомарные операции безsynchronized.- CAS-цикл повторяется при конкуренции; поток не засыпает, но тратит циклы процессора.
- ABA-проблема: CAS не видит промежуточных изменений;
AtomicStampedReferenceзащищает от неё. LongAdderбыстрееAtomicLongпод высокой конкуренцией — за счёт распределения по ячейкам.- Atomics — для простых операций на горячем пути; блокировки — для составных и сложных сценариев.
Что почитать дальше
- Модель памяти Java (happens-before) — почему видимость изменений — отдельная проблема и как она связана с атомарностью.
- Блокировки: ReentrantLock и условия — когда нужны явные блокировки вместо атомарных переменных.
- Конкурентные коллекции — готовые потокобезопасные структуры данных, построенные в том числе на CAS.