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

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

Если переопределить hashCode(), но оставить непереопределенным equals():


🟢 Junior Level

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

Если переопределить hashCode(), но оставить непереопределенным equals():

  1. Логически эквивалентные объекты будут выдавать одинаковый хеш-код и безошибочно попадать в одну и ту же корзину (бакет) HashMap или HashSet.
  2. Однако внутри корзины для точного сопоставления ключей вызывается метод equals(). Так как он не переопределен, сработает дефолтная реализация от java.lang.Object, которая сравнивает объекты строго по равенству ссылок в памяти (this == o).
  3. При попытке получить значение по новому экземпляру с теми же полями (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;
  1. first.hash == hash: вычислен индекс корзины (например, 5). В ячейке table[5] лежит нода со sku1. Хеши равны $\implies$ true.
  2. (k = first.key) == key: сравнение ссылок. Ссылка первого объекта (sku1 в куче 0x100) сравнивается со ссылкой переданного объекта (sku2 в куче 0x200). Результат $\implies$ false.
  3. key.equals(k): вызывается метод sku2.equals(sku1). Так как метод equals не переопределен, срабатывает Object.equals(Object obj):
    public boolean equals(Object obj) {
        return (this == obj); // Снова сравнение ссылок в куче!
    }
    

    Результат $\implies$ false.

  4. 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!
    }
}

Последствия для бизнеса:

  1. Двойные списания денег: проверка на дубликат не работает вообще, так как каждый входящий PaymentKey — это новый объект в памяти, который Set считает уникальным.
  2. Однобакетная утечка памяти (Single-Bucket Memory Leak): все платежи с одинаковым идентификатором (например, повторные запросы) имеют одинаковый hashCode() и направляются в одну и ту же корзину.
  3. Деградация в TreeNode: корзина раздувается до тысяч узлов. При превышении 8 элементов она превращается в красно-черное дерево. Поскольку класс не реализует Comparable, на каждой вставке вызывается аварийный tieBreakOrder() на базе System.identityHashCode(), сжигая 100% процессора и завершаясь падением 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() на основе всех компонентов заголовка рекорда. Разработчик физически не может переопределить один метод и забыть второй. Кроме того, поля в record являются final, что гарантирует неизменяемость ключа и соблюдение принципа консистентности.


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

❌ “Если hashCode() переопределен, HashMap найдет объект в любом случае.”
Правильно: Хеш-код лишь выбирает корзину. Внутри корзины идентификация происходит исключительно через equals().

❌ “Забытый equals() менее опасен, чем забытый hashCode().”
Правильно: Он одинаково опасен, а с точки зрения производительности даже хуже: он собирает все дубликаты в одну корзину, вызывая деградацию в дерево и замедление поиска.

❌ “Для ключей HashMap достаточно совпадения полей в hashCode().”
Правильно: Оба метода обязаны быть переопределены и опираться на одни и те же неизменяемые поля.


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