Що станеться, якщо перевизначити hashCode(), але не equals()
Якщо перевизначити hashCode(), але залишити неперевизначеним equals():
🟢 Junior Level
Короткий опис для співбесіди (30 секунд)
Якщо перевизначити hashCode(), але залишити неперевизначеним equals():
- Логічно еквівалентні об’єкти генеруватимуть однаковий геш-код і безпомилково потраплятимуть в один і той самий кошик (бакет)
HashMapабоHashSet. - Проте всередині бакета для точної ідентифікації ключа викликається метод
equals(). Оскільки він не перевизначений, спрацює стандартна реалізація відjava.lang.Object, яка порівнює об’єкти суворо за ідентичністю посилань у пам’яті (this == o). - При спробі отримати значення за новим екземпляром з ідентичними полями (
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;
first.hash == hash: обчислено індекс бакета (наприклад, 5). У комірціtable[5]розташований вузол зіsku1. Хеші рівні $\implies$true.(k = first.key) == key: пряме порівняння посилань. Посилання збереженого об’єкта (sku1у купі за адресою0x100) порівнюється з посиланням переданого об’єкта (sku2у купі за адресою0x200). Результат $\implies$false.key.equals(k): викликається методsku2.equals(sku1). Оскільки методequalsне перевизначено, виконуєтьсяObject.equals(Object obj):public boolean equals(Object obj) { return (this == obj); // Знову порівняння посилань пам'яті! }Результат $\implies$
false.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!
}
}
Наслідки для бізнес-системи:
- Подвійні списання коштів клієнтів: перевірка на дублікат не працює взагалі, оскільки кожен вхідний
PaymentKey— це новий об’єкт у купі, якийSetсприймає як абсолютно унікальний. - Однобакетний витік пам’яті (Single-Bucket Memory Leak): усі платежі з однаковим ідентифікатором (наприклад, повторні виклики та ретраї) мають однаковий
hashCode()і скеровуються в один і той самий бакет. - Деградація в
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().»
Правильно: Обидва методи зобов’язані бути перевизначені парою і спиратися на одні й ті самі незмінні поля.