CAS и Atomic-классы: как устроены неблокирующие операции в Java
Зачем нужен CAS, если есть synchronized
synchronized решает проблему гонки через блокировку: поток, который не получил монитор, уходит в ожидание, ОС делает context switch. Это дорого — переключение контекста стоит тысячи тактов, а поток, который просто хотел инкрементировать счётчик, простаивает. Compare-And-Swap (CAS) — способ синхронизации без блокировок: вместо "жди своей очереди" поток пытается изменить значение атомарно, и если не вышло — пробует ещё раз, не засыпая.
CAS — это не Java-фича, а инструкция процессора (CMPXCHG на x86). Она принимает три аргумента: адрес памяти, ожидаемое значение и новое значение. Если по адресу лежит ожидаемое значение — атомарно заменяет его на новое и возвращает true. Если нет — значит, кто-то успел изменить ячейку раньше, и операция возвращает false, ничего не трогая.
Как это выглядит в коде
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-генераторов, где важно текущее точное значение.
ABA-проблема
CAS сравнивает только значение, а не историю изменений. Классический сценарий: поток А прочитал значение X, собрался сделать CAS(X, Y). Пока он готовится, поток Б меняет X на Z, а затем снова на X. Поток А делает CAS(X, Y) — и он успешен, хотя по факту значение "путешествовало". Для простого счётчика это не страшно, но для lock-free структур данных (стеков, очередей на основе указателей) это может привести к повреждению структуры — указатель как бы вернулся на старое место, но объект за ним уже другой.
Решение — AtomicStampedReference или AtomicMarkableReference: к значению добавляется версия (stamp) или флаг, и CAS сравнивает пару (значение, версия), а не только значение. Версия инкрементируется при каждом изменении, так что даже если значение вернулось к исходному, версия уже другая.
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 при захвате незанятой блокировки — это быстрый путь без похода в ОС.