Що станеться, якщо перевизначити equals(), але не перевизначити hashCode()?
Якщо перевизначити equals(), але забути про hashCode(), логічно рівні об'єкти матимуть різні хеш-коди. Це зламає роботу HashMap і HashSet.
🟢 Junior Level
Якщо перевизначити equals(), але забути про hashCode(), логічно рівні об’єкти матимуть різні хеш-коди. Це зламає роботу HashMap і HashSet.
Що станеться:
public class User {
private Long id;
// equals() перевизначено, hashCode() — НІ
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof User)) return false;
User user = (User) o;
return id != null && id.equals(user.id);
}
}
Set<User> set = new HashSet<>();
set.add(new User(1L)); // hashCode = identity_A → бакет X
set.add(new User(1L)); // hashCode = identity_B → бакет Y (інший!)
System.out.println(set.size()); // 2! HashSet перевіряє equals тільки всередині бакета.
// Бакети РІЗНІ — equals навіть не викликається.
Головне правило: Перевизначив equals — перевизначай і hashCode!
🟡 Middle Level
Практичні наслідки
1. Втрата даних в HashMap:
objAкладеться в бакет №100 (його identity hash code)objB(рівний objA за equals) шукається в бакеті №200 (інший identity hash code)map.get(objB)повертаєnull, хоча ключ є
2. Дублікати в HashSet:
- Рівні об’єкти потрапляють в різні бакети
- HashSet допускає “дублікати” — порушення бізнес-логіки
3. Неможливість видалення:
map.remove(objB)шукає в неправильному бакеті- Об’єкт залишається в пам’яті — витік
Чому це важко відлагоджувати
Помилка може не проявлятися:
- Якщо об’єкти не використовуються в хеш-колекціях
- Якщо різні hashCode випадково потрапили в один бакет
- При наступному resize індекси зміняться — недетермінований баг
Як запобігти
| Інструмент | Що робіть |
|---|---|
| SonarQube | Підсвічує як Critical Bug |
| SpotBugs | Попередження про порушення контракту |
| IntelliJ IDEA | Попередження при генерації |
Lombok @EqualsAndHashCode |
Генерує обидва методи |
| Java 14+ Records | Автоматична генерація |
🔴 Senior Level
Механізм збою на рівні JVM
Object.hashCode() за замовчуванням повертає identity hash code — число, яке JVM пов’язує з об’єктом при першому виклику System.identityHashCode(). Воно НЕ дорівнює адресі в пам’яті (хоча може бути пов’язане) і НЕ змінюється при GC-переміщенні. Два різні екземпляри new User(1L) завжди мають різні identity hash codes, навіть якщо equals() повертає true.
Heisenbug у production
Heisenbug — баг, який проявляється або зникає залежно від умов (названий на честь принципу невизначеності Гейзенберга). У цьому випадку: в малих тестах різні hashCode випадково потрапили в один бакет — тест проходить. У production з великою картою — різні бакети — баг проявляється.
// У тесті (мала карта): різні hashCode потрапили в один бакет → працює
// У production (велика карта, інший resize): різні бакети → падає
Це один з найбільш підступних багів: проходить усі тести, але падає у production за певних умов.
Memory Leak Pattern
Map<User, Data> cache = new HashMap<>();
cache.put(new User(1L), loadData()); // Бакет A
// Рівний об'єкт шукає в бакеті B → null → завантажуємо знову
cache.put(new User(1L), loadData()); // Ще одна копія в бакеті B
// Результат: витік пам'яті + дублювання даних
Security Implications
У системах з кешуванням за ключем-об’єктом порушення контракту може призвести до:
- Keypass-обходу кешу (cache miss для рівних ключів)
- Витоку чутливих даних (дублювання в пам’яті)
Best Practices
- Якщо об’єкт буде ключем в хеш-колекції — генеруйте обидва методи. Виняток: identity-семантика навмисне.
- Використовуйте IDE — автоматична генерація
equals()іhashCode() - Records (Java 14+) — ідеальний варіант для ключів. Records генерують hashCode з УСІХ полів. Якщо у вас є transient або обчислювальні поля — вони теж потраплять в hashCode, що може бути небажано.
- Unit-тести — перевіряйте контракт:
assertEquals(a.equals(b), a.hashCode() == b.hashCode() || !a.equals(b));
Lombok Pitfall
@EqualsAndHashCode // Генерує обидва методи з УСІХ полів
class User {
Long id;
transient String cache; // Не включайте transient/cache поля!
}
Використовуйте @EqualsAndHashCode(exclude = "cache").
🎯 Шпаргалка для співбесіди
Обов’язково знати:
- Без hashCode() рівні об’єкти потрапляють в РІЗНІ бакети (різні identity hash codes)
- HashSet допустить дублікати: size = 2 для двох рівних об’єктів
- map.get() поверне null для рівного об’єкта — шукає в неправильному бакеті
- Неможливість remove() → витік пам’яті (ghost entries)
- Heisenbug: може пройти тести в малих картах, впасти у production
- Один з найбільш підступних багів — рівні об’єкти випадково потрапили в один бакет → тест зелений
Часті уточнюючі питання:
- Чому Set.size() = 2 для рівних об’єктів? — HashSet перевіряє equals тільки всередині бакета; бакети різні → equals не викликається
- Як IDE допомагає? — SonarQube (Critical Bug), SpotBugs, IntelliJ IDEA попереджають
- Що таке Lombok pitfall? — @EqualsAndHashCode генерує з УСІХ полів, включаючи transient/cache
- Records вирішують проблему? — так, генерують обидва методи з усіх компонентів
Червоні прапорці (НЕ говорити):
- «Можна перевизначити тільки equals, якщо об’єкти не в Map» — крихке рішення
- «Це легко помітити» — ні, може проявитися тільки за певних умов resize
- «hashCode не потрібен для identity-семантики» — тоді не перевизначайте equals теж
Пов’язані теми:
- [[7. Що таке контракт equals() і hashCode()]]
- [[8. Якщо два об’єкти рівні за equals(), що можна сказати про їх hashCode()]]
- [[11. Що станеться, якщо перевизначити hashCode() але не перевизначити equals()]]