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

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

Якщо перевизначити equals(), але забути про hashCode(), логічно рівні об'єкти матимуть різні хеш-коди. Це зламає роботу HashMap і HashSet.

Мовні версії: English Russian Ukrainian

🟢 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

  1. Якщо об’єкт буде ключем в хеш-колекції — генеруйте обидва методи. Виняток: identity-семантика навмисне.
  2. Використовуйте IDE — автоматична генерація equals() і hashCode()
  3. Records (Java 14+) — ідеальний варіант для ключів. Records генерують hashCode з УСІХ полів. Якщо у вас є transient або обчислювальні поля — вони теж потраплять в hashCode, що може бути небажано.
  4. 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()]]