🔑 Розділ 10 · Питання #12

Чи можна використовувати mutable об'єкт як ключ в HashMap

Технічно мова Java дозволяє використовувати будь-який змінюваний (мутабельний) об'єкт як ключ у HashMap, оскільки метод put(K key, V value) приймає будь-який тип, похідний від j...


🟢 Junior Level

Короткий опис для співбесіди (30 секунд)

Технічно мова Java дозволяє використовувати будь-який змінюваний (мутабельний) об’єкт як ключ у HashMap, оскільки метод put(K key, V value) приймає будь-який тип, похідний від java.lang.Object. Проте в професійній інженерній практиці це вважається грубим антипатерном.

Ключ у HashMap зобов’язаний бути імутабельним (незмінним). Якщо змінити значення полів об’єкта-ключа після того, як його вже було додано в HashMap:

  1. Значення key.hashCode() зміниться;
  2. При подальшому виклику map.get(key) мапа обчислить новий індекс бакета і шукатиме елемент в іншому кошику;
  3. У новому бакеті шуканого запису немає — метод поверне null;
  4. Сам елемент назавжди залишиться заблокованим у старому бакеті («вузол-привид»), породжуючи витік пам’яті.

Ідеальними ключами для HashMap є незмінні типи: String, Integer, UUID, LocalDate або класи record (Java 16+).


Демонстрація проблеми «зламаного ключа» на Java 21

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

public class MutableKeyTrapDemo {

    public static class MutableAccount {
        private String accountNumber; // Поле можна змінювати через сетер!

        public MutableAccount(String accountNumber) {
            this.accountNumber = accountNumber;
        }

        public void setAccountNumber(String accountNumber) {
            this.accountNumber = accountNumber;
        }

        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (!(o instanceof MutableAccount that)) return false;
            return Objects.equals(accountNumber, that.accountNumber);
        }

        @Override
        public int hashCode() {
            return Objects.hash(accountNumber);
        }
    }

    public static void main(String[] args) {
        Map<MutableAccount, String> accounts = new HashMap<>();

        MutableAccount acc = new MutableAccount("ACC-001");
        accounts.put(acc, "VIP-Client"); // Поміщено в бакет на основі гешу "ACC-001"

        // Перевіряємо: до зміни ключа все працює коректно
        System.out.println("До мутації: " + accounts.get(acc)); // VIP-Client

        // ❌ МУТАЦІЯ КЛЮЧА:
        acc.setAccountNumber("ACC-999"); // Геш-код об'єкта змінився прямо в купі!

        // ❌ КАТАСТРОФА:
        System.out.println("Після мутації: " + accounts.get(acc)); // null! (Дані втрачено для get)
        System.out.println("Розмір мапи: " + accounts.size());     // 1 (запис застряг усередині!)
    }
}

🟡 Middle Level

Патерн безпечної модифікації: Delete-Mutate-Insert

Якщо бізнес-вимоги неминуче вимагають модифікації об’єкта, який уже використовується в ролі ключа в мапі, єдиний надійний інженерний підхід — патерн «Видалити $\to$ Змінити $\to$ Додати знову»:

public class SafeKeyMutationService {
    private final Map<MutableAccount, String> registry = new HashMap<>();

    public void updateAccountNumber(MutableAccount account, String newNumber) {
        // 1. Витягуємо старе значення і видаляємо ключ ДО його мутації (за старим гешем!)
        String value = registry.remove(account);
        if (value == null) {
            throw new IllegalStateException("Акаунт не знайдено в реєстрі");
        }

        // 2. Безпечно мутуємо об'єкт за межами колекції
        account.setAccountNumber(newNumber);

        // 3. Вставляємо назад (буде обчислено новий геш і новий бакет)
        registry.put(account, value);
    }
}

Чи можна змінювати поля, які НЕ входять до equals та hashCode?

Технічно — так, це допустимо. Якщо поле не бере участі у формулах equals() та hashCode(), його зміна ніяк не вплине на обчислення індексу бакета та пошук вузла:

public class UserKey {
    private final long userId; // Бере участь в equals та hashCode (оголошено final!)
    private String lastAccessedIp; // НЕ бере участі в equals та hashCode (може змінюватися)
}

[!WARNING] Архітектурний ризик (Code Smell):
Наявність змінних полів в об’єкті-ключі вводить в оману інших розробників команди. Під час подальшого рефакторингу хтось може автоматично згенерувати equals/hashCode за допомогою IDE або Lombok, додавши всі поля. Це миттєво зламає стабільність колекції у продакшені. Ключ мапи повинен бути чистим об’єктом-значенням (Value Object).


🔴 Senior Level

Анатомія витоку пам’яті: Ghost Entries у Heap

Коли ключ мутує безпосередньо всередині HashMap, виникає специфічний різновид прихованого витоку пам’яті:

Купа JVM (Heap):
table[3] ──► [ Node: key (hash="ACC-001"), value="VIP" ] ◄── Застрягло назавжди!
                ▲
                │ acc.setAccountNumber("ACC-999")
                │ Поточний hash(acc) вказує на table[11]!

Симптоми аномалії в продакшені:

  1. map.get(key) == null та map.containsKey(key) == false;
  2. map.remove(key) == null — елемент неможливо видалити стандартним методом;
  3. Ітератор map.entrySet() успішно знаходить елемент! Під час повного перебору мапи через цикл for (var entry : map.entrySet()) елемент відображається, оскільки ітератор просто послідовно обходить усі бакети масиву, ігноруючи геш-коди;
  4. Постійне зростання Old Generation: старі записи ніколи не збиратимуться збирачем сміття (GC), доки живий сам екземпляр HashMap.

Альтернатива: java.util.IdentityHashMap

Якщо архітектурна задача дійсно вимагає організації мапи на базі змінних об’єктів (наприклад, побудова графа серіалізації, відстеження циклічних залежностей у контейнері Spring IoC або глибоке клонування), стандартну HashMap замінюють на IdentityHashMap:

Map<MutableAccount, String> identityMap = new IdentityHashMap<>();
MutableAccount acc = new MutableAccount("ACC-001");
identityMap.put(acc, "VIP");

acc.setAccountNumber("ACC-999"); // Мутуємо об'єкт!

// Працює бездоганно! Повертає "VIP":
System.out.println(identityMap.get(acc)); 

Чому це працює:

  • IdentityHashMap повністю ігнорує перевизначені методи equals() та hashCode();
  • Індексація базується на нативному System.identityHashCode(acc) (псевдовипадковий геш адреси із заголовка Mark Word, який ніколи не змінюється при мутації полів);
  • Порівняння ключів виконується суворо за фізичною ідентичністю посилань: k1 == k2.

Архітектурний стандарт Java 21: Records для складених ключів

Для композитних ключів у сучасній Java стандартом де-факто є record:

// Ідеальний незмінний ключ:
public record OrderKey(long customerId, String regionCode) {
    // Компактний валідаційний конструктор
    public OrderKey {
        Objects.requireNonNull(regionCode, "Region code must not be null");
    }
}

Усі компоненти рекорда за замовчуванням є private final, гетери унеможливлюють модифікацію, а equals та hashCode генеруються компілятором на рівні байт-коду, повністю усуваючи людський фактор помилки.


🎯 Шпаргалка для інтерв’ю

1. 30-секундний есенс (Elevator Pitch)

Технічно використовувати мутабельний об’єкт як ключ у HashMap можна, проте категорично не рекомендується. Якщо після додавання в мапу змінити поле ключа, яке бере участь у hashCode(), об’єкт «загубиться»: під час виклику get() мапа обчислить новий геш і піде шукати запис в іншому бакеті, повернувши null. Старий об’єкт назавжди застрягне в пам’яті (Ghost Entry), спричиняючи прогресуючий витік пам’яті. Ключі HashMap повинні бути суворо незмінними (String, Integer, UUID, record).


2. 4 каверзні питання з глибокими відповідями

Питання 1: Що станеться, якщо змінити поле об’єкта-ключа, яке НЕ входить до розрахунку hashCode() та equals()?

Відповідь: З точки зору коректності роботи HashMap нічого не зламається: визначення бакета та порівняння ключів спираються виключно на поля, задіяні в контракті. Метод get() продовжить знаходити збережений запис. Проте це грубе порушення чистоти коду (Code Smell), оскільки об’єкт перестає бути самодостатнім Value Object, а під час майбутнього рефакторингу це поле можуть випадково включити в контракт, зламавши продакшн.

Питання 2: Як знайти та видалити елемент із HashMap, якщо його ключ було мутовано після додавання?

Відповідь: Стандартний виклик map.remove(key) не допоможе, оскільки він шукатиме вузол у новому бакеті. Єдиний спосіб очистити пошкоджений запис — застосувати повний обхід колекції через явний ітератор:

Iterator<Map.Entry<MutableKey, Value>> it = map.entrySet().iterator();
while (it.hasNext()) {
    Map.Entry<MutableKey, Value> entry = it.next();
    if (entry.getKey() == brokenKey) { // Порівняння за посиланням ==!
        it.remove(); // Безпечне видалення з поточного бакета
    }
}

Питання 3: У яких сценаріях IdentityHashMap підходить для мутабельних ключів і чим її семантика відрізняється від HashMap?

Відповідь: IdentityHashMap ідеально підходить для службових задач обробки графів об’єктів (серіалізація Jackson/Kryo, виявлення циклів, відстеження життєвого циклу бінів у Spring BeanPostProcessor), де важливий не логічний зміст даних, а фізична унікальність екземпляра в купі. В IdentityHashMap ключі порівнюються через ==, а геш береться з System.identityHashCode(), тому мутація полів ніяк не впливає на пошук.

Питання 4: Чому StringBuilder або ArrayList категорично не можна використовувати як ключі HashMap?

Відповідь: Клас StringBuilder узагалі не перевизначає equals та hashCode (використовує посилальну рівність Object). Клас ArrayList перевизначає обидва методи на основі вмісту своїх елементів: при будь-якому додаванні або видаленні елемента зі списку (list.add("item")) його hashCode() динамічно мутує, що миттєво руйнує позиціонування списку всередині HashMap.


3. Типові помилки та Red Flags

❌ «Можна використовувати змінний ключ, головне просто не викликати сетери.»
Правильно: Якщо об’єкт змінний, сетер може бути випадково викликаний іншим потоком або зовнішнім фреймворком. Незмінність повинна бути зафіксована на рівні типів (final).

❌ «Якщо ключ мутував, розмір map.size() зменшиться.»
Правильно: Розмір мапи не зміниться. Елемент залишиться у старому бакеті у вигляді «запису-привида».

❌ «Використання сутності JPA з автоінкрементним ID як ключа.»
Правильно: До виклику persist поле id == null, після збереження id призначається базою даних. Це класична мутація ключа, що руйнує колекцію.


4. Пов’язані питання