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

Можно ли хранить null значение в HashMap

В отличие от ключей, которые обязаны быть уникальными (поэтому null-ключ может быть только один), одно и то же значение null может быть ассоциировано с любым числом различных кл...


🟢 Junior Level

Да, HashMap разрешает хранить null в качестве значений, причем в неограниченном количестве.

В отличие от ключей, которые обязаны быть уникальными (поэтому null-ключ может быть только один), одно и то же значение null может быть ассоциировано с любым числом различных ключей.

import java.util.HashMap;
import java.util.Map;

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

        map.put("key1", null);
        map.put("key2", null);
        map.put("key3", "Active");

        System.out.println(map.size());       // 3 (все три записи сохранены)
        System.out.println(map.get("key1"));  // null
    }
}

Главная проблема: неоднозначность (Ambiguity)

Когда метод map.get(key) возвращает null, возникают две взаимоисключающие ситуации:

  1. Запрашиваемого ключа вообще нет в таблице.
  2. Ключ присутствует, но с ним явно ассоциировано значение null.

Чтобы гарантированно отличить эти два случая, необходимо использовать метод map.containsKey(key):

if (map.get(key) == null) {
    if (map.containsKey(key)) {
        System.out.println("Ключ есть, но значение равно null");
    } else {
        System.out.println("Такого ключа в Map нет");
    }
}

Аналогия: Представьте почтовые ящики. В ящике №1 лежит пустой белый конверт без письма (значение null), а ящика №2 в доме вообще не существует. В обоих случаях, заглянув внутрь, вы не прочитаете никакого полезного текста, но в первом случае ячейка существует физически.


🟡 Middle Level

Поведение современных методов Java 8+ при работе с null-значениями

Методы, появившиеся в интерфейсе Map в Java 8, имеют специфическую семантику обработки null-значений:

1. getOrDefault(key, defaultValue)

Если ключ присутствует в таблице, но ассоциирован со значением null, метод вернет null, а не дефолтное значение:

Map<String, String> map = new HashMap<>();
map.put("user", null);

String val = map.getOrDefault("user", "Anonymous");
System.out.println(val); // Выведет null, а НЕ "Anonymous"!

Правило: getOrDefault обращается к дефолтному значению только при полном отсутствии ключа (!map.containsKey(key)).

2. putIfAbsent(key, value)

Если ключ уже присутствует, но его текущее значение равно null, метод перезапишет null новым значением:

Map<String, String> map = new HashMap<>();
map.put("config", null);

map.putIfAbsent("config", "default_timeout");
System.out.println(map.get("config")); // "default_timeout"

Спецификация JDK: «If the specified key is not already associated with a value or is mapped to null, associates it with the given value».

3. computeIfAbsent(key, mappingFunction)

Аналогично putIfAbsent: если ключ связан с null, функция вычисления будет выполнена, а результат сохранен:

map.put("data", null);
map.computeIfAbsent("data", k -> calculateData()); // calculateData() выполнится!

4. merge(key, value, remappingFunction)

Если старое значение равно null, метод просто записывает переданное значение без вызова функции слияния. Если же функция слияния вернет null, запись полностью удаляется из карты:

map.put("counter", 10);
map.merge("counter", 5, (oldV, newV) -> null); // Удаляет запись "counter" из Map!
System.out.println(map.containsKey("counter")); // false

Поддержка null-значений в семействе Map

Коллекция null-значения Реакция на map.put(key, null)
HashMap ✅ Разрешены (любое число) Успешно сохраняет Node с value = null
LinkedHashMap ✅ Разрешены Сохраняет и поддерживает порядок итерации
TreeMap ✅ Разрешены Сохраняет (компаратор проверяет только ключи)
ConcurrentHashMap ❌ Запрещены Выбрасывает NullPointerException
Hashtable ❌ Запрещены Выбрасывает NullPointerException
Map.of(...) (Java 9+) ❌ Запрещены Выбрасывает NullPointerException

🔴 Senior Level

Физическое представление null-значения в куче (HotSpot Heap)

В структуре внутреннего узла HashMap:

static class Node<K,V> implements Map.Entry<K,V> {
    final int hash;
    final K key;
    V value;            // Поле ссылки на объект значения
    Node<K,V> next;
}
  • Поле value — это стандартный 4-байтный указатель (при включенном -XX:+UseCompressedOops).
  • При записи null поле value заполняется машинным нулем (0x00000000).
  • Заблуждение об экономии памяти: Хранение null-значения не экономит память под сам узел! Объект Node<K,V> все равно полноценно аллоцируется в куче, занимая 32 байта (плюс ссылка на него в массиве table).

Почему ConcurrentHashMap и Hashtable запрещают null-значения?

В многопоточной среде разделение понятий «ключа нет» и «значение равно null» через пару методов get() и containsKey() принципиально невозможно из-за состояния гонки (TOCTOU — Time of Check to Time of Use):

// Гипотетический небезопасный код:
if (map.get(key) == null) {
    // В этот момент другой поток успевает удалить ключ!
    if (map.containsKey(key)) {
        // Мы считаем, что значение было null, хотя ключа уже нет в таблице!
    }
}

Запрет null в ConcurrentHashMap гарантирует инвариант: если map.get(key) вернул null, это однозначно означает, что ключа нет в таблице.

Архитектурные альтернативы и антипаттерны

Антипаттерн: Map<String, Optional<User>>

Некоторые разработчики пытаются обернуть значение в Optional, чтобы явно показать отсутствие данных:

Map<String, Optional<User>> badMap = new HashMap<>(); // АНТИПАТТЕРН!
badMap.put("john", Optional.empty());

Почему это плохо:

  1. Класс Optional создавался Брайаном Гетцем исключительно как возвращаемый тип методов для предотвращения NPE.
  2. Optional не реализует Serializable, что ломает распределенную сериализацию карты (RMI, Hazelcast, Ignite).
  3. Создание лишнего объекта-обертки Optional на каждый элемент карты увеличивает расход оперативной памяти на 16–24 байта и повышает GC pressure.

Рекомендуемые практики:

  1. Метод java.util.Optional.ofNullable в точке чтения:
    Optional<User> user = Optional.ofNullable(map.get(userId));
    
  2. Паттерн Null Object: Ассоциировать ключ с типизированным объектом-заглушкой (например, User.ANONYMOUS), имеющим пустое нейтральное поведение.
  3. Удаление записи вместо записи null: Если данных нет, запись должна удаляться из карты вызовом map.remove(key).

4 Tricky Questions

1. Что вернет вызов map.getOrDefault("key", "default"), если ключ "key" сохранен со значением null?

Ответ: Метод вернет null, а не "default". В исходном коде Map.getOrDefault реализация выглядит так:

default V getOrDefault(Object key, V defaultValue) {
    V v;
    return (((v = get(key)) != null) || containsKey(key))
        ? v
        : defaultValue;
}

Если ключ присутствует в таблице (containsKey(key) == true), тернарный оператор вернет значение v (то есть null), полностью проигнорировав переданное дефолтное значение "default". Дефолтное значение возвращается только если ключ отсутствует физически.


2. Как сработает метод putIfAbsent("k", "v"), если в карте уже хранится запись ("k", null)?

Ответ: Метод перезапишет значение на "v" и вернет null (старое значение). Согласно спецификации интерфейса Map, метод putIfAbsent вставляет новое значение не только при полном отсутствии ключа, но и в том случае, если текущее значение ключа ассоциировано с null:

default V putIfAbsent(K key, V value) {
    V v = get(key);
    if (v == null) {
        v = put(key, value);
    }
    return v;
}

3. Разрешает ли TreeMap хранить null в качестве значений?

Ответ: Да, абсолютно разрешает в неограниченном количестве. TreeMap предъявляет жесткие требования упорядочивания исключительно к ключам (они обязаны реализовывать Comparable или сравниваться переданным Comparator). Значения в TreeMap никак не участвуют в построении и балансировке красно-чёрного дерева, поэтому любые значения (включая null) сохраняются без ограничений и исключений.


4. Что произойдет, если в методе map.merge(key, value, remappingFunction) функция пересчета вернет null?

Ответ: Запись с этим ключом будет полностью удалена из HashMap. В коде метода merge:

V oldValue = p.val;
V newValue = (oldValue == null) ? value : remappingFunction.apply(oldValue, value);
if (newValue != null) {
    p.val = newValue;
} else {
    // Если функция вернула null, узел физически вырезается из бакета!
    removeNode(hash, key, null, false, true);
}

Это стандартный идиоматический способ атомарного удаления записей по условию в Java 8+.


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

  • Поддержка null-значений: HashMap разрешает любое количество null-значений.
  • Проблема Неоднозначности: map.get(key) == null может означать как отсутствие ключа, так и значение null. Для различия используйте map.containsKey(key).
  • Ловушка getOrDefault: Если ключ есть, но значение null, метод вернет null, а не дефолт!
  • Поведение putIfAbsent: Перезаписывает старое значение, если оно было null.
  • Поведение merge: Если функция слияния возвращает null, узел удаляется из карты.
  • Где null-значения запрещены: ConcurrentHashMap, Hashtable, Map.of() — выбросят NullPointerException.
  • Антипаттерн: Никогда не используйте Map<K, Optional<V>> (лишняя память, не сериализуем). Оборачивайте результат Optional.ofNullable(map.get(k)) на этапе чтения.

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