null

HashMap в Java: 10 фактов, о которых вы, возможно, не знали

HashMap – наверное, самая используемая коллекция в Java. Мы кладём в неё элементы, достаём, редко задумываясь, что происходит под капотом. И в целом это нормально: класс работает, документация есть, сообщество стабильно советует переопределять equals и hashCode. Но если копнуть чуть глубже, обнаруживается немало деталей, которые могут удивить даже опытного разработчика.

Я собрала десять таких моментов. Не про то, как устроен HashMap в целом, а про то, что обычно остаётся за скобками.

1. Почему порог деревизации – именно 8

С Java 8, когда в одной корзине становится больше восьми элементов, связанный список преобразуется в красно-чёрное дерево. Логика понятна: начиная с какого-то размера список работает слишком медленно. Но почему порог установлен именно на восьми?

Ответ лежит в теории вероятностей. Разработчики Oracle смоделировали ситуацию с хорошей хеш-функцией и стандартным нагрузочным фактором 0.75. Согласно распределению Пуассона, вероятность того, что в одну корзину попадёт восемь элементов, составляет примерно 0.00000006. Это шесть случаев на десять миллионов.

Восемь здесь – не случайное число, а своеобразная «защита от дурака». Если коллизий так много, значит, либо хеш-функция работает плохо, либо кто-то пытается организовать атаку. В штатной же ситуации дерево просто не нужно. Кстати, обратный переход из дерева в список происходит, когда элементов становится меньше шести – это предотвращает частые преобразования при добавлении и удалении.

2. Сдвиг на 16 бит: зачем он нужен

В исходном коде HashMap есть такая строчка:

(h = key.hashCode()) ^ (h >>> 16)

На первый взгляд кажется лишней. Зачем перед вычислением индекса ещё что-то делать с хеш-кодом?

Дело в том, что размер внутреннего массива всегда является степенью двойки. Индекс корзины вычисляется не через взятие остатка, а через побитовое И: (n - 1) & hash. Это быстро, но есть ограничение: в вычислении участвуют только младшие биты хеш-кода. Старшие, по сути, игнорируются.

Сдвиг на 16 бит и XOR решают эту проблему. Они «перемешивают» старшие и младшие биты, и даже если у двух объектов хеш-коды различаются только в старшей части, они всё равно попадут в разные корзины.

3. Массив создаётся не сразу

Когда вы пишете new HashMap<>(), внутренний массив ещё не существует. Он создаётся только при первой вставке элемента. До этого объект занимает минимум памяти – фактически только накладные расходы на сам экземпляр.

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

4. Начальная ёмкость считается не так, как вы думаете

Конструктор new HashMap<>(100) часто понимают неправильно. Кажется, что если передать 100, то можно положить 100 элементов без расширения. На самом деле 100 – это размер внутреннего массива. Порог срабатывает при превышении ёмкость * loadFactor, то есть при 75 элементах.

Чтобы без расширения поместилось ровно 100 элементов, раньше нужно было писать (int) Math.ceil(100 / 0.75). Выглядит как заклинание… К счастью, в Java 19 появился метод HashMap.newHashMap(100), который делает этот расчёт автоматически.

5. Итерация зависит от ёмкости, а не только от размера

В документации сказано: время итерации пропорционально ёмкости плюс размер. Это не просто формальность.

Если вы создали мапу с ёмкостью 10 000, но положили в неё два элемента, итератор всё равно обойдёт все 10 000 корзин, проверяя каждую на наличие данных. Пустые корзины не пропускаются. Поэтому если вы планируете часто перебирать элементы, не стоит завышать начальную ёмкость без необходимости.

6. putAll может работать медленнее ручного копирования

Неожиданный факт, но в некоторых версиях Java для больших мап метод putAll (и конструктор копирования) работает медленнее, чем обычный цикл по entrySet() с ручным добавлением.

Исследователи OpenJDK связывают это с тем, что JIT-компилятор не всегда может эффективно оптимизировать полиморфные вызовы на границах методов. В простом же цикле оптимизаций больше, и код выполняется быстрее – разница может достигать 20–30%. Ситуация варьируется от версии к версии, но о ней полезно знать, если вы работаете с действительно большими объёмами данных.

7. Ключ null всегда попадает в нулевую корзину

То, что HashMap допускает один ключ null, знают все. Но мало кто задумывается, куда именно он попадает.

В методе hash() есть явная проверка: для null возвращается 0. При вычислении индекса (n - 1) & 0 результат всегда будет 0. То есть все ключи null хранятся строго в нулевой корзине и всегда располагаются первыми в списке или дереве этой корзины.

8. Бесконечный цикл в Java 7 больше не актуален

В Java 7 и более ранних версиях в многопоточной среде при одновременном расширении HashMap мог возникнуть бесконечный цикл – программа просто зависала.

Причина была в способе переноса элементов: использовалась вставка в начало списка, что в конкурентной среде могло замкнуть список само на себя. В Java 8 отказались от этого подхода в пользу вставки в конец. Бесконечные циклы ушли в прошлое. Но это не делает HashMap потокобезопасной – проблемы с потерей данных и состояниями гонки остались.

9. computeIfAbsent и merge могут бросить исключение во время выполнения

Методы compute, computeIfAbsent и merge удобны тем, что позволяют атомарно выполнить сложную логику вставки. Но у них есть особенность: они могут изменять мапу во время работы переданной функции.

Если внутри этой функции попытаться снова изменить ту же мапу (например, вызвать put), можно получить ConcurrentModificationException. Это не баг, а защита от некорректного состояния. Методы стараются отследить такие ситуации и прервать выполнение, чтобы не допустить разрушения структуры данных.

10. Даже списки в Java 8 ищут быстрее

Даже если в корзине меньше восьми элементов и дерево ещё не создано, поиск работает чуть быстрее обычного последовательного перебора. В коде есть проверка: если ключи реализуют интерфейс Comparable, алгоритм использует сравнение для более быстрого ветвления.

Это микрооптимизация, но она хорошо иллюстрирует подход разработчиков: улучшения делаются даже там, где кажется, что они не особо нужны

​​​​​​​​​​​​​​​​​​Эти детали редко всплывают в повседневной работе. Но когда сталкиваешься с неожиданным поведением или пытаешься выжать из приложения максимум производительности, знание внутреннего устройства HashMap помогает быстрее понять, что идёт не так и как это исправить.
Исходный код HashMap открыт, и в нём можно найти ещё много интересного. Иногда полезно просто заглянуть – хотя бы ради любопытства.

 

 

Next

Коротко о себе:

Работаю кем-то в компании Tune-it. Занимаюсь какими-то проектами, связанными с чем-то.

Nothing has been found. n is 0