Если два объекта имеют одинаковый hashCode(), обязательно ли они равны по equals()
Ситуация, при которой два логически разных объекта (a.equals(b) == false) имеют одинаковое значение hashCode(), называется коллизией хеш-функции (Hash Collision). Это абсолютно...
🟢 Junior Level
Краткий ответ для собеседования (30 секунд)
НЕТ, КАТЕГОРИЧЕСКИ НЕ ОБЯЗАТЕЛЬНО!
Ситуация, при которой два логически разных объекта (a.equals(b) == false) имеют одинаковое значение hashCode(), называется коллизией хеш-функции (Hash Collision). Это абсолютно нормальное, штатное явление при работе с хеш-таблицами в Java.
В контракте Java (java.lang.Object) действует строгое одностороннее правило:
- Равенство по
equals()$\implies$ Равенство поhashCode()(ОБЯЗАТЕЛЬНОЕ требование); - Равенство по
hashCode()$\not\implies$ Равенство поequals()(Равенство НЕ гарантируется).
Коллизии математически неизбежны по принципу Дирихле (Pigeonhole Principle): метод hashCode() возвращает 32-битный примитив int (всего $\approx 4.29 \times 10^9$ уникальных значений), в то время как число возможных состояний объектов в оперативной памяти бесконечно.
Примеры гарантированных коллизий в стандартных классах JDK
public class StandardJdkCollisionsDemo {
public static void main(String[] args) {
// 1. Коллизия строк: "Aa" и "BB"
// Формула String.hashCode(): 31 * char[0] + char[1]
// "Aa": 31 * 65 + 97 = 2015 + 97 = 2112
// "BB": 31 * 66 + 66 = 2046 + 66 = 2112
String s1 = "Aa";
String s2 = "BB";
System.out.println("Хеш s1: " + s1.hashCode()); // 2112
System.out.println("Хеш s2: " + s2.hashCode()); // 2112
System.out.println("Строки равны по equals? " + s1.equals(s2)); // false!
// 2. Длинные строки с одинаковым хешем: "Siblings" и "Teheran"
System.out.println("Siblings hash: " + "Siblings".hashCode()); // 231609873
System.out.println("Teheran hash: " + "Teheran".hashCode()); // 231609873
// 3. Коллизия Long: 1L и 4294967297L (1 + 2^32)
// Метод Long.hashCode() сворачивает 64 бита в 32: (int)(v ^ (v >>> 32))
Long l1 = 1L;
Long l2 = 4294967297L;
System.out.println("l1 hash: " + l1.hashCode()); // 1
System.out.println("l2 hash: " + l2.hashCode()); // 1
System.out.println("Longs равны по equals? " + l1.equals(l2)); // false!
}
}
🟡 Middle Level
Двухступенчатый фильтр поиска в HashMap
Архитектура HashMap и HashSet специально спроектирована с учетом того, что у разных объектов могут совпадать хеш-коды:
Запрос map.get(key)
│
▼
[ 1-й ЭТАП: hashCode() — Быстрый грубый фильтр ]
Вычисление индекса корзины: index = (n - 1) & hash
Мгновенно отсекает 99.9% чужих корзин!
│
▼
[ 2-й ЭТАП: equals() — Медленный точный фильтр ]
Поиск внутри найденной цепочки корзины:
Сравнение p.hash == hash && (p.key == key || key.equals(p.key))
Точно идентифицирует целевой объект среди коллизий
Обновление значения vs Добавление коллизии
Когда в HashMap вызывается метод put(key, value) и хеш ключа совпадает с уже существующим в корзине элементом, мапа проверяет равенство ключей через equals():
- Если
k.equals(existingKey) == true:
Мапа считает, что это тот же самый ключ. Значение старого узла перезаписывается новым (e.value = value), а методput()возвращает старое значение. Размер мапы (size) не меняется. - Если
k.equals(existingKey) == false:
Мапа фиксирует коллизию. Создается новый узелNode<K,V>, который привязывается к концу связного списка в корзине (Tail Insertion). В мапе теперь хранятся оба элемента, а размер мапы увеличивается (size++).
🔴 Senior Level
Механизм разрешения конфликтов в красно-черном дереве TreeNode
Что делает HashMap, когда в одной корзине накапливается $\ge 8$ элементов с абсолютно одинаковым hashCode()?
Красно-черное дерево поиска требует четкого упорядочивания ($<$ или $>$). Если хеш-коды одинаковы, бинарное сравнение по hash становится бесполезным. HotSpot решает эту дилемму следующим каскадом:
// Фрагмент логики вставки в TreeNode (HotSpot):
int dir;
Class<?> kc;
// 1. Попытка сравнить через Comparable
if ((kc = comparableClassFor(k)) != null &&
(dir = compareComparables(kc, k, pk)) != 0) {
// Направление влево/вправо определено методом compareTo()!
}
// 2. Если Comparable нет или compareTo() вернул 0 (но equals == false)
else {
// Рекурсивный поиск в обоих поддеревьях (find)
// Если узел не найден — детерминированный арбитраж по System.identityHashCode:
dir = tieBreakOrder(k, pk);
}
comparableClassFor: проверяется, реализует ли класс ключаComparable<MyClass>. Если да, вызываетсяk.compareTo(pk).- Fallback через
tieBreakOrder: если класс не реализуетComparable, JVM сравнивает строковые имена классов (getClass().getName()), а если и они равны — вызываетSystem.identityHashCode(k)(нативный адрес/хеш из заголовка Mark Word), гарантируя устойчивый порядок балансировки дерева.
Атака Hash-Flooding DoS через преднамеренные коллизии
Хеш-функция String.hashCode() является полиномиальной:
\(h = s[0] \cdot 31^{n-1} + s[1] \cdot 31^{n-2} + \dots + s[n-1]\)
Поскольку $31 \times 65 + 97 = 31 \times 66 + 66 = 2112$, строки "Aa" и "BB" эквивалентны по модулю хеша.
Злоумышленник может сгенерировать $2^{16} = 65\,536$ строк, состоящих из комбинаций пар "Aa" и "BB":
"AaAaAaAa", "AaAaAaBB", "AaAaBBAa", "AaAaBBBB"…
Все эти $65\,536$ строк имеют одинаковый hashCode()!
- В Java 7: отправка формы с такими строками на сервер (Tomcat) приводила к построению связного списка длиной 65 536 элементов. Вставка каждого элемента требовала $O(n)$ сравнений. Вся операция занимала $O(n^2)$ времени — процессор зависал на 100% на несколько минут (DoS-атака).
- В Java 8+: при 8 элементах список превращается в красно-черное дерево, снижая сложность до $O(n \log n)$ и ликвидируя уязвимость.
🎯 Шпаргалка для интервью
1. 30-секундный эссенс (Elevator Pitch)
Одинаковый hashCode() НЕ гарантирует равенство по equals(). Совпадение хешей у неравных объектов называется коллизией. Это неизбежно из-за принципа Дирихле (32-битный int ограничен 4.3 млрд значений, а вариантов объектов бесконечно много). В HashMap равенство хешей лишь направляет объекты в одну корзину, где они мирно сосуществуют в виде связного списка или красно-черного дерева и различаются методом equals(). При равенстве хешей перезапись значения происходит ТОЛЬКО при equals() == true.
2. 4 каверзных вопроса с глубокими ответами
Вопрос 1: Почему в Java hashCode() возвращает 32-битный int, а не 64-битный long, что сильно сократило бы вероятность коллизий?
Ответ:
- Память и размеры структур: поле
final int hashхранится в каждом узлеNode<K,V>. Использованиеlongувеличило бы размер ноды на 8 байт (с учетом выравнивания памяти в 64-битной JVM), что создало бы гигантский оверхед на миллиардах записей; - Аппаратная эффективность: 32-битные операции с целыми числами аппаратно оптимизированы на всех процессорных архитектурах;
- Размер таблицы: реальные коллекции
HashMapредко содержат более нескольких миллионов элементов. Индекс корзины все равно берется по младшим 4–20 битам маски(n - 1) & hash, поэтому 64-битный хеш никак не спас бы от коллизий корзин.
Вопрос 2: Как сгенерировать произвольное количество строк с абсолютно одинаковым hashCode() для стандартного String?
Ответ:
Базовый строительный блок — любая пара двухсимвольных строк, дающая одинаковый хеш: например, "Aa" и "BB" (хеш 2112). Поскольку алгоритм String.hashCode() линеен по умножению на 31, любая комбинация из $N$ блоков "Aa" и "BB" длины $2N$ даст одинаковый хеш-код. Взяв $K$ позиций, можно сгенерировать $2^K$ уникальных строк с идентичным хеш-кодом методом декартова произведения (например, "AaAa", "AaBB", "BBAa", "BBBB").
Вопрос 3: Что произойдет в HashMap, если метод hashCode() у всех добавляемых ключей будет возвращать константу (например, return 42;)?
Ответ:
Все элементы коллекции будут направляться в одну-единственную корзину таблицы (индекс (n - 1) & 42). До 8 элементов они будут выстраиваться в односвязный список со сложностью поиска $O(n)$. При добавлении 8-го элемента (при емкости $\ge 64$) корзина превратится в красно-черное дерево, и сложность поиска/вставки стабилизируется на уровне $O(\log n)$. HashMap не сломается и не потеряет данные, но ее производительность деградирует по сравнению с $O(1)$.
Вопрос 4: Как HashMap отличает вставку нового элемента от перезаписи значения существующего ключа при совпадении hashCode()?
Ответ:
При совпадении хешей HashMap переходит ко второй ступени проверки: вызывает метод key.equals(candidateKey). Если equals() возвращает true, мапа констатирует совпадение ключей и перезаписывает существующее значение в поле Node.value. Если equals() возвращает false, мапа считает это коллизией и создает новый узел в списке или дереве.
3. Типичные ошибки и Red Flags
❌ “Если хеши совпали, то и объекты равны по equals.”
Правильно: Это ошибка начинающих. Равенство хешей — лишь необходимое, но недостаточное условие эквивалентности.
❌ “Хорошая хеш-функция должна гарантировать отсутствие коллизий.”
Правильно: Хеш-функция общего назначения в принципе не может исключить коллизии из-за принципа Дирихле. Она обязана лишь обеспечивать равномерное псевдослучайное распределение.
❌ “При совпадении hashCode() старый элемент удаляется из HashMap.”
Правильно: Если equals() == false, оба элемента сохраняются в структуре корзины.