Какую границу атомарности устанавливает compute для одного ключа в ConcurrentHashMap?
Для одного ключа compute атомарно связывает чтение текущего значения, выполнение remapping-функции и установку результата. Параллельные операции над тем же ключом не должны наблюдать промежуточное состояние обновления.
Эта гарантия не распространяется на несколько ключей, произвольные внешние объекты и побочные эффекты внутри функции. Remapping-функция должна быть короткой, не должна изменять эту же map во время вычисления и не должна рассчитывать на выполнение транзакции для других данных.
Обычная последовательность «прочитать значение — вычислить новое — записать результат» создаёт гонку между потоками. Даже если отдельные операции get и put потокобезопасны, составная последовательность может потерять обновление.
Методы вычисления в ConcurrentHashMap появились как средство выразить такую составную операцию непосредственно внутри concurrent-контейнера. Контейнер сам координирует доступ к записи для конкретного ключа, избавляя вызывающий код от ручного протокола блокировок или циклов повторного чтения.
Пусть два потока одновременно обновляют значение одного ключа. Если каждый сначала прочитает старое значение, затем вычислит новое и отдельно выполнит put, один результат может затереть другой.
Неверно также считать, что атомарность compute превращает всю программу в транзакцию. Обновление другого ключа, запись в сторонний список или вызов внешнего сервиса не становятся частью той же атомарной операции.
Во время compute контейнер получает текущее значение ключа, передаёт его функции и устанавливает возвращённый результат как новое значение. Для одного ключа весь вызов рассматривается как единая операция; конкурирующие обновления этого ключа не могут вклиниться между чтением и записью результата.
Если ключа не было, функции передаётся null. Возврат null означает удаление отображения для ключа, а ненулевой результат устанавливает или заменяет значение. ConcurrentHashMap не допускает null в качестве ключа или значения, поэтому null имеет специальную семантику.
Минимальный пример атомарного обновления счётчика:
Для одного пользователя два одновременных вызова не должны потерять одно из приращений. При этом функция может временно блокировать конкурирующие операции для того же ключа, поэтому длительные вычисления внутри неё ухудшают масштабируемость.
Функция не должна вызывать изменяющие методы этой же map для того же вычисления. Это может привести к рекурсии, взаимной блокировке или исключению, поскольку реализация обязана сохранять согласованность своей внутренней операции. Нельзя помещать туда сетевые запросы, ожидание другой блокировки или тяжёлую бизнес-логику без оценки последствий.
Гарантия относится к обновлению отображения, но не к внешним побочным эффектам. Если функция записывает событие в стороннюю структуру, атомарность map не делает эту запись согласованной с изменением ключа. При повторной попытке вызывающего кода внешний эффект также может быть выполнен повторно.
Операции над разными ключами обычно могут выполняться параллельно, однако это не следует трактовать как гарантию независимой транзакционности: функция одного ключа может зависеть от внешнего состояния, которое изменяется другими потоками.
Сервис собирает число запросов по идентификатору клиента. Вариант с обычным HashMap под общей блокировкой прост, но блокирует все ключи одной глобальной критической секцией. Вариант с get и put у ConcurrentHashMap быстрее выглядит, но теряет приращения при гонке.
Цикл с putIfAbsent и повторными попытками может решить задачу, но усложняет код и требует аккуратно обрабатывать конкуренцию. Для простого счётчика выбран compute: контейнер атомарно обновляет значение конкретного клиента, а обновления разных клиентов могут выполняться параллельно.
Если функция обновления становится тяжёлой или счётчик имеет очень высокую конкуренцию по одному ключу, стоит рассмотреть отдельную архитектуру: очередь событий, шардирование ключа или специализированный счётчик. compute устраняет гонку обновления, но не отменяет стоимость конкуренции и не превращает map в полнофункциональное хранилище транзакций.
ConcurrentHashMap?Ответ: Полагаться на это нельзя. Документация требует, чтобы вычисление было коротким и простым и не пыталось изменять эту же map во время вычисления. Даже если конкретный сценарий с другим ключом иногда завершится, он создаёт зависимость от внутренней координации контейнера и может привести к рекурсии, блокировкам или исключению.
compute?Ответ: Нет. compute устанавливает атомарную границу вокруг вычисления одного отображения для одного ключа. Изменение второго ключа не присоединяется к этой транзакции: другой поток может наблюдать его отдельно, а исключение или сбой между операциями не обеспечивает отката уже выполненного изменения.
compute однократное выполнение внешнего побочного эффекта внутри функции?Ответ: Нет, такой контракт нельзя использовать как механизм exactly-once для внешних действий. Для данного вызова и ключа функция применяется контейнером атомарно, но повторный вызов compute самим приложением, восстановление после ошибки или повторная обработка бизнес-события могут снова выполнить функцию. Побочные эффекты следует выносить за пределы remapping-функции либо делать идемпотентными и защищать отдельным протоколом.