Главная › Блог › CAS и Atomic-классы: как устроены неблокирующие операции в Java

CAS и Atomic-классы: как устроены неблокирующие операции в Java

Зачем нужен CAS, если есть synchronized

synchronized решает проблему гонки через блокировку: поток, который не получил монитор, уходит в ожидание, ОС делает context switch. Это дорого — переключение контекста стоит тысячи тактов, а поток, который просто хотел инкрементировать счётчик, простаивает. Compare-And-Swap (CAS) — способ синхронизации без блокировок: вместо "жди своей очереди" поток пытается изменить значение атомарно, и если не вышло — пробует ещё раз, не засыпая.

CAS — это не Java-фича, а инструкция процессора (CMPXCHG на x86). Она принимает три аргумента: адрес памяти, ожидаемое значение и новое значение. Если по адресу лежит ожидаемое значение — атомарно заменяет его на новое и возвращает true. Если нет — значит, кто-то успел изменить ячейку раньше, и операция возвращает false, ничего не трогая.

Представь два человека редактируют один гугл-документ офлайн-синком раз в секунду: каждый берёт локальную копию, вносит правку и пытается применить patch только если документ не изменился с момента, как он его скачал. Если кто-то успел раньше — твой patch отклоняется, ты берёшь свежую копию и пробуешь снова. Никто никого не блокирует, но при высокой конкуренции все бесконечно пересчитывают patch заново.

Как это выглядит в коде

AtomicInteger.incrementAndGet() — классический пример CAS-цикла:

public final int incrementAndGet() {
    for (;;) {
        int current = get();
        int next = current + 1;
        if (compareAndSet(current, next))
            return next;
    }
}

Это и есть паттерн "read-modify-CAS-retry": прочитали текущее значение, посчитали новое, попытались записать с проверкой, что никто не вмешался. Если вмешался — цикл повторяется. При низкой конкуренции это почти бесплатно: одна инструкция CAS вместо захвата монитора. При высокой конкуренции — куча потоков крутятся в цикле и жгут CPU, retry storm.

LongAdder: когда AtomicLong не справляется

Под сильной конкуренцией AtomicLong деградирует — все потоки бьются за одну ячейку памяти. LongAdder решает это через striping: внутри лежит массив Cell, каждый поток (через hash от своего ThreadLocalRandom) пишет в свою ячейку, а итоговое значение считается суммированием всех ячеек при вызове sum(). Запись становится почти без конфликтов, зато чтение суммы дороже и не даёт строгой консистентности в моменте — это компромисс, уместный для счётчиков метрик, но не для id-генераторов, где важно текущее точное значение.

Чем AtomicLong отличается от LongAdder и когда что выбирать
AtomicLong — одна ячейка с CAS, идеальна при низкой конкуренции и когда нужно читать точное значение прямо сейчас. LongAdder размазывает запись по нескольким ячейкам (striping), отлично масштабируется под высокой конкуренцией на запись, но sum() — это агрегация, не атомарный снимок. Для счётчиков запросов в метриках — LongAdder, для sequence/id — AtomicLong.

ABA-проблема

CAS сравнивает только значение, а не историю изменений. Классический сценарий: поток А прочитал значение X, собрался сделать CAS(X, Y). Пока он готовится, поток Б меняет X на Z, а затем снова на X. Поток А делает CAS(X, Y) — и он успешен, хотя по факту значение "путешествовало". Для простого счётчика это не страшно, но для lock-free структур данных (стеков, очередей на основе указателей) это может привести к повреждению структуры — указатель как бы вернулся на старое место, но объект за ним уже другой.

Решение — AtomicStampedReference или AtomicMarkableReference: к значению добавляется версия (stamp) или флаг, и CAS сравнивает пару (значение, версия), а не только значение. Версия инкрементируется при каждом изменении, так что даже если значение вернулось к исходному, версия уже другая.

Частая ошибка на собесе — сказать, что CAS "не подвержен гонкам". CAS защищает от гонки за запись одной ячейки, но сам по себе не решает составные инварианты. Если нужно атомарно изменить два связанных поля (например, баланс и версию аккаунта), один AtomicLong не поможет — либо объединяй поля в один объект и меняй ссылку через AtomicReference, либо используй блокировку.

Unsafe и VarHandle

Исторически CAS в Java реализован через sun.misc.Unsafe.compareAndSwapInt/Long/Object — это внутренний API, который давал прямой доступ к памяти в обход проверок JVM. С Java 9 его постепенно заменяет VarHandle — публичный, безопасный и переносимый способ делать те же атомарные операции (compareAndSet, getAndAdd, getAndBitwiseOr) над полями и элементами массивов, без завязки на внутренний нестабильный API.

Где CAS встречается на практике

ConcurrentHashMap использует CAS для вставки в пустой бакет без блокировки всей структуры. AtomicInteger/AtomicLong — счётчики и генераторы id. AtomicReference — для реализации immutable-снапшотов состояния (паттерн compare-and-swap на уровне объектов, например в лок-фри стеке Трайбера). ReentrantLock внутри тоже частично опирается на CAS при захвате незанятой блокировки — это быстрый путь без похода в ОС.

Ещё разборы