В практической ситуации после операции merge запись неожиданно исчезает, когда функция пересчёта возвращает null. Какой контракт этой операции объясняет удаление?
Контракт Map.merge трактует результат null от функции пересчёта как команду удалить отображение ключа. Если функция возвращает ненулевое значение, оно становится новым значением ключа.
Это поведение относится именно к контракту merge, а не является общей особенностью всех методов Map или всех реализаций коллекций.
Операция merge появилась в Java 8 вместе с набором методов для типичных условных обновлений карт. До этого разработчику часто приходилось отдельно проверять наличие ключа, получать старое значение и выполнять put или remove.
merge оформляет распространённый сценарий «добавить значение, если ключ отсутствует, иначе объединить его со старым значением» в единую операцию с явно заданным контрактом.
Функция пересчёта может вернуть null не только случайно, но и намеренно — например, когда итоговое значение больше не имеет смысла. По контракту merge это означает удаление отображения ключа.
Ошибка возникает, если разработчик воспринимает null как обычное новое значение. В результате запись исчезает из карты, а последующий get возвращает null, причём такой результат нельзя отличить от хранения null без дополнительной проверки containsKey.
Алгоритм контракта можно описать так:
null, карта пытается сохранить переданное ненулевое значение.null приводит к удалению отображения ключа.Передаваемое исходное значение для merge должно быть ненулевым. Поэтому merge нельзя использовать как универсальный способ записать null в карту.
В примере нулевой остаток представлен отсутствием записи. Это удобно для компактного хранения, но опасно, если бизнес-логика различает состояния «ключ отсутствует» и «ключ имеет значение null».
Поведение удаления одинаково закреплено контрактом Map.merge, но сложность и потокобезопасность зависят от реализации. Для обычного HashMap типичная сложность близка к O(1), для TreeMap — O(log n), не считая стоимости функции пересчёта.
Нельзя автоматически считать вызов атомарным для любой реализации Map. У потокобезопасных карт, например ConcurrentHashMap, контракт реализации предусматривает более сильные гарантии, но функция пересчёта всё равно не должна самостоятельно изменять ту же карту: это может привести к ошибкам, рекурсии или неопределённому для прикладной логики поведению.
Сервис обрабатывает изменения остатков товаров. Разработчик использует merge: положительный результат увеличивает остаток, а нулевой результат возвращает как null, чтобы автоматически удалять товары без остатка.
Рассматривались два варианта. Хранить нулевые остатки проще для диагностики и позволяет отличать запись от её отсутствия, но карта постепенно содержит много неактуальных элементов. Удалять запись при нулевом остатке экономнее по памяти и ускоряет обход актуальных товаров, однако get уже не различает удалённый товар и товар с явно сохранённым null.
Выбран вариант с удалением, потому что доменная модель определяет отсутствие записи как отсутствие доступного остатка. Для проверки результата используются containsKey и отдельная логика обработки отсутствующего товара, а не сравнение одного лишь результата get с null.
Удаляет ли merge ключ, если ключ отсутствовал до вызова, а функция пересчёта вернула null?
Функция пересчёта вызывается только при наличии ненулевого старого значения. Для отсутствующего ключа или ключа со значением null используется переданное исходное значение, которое должно быть ненулевым. Поэтому в обычном сценарии отсутствующий ключ не может быть удалён результатом функции, которая не вызывалась.
Можно ли с помощью merge сохранить в карте значение null?
Нет, аргумент исходного значения для merge обязан быть ненулевым. Если ключ отсутствует или связан с null, операция пытается записать это ненулевое значение. Если ключ уже связан с ненулевым значением, возврат null из функции означает удаление, а не сохранение null.
Гарантирует ли merge атомарное обновление при работе с любой картой?
Нет. Контракт метода описывает смысл обновления, но уровень потокобезопасности определяется конкретной реализацией. Для обычной HashMap конкурентное изменение без внешней синхронизации небезопасно. Реализация вроде ConcurrentHashMap предоставляет согласованную атомарную семантику подобных обновлений, но функция пересчёта должна быть короткой, не блокирующей и не изменять ту же карту.