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

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

поток A поток B 7 7 8 общая ячейка 7 8 9 ✓ ✗ ✓ оба потока прочитали 7 A: CAS(7 → 8) — получилось B: CAS(7 → 8) — в ячейке уже 8, отказ B перечитал 8 и повторил: CAS(8 → 9) итог 9 — ни одно обновление не потеряно

Оба потока прочитали 7. CAS первого проходит, CAS второго получает отказ: в ячейке уже не то значение, которое он ожидал. Поток при этом не засыпает — перечитывает свежее значение и пробует снова.

Обязательно

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

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

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

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

шаг 1 A прочитал X шаг 2 B поставил Y шаг 3 B вернул X шаг 4 CAS у A проходит

Между чтением и записью значение сменилось дважды и вернулось к прежнему, поэтому 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 четыре потока одна ячейка повторы CAS LongAdder четыре потока своя ячейка sum() в конце

Одна и та же нагрузка сверху и снизу: у AtomicLong все потоки упираются в общую ячейку, у LongAdder каждый пишет в свою, а сумма собирается при чтении.

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

Что почитать дальше