Вопрос 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()]]