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

Що станеться, якщо перевизначити equals(), але не hashCode()

Якщо в класі перевизначити метод equals(), але залишити метод hashCode() неперевизначеним, клас успадкує базову реалізацію від java.lang.Object. Стандартний Object.hashCode() по...


🟢 Junior Level

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

Якщо в класі перевизначити метод equals(), але залишити метод hashCode() неперевизначеним, клас успадкує базову реалізацію від java.lang.Object. Стандартний Object.hashCode() повертає Identity Hash Code (число, псевдовипадково згенероване JVM для конкретного об’єкта під час першого звернення та збережене в заголовку пам’яті).

У результаті два логічно еквівалентні об’єкти матимуть різні геш-коди. Це призведе до повної деградації та некоректної роботи всіх геш-колекцій платформи Java:

  1. Втрата даних у HashMap: виклик map.get(new Key(id)) поверне null, хоча запис із таким логічним ключем гарантовано присутній у мапі;
  2. Поява дублікатів у HashSet: метод set.add(duplicate) успішно додасть дублюючий елемент, зруйнувавши ключовий інваріант унікальності множини;
  3. Витоки пам’яті (Memory Leaks): виклик map.remove(key) не знайде елемент у цільовому бакеті, і «мертві» записи назавжди залишаться у купі (heap).

Демонстрація катастрофи на Java 21

import java.util.HashSet;
import java.util.HashMap;
import java.util.Set;
import java.util.Map;

public class BrokenContractDemo {

    // Клас із порушеним контрактом: equals перевизначено, hashCode — НІ!
    public static class DefectiveUser {
        private final long id;
        public DefectiveUser(long id) { this.id = id; }

        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (!(o instanceof DefectiveUser that)) return false;
            return this.id == that.id;
        }
        // hashCode() забуто! Використовується Object.hashCode()
    }

    public static void main(String[] args) {
        DefectiveUser u1 = new DefectiveUser(42L);
        DefectiveUser u2 = new DefectiveUser(42L);

        System.out.println("Логічно рівні за equals? " + u1.equals(u2)); // true

        // 1. Поломка HashSet:
        Set<DefectiveUser> set = new HashSet<>();
        set.add(u1);
        set.add(u2);
        // У сеті ДВА логічно однакових користувачі! Інваріант множини зруйновано:
        System.out.println("Розмір HashSet: " + set.size()); // 2! (Очікувалося: 1)

        // 2. Поломка HashMap:
        Map<DefectiveUser, String> cache = new HashMap<>();
        cache.put(u1, "AdminPermissions");
        // Намагаємося отримати дані за ідентичним ключем u2:
        System.out.println("Значення за u2: " + cache.get(u2)); // null! (Дані "втрачено")
    }
}

🟡 Middle Level

Чому equals() навіть не викликається: Механіка пошуку в HashMap

Щоб зрозуміти першопричину збою, поглянемо на внутрішню логіку пошуку вузла HashMap.getNode():

// Вихідний код HashMap:
int hash = hash(key); // Викликає key.hashCode()
Node<K,V> first = tab[(n - 1) & hash]; // Обчислює індекс бакета
if (first != null) {
    if (first.hash == hash && ((k = first.key) == key || (key != null && key.equals(k))))
        return first;
    // ... обхід ланцюжка колізій або червоно-чорного дерева
}
return null;

Покроковий сценарій краху:

  1. При виклику map.put(u1, "Data") в об’єкта u1 запитується hashCode(). Нехай JVM повернула 1001. Мапа обчислює індекс бакета: (16 - 1) & 1001 = 9. Вузол записується в бакет table[9].
  2. Пізніше сервіс запитує значення за новим, але логічно рівним ключем: map.get(new DefectiveUser(42L)).
  3. Створений об’єкт u2 розташований за іншою фізичною адресою в купі, тому успадкований Object.hashCode() повертає для нього інше псевдовипадкове число, наприклад 2044.
  4. Мапа обчислює індекс бакета: (16 - 1) & 2044 = 12.
  5. Мапа звертається до комірки table[12]. Цей бакет виявляється порожнім (або містить зовсім інші елементи).
  6. Метод equals() навіть не викликається, оскільки пошук здійснювався в неправильному бакеті! Метод повертає null.

Вплив на корпоративні фреймворки (Spring та Hibernate)

У реальних Enterprise-додатках така помилка призводить до прихованих системних деградацій:

  1. Spring Cache (@Cacheable): якщо складений бізнес-ключ методу кешування не перевизначає hashCode(), кожен новий HTTP-запит (який створює новий екземпляр DTO) призводить до промаху кешу (Cache Miss). Кеш безконтрольно роздувається мільйонами дублікатів, а навантаження на базу даних не зменшується.
  2. JPA / Hibernate L1 Cache (Persistence Context): Hibernate зберігає сутності поточного сеансу у внутрішніх мапах контексту персистентності. Некоректний hashCode() спричиняє появу дублікатів однієї й тієї самої сутності в сесії та викидання виключень NonUniqueObjectException.

🔴 Senior Level

Як HotSpot C++ генерує Object.hashCode()

Коли hashCode() не перевизначено, викликається інтринсик віртуальної машини java.lang.Object.hashCode(), який у C++ ядрі HotSpot (synchronizer.cpp) звертається до процедури get_next_hash:

JVM Flag: -XX:hashCode=N (типово N=5)
Алгоритм 5: Marsaglia's xor-shift PRNG на локальний стан потоку (Thread-Local).
  • Хеш-код за замовчуванням не є адресою пам’яті (оскільки збирач сміття постійно переміщує об’єкти під час дефрагментації та збирання);
  • Це псевдовипадкове 31-бітне число, згенероване алгоритмом Марсальї та назавжди записане в заголовок об’єкта (Mark Word);
  • Два послідовні виклики new User(1L) гарантовано отримають два абсолютно різні псевдовипадкові числа у заголовках своїх об’єктів.

Архітектура витоку пам’яті (Ghost Entries Leak Pattern)

Неможливість видалення об’єктів із довгоживучих мап призводить до класичного прогресуючого витоку пам’яті у старшому поколінні (Old Gen):

public class LeakyCacheService {
    // Довгоживучий кеш у Singleton-сервісі:
    private final Map<DefectiveKey, SessionData> activeSessions = new ConcurrentHashMap<>();

    public void login(long userId, SessionData data) {
        activeSessions.put(new DefectiveKey(userId), data);
    }

    public void logout(long userId) {
        // ❌ ВИТІК ПАМ'ЯТІ: remove() шукає ключ в іншому бакеті!
        // Старий запис назавжди залишається у ConcurrentHashMap!
        activeSessions.remove(new DefectiveKey(userId));
    }
}

При тисячах операцій входу/виходу на добу пам’ять процесу неухильно вичерпується, доки додаток не аварійно завершиться з OutOfMemoryError: Java heap space.


Інструменти статичного аналізу в CI/CD

У зрілих інженерних командах поява цієї помилки блокується на етапі автоматизованого контролю якості коду:

  1. SonarQube / SonarLint: правило java:S1206 (“equals(Object)” and “hashCode()” should be overridden in pairs) має статус Major / Critical Bug та блокує проходження Quality Gate у pull request;
  2. SpotBugs: правила-детектори HE_EQUALS_USE_HASHCODE та HE_EQUALS_NO_HASHCODE;
  3. Java 14+ Records: для DTO та ключів кешів рекомендується застосовувати record, де компілятор автоматично генерує узгоджену пару методів за всіма полями компонента:
    public record UserKey(long id) {} // Ідеальний контракт гарантований компілятором
    

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

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

Якщо перевизначити equals(), але забути про hashCode(), логічно рівні об’єкти отримають різні псевдовипадкові значення від Object.hashCode() (Identity Hash Code). Це повністю руйнує роботу геш-колекцій: map.get() не знайде наявний об’єкт і поверне null, у HashSet виникнуть дублікати (size зростатиме всупереч бізнес-логіці), а метод remove() не зможе видалити запис, породжуючи накопичувальний витік пам’яті в купі (Ghost Entries). Метод equals() навіть не буде викликано, оскільки пошук одразу піде в чужий бакет.


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

Питання 1: Чому в HashSet виклик contains() повертає false для об’єкта, який гарантовано був доданий раніше, якщо перевизначено лише equals()?

Відповідь: HashSet делегує операції внутрішньому екземпляру HashMap. При виклику contains(new Key(1)) спочатку викликається hashCode() для визначення бакета. Оскільки метод не перевизначено, новий екземпляр отримує свій власний identityHashCode, відмінний від раніше збереженого. HashSet звертається до бакета з іншим номером. Цей бакет виявляється порожнім, і метод миттєво повертає false, навіть не звертаючись до виклику equals().

Питання 2: Як саме виникає витік пам’яті («ghost entries») у довгоживучих кешах через забутий hashCode()?

Відповідь: Коли сесія або сутність видаляється з кешу викликом cache.remove(new Key(id)), мапа обчислює геш переданого тимчасового ключа. Через неузгоджений контракт цей геш вказує на інший бакет, ніж той, де зберігається реальний запис. Метод remove() нічого не знаходить і повертає null. Оригінальний вузол Node<K,V> із сесійними даними залишається прикріпленим до масиву table[] і ніколи не збирається збирачем сміття (GC), безперервно вичерпуючи пам’ять покоління Old Gen.

Питання 3: Чи може програма працювати коректно, якщо в ній перевизначено лише equals(), і за яких умов?

Відповідь: Так, але тільки за умови, що об’єкти цього класу ніколи не використовуються як ключі в геш-таблицях (HashMap, LinkedHashMap, ConcurrentHashMap) і не поміщаються в геш-множини (HashSet, LinkedHashSet). Наприклад, якщо об’єкти зберігаються виключно в ArrayList, LinkedList або масивах, де пошук здійснюється методами list.contains() чи list.indexOf() (вони використовують суто equals()). Проте в архітектурі це вважається критичним технічним боргом («міна сповільненої дії»).

Питання 4: Що робить анотація Lombok @EqualsAndHashCode і в чому полягає небезпека її сліпого застосування?

Відповідь: Анотація @EqualsAndHashCode автоматично генерує обидва методи за всіма нестатичними полями, забезпечуючи дотримання контракту. Небезпека полягає в тому, що за замовчуванням вона включає абсолютно всі поля, зокрема змінювані (mutable) колекції, кешовані поля та циклічні зв’язки (що в JPA-сутностях призводить до StackOverflowError або раптової зміни гешу після мутації зв’язаної колекції). Для безпечного використання слід застосовувати @EqualsAndHashCode(onlyExplicitlyIncluded = true) із явною декларацією незмінних бізнес-полів через @EqualsAndHashCode.Include.


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

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

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

❌ «Lombok завжди бездоганно вирішує проблему без налаштування.»
Правильно: Без явної конфігурації Lombok може додати змінювані або циклічні поля, що призведе до руйнування контракту консистентності або переповнення стека.


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