🔑 Раздел 10 · Вопрос #10

Что произойдёт, если переопределить equals() но не переопределить hashCode()

Если в классе переопределить метод equals(), но оставить метод hashCode() непереопределенным, класс унаследует реализацию по умолчанию от java.lang.Object. Стандартный Object.ha...


🟢 Junior Level

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

Если в классе переопределить метод equals(), но оставить метод hashCode() непереопределенным, класс унаследует реализацию по умолчанию от java.lang.Object. Стандартный Object.hashCode() возвращает Identity Hash Code (число, псевдослучайно сгенерированное JVM для конкретного физического адреса объекта в памяти).

В результате два логически эквивалентных объекта будут иметь разные хеш-коды. Это приведет к полному разрушению работы всех хеш-коллекций платформы Java:

  1. Потеря данных в HashMap: метод map.get(new Key(id)) вернет null, хотя запись с таким ключом гарантированно присутствует в мапе;
  2. Дубликаты в HashSet: метод set.add(duplicate) успешно добавит дублирующий элемент, нарушая главный инвариант множества;
  3. Утечки памяти (Memory Leaks): вызов map.remove(key) не найдет объект в целевой корзине, и «мертвые» записи останутся висеть в памяти навсегда.

Демонстрация катастрофы на Java 21

import java.util.HashSet;
import java.util.HashMap;
import java.util.Set;
import java.util.Map;

public class BrokenContractDemo {

    // Класс с нарушенным контрактом: equals переопределен, hashCode — НЕТ!
    public static class DefectiveUser {
        private final long id;
        public DefectiveUser(long id) { this.id = id; }

        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (!(o instanceof DefectiveUser that)) return false;
            return this.id == that.id;
        }
        // hashCode() забыт! Используется Object.hashCode()
    }

    public static void main(String[] args) {
        DefectiveUser u1 = new DefectiveUser(42L);
        DefectiveUser u2 = new DefectiveUser(42L);

        System.out.println("Логически равны? " + u1.equals(u2)); // true

        // 1. Поломка HashSet:
        Set<DefectiveUser> set = new HashSet<>();
        set.add(u1);
        set.add(u2);
        // В сете ДВА одинаковых пользователя! Инвариант множества разрушен:
        System.out.println("Размер HashSet: " + set.size()); // 2! (Ожидалось: 1)

        // 2. Поломка HashMap:
        Map<DefectiveUser, String> cache = new HashMap<>();
        cache.put(u1, "AdminPermissions");
        // Пытаемся получить данные по идентичному ключу u2:
        System.out.println("Значение по u2: " + cache.get(u2)); // null! (Данные "потеряны")
    }
}

🟡 Middle Level

Почему equals() даже не вызывается: Механика поиска в HashMap

Чтобы понять природу сбоя, необходимо взглянуть на внутренний цикл поиска HashMap.getNode():

// Исходный код HashMap:
int hash = hash(key); // Вызывает key.hashCode()
Node<K,V> first = tab[(n - 1) & hash]; // Вычисляет индекс корзины
if (first != null) {
    if (first.hash == hash && ((k = first.key) == key || (key != null && key.equals(k))))
        return first;
    // ... обход цепочки коллизий
}
return null;

Пошаговый сценарий краха:

  1. При вызове map.put(u1, "Data") у объекта u1 запрашивается hashCode(). Пусть JVM вернула 1001. Мапа вычисляет индекс корзины: (16 - 1) & 1001 = 9. Нода записывается в корзину table[9].
  2. Позже клиент запрашивает данные по логически эквивалентному ключу: map.get(new DefectiveUser(42L)).
  3. Созданный объект u2 находится в другом адресе кучи, и Object.hashCode() возвращает для него другое случайное число, например 2044.
  4. Мапа вычисляет индекс: (16 - 1) & 2044 = 12.
  5. Мапа идет в корзину table[12]. Корзина оказывается пустой (или содержит совершенно другие данные).
  6. Метод equals() даже не вызывается, так как мапа искала объект не в той корзине! Возвращается null.

Влияние на корпоративные фреймворки

В реальных Enterprise-приложениях эта ошибка часто приводит к скрытым системным инцидентам:

  1. Spring Cache (@Cacheable): если аргумент метода кэширования не переопределяет hashCode(), каждый входящий HTTP-запрос (создающий новый экземпляр DTO) приводит к промаху кэша (Cache Miss). Кэш бесполезно раздувается миллионами дубликатов, а нагрузка на базу данных не снижается.
  2. JPA / Hibernate L1 Cache (Persistence Context): Hibernate использует Map сущностей в контексте персистентности. Ошибка в hashCode() может приводить к дублированию сущностей в сессии и исключениям NonUniqueObjectException.

🔴 Senior Level

Как HotSpot C++ генерирует Object.hashCode()

Когда hashCode() не переопределен, вызывается JVM-интринсик java.lang.Object.hashCode(), который в C++ коде HotSpot (synchronizer.cpp) обращается к функции get_next_hash:

Флаг JVM: -XX:hashCode=N (по умолчанию 5)
Алгоритм 5: Marsaglia's xor-shift PRNG на локальное состояние потока.
  • Хеш-код по умолчанию не является прямым адресом памяти (так как сборщик мусора постоянно перемещает объекты при дефрагментации);
  • Это псевдослучайное 31-битное число, сгенерированное алгоритмом Марсальи и навсегда записанное в заголовок объекта (Mark Word);
  • Два последовательных вызова new User(1L) гарантированно получат два абсолютно разных псевдослучайных числа в заголовках своих объектов.

Архитектура утечки памяти (Ghost Entries Leak Pattern)

Невозможность удаления объектов из коллекций приводит к классической прогрессирующей утечке памяти в Old Generation:

public class LeakyCacheService {
    // Долгоживущий кэш в Singleton-бине:
    private final Map<DefectiveKey, SessionData> activeSessions = new ConcurrentHashMap<>();

    public void login(long userId, SessionData data) {
        activeSessions.put(new DefectiveKey(userId), data);
    }

    public void logout(long userId) {
        // ❌ УТЕЧКА ПАМЯТИ: remove() ищет ключ в другой корзине!
        // Старая запись навсегда остается в ConcurrentHashMap!
        activeSessions.remove(new DefectiveKey(userId));
    }
}

При тысячах операций логина/логаута в сутки память процесса неуклонно растет, пока приложение не упадет с OutOfMemoryError: Java heap space.


Инструменты статического анализа в CI/CD

В зрелых инженерных командах появление этой ошибки блокируется на этапе автоматического аудита кода:

  1. SonarQube / SonarLint: правило java:S1206 (“equals(Object)” and “hashCode()” should be overridden in pairs) помечается как Major / Critical Bug и блокирует Quality Gate pull request’а;
  2. SpotBugs: детекторы HE_EQUALS_USE_HASHCODE и HE_EQUALS_NO_HASHCODE;
  3. Java 14+ Records: для DTO и ключей кэшей следует использовать record, где компилятор генерирует оба метода автоматически по всем полям компонента:
    public record UserKey(long id) {} // Идеальный контракт гарантирован компилятором
    

🎯 Шпаргалка для интервью

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

Если переопределить equals(), но не переопределить hashCode(), равные объекты получат разные псевдослучайные хеши от Object.hashCode() (Identity Hash Code). Это полностью ломает хеш-коллекции: map.get() не найдет существующий объект и вернет null, в HashSet появятся дубликаты (size вырастет), а метод remove() не сможет удалить запись, создавая прогрессирующую утечку памяти в куче (Ghost Entries). Метод equals() даже не будет вызван, так как поиск пойдет в неверную корзину.


2. 4 каверзных вопроса с глубокими ответами

Вопрос 1: Почему в HashSet вызов contains() возвращает false для объекта, который точно был добавлен ранее, если переопределен только equals()?

Ответ: Коллекция HashSet делегирует хранение внутренней HashMap. При вызове contains(new Key(1)) сначала вызывается hashCode() для вычисления индекса корзины. Так как hashCode() не переопределен, новый экземпляр new Key(1) получает свой собственный identityHashCode, отличный от ранее добавленного. HashSet обращается к корзине с другим индексом. Корзина оказывается пустой, и метод мгновенно возвращает false, даже не вызывая equals().

Вопрос 2: Как именно происходит утечка памяти («ghost entries») в долгоживущих кэшах из-за забытого hashCode()?

Ответ: Когда сущность удаляется из кэша вызовом cache.remove(new Key(id)), мапа вычисляет хеш переданного временного объекта-ключа. Из-за ненаследованного контракта этот хеш указывает на корзину, отличную от той, где реально хранится оригинальная запись. Метод remove() ничего не находит и возвращает null. Оригинальный узел Node<K,V> со всеми данными сессии остается привязанным к массиву table[] и никогда не будет собран сборщиком мусора, раздувая Old Gen.

Вопрос 3: Может ли программа работать корректно, если в ней переопределен только equals(), и в каком единственном сценарии?

Ответ: Да, но только в том случае, если объекты данного класса никогда не используются в качестве ключей в хеш-таблицах (HashMap, LinkedHashMap, ConcurrentHashMap) и не помещаются в хеш-множества (HashSet, LinkedHashSet). Например, если объекты хранятся исключительно в ArrayList, LinkedList или массивах и проверяются методами list.contains() или list.indexOf() (которые используют только equals()). Однако это считается крайне опасным архитектурным долгом (хрупкий дизайн).

Вопрос 4: Что делает Lombok-аннотация @EqualsAndHashCode, и в чём опасность ее бездумного применения?

Ответ: Аннотация @EqualsAndHashCode автоматически генерирует оба метода по всем нестатическим полям класса, гарантируя соблюдение контракта. Опасность заключается в том, что по умолчанию она включает в хеш абсолютно все поля, включая мутабельные коллекции, кэшируемые значения и циклические ссылки (что в сущностях JPA приводит к StackOverflowError или смене хеша при модификации дочерней коллекции). Для безопасного применения следует использовать @EqualsAndHashCode(onlyExplicitlyIncluded = true) с явной пометкой неизменяемых бизнес-полей через @EqualsAndHashCode.Include.


3. Типичные ошибки и Red Flags

❌ “Если в HashSet добавить два объекта с одинаковым equals, второй заменит первый.”
Правильно: При забытом hashCode() оба объекта попадут в разные корзины и оба останутся в сете, образовав дубликаты.

❌ “Метод equals() всегда вызывается при поиске в HashMap.”
Правильно: Если хеши ключей указывают на разные корзины, equals() даже не будет вызван.

❌ “Lombok всегда решает проблему автоматически.”
Правильно: Без явной настройки Lombok может включить мутабельные поля, что сломает контракт консистентности.


4. Связанные вопросы