🔑 Раздел 10 · Вопрос #24

Можно ли хранить null ключ в HashMap

В классе java.util.HashMap функция хеширования содержит тернарную проверку:


🟢 Junior Level

Да, HashMap полностью разрешает хранить null в качестве ключа, но только в единственном экземпляре.

Ключевые особенности:

  1. Единственность ключа: Как и для любых других ключей, в HashMap не может быть двух одинаковых ключей. Если повторно вызвать map.put(null, "новое значение"), старое значение будет перезаписано.
  2. Всегда 0-й бакет: Хеш-код для null принудительно принимается равным 0. Поэтому запись с ключом null всегда попадает в корзину с индексом 0 (table[0]).
  3. Безопасность вызовов: При работе с null-ключом исключение NullPointerException не возникает, так как методы hashCode() и equals() у null не вызываются.
import java.util.HashMap;
import java.util.Map;

public class NullKeyDemo {
    public static void main(String[] args) {
        Map<String, String> map = new HashMap<>();

        map.put(null, "Default Value");
        System.out.println(map.get(null)); // "Default Value"

        // Перезапись значения по null-ключу
        map.put(null, "Updated Value");
        System.out.println(map.get(null)); // "Updated Value"
        System.out.println(map.size());    // 1 (размер не увеличился)
    }
}

Аналогия: Ключ null — это специальная папка «Без категории» на первой полке шкафа (в нулевом ящике). Такая папка может быть только одна, и если в нее положить новый документ, он заменит предыдущий.


🟡 Middle Level

Реализация функции хеширования для null

В классе java.util.HashMap функция хеширования содержит тернарную проверку:

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

Вычисление индекса корзины происходит по стандартной формуле: \(\text{index} = (n - 1) \ \& \ \text{hash} = (n - 1) \ \& \ 0 = 0\)

Следовательно, null-ключ всегда помещается строго в ячейку table[0].

Сравнение ключей при поиске

Внутри бакета table[0] могут находиться и другие элементы (не-null ключи, чей хеш дал нулевой остаток от деления). Поиск узла в цепочке выполняется следующим выражением:

if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
  • Для null-ключа: key == null.
  • Вторая часть проверки (key != null && key.equals(k)) безопасно отсекается по короткой схеме (short-circuit evaluation).
  • Сравнение происходит по ссылке: (k = p.key) == key $\implies$ p.key == null.
  • Метод equals() никогда не вызывается у null, что гарантирует отсутствие NullPointerException.

Поддержка null-ключа в различных коллекциях

Коллекция null-ключ null-значения Поведение при put(null, ...)
HashMap ✅ Разрешен (1) ✅ Разрешены Сохраняет в table[0]
LinkedHashMap ✅ Разрешен (1) ✅ Разрешены Сохраняет в table[0] + связь в списке
ConcurrentHashMap ❌ Запрещен ❌ Запрещены Выбрасывает NullPointerException
Hashtable ❌ Запрещен ❌ Запрещены Выбрасывает NullPointerException
TreeMap ⚠️ Условно ✅ Разрешены По умолчанию NPE, но разрешен при Comparator.nullsFirst
Map.of(...) (Java 9+) ❌ Запрещен ❌ Запрещены Выбрасывает NullPointerException

🔴 Senior Level

Эволюция реализации: Java 7 vs Java 8+

В Java 7 для обработки null-ключа существовали отдельные специализированные методы:

  • putForNullKey(V value)
  • getForNullKey()

Это было связано с тем, что в Java 7 бакет table[0] обрабатывался обособленно во избежание лишних проверок в общем цикле.

В Java 8+ архитектуру унифицировали:

  • Благодаря функции hash(key), возвращающей 0 для null, вся цепочка логики (putVal, getNode, removeNode) стала единой для любых типов ключей.
  • Выделенные методы putForNullKey и getForNullKey были удалены из исходного кода OpenJDK.

Бакет table[0] не зарезервирован только под null

Распространенное заблуждение: считать, что ячейка table[0] выделена исключительно под null. Это не так:

  1. Любой объект с key.hashCode() == 0 попадет в бакет 0.
  2. Любой объект, чей хеш кратен степени двойки (например, младшие биты хеша равны 0000), попадет в table[0].
  3. В бакете table[0] может образоваться полноценный связный список или даже красно-чёрное дерево TreeNode, если туда попадут 8 коллизионных ключей вместе с null!

Проблемы архитектуры при использовании null-ключей

  1. Несовместимость с многопоточным рефакторингом: Если кодовая база опирается на null-ключи в HashMap, попытка миграции на ConcurrentHashMap при переводе компонента в многопоточный режим приведет к массовым падениям с NullPointerException в runtime.
  2. Невозможность использования фабрик Java 9+: Методы Map.of(), Map.ofEntries() и коллекторы Collectors.toUnmodifiableMap() принципиально отклоняют null как ключи, выбрасывая NullPointerException.
  3. Нарушение семантики DDD (Domain-Driven Design): Ключ ассоциативного массива в бизнес-логике представляет собой идентификатор сущности (ID пользователя, артикул товара). Отсутствующий идентификатор (null) не должен являться валидным идентификатором.

Рекомендуемый паттерн: Null Object Pattern

// Вместо map.put(null, defaultHandler):
public enum SystemKey {
    ANONYMOUS,
    DEFAULT
}
map.put(SystemKey.DEFAULT, defaultHandler);

4 Tricky Questions

1. Можно ли сохранить null в качестве ключа в TreeMap?

Ответ: По умолчанию — нет, но при наличии специального компаратора — да. По умолчанию new TreeMap<>() использует естественный порядок элементов (Comparable). При первой вставке или сравнении вызывается k.compareTo(...), что при передаче null немедленно приводит к NullPointerException. Однако если создать TreeMap с компаратором, поддерживающим null (например, Comparator.nullsFirst(Comparator.naturalOrder())):

Map<String, String> treeMap = new TreeMap<>(Comparator.nullsFirst(String::compareTo));
treeMap.put(null, "Works!"); // Отработает успешно без ошибок!
System.out.println(treeMap.get(null)); // "Works!"

В этом случае компаратор перехватывает null до вызова методов объекта и корректно позиционирует его в корне или левом краю дерева.


2. Что произойдет, если положить null-ключ в Collections.unmodifiableMap(map)?

Ответ: Код выполнится успешно. Класс Collections.unmodifiableMap является лишь легковесным декоратором-оберткой над переданной исходной картой. Он не накладывает дополнительных ограничений на типы данных. Если базовой коллекцией выступает HashMap, вызовы map.put(null, "val") до оборачивания или чтение unmodMap.get(null) работают без каких-либо исключений.


3. Может ли бакет table[0], содержащий null-ключ, превратиться в красно-чёрное дерево (TreeNode)?

Ответ: Да, может. Если в бакет table[0] помимо узла с ключом null добавятся еще 8 ключей с хешем 0 (или дающих индекс 0), и общий размер таблицы достигнет $\ge 64$, бакет 0 будет трифицирован в красно-чёрное дерево. При этом узел null станет одним из TreeNode. Поскольку null не реализует Comparable, при балансировке дерева HashMap упорядочит его относительно других узлов с помощью fallback-метода tieBreakOrder(a, b) на основе System.identityHashCode(a).


4. Почему Map.of(null, "value") выбрасывает исключение, в то время как new HashMap<>() принимает null?

Ответ: Методы Map.of(), появившиеся в Java 9, проектировались в соответствии с современными стандартами дизайна API. Создатели языка (Брайан Гетц и команда OpenJDK) признали разрешение null в старых коллекциях (Java 1.2) исторической ошибкой («Billion-Dollar Mistake»), провоцирующей трудноуловимые баги и усложняющей внутреннюю оптимизацию. Во всех современных фабриках неизменяемых коллекций (List.of, Set.of, Map.of) null строго запрещен на уровне валидации аргументов (Objects.requireNonNull).


🎯 Шпаргалка для интервью

  • Поддержка null: HashMap разрешает ровно один null-ключ. Повторный put(null, val) перезаписывает значение.
  • Где хранится: Всегда в ячейке table[0], так как hash(null) == 0.
  • Сравнение: Сравнивается по ссылке key == null. Метод equals() не вызывается, исключение NPE невозможно.
  • Бакет 0: Не изолирован для null — туда могут попадать обычные ключи с коллизионным хешем.
  • Другие коллекции:
    • ConcurrentHashMap, Hashtable — строго запрещают null (NPE).
    • TreeMap — запрещает по умолчанию, но разрешает при компараторе Comparator.nullsFirst(...).
    • Map.of() — строго запрещает null (NPE).
  • Production Best Practice: Избегайте null-ключей, заменяя их паттерном Null Object или специальными enum-значениями.

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