W3docs

Атомарные переменные в 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 при наличии конкуренции, и значительно проще, чем вручную оборачивать одно поле в блокировку.

Семейство классов

Пакет содержит восемь часто используемых классов:

КлассОборачиваетОсновные операции
AtomicIntegerintget, set, incrementAndGet, addAndGet, compareAndSet
AtomicLonglongаналогично, для long
AtomicBooleanbooleanget, set, compareAndSet
AtomicReference<V>V (любая ссылка на объект)get, set, compareAndSet, updateAndGet
AtomicIntegerArrayint[]атомарные операции по индексу
AtomicLongArraylong[]атомарные операции по индексу
AtomicReferenceArray<V>V[]атомарные операции по индексу
LongAdder / LongAccumulatorlongсчётчик при высокой конкуренции

Первые четыре используются в 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 changed

incrementAndGet — то, что нужно для простого счётчика; под капотом это 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 hidden

updateAndGet, 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 инкрементов каждый.

java— editable, runs on the server

Что стоит вынести из запуска:

  • 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), которых нет у встроенного монитора.

Практика

Практика
Какой из вариантов является правильным способом безопасно инкрементировать общий счётчик из множества потоков в плотном цикле?
Какой из вариантов является правильным способом безопасно инкрементировать общий счётчик из множества потоков в плотном цикле?
Was this page helpful?