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

Что такое контракт equals() и hashCode()

Если нарушить это правило (например, переопределить equals(), но забыть hashCode()), то хеш-коллекции (HashMap, HashSet, ConcurrentHashMap) перестанут работать: вы не сможете на...


🟢 Junior Level

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

Контракт equals() и hashCode() — это фундаментальный набор правил спецификации языка Java (java.lang.Object), регулирующий взаимосвязь между логическим равенством двух объектов и их числовыми хеш-кодами:

  1. Обязательное условие: если два объекта логически равны по методу equals() (x.equals(y) == true), то их вызовы hashCode() обязаны возвращать абсолютно одинаковые целочисленные значения (x.hashCode() == y.hashCode()).
  2. Необязательное обратное условие: если у двух объектов одинаковый hashCode(), они не обязательно равны по equals() (это ситуация коллизии).
  3. Консистентность (Непротиворечивость): многократные вызовы hashCode() и equals() должны возвращать одно и то же значение, пока не изменятся поля, участвующие в сравнении.

Если нарушить это правило (например, переопределить equals(), но забыть hashCode()), то хеш-коллекции (HashMap, HashSet, ConcurrentHashMap) перестанут работать: вы не сможете найти добавленный объект методом get(), а в HashSet появятся дубликаты.


5 аксиом метода equals() по спецификации Java SE

Для любого ненулевого объекта x, y, z метод equals() обязан удовлетворять математическим свойствам отношения эквивалентности:

Свойство Правило Описание
1. Рефлексивность (Reflexive) x.equals(x) == true Объект всегда равен самому себе.
2. Симметричность (Symmetric) x.equals(y) == y.equals(x) Если $X$ равен $Y$, то и $Y$ обязан быть равен $X$.
3. Транзитивность (Transitive) Если x.equals(y) и y.equals(z), то x.equals(z) Если $X = Y$ и $Y = Z$, то $X$ гарантированно равен $Z$.
4. Консистентность (Consistent) Повторные вызовы дают один результат Пока поля объекта не изменились, результат вызова неизменен.
5. Сравнение с null x.equals(null) == false Любой вызов с null обязан возвращать false, а не бросать NPE.

Идиоматическая реализация на Java 21

import java.util.Objects;

// 1. Современный подход: Java 21 Record автоматически реализует идеальный контракт
public record UserRecord(long id, String email) {}

// 2. Классический POJO-класс с ручным переопределением
public class UserProfile {
    private final long id;
    private final String email;

    public UserProfile(long id, String email) {
        this.id = id;
        this.email = email;
    }

    @Override
    public boolean equals(Object o) {
        // 1. Проверка идентичности ссылок
        if (this == o) return true;
        // 2. Проверка на null и точное совпадение типов (или instanceof)
        if (o == null || getClass() != o.getClass()) return false;
        // 3. Приведение типов и полейное сравнение
        UserProfile that = (UserProfile) o;
        return this.id == that.id && Objects.equals(this.email, that.email);
    }

    @Override
    public int hashCode() {
        // Вычисление хеша на основе ТЕХ ЖЕ САМЫХ полей, что и в equals!
        return Objects.hash(id, email);
    }
}

🟡 Middle Level

Рецепт Джошуа Блоха и магия числа 31

В классическом труде Effective Java Джошуа Блох предложил канонический алгоритм генерации hashCode():

@Override
public int hashCode() {
    int result = 17; // Ненулевое простое начальное число
    result = 31 * result + (int) (id ^ (id >>> 32)); // Хеширование long
    result = 31 * result + (email != null ? email.hashCode() : 0);
    return result;
}

Почему именно число 31:

  1. Нечетное простое число: использование простого числа (Prime Number) уменьшает вероятность коллизий при перемножении с хешами полей. Если бы множитель был четным (например, 2), умножение приводило бы к сдвигу влево и быстрому вытеснению старших битов за пределы 32-битного диапазона int (потеря информации).
  2. Аппаратная микрооптимизация компилятора JIT:
    Умножение на 31 компилятор HotSpot заменяет всего двумя инструкциями CPU — побитовым сдвигом влево и вычитанием: \(31 \times i = (i \ll 5) - i\) Эта операция выполняется процессором за 1 такт, не задействуя тяжелый блок целочисленного умножения (imul).

Нарушение симметричности при наследовании (instanceof vs getClass)

Классическая ловушка при наследовании классов:

public class Point {
    private final int x, y;
    public Point(int x, int y) { this.x = x; this.y = y; }

    @Override
    public boolean equals(Object o) {
        if (!(o instanceof Point p)) return false;
        return this.x == p.x && this.y == p.y;
    }
}

public class ColorPoint extends Point {
    private final String color;
    public ColorPoint(int x, int y, String color) { super(x, y); this.color = color; }

    @Override
    public boolean equals(Object o) {
        if (!(o instanceof ColorPoint cp)) return false;
        return super.equals(o) && Objects.equals(this.color, cp.color);
    }
}

Нарушение контракта:

Point p = new Point(1, 2);
ColorPoint cp = new ColorPoint(1, 2, "RED");

p.equals(cp);  // true  (Point проверяет x,y; ColorPoint является instanceof Point)
cp.equals(p);  // false (ColorPoint требует instanceof ColorPoint, а Point им не является!)

Симметричность разрушена! Если положить их в коллекцию, поведение будет зависеть от того, какой объект был передан первым.

[!TIP] Архитектурное правило Effective Java:
Невозможно расширить инстанциируемый класс новым значащим полем, сохранив при этом контракт equals(). Решение: отдавать предпочтение композиции вместо наследования (ColorPoint хранит Point внутри себя как поле), либо использовать строгое сравнение getClass() != o.getClass(), запрещая полиморфное равенство.


🔴 Senior Level

Устройство identityHashCode в памяти HotSpot (Mark Word)

Если класс не переопределяет метод hashCode(), вызывается нативный метод Object.hashCode(), возвращающий Identity Hash Code.

64-битный заголовок объекта (Mark Word в 64-bit HotSpot):
┌──────────────────────────────────────────────┬────────┬────────┬───────┐
│              Identity Hash Code (31 бит)     │ Unused │ Age (4)│ Biased│ ... Lock Bits (2)
└──────────────────────────────────────────────┴────────┴────────┴───────┘
  1. Ленивая генерация: при создании объекта identityHashCode не вычисляется. Он генерируется при первом вызове System.identityHashCode(obj) с помощью генератора псевдослучайных чисел Марсальи (XOR-shift) и навсегда «впечатывается» в Mark Word заголовка объекта в памяти.
  2. Иммутабельность при перемещениях GC: когда сборщик мусора перемещает объект из Young Gen в Old Gen (или компактизирует память), физический адрес объекта меняется, но значение identityHashCode в заголовке остается неизменным!
  3. Вытеснение при тяжелой синхронизации (Fat Lock): если на объекте вызывается synchronized(obj) и монитор раздувается до уровня ядра ОС (ObjectMonitor), заголовок объекта заменяется на указатель на Си++ структуру монитора в native memory. HotSpot заблаговременно переносит оригинальный хеш в структуру ObjectMonitor._header (Displaced Mark Word), сохраняя контракт.

Опасные антипаттерны: java.net.URL и JPA Entity

1. Катастрофа java.net.URL.equals()

Класс java.net.URL содержит один из старейших архитектурных багов Java: метод URL.equals() пытается разрешить доменные имена в IP-адреса через блокирующий сетевой DNS-запрос!

  • Вызов urlMap.get(url) отправляет сетевой запрос к DNS-серверу;
  • Если DNS-сервер недоступен, вызов зависает на секунды;
  • Если у домена настроен Round-Robin DNS, два вызова equals() на одном и том же URL в разное время вернут разные IP-адреса, нарушая аксиому консистентности.
    Решение: в современных приложениях всегда использовать java.net.URI, который выполняет строгое посимвольное сравнение строк без выхода в сеть.

2. Проблема JPA / Hibernate с @GeneratedValue ID

В JPA сущностях начинающие разработчики генерируют equals и hashCode по полю @Id:

@Entity
public class Order {
    @Id @GeneratedValue
    private Long id; // До вызова entityManager.persist() поле id == null!
}
  1. Создаем объект Order order = new Order(). Поле id == null.
  2. Добавляем его в Set<Order> set = new HashSet<>(). Хеш вычисляется от null.
  3. Сохраняем заказ в базу: база генерирует id = 42.
  4. Значение order.hashCode() изменилось прямо внутри HashSet!
  5. Вызов set.contains(order) вернет false, и объект невозможно удалить из сета — классическая утечка памяти (Memory Leak).
    Решение: использовать стабильный бизнес-ключ (Natural ID / UUID) либо фиксировать константный hashCode() для сущностей одного класса.

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

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

Контракт equals() и hashCode() требует: если два объекта равны по equals(), их hashCode() обязаны совпадать. Обратное неверно (совпадение хешей при разных объектах — это коллизия). equals() обязан быть рефлексивным, симметричным, транзитивным, консистентным и возвращать false для null. Если нарушить контракт, хеш-коллекции (HashMap, HashSet) ломаются: get() не находит существующие ключи, а в Set появляются дубликаты. В современных классах данных рекомендуется использовать record или проверку через getClass().


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

Вопрос 1: Почему невозможно корректно расширить класс с добавлением нового значащего поля, сохранив контракт equals(), если в базовом классе проверка сделана через instanceof?

Ответ: Если суперкласс Point проверяет instanceof Point, а подкласс ColorPoint добавляет поле цвета, возникает неразрешимое противоречие между свойствами симметричности и транзитивности:

  • Если подкласс игнорирует цвет при сравнении с суперклассом, нарушается транзитивность: ColorPoint(RED, 1, 2) == Point(1, 2) и Point(1, 2) == ColorPoint(BLUE, 1, 2), но RED == BLUE вернет false!
  • Если подкласс учитывает цвет, нарушается симметричность: point.equals(colorPoint) == true, но colorPoint.equals(point) == false.
    Единственное архитектурно корректное решение — использовать композицию вместо наследования.

Вопрос 2: Как hashCode() хранится в памяти JVM HotSpot, и почему он никогда не изменяется даже при перемещении объекта сборщиком мусора?

Ответ: Дефолтный identityHashCode вычисляется лениво при первом запросе и записывается в 31 бит заголовка объекта (Mark Word). Сборщик мусора перемещает объект по адресному пространству кучи, но заголовок копируется вместе со всем телом объекта. Таким образом, числовой хеш остается навечно зафиксированным. Если на объекте возникает тяжелая синхронизация (Fat Lock), JVM сохраняет заголовок в структуру ObjectMonitor, гарантируя неизменность hashCode().

Вопрос 3: Почему использование java.net.URL в качестве ключа в HashMap считается грубой ошибкой?

Ответ: Метод java.net.URL.equals() обращается к операционной системе для резолва доменных имен через DNS-запрос, пытаясь сравнить IP-адреса хостов. Это приводит к блокировке потока на время сетевого вызова, падению производительности до тысяч миллисекунд и нарушению принципа консистентности (если IP-адрес хоста изменился или сеть временно упала). Для использования в коллекциях предназначен класс java.net.URI, проводящий строгое посимвольное сопоставление без выхода в сеть.

Вопрос 4: Как правильно переопределять equals() и hashCode() для JPA/Hibernate Entity с автогенерируемым @Id?

Ответ: Нельзя полагаться на автоинкрементный @Id, так как до вызова persist() он равен null, а после сохранения изменяется, ломая инвариант HashSet/HashMap.
Существует две признанные стратегии:

  1. Business Key (Natural ID): использовать комбинацию неизменяемых бизнес-полей (например, UUID, номер паспорта, email);
  2. Константный hashCode(): возвращать фиксированное простое число (например, return 31;), а в equals() сравнивать id только в том случае, если оба id != null. При таком подходе новые сущности до коммита попадают в одну корзину (коллизия), но гарантированно не теряются после присвоения ключа базой данных.

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

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

❌ “Переопределение equals() с аргументом конкретного типа: public boolean equals(MyClass o).”
Правильно: Это перегрузка (overloading), а не переопределение (overriding). Метод equals в Object принимает Object o. При помещении в коллекцию будет вызван стандартный Object.equals(Object).

❌ “Использование случайных чисел (Random) или времени (currentTimeMillis) внутри hashCode().”
Правильно: Это нарушает аксиому консистентности. Хеш обязан вычисляться детерминированно только на основе полей объекта.


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