В следующем коде функция обновления записывает сообщение в журнал. Какой скрытый риск возникает при конкуренции нескольких потоков?
import java.util.concurrent.atomic.AtomicInteger;
class Demo {
static final AtomicInteger value = new AtomicInteger();
static int next() {
return value.updateAndGet(old -> {
System.out.println("old=" + old);
return old + 1;
});
}
}
Функция, переданная в updateAndGet, может быть выполнена несколько раз для одной успешной операции. Поэтому она должна быть без побочных эффектов: сообщение в журнале может появиться несколько раз, даже если значение счётчика увеличилось только один раз.
Атомарные классы java.util.concurrent.atomic появились как средство безопасного обновления небольших общих состояний без блокировок. Их операции основаны на CAS — сравнении текущего значения с ожидаемым и условной записью нового значения.
Методы вроде updateAndGet скрывают цикл повторных попыток CAS. Это позволяет выразить атомарное преобразование значения, но требует, чтобы переданная функция была пригодна для повторного вызова.
Несколько потоков могут одновременно прочитать одно и то же старое значение. Только один из них успешно выполнит CAS, а остальные обнаружат изменение и попробуют вычислить новое значение заново.
В результате арифметическое обновление останется корректным, но побочные действия внутри функции — запись в журнал, отправка сообщения, изменение внешнего объекта или вызов платежного API — могут выполниться несколько раз. Атомарность переменной не распространяется на эти действия.
Упрощённо updateAndGet работает так:
Поток читает oldValue, вычисляет результат и пытается заменить значение через CAS. Если другой поток успел изменить значение, CAS завершается неудачей, и функция вызывается снова с уже новым значением.
Вычисляющая функция должна быть детерминированной и не иметь наблюдаемых побочных эффектов. Допустимо читать её аргумент и вычислять результат; нельзя полагаться на то, что тело выполнится ровно один раз.
Если действие должно произойти ровно один раз после успешного изменения состояния, его следует вынести за пределы функции и отдельно спроектировать координацию. Например, можно использовать обычный цикл CAS с проверкой успешности операции, но даже тогда нужно учитывать, что повторные попытки вычисления и публикация результата — разные этапы.
Для сложной транзакционной логики лучше применить блокировку или другой примитив координации. Это может снизить параллелизм, зато позволит атомарно связать изменение состояния с побочным действием.
Сервис резервирует номер заказа через AtomicLong.updateAndGet, а внутри функции одновременно отправляет событие в Kafka. При высокой конкуренции функция может повторно выполниться для одной операции, поэтому одно и то же событие иногда публикуется несколько раз.
Вариант с оставлением отправки внутри функции прост, но некорректен: CAS гарантирует обновление номера, а не однократную отправку события. Вариант с synchronized вокруг изменения и отправки предотвращает повторный вызов, но сериализует все заказы и уменьшает пропускную способность.
Практичнее разделить операции: атомарно получить номер, сформировать событие с этим номером и обеспечить идемпотентность потребителя либо использовать транзакционный outbox. Такой подход сохраняет параллелизм и допускает повторную доставку без повторного бизнес-эффекта.
updateAndGet, что функция будет вызвана ровно один раз?Нет. При конфликте CAS реализация повторяет вычисление. Документационный контракт допускает несколько вызовов функции, поэтому рассчитывать на однократное выполнение нельзя.
Не обязательно. Если функция чистая, например oldValue -> oldValue + 1, каждый неудачный расчёт просто отбрасывается, а успешный CAS применяет результат к актуальному значению. Проблема возникает именно у внешних побочных действий, выполненных во время расчёта.
volatile у обычного поля вместо AtomicInteger?Нет. volatile обеспечивает видимость отдельных чтений и записей, но не делает последовательность «прочитать, увеличить, записать» атомарной. Для конкурентного счётчика нужен атомарный класс, блокировка или другой механизм, а побочные действия всё равно нельзя бездумно помещать в CAS-функцию.