Программирование JavaМногопоточностьJava-разработчик серверных приложений

Почему рекурсивный вызов computeIfAbsent для того же ключа в ConcurrentHashMap может привести к IllegalStat...

Почему рекурсивный вызов computeIfAbsent для того же ключа в ConcurrentHashMap может привести к IllegalStateException?

Проходите собеседования с ИИ помощником Hintsage

Краткий ответ

ConcurrentHashMap вычисляет отсутствующее значение атомарно и резервирует состояние для конкретного ключа. Если функция вычисления во время этого процесса снова пытается вычислить значение для того же ключа, возникает рекурсивное обновление: внутреннее вычисление не может завершиться, пока не завершится внешнее. Чтобы не допустить бесконечного ожидания, реализация может обнаружить такую ситуацию и выбросить IllegalStateException.

Исторический контекст

Обычные синхронизированные коллекции защищают операции общей блокировкой, что упрощает корректность, но ограничивает параллелизм. ConcurrentHashMap создавался для безопасной работы нескольких потоков с меньшим количеством конфликтов между независимыми операциями.

Для составных операций вроде computeIfAbsent одной отдельной блокировки чтения или записи недостаточно. Коллекция должна согласованно проверить отсутствие значения, выполнить вычисление и опубликовать результат, не позволяя двум потокам одновременно установить разные значения для одного ключа.

Постановка проблемы

Пусть поток начал вычислять значение для ключа, но функция вычисления повторно обращается к той же операции с тем же ключом. Внешний вызов ждёт результат внутреннего, а внутренний не может завершиться независимо: он снова требует завершить вычисление того же ключа.

Без обнаружения такой рекурсии можно получить бесконечное ожидание или зависание. Поэтому функция вычисления должна быть короткой, не должна рекурсивно изменять ту же запись и в общем случае не должна сама модифицировать карту во время вычисления.

Подробное решение

Концептуально computeIfAbsent выполняет следующие шаги:

  1. проверяет, есть ли ненулевое значение для ключа;
  2. если значения нет, резервирует состояние вычисления для этого ключа;
  3. вызывает переданную функцию;
  4. публикует полученный результат, если он не равен null;
  5. возвращает уже опубликованное значение последующим потокам.

Реализация может использовать внутренние узлы и синхронизацию, детали которой не следует считать частью публичного контракта. Существенен контракт: вычисление для одного ключа выполняется атомарно относительно других операций над этой записью, а рекурсивное обновление, способное привести к бесконечному вычислению, может быть отклонено через IllegalStateException.

Минимальный пример:

import java.util.concurrent.ConcurrentHashMap; class Demo { public static void main(String[] args) { var map = new ConcurrentHashMap<String, Integer>(); map.computeIfAbsent("x", key -> map.computeIfAbsent("x", nestedKey -> 1)); } }

Внутренний вызов не является обычным чтением уже готового значения: он пытается повторно запустить вычисление для ключа, вычисление которого ещё не завершено. В такой ситуации реализация ConcurrentHashMap обнаруживает рекурсивное обновление и выбрасывает IllegalStateException.

Функция должна быть короткой и предсказуемой. Долгий ввод-вывод, ожидание внешней блокировки или вызов другой логики, которая снова обращается к той же карте, увеличивают время удержания внутреннего состояния и могут создать сильную конкуренцию или взаимное ожидание.

Ситуация из практики

Сервис кэширует разобранные схемы по идентификатору. Разбор схемы неожиданно обращается к тому же кэшу за этой же схемой. При использовании обычной последовательности проверки и записи можно получить гонку: два потока начнут одинаковый дорогой разбор. При использовании рекурсивного computeIfAbsent гонка устраняется, но появляется циклическая зависимость вычислений.

Вариант с синхронизацией всего сервиса проще для понимания, однако снижает пропускную способность и блокирует независимые ключи. Вариант с ручной проверкой, последующим вычислением и putIfAbsent допускает дублирование дорогостоящей работы: несколько потоков могут вычислить одно значение одновременно.

Выбранное решение — оставить computeIfAbsent, но вынести разбор в функцию без обращений к этому же кэшу. Зависимые данные предварительно получают за пределами вычисления либо передают в него явно. Это сохраняет атомарность публикации значения и устраняет рекурсию; результатом становится отсутствие циклических вычислений и контролируемая конкуренция по ключам.

Что кандидаты часто упускают

  1. Гарантирует ли computeIfAbsent вызов функции ровно один раз при параллельных обращениях к одному ключу?

    Нет, формулировка не означает безусловную гарантию ровно одного вызова во всех возможных обстоятельствах жизненного цикла записи. Для конкретного атомарного вычисления функция применяется не более одного раза для отсутствующего ключа, а остальные потоки используют опубликованный результат или ждут завершения вычисления. Если функция возвращает null или выбрасывает исключение, значение не публикуется, и последующий вызов может запустить вычисление снова.

  2. Что произойдёт, если функция вычисления вернёт null?

    ConcurrentHashMap не поддерживает null как значение или ключ. Поэтому null означает, что отображение не добавляется; вызов возвращает null, а последующий вызов для того же ключа снова может попытаться вычислить значение. Если отсутствие результата является нормальным состоянием, его обычно представляют специальным объектом-значением или используют отдельную структуру для кэширования отрицательного результата.

  3. Почему опасно выполнять внутри функции вычисления долгий ввод-вывод?

    Пока вычисление для ключа не завершено, другие потоки, работающие с тем же ключом, не могут получить готовый результат через эту операцию. Долгий ввод-вывод увеличивает задержку, удерживает внутреннее состояние координации и может усилить конкуренцию; если внешний ресурс сам ожидает действий потока, работающего с картой, возникает риск взаимного ожидания.

    Практический компромисс — выполнить дорогую или блокирующую работу вне карты, а затем отдельно опубликовать результат с подходящей проверкой актуальности. Если требуется строго одно вычисление на ключ, функцию оставляют короткой и не допускают из неё обратных обращений к той же карте.