🔑 Розділ 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 байти та підвищує навантаження на Garbage Collector (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)) на етапі читання.

Червоні прапорці (чого уникати)

  • Твердження, що HashMap забороняє null-значення.
  • Твердження, що getOrDefault(k, defaultVal) поверне дефолтне значення, якщо ключ є, але його значення null.
  • Твердження, що зберігання null-значень заощаджує пам’ять під сам вузол Node.
  • Використання Map<K, Optional<V>> у production коді.

Пов’язані теми