Когда несколько потоков обращаются к одному счётчику, обычное counter++ ломается — не потому что операция неправильная, а потому что она не атомарная. Класс AtomicInteger и его родственники решают эту задачу без блокировок — через аппаратную инструкцию CAS.
Оба потока прочитали 7. CAS первого проходит, CAS второго получает отказ: в ячейке уже не то значение, которое он ожидал. Поток при этом не засыпает — перечитывает свежее значение и пробует снова.
Почему counter++ опасен в многопоточном коде
Запись counter++ выглядит как одна операция, но процессор разбивает её на три шага:
- Прочитать текущее значение из памяти.
- Прибавить единицу.
- Записать результат обратно.
Если два потока выполняют эти три шага вперемешку, оба могут прочитать одно значение, увеличить его и записать обратно — счётчик окажется на единицу меньше, чем нужно. Это классическое состояние гонки (race condition).
Один из способов защититься — поставить synchronized. Дорог он не всегда: когда за монитор никто не борется, HotSpot проходит его почти даром, а при короткой конкуренции сначала даёт потоку немного покрутиться на месте и только потом усыпляет. Плохо становится, когда потоков много и бьются они за один монитор постоянно: вот тогда начинаются настоящие засыпания и пробуждения, а это уже поход в ядро операционной системы. Для простого прибавления к числу есть способ дешевле.
CAS — инструкция «проверь и замени»
CAS (compare-and-swap) — одна неделимая инструкция процессора:
«Если текущее значение по этому адресу равно ожидаемому, замени его на новое. Иначе — ничего не делай, скажи, что не получилось.»
Короткая формула: CAS(адрес, ожидаемое, новое) → true/false
Поскольку инструкция атомарная на уровне железа, никакой другой поток не может вклиниться между проверкой и записью. Не нужен монитор — нет засыпания и пробуждения потоков. На этой инструкции построен весь пакет java.util.concurrent.atomic.
Атомарность — не единственное, что даёт атомик. Обычные get() и set() у него это volatile-чтение и volatile-запись: с теми же барьерами и тем же happens-before, что у volatile-поля. То есть AtomicInteger читается как «volatile-поле плюс CAS», и всё, что вы записали до set(), увидит поток, прочитавший это значение через get(). Отдельный volatile рядом с атомиком не нужен; правила видимости — в статье про модель памяти.
AtomicInteger, AtomicLong, AtomicReference
AtomicInteger и AtomicLong — обёртки над int и long с набором атомарных операций. AtomicReference<V> делает то же со ссылкой на объект: заменить узел структуры данных или подменить конфигурацию без блокировки.
Именно второе — главное его применение в сервисах, и выглядит оно так: всё изменяемое состояние собрано в неизменяемый снимок, а меняется он подменой одной ссылки.
record Rates(Map<String, BigDecimal> byCurrency, Instant loadedAt) {}
private final AtomicReference<Rates> rates = new AtomicReference<>(Rates.empty());
public BigDecimal rate(String currency) {
return rates.get().byCurrency().get(currency); // читают тысячи потоков, без замков
}
@Scheduled(fixedDelay = 60_000)
void refresh() {
rates.set(new Rates(loadAll(), Instant.now())); // писатель один, подменяет целиком
}
Читатели никогда не ждут и никогда не видят полуобновлённый справочник: они работают либо со старым снимком целиком, либо с новым. Это тот же приём, что у CopyOnWriteArrayList, только вручную и для произвольной структуры; условие одно — снимок должен быть неизменяемым, иначе гарантия рассыпается.
живой пример
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
public class AtomicBasics {
public static void main(String[] args) {
AtomicInteger counter = new AtomicInteger(0);
System.out.println("incrementAndGet -> " + counter.incrementAndGet());
System.out.println("getAndIncrement -> " + counter.getAndIncrement());
System.out.println("addAndGet(5) -> " + counter.addAndGet(5));
System.out.println("compareAndSet(7, 20) -> " + counter.compareAndSet(7, 20));
System.out.println("compareAndSet(7, 99) -> " + counter.compareAndSet(7, 99));
AtomicReference<String> config = new AtomicReference<>("v1");
System.out.println("v1 -> v2: " + config.compareAndSet("v1", "v2"));
System.out.println("v1 -> v3: " + config.compareAndSet("v1", "v3"));
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
По строкам вывода. incrementAndGet -> 1: прибавил и вернул новое значение. getAndIncrement -> 1: вернул прежнее, а в счётчике уже 2. addAndGet(5) -> 7: прибавил пять. compareAndSet(7, 20) -> true: ручной CAS, ожидание совпало, записали 20. compareAndSet(7, 99) -> false: там уже 20, ожидание не совпало, запись не прошла. Со ссылкой то же самое: v1 -> v2: true, а v1 -> v3: false, потому что поток рассчитывал на v1, а там уже v2. Это и есть защита от затирания чужой записи.
У методов, которые принимают вашу функцию — updateAndGet, accumulateAndGet, — CAS работает в цикле: не удалось заменить, потому что другой поток успел раньше, — читаем свежее значение и пробуем снова. А для простого прибавления вроде incrementAndGet цикл обычно не нужен: на процессорах x86 JVM подставляет одну машинную команду атомарного сложения.
Практический вывод из этого: в прикладном коде цикл руками не пишут. Всё, что сложнее прибавления, выражается одной строкой:
counter.updateAndGet(v -> Math.min(v + 1, limit)); // своё правило обновления
maxSeen.accumulateAndGet(value, Math::max); // свернуть с новым значением
Функция внутри обязана быть чистой и быстрой: при конкуренции её вызовут несколько раз подряд, поэтому запись в лог, поход в базу или изменение других полей внутри неё дадут эффекты, которых вы не заказывали. Разбирать CAS-цикл руками стоит один раз, ради понимания, что внутри; дальше — готовые методы.
Есть и специальный атомик под самый частый случай прикладного кода — «сделать ровно один раз». AtomicBoolean с compareAndSet(false, true) отвечает true ровно одному потоку, сколько бы их ни пришло:
private final AtomicBoolean started = new AtomicBoolean();
void start() {
if (!started.compareAndSet(false, true)) return; // второй и последующие вызовы уходят сразу
doExpensiveInit();
}
Проверка if (!started.get()) { started.set(true); ... } здесь не работает по тем же причинам, что i++: между чтением и записью вклинивается второй поток, и инициализация выполняется дважды.
Как выглядит lock-free инкремент внутри
Соберём инкремент руками — тем циклом, что JDK прячет внутри updateAndGet, и посчитаем холостые попытки:
живой пример
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.IntStream;
public class CasLoop {
static final AtomicInteger counter = new AtomicInteger();
static final AtomicInteger retries = new AtomicInteger();
static void increment() {
while (true) {
int current = counter.get();
if (counter.compareAndSet(current, current + 1)) return;
retries.incrementAndGet();
}
}
public static void main(String[] args) {
IntStream.range(0, 4).parallel().forEach(t -> {
for (int i = 0; i < 50_000; i++) increment();
});
System.out.println("счётчик: " + counter.get());
System.out.println("холостых заходов в цикл: " + retries.get());
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Счётчик всегда выходит ровно 200000 — ни одно обновление не потеряно. А число холостых заходов скачет от запуска к запуску: это и есть конкуренция за ячейку. При низкой конкуренции CAS срабатывает с первой попытки, при высокой — потоки крутятся в цикле, не засыпая. Это и есть lock-free: в каждый момент хотя бы один поток продвигается. С замком иначе: если поток, державший synchronized, сняли с процессора, остальные стоят у двери, и не продвигается никто.
ABA-проблема
У CAS есть тонкая слабость: он сравнивает только значение, а не «историю» изменений.
Представьте: поток A прочитал значение "X". Пока он думает, поток B поменял "X" → "Y" → снова "X". Когда поток A выполняет CAS с ожидаемым "X", проверка проходит успешно — хотя значение за это время уже дважды менялось.
Между чтением и записью значение сменилось дважды и вернулось к прежнему, поэтому CAS потока A проходит и подмены не замечает.
Решение — AtomicStampedReference: он хранит пару (ссылка + числовой штамп), и CAS проверяет оба. Обе реакции на одну подмену:
живой пример
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.atomic.AtomicStampedReference;
public class AbaDemo {
public static void main(String[] args) {
AtomicReference<String> plain = new AtomicReference<>("X");
AtomicStampedReference<String> stamped = new AtomicStampedReference<>("X", 0);
int[] holder = new int[1];
String seen = stamped.get(holder);
plain.set("Y"); plain.set("X");
stamped.set("Y", 1); stamped.set("X", 2);
System.out.println("CAS по значению: " + plain.compareAndSet("X", "Z"));
System.out.println("CAS со штампом: " + stamped.compareAndSet(seen, "Z", holder[0], holder[0] + 1));
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Обычный CAS отвечает true и подмены не замечает. Штампованный — false: значение то же, а штамп уже 2, а не 0.
В прикладных задачах ABA встречается редко, и не по везению. Главный её источник — переиспользование памяти: объект освободили, по тому же адресу лёг новый, а CAS видит тот же адрес и радуется. В Java этот источник убирает сборщик мусора: пока у вас есть ссылка на объект, он никуда не денется и его место никому не достанется. Поэтому вспоминать про ABA приходится там, где узлы переиспользуют вручную — в lock-free очередях и пулах объектов.
LongAdder — когда конкуренция высокая
AtomicLong отлично работает при умеренной нагрузке. Но если десятки потоков непрерывно инкрементируют один и тот же объект, они начинают «биться» за него — CAS-циклы становятся длиннее, ядра процессора греются вхолостую.
LongAdder решает это иначе: он хранит не одно значение, а массив ячеек. Каждый поток обычно работает со своей ячейкой, почти не сталкиваясь с другими. Итог — сумма всех ячеек, которую возвращает sum().
Одна и та же нагрузка сверху и снизу: у AtomicLong все потоки упираются в общую ячейку, у LongAdder каждый пишет в свою, а сумма собирается при чтении.
Одних только отдельных ячеек было бы мало: соседние long в обычном массиве попадают в одну строку кэша, и потоки дерутся за неё, как за одну переменную. Это называют ложным разделением (false sharing), и именно поэтому ячейки LongAdder разнесены по памяти так, чтобы каждая заняла свою строку. Простой массив из шестнадцати AtomicLong этого не делает — потому им задачу и не решают.
Разницу видно на шестнадцати параллельных задачах:
живой пример
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.LongAdder;
import java.util.stream.IntStream;
public class AdderVsAtomic {
static long millis(Runnable step) {
long start = System.nanoTime();
IntStream.range(0, 16).parallel().forEach(t -> {
for (int i = 0; i < 1_000_000; i++) step.run();
});
return (System.nanoTime() - start) / 1_000_000;
}
public static void main(String[] args) {
millis(new AtomicLong()::incrementAndGet); // прогрев: первый прогон уходит на компиляцию
millis(new LongAdder()::increment);
AtomicLong atomic = new AtomicLong();
LongAdder adder = new LongAdder();
System.out.println("AtomicLong: " + millis(atomic::incrementAndGet) + " мс");
System.out.println("LongAdder: " + millis(adder::increment) + " мс");
System.out.println("итог: " + atomic.get() + " и " + adder.sum());
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Первые два прогона холостые — они нужны, чтобы JIT успел скомпилировать оба цикла: иначе вся компиляция достаётся тому, кого меряют первым, и сравнивают уже не структуры данных, а везение. Итог у обоих одинаковый — 16 миллионов, ничего не потеряно. А время отличается на порядок: сотни миллисекунд против десятков. Точные числа зависят от машины: шестнадцать задач раскладываются на общий пул ForkJoinPool, а в нём потоков по числу ядер минус один — не шестнадцать. Но порядок разницы держится везде.
Цена — sum() не гарантирует точность в момент вызова: пока он обходит ячейки, часть успевает измениться. Поэтому LongAdder годится для счётчиков и статистики, но не там, где нужна точная атомарная точка чтения.
Рядом с ним живёт LongAccumulator — то же устройство с ячейками, но сворачивает значения не сложением, а вашей функцией. Самое частое применение — максимум без блокировок:
LongAccumulator maxLatency = new LongAccumulator(Math::max, 0);
maxLatency.accumulate(tookMillis); // из любого числа потоков
long worst = maxLatency.get();
Требование к функции жёсткое и вытекает из устройства: она должна быть ассоциативной и коммутативной (порядок и группировка не влияют на результат), потому что ячейки складываются в произвольном порядке. Максимум, минимум, сумма, побитовое ИЛИ подходят; вычитание или деление — нет.
Когда выбирать atomics, а когда блокировки
Атомарные переменные выигрывают на простой операции — инкремент, замена ссылки, флаг — при умеренной конкуренции и на горячем пути, где дорога каждая наносекунда. Блокировки (synchronized, ReentrantLock) берут своё там, где надо защитить составную операцию из нескольких шагов или согласованно обновить сразу несколько переменных: одним CAS это не выражается, а чтение кода с synchronized проще.
Есть и случай, когда атомик прямо хуже замка, и он обратен интуиции «lock-free всегда быстрее». При постоянной драке за одну ячейку CAS-цикл превращается в трату процессора впустую: поток не спит, а крутится, читая и перечитывая значение, которое соседи меняют быстрее, чем он успевает записать. Восемь потоков на восьми ядрах в таком цикле сжигают все восемь ядер, продвигая работу едва-едва, и в этот момент замок выигрывает: он усыпляет лишних, освобождая процессор тому, кто реально работает.
Симптом на графиках узнаваемый: загрузка процессора под сто процентов, а пропускная способность не растёт. Ответов два: разнести конкуренцию по ячейкам (LongAdder, шардирование по ключу) или взять замок.
Правило: начинайте с synchronized или ReentrantLock, если не уверены. Atomics вводите там, где измерили узкое место и знаете, что блокировка здесь лишняя.
Глубже: цена синхронизации: ложное разделение и закон Амдаларасширенное
LongAdder выше объяснён как «несколько ячеек вместо одной», но не сказано, почему ячейки в массиве подряд не помогли бы. Процессор хранит данные в кеше строками по 64 байта. Два потока пишут в две разные переменные, но если те лежат в одной строке, каждая запись делает строку недействительной в кеше другого ядра, и ядра гоняют её туда-сюда, будто переменная одна. Это ложное разделение, false sharing: по коду конкуренции нет, по железу есть. LongAdder и @Contended раскладывают ячейки так, чтобы каждая заняла свою строку, и получают выигрыш в разы.
Дальше цена самих инструментов. Обычная запись в поле стоит наносекунду. volatile и CAS требуют барьера памяти: процессор сбрасывает буфер записи и ждёт, десятки наносекунд. Захват свободного ReentrantLock те же десятки; спорный замок это парковка потока и переключение контекста, микросекунды. Отсюда порядок предпочтений из основной части: не делить, потом атомик, потом замок.
И потолок. Закон Амдала: если доля работы, которую нельзя распараллелить, равна s, то ускорение на любом числе ядер не превысит 1/s. Программа, где десять процентов времени идёт под замком, быстрее чем в десять раз не станет, хоть на ста ядрах. Поэтому измерять начинают не с числа потоков, а с длины критической секции: сокращение того, что делается под замком, даёт больше, чем любой новый пул.
Коротко
counter++не атомарен — два потока могут потерять обновление.- CAS — аппаратная инструкция «замени, если значение совпадает»; на ней построен весь пакет
java.util.concurrent.atomic. - CAS-цикл повторяется при конкуренции; поток не засыпает, но тратит циклы процессора.
- ABA-проблема: CAS не видит промежуточных изменений;
AtomicStampedReferenceзащищает от неё. LongAdderбыстрееAtomicLongпод высокой конкуренцией — за счёт ячеек; плата — приблизительныйsum().- Ложное разделение: две переменные в одной строке кеша дерутся, как одна;
LongAdderразносит ячейки. Закон Амдала: доля кода под замком задаёт потолок ускорения. get()иset()атомика это volatile-чтение и запись: атомик даёт и атомарность, и видимость, отдельныйvolatileне нужен.- CAS-цикл руками пишут только ради понимания: в коде
updateAndGetиaccumulateAndGetс чистой и быстрой функцией, «ровно один раз» —AtomicBoolean.compareAndSet(false, true). AtomicReferenceв сервисах держит неизменяемый снимок (справочник, конфигурацию), который подменяют целиком;LongAccumulator(Math::max, 0)даёт максимум без блокировок.- При постоянной драке за одну ячейку CAS жжёт процессор вхолостую и проигрывает замку: признак — сто процентов загрузки без роста пропускной способности.
Что почитать дальше
- Гонки в многопоточных программах — с чего начинается проблема, которую закрывают атомарные переменные.
- Модель памяти Java (happens-before) — почему видимость изменений — отдельная проблема и как она связана с атомарностью.
- Блокировки: ReentrantLock и условия — когда нужны явные блокировки вместо атомарных переменных.
- Конкурентные коллекции — готовые потокобезопасные структуры данных, построенные в том числе на CAS.