← назад к разделу

Когда несколько потоков обращаются к одному счётчику, обычное counter++ ломается — не потому что операция неправильная, а потому что она не атомарная. Класс AtomicInteger и его родственники решают эту задачу без блокировок — через аппаратную инструкцию CAS.

Почему counter++ опасен в многопоточном коде

Запись counter++ выглядит как одна операция, но процессор разбивает её на три шага:

  1. Прочитать текущее значение из памяти.
  2. Прибавить единицу.
  3. Записать результат обратно.

Если два потока выполняют эти три шага вперемешку, оба могут прочитать одно и то же значение, увеличить его и записать обратно — итоговый счётчик окажется на единицу меньше, чем нужно. Это классическое состояние гонки (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.