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

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

Якщо перевизначити hashCode(), але залишити неперевизначеним equals():


🟢 Junior Level

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

Якщо перевизначити hashCode(), але залишити неперевизначеним equals():

  1. Логічно еквівалентні об’єкти генеруватимуть однаковий геш-код і безпомилково потраплятимуть в один і той самий кошик (бакет) HashMap або HashSet.
  2. Проте всередині бакета для точної ідентифікації ключа викликається метод equals(). Оскільки він не перевизначений, спрацює стандартна реалізація від java.lang.Object, яка порівнює об’єкти суворо за ідентичністю посилань у пам’яті (this == o).
  3. При спробі отримати значення за новим екземпляром з ідентичними полями (map.get(new Key("A"))), посилання в пам’яті купи не збіжаться (0x100 != 0x200), метод equals() поверне false, і HashMap поверне null, вважаючи, що такого ключа немає. У HashSet натомість безкінечно накопичуватимуться дублікати всередині одного й того самого бакета.

Порівняльна таблиця двох фатальних помилок контракту

Сценарій Визначення бакета при get() Пошук усередині бакета Результат map.get() Поведінка HashSet
Перевизначено equals(), забуто hashCode() ❌ Помилкове (об’єкти розлітаються по різних бакетах) До виклику equals черга не доходить null Дублікати розмазуються по всій таблиці
Перевизначено hashCode(), забуто equals() ✅ Точне (об’єкти потрапляють в один бакет) ❌ Провальне (порівнюються посилання this == obj) null Дублікати накопичуються в одному бакеті

Демонстрація проблеми на Java 21

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

public class BrokenEqualsDemo {

    public static class SkuCode {
        private final String code;
        public SkuCode(String code) { this.code = code; }

        @Override
        public int hashCode() {
            return code != null ? code.hashCode() : 0; // Геш перевизначено коректно!
        }
        // equals() НЕ перевизначено! Порівнює посилання через this == o
    }

    public static void main(String[] args) {
        SkuCode sku1 = new SkuCode("ITEM-777");
        SkuCode sku2 = new SkuCode("ITEM-777");

        // 1. Геші повністю збігаються, бакет один і той самий:
        System.out.println("Геші однакові? " + (sku1.hashCode() == sku2.hashCode())); // true
        System.out.println("Рівні за equals? " + sku1.equals(sku2)); // false! (різні об'єкти в купі)

        // 2. Катастрофа в HashMap:
        Map<SkuCode, Integer> prices = new HashMap<>();
        prices.put(sku1, 1500);

        // Пошук за новим об'єктом sku2 поверне null!
        System.out.println("Ціна товару за sku2: " + prices.get(sku2)); // null!

        // АЛЕ пошук за ТИМ САМИМ посиланням sku1 спрацює:
        System.out.println("Ціна товару за sku1: " + prices.get(sku1)); // 1500

        // 3. Катастрофа в HashSet:
        Set<SkuCode> uniqueSkus = new HashSet<>();
        uniqueSkus.add(sku1);
        uniqueSkus.add(sku2);
        System.out.println("Розмір множини: " + uniqueSkus.size()); // 2! (Дублікат збережено)
    }
}

🟡 Middle Level

Покрокове трасування HotSpot HashMap.getNode()

Чому map.get(sku2) повертає null, навіть коли геш обчислено ідеально:

// Вихідний код пошуку ключа в HashMap:
if (first.hash == hash && 
   ((k = first.key) == key || (key != null && key.equals(k))))
    return first;
  1. first.hash == hash: обчислено індекс бакета (наприклад, 5). У комірці table[5] розташований вузол зі sku1. Хеші рівні $\implies$ true.
  2. (k = first.key) == key: пряме порівняння посилань. Посилання збереженого об’єкта (sku1 у купі за адресою 0x100) порівнюється з посиланням переданого об’єкта (sku2 у купі за адресою 0x200). Результат $\implies$ false.
  3. key.equals(k): викликається метод sku2.equals(sku1). Оскільки метод equals не перевизначено, виконується Object.equals(Object obj):
    public boolean equals(Object obj) {
        return (this == obj); // Знову порівняння посилань пам'яті!
    }
    

    Результат $\implies$ false.

  4. HashMap проходить до кінця зв’язного списку бакета, не знаходить жодного збігу за посиланням і повертає null.

🔴 Senior Level

Катастрофа в продакшені: Дедуплікація подій у Kafka / RabbitMQ

Найбільш руйнівний сценарій у мікросервісній архітектурі — використання класу із забутим equals() у шарі дедуплікації повідомлень (Idempotent Consumer):

@Component
public class PaymentDeduplicationFilter {
    // Множина оброблених транзакцій для захисту від подвійних списань:
    private final Set<PaymentKey> processedKeys = ConcurrentHashMap.newKeySet();

    public boolean isDuplicate(PaymentKey key) {
        // Кожен HTTP-запит або Kafka Record десеріалізується в НОВИЙ об'єкт у купі!
        // key.equals() порівнює посилання в пам'яті!
        return !processedKeys.add(key); // ЗАВЖДИ повертає false!
    }
}

Наслідки для бізнес-системи:

  1. Подвійні списання коштів клієнтів: перевірка на дублікат не працює взагалі, оскільки кожен вхідний PaymentKey — це новий об’єкт у купі, який Set сприймає як абсолютно унікальний.
  2. Однобакетний витік пам’яті (Single-Bucket Memory Leak): усі платежі з однаковим ідентифікатором (наприклад, повторні виклики та ретраї) мають однаковий hashCode() і скеровуються в один і той самий бакет.
  3. Деградація в TreeNode: бакет розростається до тисяч вузлів. При перевищенні порогу 8 елементів він перетворюється на червоно-чорне дерево. Оскільки клас не імплементує Comparable, на кожній вставці викликається аварійний метод tieBreakOrder() на основі System.identityHashCode(), спричиняючи 100% завантаження CPU та аварійне падіння JVM з OutOfMemoryError.

Архітектурне тестування контракту через EqualsVerifier

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

import nl.jqno.equalsverifier.EqualsVerifier;
import org.junit.jupiter.api.Test;

public class ContractVerificationTest {
    @Test
    void verifySkuCodeContract() {
        // Автоматично тестує 5 аксіом equals, взаємозв'язок із hashCode,
        // коректність null-перевірок, рефлексивність та поведінку при спадкуванні:
        EqualsVerifier.simple().forClass(SkuCode.class).verify();
        // Тест гарантовано впаде зі зрозумілим описом помилки:
        // "Subclass does not override equals, but does override hashCode"
    }
}

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

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

Якщо перевизначити hashCode(), але забути equals(), об’єкти потраплять у правильний бакет HashMap, проте всередині бакета виконається Object.equals(), який порівнює фізичні посилання в пам’яті (==). Пошук за новим об’єктом new Key(...) завжди повертатиме null, а в HashSet накопичуватимуться дублікати всередині одного-єдиного кошика. У продакшені це руйнує дедуплікацію повідомлень (платежі списуються двічі), призводить до розростання бакета в червоно-чорне дерево та спричиняє OutOfMemoryError.


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

Питання 1: У чому принципова відмінність поведінки HashSet при забутому equals() порівняно із забутим hashCode()?

Відповідь:

  • При забутому hashCode(): дублікати отримують різні геш-коди і розпорошуються по різних бакетах усієї таблиці.
  • При забутому equals(): дублікати мають однаковий геш-код і накопичуються в одному й тому самому бакеті. Ланцюжок колізій у цьому бакеті нескінченно зростає, перетворюється на червоно-чорне дерево TreeNode і створює екстремальне навантаження на процесор під час балансування дерева.

Питання 2: Чи спрацює виклик map.get(key), якщо передати рівно те саме посилання на об’єкт, яке використовувалося під час map.put(key, value)?

Відповідь: Так, успішно спрацює. У внутрішній реалізації HashMap перша перевірка вузла виглядає як (k = first.key) == key. Якщо передано абсолютно те саме посилання з купи, умова рівності посилань поверне true, і мапа віддасть збережене значення, навіть не викликаючи метод equals(). Баг проявляється лише тоді, коли для пошуку створюється новий об’єкт із тими самими значеннями полів (new Key(...)), що й відбувається у 99% реальних застосунків під час десеріалізації запитів.

Питання 3: Чому дедуплікація повідомлень у Kafka/JMS на основі HashSet<EventDto> ламається, якщо в DTO перевизначено лише hashCode()?

Відповідь: Фреймворки десеріалізації (Jackson, Protobuf тощо) під час обробки кожного нового повідомлення створюють у пам’яті новий екземпляр класу EventDto. Метод set.add(event) викликає equals() для перевірки наявності об’єкта в кошику. Оскільки equals() не перевизначено, він порівнює посилання (this == obj). Два окремі екземпляри в пам’яті ніколи не є рівними за посиланням. set.add() завжди повертає true, фільтр дедуплікації вважає кожне повідомлення унікальним і пропускає дублікати далі в обробку.

Питання 4: Як використання java.lang.Record у Java 21 повністю виключає обидві ці помилки?

Відповідь: Компілятор Java для кожного record автоматично генерує канонічний байткод методів equals() та hashCode() на основі всіх компонентів заголовка рекорда. Розробник фізично не може випадково перевизначити один метод і забути інший. Крім того, усі поля рекорда є final, що гарантує незмінність ключа та дотримання принципу консистентності.


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

❌ «Якщо hashCode() перевизначено, HashMap знайде елемент у будь-якому випадку.»
Правильно: Геш-код лише знаходить бакет. Усередині бакета ідентифікація вузла відбувається виключно через equals().

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

❌ «Для ключів HashMap достатньо збігу полів у hashCode().»
Правильно: Обидва методи зобов’язані бути перевизначені парою і спиратися на одні й ті самі незмінні поля.


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