Атомарные переменные в Java
Потокобезопасные операции без блокировок в Java с классами java.util.concurrent.atomic — счётчики, ссылки и compare-and-set.
volatile делает отдельное чтение или запись потокобезопасными. Но counter++ им не сделать — это три операции. Пакет java.util.concurrent.atomic заполняет этот пробел. Его классы оборачивают одно значение и предоставляют операции вроде increment-and-get и compare-and-set как единые атомарные инструкции — без блокировок, без блока synchronized, только примитив процессора (compare-and-swap, или CAS), который JVM компилирует в машинный код.
Атомарные типы — правильный инструмент для удивительно большого числа многопоточных паттернов: счётчики, порядковые номера, флагоподобное состояние и любой идиом «опубликовать новый неизменяемый снимок». Они быстрее, чем synchronized при наличии конкуренции, и значительно проще, чем вручную оборачивать одно поле в блокировку.
Семейство классов
Пакет содержит восемь часто используемых классов:
| Класс | Оборачивает | Основные операции |
|---|---|---|
AtomicInteger | int | get, set, incrementAndGet, addAndGet, compareAndSet |
AtomicLong | long | аналогично, для long |
AtomicBoolean | boolean | get, set, compareAndSet |
AtomicReference<V> | V (любая ссылка на объект) | get, set, compareAndSet, updateAndGet |
AtomicIntegerArray | int[] | атомарные операции по индексу |
AtomicLongArray | long[] | атомарные операции по индексу |
AtomicReferenceArray<V> | V[] | атомарные операции по индексу |
LongAdder / LongAccumulator | long | счётчик при высокой конкуренции |
Первые четыре используются в 99% случаев.
AtomicInteger — правильный счётчик
Замена для «volatile int плюс ++»:
AtomicInteger counter = new AtomicInteger(); // starts at 0
counter.incrementAndGet(); // ++counter, atomic
counter.getAndIncrement(); // counter++, atomic
counter.addAndGet(5); // counter += 5, atomic
counter.set(42); // counter = 42, atomic
int n = counter.get(); // read
counter.compareAndSet(42, 100); // if (counter == 42) counter = 100; return whether it changedincrementAndGet — то, что нужно для простого счётчика; под капотом это CAS-цикл, который процессор выполняет за одну инструкцию на современном x86 (LOCK XADD). Вся операция — единственная транзакция памяти на уровне шины, что намного дешевле, чем захват даже неоспариваемой блокировки synchronized.
compareAndSet(expected, new) — строительный блок почти для всего остального. Он атомарно записывает new только если текущее значение равно expected и возвращает, произошла ли запись. С его помощью можно построить любое атомарное обновление одного поля:
AtomicInteger max = new AtomicInteger(Integer.MIN_VALUE);
void recordMax(int v) {
int cur;
do {
cur = max.get();
if (v <= cur) return; // nothing to do
} while (!max.compareAndSet(cur, v)); // retry if someone else updated
}CAS-цикл — стандартный паттерн: прочитать, вычислить, попытаться записать, повторить при конфликте. Именно так реализован incrementAndGet; именно так вы бы написали любое составное обновление одного поля.
Java 8 упростила цикл:
max.updateAndGet(cur -> Math.max(cur, v)); // CAS loop hiddenupdateAndGet, accumulateAndGet и getAndUpdate принимают функцию и выполняют CAS-цикл за вас. Предпочитайте их там, где они подходят.
AtomicReference<V> — правильный способ подменить объект
Когда общее состояние — не примитив, а, например, карта конфигурации, кэшированный снимок или неизменяемый контейнер, — AtomicReference позволяет атомарно заменить весь объект:
AtomicReference<Config> currentConfig = new AtomicReference<>(initialConfig);
void reload() {
Config c = readConfigFromDisk(); // expensive, lock-free
currentConfig.set(c); // publish atomically
}
Config get() { return currentConfig.get(); }Хитрость: содержимое Config должно быть неизменяемым (или не трогаться после публикации). Атомарная замена публикует готовое значение; если другие потоки затем изменят внутренности значения — безопасность теряется. Это паттерн неизменяемого снимка, именно так строятся большинство конкурентных кэшей, таблиц маршрутизации и объектов «глобальной конфигурации».
updateAndGet для ссылки тоже крайне полезен:
AtomicReference<List<String>> log = new AtomicReference<>(List.of());
void append(String line) {
log.updateAndGet(old -> {
var copy = new ArrayList<>(old);
copy.add(line);
return List.copyOf(copy); // immutable snapshot
});
}Каждый читатель получает согласованный неизменяемый список. Писатели конкурируют; CAS-цикл повторяет попытки для проигравших. Дёшево при низкой конкуренции, медленно но корректно при высокой.
LongAdder — счётчик при высокой конкуренции
При сильной конкуренции AtomicLong.incrementAndGet становится узким местом — каждый поток долбит один и тот же адрес памяти, и процессор вынужден сериализовать транзакции шины. LongAdder решает эту проблему, поддерживая несколько внутренних счётчиков — по одному на процессор — и суммируя их при чтении:
LongAdder requestCount = new LongAdder();
void onRequest() { requestCount.increment(); } // append-only, no contention
long snapshot() { return requestCount.sum(); } // sums every cell — not atomic but eventually consistentИспользуйте LongAdder, когда:
- Счётчик инкрементируется из множества потоков одновременно (например: метрики запросов в веб-сервере).
- Вы читаете его редко (раз в несколько секунд для дашборда).
Используйте AtomicLong, когда:
- Инкрементирование редкое или однопоточное.
- Нужно мгновенное точное чтение.
LongAdder — один из самых быстрых конкурентных счётчиков в существующих реализациях, но за это приходится платить: sum() не атомарен с конкурентными вызовами increment(). Для типичного случая отчётности по метрикам это приемлемо.
Что атомарные типы не делают
Атомарные типы работают с одним полем. Они не компонуются между несколькими полями:
AtomicInteger a = new AtomicInteger();
AtomicInteger b = new AtomicInteger();
a.incrementAndGet(); // atomic on its own
b.incrementAndGet(); // atomic on its own
// but the pair is NOT atomic — another thread can see new a, old bЕсли ваш инвариант охватывает несколько полей («a == b + 1 всегда»), нужна блокировка (или один атомарный объект-контейнер, содержащий оба значения).
Атомарные типы также не помогают с видимостью несвязанных полей. Запись в атомарный тип не публикует другие поля так, как это делает volatile. Сделайте эти другие поля volatile (или final, или записывайте их через атомарный тип).
compareAndExchange и новый API (Java 9+)
Java 9 добавила compareAndExchange (возвращает текущее значение, а не просто boolean):
int prev = counter.compareAndExchange(expected, newVal);
if (prev == expected) { // we won
...
} else { // somebody else got there first
// prev is the actual current value
}Java 9 также добавила API VarHandle, который предоставляет слабый CAS, упорядоченный доступ и прочее для низкоуровневых конкурентных библиотек. Вам это понадобится крайне редко; упомянуто здесь, чтобы вы видели это название.
Практический пример: счётчик и снимок
Программа ниже сравнивает четыре счётчика: несинхронизированный, volatile, AtomicInteger и LongAdder. Все четыре атакуют 8 потоков, делающих по 100 000 инкрементов каждый.
Что стоит вынести из запуска:
plainиvolatileоба теряли обновления — иногда катастрофически (итоговый счёт значительно ниже ожидаемых800 000).volatileустраняет проблему видимости, ноn++по-прежнему три операции. Это самое важное, что нужно помнить оvolatile: он не делает составные обновления атомарными.AtomicIntegerвыдавал точно ожидаемый счёт при каждом запуске. Стоимость одного инкремента — несколько наносекунд, что заметно больше, чемn++у обычногоint(одна-две инструкции), но без захвата блокировок и без блокировки потоков. При конкуренции он быстрееsynchronizedс большим отрывом.LongAdderбыл самым быстрым счётчиком при нагрузке 8 потоков — он рассредоточивает записи по отдельным ячейкам на процессор, и потоки не конкурируют за одну строку кэша. Плата за это:sum()не атомарен сincrement()(читатель может увидеть чуть устаревший итог), что именно то, что нужно для метрик и счётчиков, где мгновенная точность не важна.- CAS-цикл для максимума записал наибольшее значение среди всех образцов. Цикл — общий паттерн: прочитать текущее значение, вычислить желаемое новое, попытаться записать; если кто-то другой записал первым, CAS не проходит и вы повторяете попытку. Большинство вызовов
updateAndGetиaccumulateAndGet— это тот же цикл с скрытым шаблонным кодом. AtomicReference<List<String>>создал неизменяемый снимок лога. Каждый писатель строил новую неизменяемую копию и пытался её опубликовать; при конкуренции два писателя могут оба построить копию, и CAS одного из них не проходит — этот поток повторяет попытку, читает свежеобновлённый список и объединяет. Паттерн расточителен при высокой конкуренции (много выброшенных копий), но идеален для снимков типа «много чтений, редкое перестроение».
Что дальше
Следующая глава, Блокировки Java, начинает рассказ о java.util.concurrent.locks — интерфейсе Lock, причинах его существования наряду с synchronized и возможностях (tryLock, lockInterruptibly, Condition), которых нет у встроенного монитора.