сеньорчикОткрыть в Telegram
← вся теориятеория к собесу · Java: ядро языка

Java: equals и hashCode

equals и hashCode: пара, по которой видно всё

Три строки, которые стоит один раз увидеть. Класс Point с переопределённым equals, но без hashCode. Кладём в HashSet точку (1,2), тут же спрашиваем contains для такой же точки - и получаем false. Объект в множестве есть, а множество его не находит.

Это любимая пара вопросов Java-собесов, и не случайно: по ней сразу видно, понимаешь ли ты, как устроен HashMap. Кривой контракт не бросает исключений, он тихо теряет объекты, и такой баг ищут днями.

// Формулировки: «зачем переопределять hashCode вместе с equals?», «что будет, если hashCode вернёт константу?», «мутабельный ключ в HashMap - что пойдёт не так?»

Контракт: кто кому обязан

Картотека объясняет всё. hashCode - номер ящика, equals - сверка документов внутри ящика. Поиск идёт в два шага: сначала по хешу выбирается ящик, потом внутри него сравниваются кандидаты. Если у одинаковых по смыслу объектов номера ящиков разные, до сверки дело просто не доходит - ровно это и случилось в замере выше.

Отсюда главный инвариант: equals-равные объекты ОБЯЗАНЫ иметь одинаковый hashCode. Обратное не требуется - разные объекты с одинаковым хешем это легальная коллизия, hash-структуры к ней готовы.

// Сам equals обязан быть рефлексивным (объект равен себе), симметричным (если x равен y, то y равен x), транзитивным, стабильным между вызовами и возвращать false на null. Звучит академично, но каждое свойство ломается живым кодом - чаще всего симметричность при наследовании.

контракт
равные объекты обязаны давать равные хеши
коллизия
разные объекты с одинаковым хешем, это законно

Что ломается: исчезающие объекты

Первый случай мы уже видели: equals есть, hashCode забыт - и contains возвращает false на только что положенный объект. Без своего hashCode объект отдаёт хеш по умолчанию, привязанный к конкретному экземпляру, поэтому второй объект ищется в другом ящике.

Второй случай подлее, потому что контракт вроде бы соблюдён. Положил ключ в HashSet, потом изменил поле, участвующее в хеше. Проверил: contains для того же самого объекта вернул false, а размер множества остался единицей. Запись лежит в ящике, вычисленном по старому хешу, ищется по новому и не находится никогда. Ключи hash-структур обязаны быть неизменяемыми.

// Третий случай встречается в коде чаще всех: метод объявлен как equals(MyType) вместо equals(Object). Это перегрузка, а не переопределение - компилятор молчит, коллекции зовут версию с Object, и contains снова false. Ловится аннотацией @Override.

Set<Point> set = new HashSet<>();
set.add(new Point(1, 2));
set.contains(new Point(1, 2)); // false - hashCode не переопределён
хеш по умолчанию
привязан к экземпляру, у копии он другой
изменяемый ключ
смена поля после put теряет запись навсегда

Константный хеш: не список, а дерево - и всё равно катастрофа

Ходовое утверждение «hashCode вернул константу - HashMap стала связным списком» устарело. С Java 8 переполненный ящик превращается в красно-чёрное дерево, и это меняет цифры. Я замерил на 20 000 ключах с константным хешем.

Ключ, реализующий Comparable: 20 000 поисков за 20,3 мс. Дерево строится по compareTo и работает за логарифм. Ключ БЕЗ Comparable: те же 20 000 поисков за 1609 мс. Разница в 79 раз, потому что упорядочить такие ключи нечем и дерево вырождается.

Для сравнения: нормальный хеш даёт те же 20 000 поисков за 2,6 мс. То есть константа замедляет в 8 раз при Comparable-ключе и в 600 с лишним раз без него.

// Формулировка для собеседования: «контракт цел, поэтому компилируется и работает, но все объекты падают в один ящик; с Java 8 он превращается в дерево, а не в список, и спасает это только если ключ Comparable».

превращение в дерево
переполненный ящик HashMap с Java 8 становится деревом
Comparable
даёт дереву порядок, без него оно вырождается

Как писать правильно: record и третий брат compareTo

Ручной канон: проверка на себя, проверка на null и тип, потом сравнение значимых полей через Objects.equals, а hashCode - через Objects.hash из тех же самых полей. Выбор между getClass() и instanceof - это выбор строгости: instanceof пропускает наследников и рискует симметричностью, если наследник добавил поля.

Наследование и equals конфликтуют по своей природе, поэтому современный ответ для объектов-значений радикален: record. Он генерирует equals, hashCode и toString по компонентам, финален и неизменяем - проблема снята конструкцией языка, а не дисциплиной.

// Есть и третий брат - compareTo. В TreeMap и TreeSet равенством считается compareTo == 0, а не equals, и они расходятся чаще, чем кажется. Живой пример я проверил: у BigDecimal значения 1.0 и 1.00 не равны через equals (различается масштаб), но compareTo даёт ноль. Один и тот же список из двух таких чисел даёт HashSet размером 2 и TreeSet размером 1.

record
класс-значение со сгенерированными equals и hashCode
compareTo == 0
равенство для сортированных коллекций, не equals

Как отвечать: «Зачем переопределять hashCode вместе с equals?»

Потому что hash-структуры ищут в два шага: сначала по hashCode выбирают ящик, потом внутри него сверяют equals. Контракт требует, чтобы равные по equals объекты давали одинаковый хеш. Переопределишь только equals - одинаковые по смыслу объекты разойдутся по разным ящикам, и до сверки дело не дойдёт. Я это специально воспроизводил: кладу в HashSet точку и тут же спрашиваю contains точкой с теми же координатами, получаю false. То есть put создаст дубликат, а get не найдёт запись, которую только что положил. Поэтому пара переопределяется всегда вместе и из одних и тех же полей. Второй важный момент: поля, участвующие в хеше, не должны меняться после того, как объект стал ключом - я проверял, изменение такого поля теряет запись даже для того же самого объекта. Ну и на практике для объектов-значений я просто беру record: там equals, hashCode и toString сгенерированы по компонентам и заведомо согласованы.

Сильный ответ: объяснён механизм поиска через два шага, назван конкретный симптом поломки, добавлена проблема изменяемого ключа и дан современный практичный выход. Три уровня глубины в одном ответе.

На чём валят

  • Переопределяют equals без hashCode. HashSet не находит только что положенный объект, вопрос почти гарантирован.
  • Пишут equals(MyType) вместо equals(Object). Это перегрузка, коллекции её не зовут, спасает только @Override.
  • Кладут в HashMap изменяемый ключ и правят поле после put. Запись теряется навсегда, память при этом занята.
  • Говорят про константный хеш «станет связным списком». С Java 8 это дерево, и цифры расходятся в 79 раз: у меня 20,3 мс с Comparable-ключом против 1609 мс без него.
  • Не знают про расхождение equals и compareTo. BigDecimal 1.0 и 1.00 дают в HashSet два элемента, а в TreeSet один.

Проверьте себя

Пять вопросов из банка по этой подтеме. Всего их 15, остальные разбираются в тренажёре.

  1. #equals_hashcode1 / 5
    Два объекта равны по equals, но их hashCode различается. Что сломается на практике?
    A)Ничего: коллекции сначала проверяют equals, а hashCode используется лишь для сортировки
    B)Программа не запустится — JVM проверяет согласованность equals и hashCode при загрузке класса
    C)map.put(key, v), затем map.get(key) не найдёт значение — ключи уходят в разные бакеты
    D)Объекты начнут считаться неравными по equals, потому что hashCode переопределяет его результат
    показать ответ и разбор
    +C)map.put(key, v), затем map.get(key) не найдёт значение — ключи уходят в разные бакеты

    // разбор: HashMap кладёт запись в бакет по hashCode ключа. Если положить по одному объекту-ключу, а искать по equals-равному, но с ДРУГИМ hashCode, поиск пойдёт в другой бакет и ничего не найдёт — запись как будто пропала. То же с HashSet.contains. Это тихий баг: тесты со «своими» же ссылками его не видят, а на равных-но-разных объектах всё рушится. Причина — нарушенный контракт equals/hashCode. JVM это не проверяет.

  2. #equals_hashcode2 / 5
    Чем Comparable отличается от Comparator?
    A)Comparable умеет сравнивать только строки, а Comparator — только числа; для своих классов оба не годятся
    B)Это одно и то же, просто Comparable — старое название, а Comparator пришло ему на замену в Java 8
    C)Comparator задаётся внутри класса через compareTo, а Comparable — снаружи как отдельный объект
    D)Comparable — естественный порядок в самом классе (compareTo); Comparator — внешний, задаёт разные порядки
    показать ответ и разбор
    +D)Comparable — естественный порядок в самом классе (compareTo); Comparator — внешний, задаёт разные порядки

    // разбор: Comparable реализуется САМИМ классом (метод compareTo) и задаёт его «естественный» порядок — один на класс (например, числа по величине). Comparator — ОТДЕЛЬНЫЙ объект/лямбда, описывающий порядок снаружи; их можно иметь много (по имени, по дате, по убыванию) и передавать в sort/TreeMap, не трогая класс. Правило: один естественный порядок — Comparable; несколько или порядок для чужого класса — Comparator (удобно через comparing/thenComparing).

  3. #equals_hashcode3 / 5
    Почему рекомендуют, чтобы compareTo был согласован с equals (compareTo==0 ⇔ equals==true)?
    A)Иначе метод compareTo вернёт неверный знак и сортировка перевернётся в обратную сторону
    B)TreeSet/TreeMap считают элементы равными по compareTo, а не equals — рассогласование теряет элементы
    C)Без такого согласования реализация интерфейса Comparable попросту откажется компилироваться — это жёсткое требование компилятора
    D)Согласование нужно только для сортировки списков, а на множества и словари оно никак не влияет
    показать ответ и разбор
    +B)TreeSet/TreeMap считают элементы равными по compareTo, а не equals — рассогласование теряет элементы

    // разбор: Отсортированные коллекции (TreeSet, TreeMap) определяют равенство через compareTo, а НЕ через equals. Если compareTo(x, y) == 0, но x.equals(y) == false, элемент может «пропасть»: TreeSet сочтёт его дубликатом и не добавит, хотя по equals это другой объект. Формально согласованность не обязательна, но её нарушение делает поведение множества/словаря контринтуитивным (расходится с контрактом Set). Поэтому её держат, кроме осознанных исключений (напр. BigDecimal).

  4. #equals_hashcode4 / 5
    Почему опасно менять поля объекта, влияющие на hashCode, пока он лежит в HashSet?
    A)HashSet при изменении элемента бросит ConcurrentModificationException в момент правки поля
    B)Изменение сдвинет hashCode — объект «застрянет» в старом бакете, и contains/remove его не найдут
    C)Ничего опасного: HashSet при изменении поля автоматически перекладывает элемент в нужный бакет
    D)Элемент немедленно продублируется, и в множестве окажутся две копии одного и того же объекта
    показать ответ и разбор
    +B)Изменение сдвинет hashCode — объект «застрянет» в старом бакете, и contains/remove его не найдут

    // разбор: Позиция элемента в HashSet зафиксирована по hashCode на момент вставки. Если изменить поле, входящее в hashCode, новый хеш укажет на другой бакет, а объект физически останется в старом — contains/remove пойдут искать по новому бакету и не найдут его, при этом iterator его всё ещё видит. Множество приходит в противоречивое состояние. Отсюда правило: ключи map и элементы set должны быть эффективно неизменяемыми по полям, влияющим на равенство/хеш.

  5. #equals_hashcode5 / 5
    Как ведёт себя Object.equals() по умолчанию, если его не переопределять?
    A)Сравнивает объекты по всем их полям через рефлексию и возвращает true при полном совпадении
    B)Возвращает false для двух разных переменных, даже если они ссылаются на один объект
    C)Сравнивает ссылки (это тот же объект?), то есть работает идентично оператору ==
    D)Бросает UnsupportedOperationException, пока метод не будет переопределён в классе-наследнике
    показать ответ и разбор
    +C)Сравнивает ссылки (это тот же объект?), то есть работает идентично оператору ==

    // разбор: Реализация Object.equals — это просто this == obj: сравнение ссылок, то есть проверка идентичности (один ли это объект). Никакого сравнения по полям «из коробки» нет — его надо писать самому. Поэтому у классов без переопределённого equals два объекта с одинаковым содержимым считаются разными. String, Integer, record и др. переопределяют equals на сравнение по значению. Отсюда и необходимость переопределять equals/hashCode для своих value-классов.

дальше

Теорию прочитали. Навык ставится повторением

В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.