Как работает GetHashCode в .NET
В .NET метод GetHashCode устроен сложнее, чем «просто вернуть адрес объекта»: CLR хранит и кеширует хеш в заголовке объекта, использует разные алгоритмы для ссылочных, значимых типов, строк, делегатов и анонимных типов.
Что хранится в объекте
- У любого ссылочного объекта есть заголовок (Header) с двумя полями: указатель на таблицу методов типа (MethodTablePointer) и индекс блока синхронизации (SyncBlockIndex).
- MethodTablePointer нужен для RTTI и виртуальных вызовов (через него работает GetType и диспетчеризация методов), SyncBlockIndex ― для работы lock/Monitor и других механизмов синхронизации.
Как менялся GetHashCode у ссылочных типов
- В .NET 1.0–1.1 хеш ссылочного объекта просто брался как свободный индекс блока синхронизации (SyncBlock), который записывался в SyncBlockIndex; это создавало лишние структуры и делало хеши предсказуемыми и идущими подряд.
- Начиная с .NET 2.0 хеш для Object генерируется потоко-специфичным линейным конгруэнтным генератором: у каждого потока своё семя и множитель, что снижает риск одинаковых последовательностей хешей между потоками.
Где хранится хеш‑код
- При первом вызове GetHashCode CLR вычисляет значение и кладёт его в SyncBlockIndex; если объекту уже нужен SyncBlock для синхронизации, хеш переносится внутрь SyncBlock, а при освобождении блока обратно копируется в заголовок.
- Таким образом, хеш стараются хранить либо в заголовке объекта, либо в связанном SyncBlock, чтобы не тратить дополнительную память на отдельное поле в каждом объекте.
Значимые типы: две стратегии
- Если структура не содержит ссылочных полей и не имеет «пустот» между полями, CLR применяет быструю версию: XOR каждых 4 байт структуры, что задействует всё её содержимое; так, простые пары int,int получают разные хеши при разных значениях.
- Если есть ссылочные поля или выравнивание даёт пробелы, используется медленная версия: CLR берёт первое экземплярное поле, считает его хеш, XOR‑ит его с указателем на тип этого поля и игнорирует остальные поля, что может давать одинаковые хеши для разных значений остальных полей.
Баг с decimal и вывод
- В старых версиях .NET быстрый алгоритм давал разные хеши для чисел decimal, которые математически равны, но имеют разное внутреннее представление (например, 10.0m и 10.0000…m); это исправили только в .NET 4.
- Поведение и производительность дефолтного GetHashCode для value types могут быть неожиданными, поэтому для пользовательских структур и классов рекомендуют явно переопределять GetHashCode (и Equals) под свою модель равенства.
Строки, делегаты и анонимные типы
- String имеет собственный быстрый алгоритм (вариант djb2 с двумя накопителями), который при каждом вызове заново вычисляет хеш, не кэшируя его, чтобы не тратить память на дополнительное поле в каждом объекте строки; реализация менялась между версиями .NET, поэтому запрещено хранить такие хеши «на диск».
- В .NET 4.5 добавили опцию «randomized string hashing» на домен приложений (Marvin32), чтобы одинаковые строки в разных доменах могли иметь разные хеш‑коды и лучше защищаться от атак по коллизиям.
- Delegate по умолчанию возвращает хеш типа делегата (GetType().GetHashCode), поэтому делегаты одного типа с разными методами могут иметь одинаковый хеш.
- MulticastDelegate переопределяет GetHashCode так, чтобы учитывать всю цепочку методов: обход _invocationList и комбинирование хешей каждого элемента, поэтому при различном количестве/наборе методов хеши различаются.
- Для анонимных типов компилятор генерирует GetHashCode, который последовательно комбинирует хеш всех полей через умножение на константу и сложение, что делает их хорошо пригодными как ключи в LINQ‑операциях group/join.
- Equals у анонимных типов переопределён (сравнение по значениям всех полей), а оператор == нет, поэтому Equals может вернуть true, а оператор == — false для двух разных экземпляров с одинаковыми полями.
Подробнее расписал в своем телеграм канале -
Начать дискуссию