Чи можна зберігати 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, виникають дві взаємовиключні ситуації:
- Запитуваного ключа взагалі немає в таблиці.
- Ключ присутній, але з ним явно асоційовано значення
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());
Чому це погано:
- Клас
Optionalстворювався Браяном Ґетцом виключно як тип повернення методів для запобігання NPE. Optionalне реалізуєSerializable, що ламає розподілену серіалізацію мапи (RMI, Hazelcast, Ignite).- Створення зайвого об’єкта-обгортки
Optionalна кожен елемент мапи збільшує витрати оперативної пам’яті на 16–24 байти та підвищує навантаження на Garbage Collector (GC pressure).
Рекомендовані практики:
- Метод
java.util.Optional.ofNullableу точці читання:Optional<User> user = Optional.ofNullable(map.get(userId)); - Патерн Null Object:
Асоціювати ключ із типізованим об’єктом-заглушкою (наприклад,
User.ANONYMOUS), який має порожню нейтральну поведінку. - Видалення запису замість запису
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 коді.