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

Який контракт у equals() та hashCode()

Якщо порушити цей контракт (наприклад, перевизначити equals(), але забути про hashCode()), геш-колекції (HashMap, HashSet, ConcurrentHashMap) перестануть коректно працювати: ви...


🟢 Junior Level

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

Контракт equals() та hashCode() — це фундаментальний набір правил специфікації мови Java (java.lang.Object), який регулює взаємозв’язок між логічною рівністю двох об’єктів та їхніми числовими геш-кодами:

  1. Обов’язкова пряма умова: якщо два об’єкти логічно рівні за методом equals() (x.equals(y) == true), то їхні виклики hashCode() зобов’язані повертати абсолютно однакове цілочислове значення (x.hashCode() == y.hashCode()).
  2. Необов’язкова зворотна умова: якщо два об’єкти мають однаковий hashCode(), вони не обов’язково є рівними за equals() (це ситуація колізії гешів).
  3. Консистентність (несуперечливість): повторні виклики hashCode() та equals() повинні повертати одне й те саме значення, доки не зміняться поля, що беруть участь у порівнянні.

Якщо порушити цей контракт (наприклад, перевизначити equals(), але забути про hashCode()), геш-колекції (HashMap, HashSet, ConcurrentHashMap) перестануть коректно працювати: ви не зможете знайти доданий об’єкт через get(), а в HashSet почнуть з’являтися дублікати.


5 аксіом методу equals() за специфікацією Java SE

Для будь-яких ненульових об’єктів x, y, z метод equals() зобов’язаний задовольняти математичні властивості відношення еквівалентності:

Властивість Математичне правило Опис
1. Рефлексивність (Reflexive) x.equals(x) == true Об’єкт завжди дорівнює самому собі.
2. Симетричність (Symmetric) x.equals(y) == y.equals(x) Якщо $X$ дорівнює $Y$, то й $Y$ зобов’язаний дорівнювати $X$.
3. Транзитивність (Transitive) Якщо x.equals(y) та y.equals(z), то x.equals(z) Якщо $X = Y$ і $Y = Z$, то $X$ гарантовано дорівнює $Z$.
4. Консистентність (Consistent) Повторні виклики дають однаковий результат Доки поля об’єкта не змінилися, результат виклику лишається незмінним.
5. Порівняння з null x.equals(null) == false Будь-який виклик із null зобов’язаний повертати false, а не викидати NPE.

Ідіоматична реалізація в Java 21

import java.util.Objects;

// 1. Сучасний підхід: Java 21 Record автоматично реалізує ідеальний контракт
public record UserRecord(long id, String email) {}

// 2. Класичний POJO-клас із ручним перевизначенням
public class UserProfile {
    private final long id;
    private final String email;

    public UserProfile(long id, String email) {
        this.id = id;
        this.email = email;
    }

    @Override
    public boolean equals(Object o) {
        // 1. Перевірка ідентичності посилань
        if (this == o) return true;
        // 2. Перевірка на null та точний збіг типів (або pattern matching instanceof)
        if (o == null || getClass() != o.getClass()) return false;
        // 3. Приведення типів та покомпонентне порівняння
        UserProfile that = (UserProfile) o;
        return this.id == that.id && Objects.equals(this.email, that.email);
    }

    @Override
    public int hashCode() {
        // Обчислення гешу на основі ТИХ САМИХ полів, що й в equals!
        return Objects.hash(id, email);
    }
}

🟡 Middle Level

Рецепт Джошуа Блоха та магія числа 31

У класичній праці Effective Java Джошуа Блох запропонував канонічний алгоритм генерації hashCode():

@Override
public int hashCode() {
    int result = 17; // Ненульове просте початкове число (seed)
    result = 31 * result + (int) (id ^ (id >>> 32)); // Гешування long
    result = 31 * result + (email != null ? email.hashCode() : 0);
    return result;
}

Чому саме число 31:

  1. Непарне просте число: використання простого числа (Prime Number) суттєво мінімізує ймовірність колізій під час перемноження з гешами окремих полів. Якби множник був парним (наприклад, 2), множення було б еквівалентним бітовому зсуву ліворуч, що призводило б до швидкої втрати старших бітів за межі 32-бітного діапазону int (втрата ентропії).
  2. Апаратна мікрооптимізація JIT-компілятора:
    Множення на 31 компілятор HotSpot C2 замінює лише двома інструкціями процесора — порозрядним зсувом ліворуч та відніманням: \(31 \times i = (i \ll 5) - i\) Ця комбінація виконується за 1 машинний такт, не задіюючи важкий конвеєрний блок цілочислового множення CPU (imul).

Порушення симетричності при спадкуванні (instanceof проти getClass)

Класична пастка об’єктно-орієнтованого проектування:

public class Point {
    private final int x, y;
    public Point(int x, int y) { this.x = x; this.y = y; }

    @Override
    public boolean equals(Object o) {
        if (!(o instanceof Point p)) return false;
        return this.x == p.x && this.y == p.y;
    }
}

public class ColorPoint extends Point {
    private final String color;
    public ColorPoint(int x, int y, String color) { super(x, y); this.color = color; }

    @Override
    public boolean equals(Object o) {
        if (!(o instanceof ColorPoint cp)) return false;
        return super.equals(o) && Objects.equals(this.color, cp.color);
    }
}

Наслідок руйнування контракту:

Point p = new Point(1, 2);
ColorPoint cp = new ColorPoint(1, 2, "RED");

p.equals(cp);  // true  (Point перевіряє лише x, y; ColorPoint є екземпляром Point)
cp.equals(p);  // false (ColorPoint вимагає екземпляр ColorPoint, яким Point не є!)

Симетричність повністю зруйнована! Якщо покласти ці елементи в колекцію, поведінка пошуку залежатиме від порядку аргументів.

[!TIP] Архітектурне правило Effective Java:
Неможливо розширити інстанційований клас новим значущим полем, зберігши водночас контракт equals(). Рішення: віддавати перевагу композиції замість спадкування (ColorPoint містить Point всередині себе як поле), або застосовувати строге порівняння getClass() != o.getClass(), свідомо забороняючи поліморфну рівність.


🔴 Senior Level

Влаштування identityHashCode у пам’яті HotSpot (Mark Word)

Якщо клас не перевизначає метод hashCode(), викликається нативний метод Object.hashCode(), що повертає Identity Hash Code.

64-бітний заголовок об'єкта (Mark Word у 64-bit HotSpot JVM):
┌──────────────────────────────────────────────┬────────┬────────┬───────┐
│              Identity Hash Code (31 біт)     │ Unused │ Age (4)│ Biased│ ... Lock Bits (2)
└──────────────────────────────────────────────┴────────┴────────┴───────┘
  1. Лінива генерація: під час створення екземпляра об’єкта identityHashCode не обчислюється. Він генерується лише під час першого виклику System.identityHashCode(obj) за допомогою псевдовипадкового генератора Марсальї (Marsaglia XOR-shift) і назавжди записується в Mark Word заголовка об’єкта в купі (heap).
  2. Незмінність під час переміщень GC: коли збирач сміття копіює об’єкт з Young Generation до Old Generation (або здійснює дефрагментацію/компактизацію пам’яті), фізична адреса об’єкта змінюється, проте значення identityHashCode у заголовку переноситься байт-у-байт і залишається незмінним.
  3. Витіснення під час важкої синхронізації (Fat Lock): якщо для об’єкта виконується synchronized(obj) і монітор роздувається до системного блокування рівня ядра ОС (ObjectMonitor), біти заголовка замінюються вказівником на C++ структуру монітора в native memory. HotSpot завчасно переносить оригінальний Mark Word у поле ObjectMonitor._header (Displaced Mark Word), запобігаючи втраті гешу.

Небезпечні антипатерни: java.net.URL та сутності JPA

1. Катастрофа java.net.URL.equals()

Клас java.net.URL містить один із найвідоміших архітектурних прорахунків ранньої Java: метод URL.equals() намагається розв’язати доменні імена в IP-адреси через блокуючий мережевий DNS-запит!

  • Виклик urlMap.get(url) відправляє блокуючий мережевий запит до DNS-сервера;
  • Якщо DNS-сервер недоступний або відповідає повільно, потік зависає на секунди;
  • Якщо для домену налаштовано Round-Robin DNS, два послідовні виклики equals() на однаковому URL у різний час отримають різні IP-адреси, що прямо руйнує аксіому консистентності.
    Рішення: у сучасному коді завжди слід використовувати java.net.URI, який здійснює суто посимвольне лексичне порівняння рядків без виходу в мережу.

2. Проблема JPA / Hibernate із автогенерованим ідентифікатором (@GeneratedValue)

У JPA-сутностях типовою помилкою є генерація equals та hashCode виключно за полем первинного ключа @Id:

@Entity
public class Order {
    @Id @GeneratedValue
    private Long id; // До виклику entityManager.persist() значення id == null!
}
  1. Створюємо екземпляр: Order order = new Order(). Поле id == null.
  2. Додаємо його до множини: Set<Order> set = new HashSet<>(). Геш обчислюється від null.
  3. Зберігаємо сутність у базу даних: persistence provider призначає згенерований id = 42.
  4. Значення order.hashCode() змінюється безпосередньо всередині HashSet!
  5. Подальший виклик set.contains(order) повертає false, і елемент стає неможливо видалити з колекції — виникає витік пам’яті (Memory Leak).
    Рішення: використовувати стабільний бізнес-ключ (Natural ID / незмінний UUID) або повертати фіксований константний hashCode() для сутностей одного типу (наприклад, константу 31), делегуючи остаточне розрізнення методу equals().

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

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

Контракт equals() та hashCode() вимагає: якщо два об’єкти рівні за equals(), їхні hashCode() зобов’язані повністю збігатися. Зворотне твердження є хибним (збіг гешів у різних об’єктів — це звичайна колізія). Метод equals() зобов’язаний бути рефлексивним, симетричним, транзитивним, консистентним та повертати false для null. Якщо порушити контракт, геш-колекції ламаються: get() не знаходить наявні ключі через невідповідність бакетів, а в HashSet з’являються дублікати. Для сучасних класів даних найкраще застосовувати record або точну перевірку типу через getClass().


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

Питання 1: Чому неможливо коректно розширити клас із додаванням нового значущого поля, зберігши контракт equals(), якщо в базовому класі перевірка реалізована через instanceof?

Відповідь: Якщо базовий клас Point перевіряє instanceof Point, а підклас ColorPoint додає нове поле кольору, виникає нерозв’язне протиріччя між властивостями симетричності та транзитивності:

  • Якщо підклас ігнорує колір під час порівняння з суперкласом, порушується транзитивність: ColorPoint(RED, 1, 2) == Point(1, 2) та Point(1, 2) == ColorPoint(BLUE, 1, 2), але RED == BLUE поверне false!
  • Якщо підклас враховує колір, порушується симетричність: point.equals(colorPoint) == true, але colorPoint.equals(point) == false.
    Єдине архітектурно бездоганне рішення — використовувати композицію замість спадкування.

Питання 2: Як hashCode() зберігається в пам’яті HotSpot JVM і чому він ніколи не змінюється навіть при переміщенні об’єкта збирачем сміття (GC)?

Відповідь: Стандартний identityHashCode обчислюється ліниво під час першого звернення за алгоритмом Марсальї і записується в 31 біт заголовка об’єкта (Mark Word). Коли GC переміщує об’єкт в адресному просторі купи (наприклад, між Eden, Survivor та Tenured регіонами), весь заголовок копіюється разом із тілом об’єкта, зберігаючи зафіксований числовий геш. Якщо об’єкт зазнає блокування важким монітором (Fat Lock), HotSpot зберігає оригінальний Mark Word у C++ структурі ObjectMonitor, гарантуючи абсолютну стабільність геш-коду.

Питання 3: Чому використання java.net.URL як ключа в HashMap вважається критичною помилкою?

Відповідь: Метод java.net.URL.equals() звертається до мережевого стека ОС для резолвінгу хостів через DNS, намагаючись порівняти їхні реальні IP-адреси. Це призводить до блокування потоку виконання на час мережевого запиту (затримки від десятків до тисяч мілісекунд), деградації throughput та прямого порушення консистентності: якщо зміниться конфігурація DNS або IP-адреса хоста, раніше збережений ключ більше не буде знайдено в мапі. Замість цього слід використовувати java.net.URI, що порівнює виключно рядкові складники URI без виходу в мережу.

Питання 4: Як правильно перевизначати equals() та hashCode() для сутностей JPA/Hibernate із автогенерованим @Id?

Відповідь: Не можна покладатися виключно на автоінкрементний @Id, оскільки до виклику persist() він дорівнює null, а після вставки в базу мутує на згенероване число, руйнуючи інваріант внутрішнього бакета HashSet/HashMap.
Застосовують два підходи:

  1. Business Key (Natural ID): складати equals та hashCode на основі незмінних бізнес-полів (наприклад, унікального UUID, коду замовлення, податкового номера або email);
  2. Константний hashCode(): повертати стале фіксоване просте число (наприклад, return 31;), а в equals() порівнювати id лише тоді, коли обидва ідентифікатори ненульові (id != null && id.equals(that.id)). За такого підходу незбережені сутності потрапляють в один бакет (виникає локальна колізія), проте після генерації ID об’єкт гарантовано не загубиться в колекції.

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

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

❌ «Перевантаження методу замість перевизначення: public boolean equals(MyClass o).»
Правильно: Метод у класі Object має сигнатуру equals(Object o). Якщо змінити тип аргументу, це буде перевантаження (overloading), а під час роботи з колекціями JVM продовжить викликати базовий метод Object.equals(Object).

❌ «Використання генераторів випадкових чисел (Random) або міток часу (currentTimeMillis) всередині hashCode().»
Правильно: Це пряме порушення аксіоми консистентності. Геш-код зобов’язаний бути детермінованим і залежати лише від значущих полів об’єкта.


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