Map

Определение и контракт

Map<K,V> хранит соответствия «ключ — значение». Каждый ключ встречается не более одного раза; новое значение для того же ключа заменяет старое. Map входит в Collections Framework, но не наследует Collection.

Map<String, Integer> attempts = new HashMap<>();
attempts.put("job-42", 1);
attempts.put("job-42", 2);
 
int current = attempts.get("job-42"); // 2

Основные реализации

РеализацияПорядок ключейСредняя стоимость поискаОсобенность
HashMapне гарантированO(1)зависит от equals и hashCode
LinkedHashMapвставка или доступO(1)удобно для предсказуемого обхода и LRU-подобных структур
TreeMapсортировкаO(log n)навигация по диапазонам ключей
EnumMapпорядок enumблизко к O(1)компактна для ключей одного enum
ConcurrentHashMapне гарантированв среднем O(1)конкурентный доступ без общего глобального lock

Взгляды на карту

keySet, values и entrySet — представления, связанные с исходной картой:

for (Map.Entry<String, Integer> entry : attempts.entrySet()) {
    System.out.println(entry.getKey() + "=" + entry.getValue());
}

Для обхода пар entrySet лучше, чем повторный get по каждому ключу: ключ и значение уже доступны в одной записи. Удаление через итератор представления удаляет запись из карты.

Отсутствие значения и null

get(key) не различает «ключ отсутствует» и «ключ присутствует со значением null». Если null разрешён реализацией и важен, проверяют containsKey.

if (cache.containsKey(id)) {
    return cache.get(id); // значение может быть null
}

HashMap допускает один null-ключ и null-значения. ConcurrentHashMap, Map.of и Map.copyOf не допускают null.

Вычисление значений

Map<String, List<String>> byTeam = new HashMap<>();
byTeam.computeIfAbsent("backend", key -> new ArrayList<>())
      .add("Alice");
 
Map<String, Integer> counts = new HashMap<>();
counts.merge("ERROR", 1, Integer::sum);
  • putIfAbsent записывает значение, если нет текущего ненулевого значения.
  • computeIfAbsent лениво вычисляет значение.
  • compute, computeIfPresent и merge обновляют запись через функцию.

Функции этих методов не должны неожиданно менять ту же карту: поведение зависит от реализации, а рассуждать о коде становится трудно.

Ключи как инвариант

Ключ хеш-карты должен иметь стабильные equals и hashCode на всё время хранения:

record OrderId(long value) {}
 
Map<OrderId, String> states = new HashMap<>();
states.put(new OrderId(7), "READY");

Изменяемый ключ может оказаться в корзине, которая больше не соответствует его новому хешу. Для TreeMap аналогичную роль играет стабильный порядок Comparator/Comparable.

Ловушки

  • Полагаться на порядок HashMap.
  • Использовать изменяемый объект как ключ.
  • Путать отсутствие ключа со значением null.
  • Выполнять containsKey + put как атомарную операцию в многопоточном коде.
  • Считать computeIfAbsent универсальной защитой от повторного вычисления без учёта контракта конкретной concurrent-карты.
  • Обходить карту через keySet и get, когда нужна каждая пара.

Вопросы на собеседовании

  1. Почему Map не является Collection?
  2. Чем отличаются HashMap, LinkedHashMap и TreeMap?
  3. Что возвращает put, если ключ уже существовал?
  4. Как отличить отсутствующий ключ от ключа со значением null?
  5. Почему ключ HashMap лучше делать неизменяемым?
  6. Для чего нужны computeIfAbsent и merge?

Связанные заметки

Иерархия коллекций · HashMap · ConcurrentHashMap · Set · Equals и hashCode · Comparator Java