== против equals
Для примитивов == сравнивает значения. Для ссылочных типов == сравнивает идентичность — одна ли это ссылка на один объект в памяти, а equals() — логическое равенство по содержимому, ЕСЛИ метод переопределён.
По умолчанию Object.equals реализован как ==, то есть без переопределения «равенство по содержимому» не работает. Поэтому два разных объекта с одинаковыми полями равны по правильно написанному equals, но не по ==.
String a = new String("a");
String b = new String("a");
a == b; // false — разные объекты в куче
a.equals(b); // true — одинаковое содержимое
Контракт equals
equals обязан быть: рефлексивным (x.equals(x) — true), симметричным (x.equals(y) ⇔ y.equals(x)), транзитивным (x=y и y=z ⇒ x=z), согласованным (повторные вызовы дают тот же результат, пока объекты не менялись) и возвращать false на null (x.equals(null) == false).
Эти правила не формальность: их нарушение делает поведение коллекций и сравнений непредсказуемым. Классический пример нарушения симметрии — equals, который пытается «понимать» чужой тип (например, регистронезависимая строка, считающая себя равной обычной String, тогда как String так не считает).
equals и наследование
Самая коварная тема: нельзя одновременно сохранить контракт equals и добавлять значимые поля в подкласс расширяемого класса. Если сравнивать через instanceof, симметрия/транзитивность ломаются между объектом базового и производного класса; если через getClass(), объекты разных классов никогда не равны — это нарушает принцип подстановки Лисков для подтипов.
Практические выходы: делать класс (или сам equals) final; либо предпочесть композицию наследованию — вынести «значимые поля» в отдельный объект, а не в подкласс. Записи (records) обходят проблему тем, что финальны и сравнивают строго по компонентам.
Контракт hashCode
Главное правило связки: если два объекта равны по equals, у них ОБЯЗАН совпадать hashCode. Обратное не требуется — неравные объекты могут иметь одинаковый хеш (коллизия — это нормально). В пределах одного запуска hashCode стабилен, пока значимые поля не меняются.
Нарушение этого правила тихо ломает HashMap/HashSet: объект кладётся в бакет по hashCode, а ищется по нему же — если хеши «разъехались» с равенством, объект уходит не в тот бакет и «теряется» (get вернёт null для логически того же ключа).
Как реализовать правильно
equals и hashCode должны опираться на ОДИН И ТОТ ЖЕ набор значимых полей. Удобные помощники: Objects.equals(a, b) (null-безопасное сравнение) в equals и Objects.hash(f1, f2, …) в hashCode (для горячих путей — ручная формула 31*h + field, чтобы не аллоцировать массив varargs).
В современном коде это чаще всего не пишут руками: records генерируют equals/hashCode/toString из компонентов автоматически, а для классов их генерирует IDE. Главное — держать наборы полей синхронными между двумя методами при любом изменении класса.
@Override public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Point p)) return false;
return x == p.x && y == p.y;
}
@Override public int hashCode() { return Objects.hash(x, y); }
Изменяемые ключи
Если hashCode (или equals) зависит от изменяемого поля и это поле поменять ПОСЛЕ вставки объекта в HashMap/HashSet, объект становится недостижим: он лежит в старом бакете, а ищется по новому хешу.
Поэтому ключи хеш-коллекций делают неизменяемыми (или хотя бы не меняют значимые поля, пока объект лежит в коллекции) — см. урок про неизменяемость.
Как отвечать на собеседовании
Различите == (идентичность/значение примитива) и equals (логическое равенство, по умолчанию == у Object), перечислите пять свойств контракта equals и главное правило hashCode (equal ⇒ равный hashCode) с последствием для HashMap. Покажите зрелость на теме equals+наследование (instanceof ломает симметрию, getClass ломает Лисков → final/композиция/records) и на синхронности полей equals/hashCode + неизменяемости ключей.