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

CONCURRENCY Фаза 6. Атомарные переменные

Обязательно

Атомарные переменные и CAS в Java

Java

AtomicInteger, AtomicReference, compare-and-swap, ABA и LongAdder.

1. CAS меняет значение, только если оно ожидаемое

1 / 5

compareAndSet(expected, update) атомарно проверяет и заменяет. Если другой поток изменил значение, операция не проходит и повторяется.

  • Нет монитора и ожидания замка.
  • Цикл читает заново после неудачи.
  • Аппаратная инструкция делает шаг атомарным.
CAS(0 - 1) успех CAS(0 - 2) false

2. AtomicInteger выполняет инкремент одной операцией

2 / 5

incrementAndGet и getAndIncrement не оставляют окна между чтением и записью. AtomicReference делает то же для объекта.

  • Инкремент атомарен без synchronized.
  • Ссылка и состояние внутри объекта - разные вещи.
  • Составной инвариант может потребовать lock.
поток A +1 поток B +1 итог 2

3. ABA: значение то же, история уже другая

3 / 5

Если значение изменилось A - B - A, CAS видит прежнее A и пропускает обновление. Для счётчиков версий и штампов это важно.

  • Версия решает проблему истории.
  • AtomicStampedReference хранит значение и штамп.
  • Для простого инкремента ABA не мешает.
A v1 B v2 A v3 A то же, версия другая

4. LongAdder делит счётчик на ячейки

4 / 5

При высокой конкуренции потоки увеличивают разные ячейки, а sum складывает их. Это снижает борьбу за один CAS.

  • LongAdder - для суммы и статистики.
  • Для одного значения и compareAndSet - AtomicLong.
  • Ложное разделение лечится разнесением ячеек.
поток A поток B поток C ячейкиsum() итог

5. Атомарность одного поля не означает всего инварианта

5 / 5

Два AtomicInteger можно обновить по отдельности, но параллельный поток увидит их в несогласованный момент.

  • Составное условие требует блокировки.
  • CAS подходит независимым счётчикам и ссылкам.
  • Закон Амдала ограничивает выигрыш от lock-free.
A = 1 B = 1 читатель: A=1,B=0 нужен lock

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

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

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