Как устроен HashMap в Java: вопросы собеседования
Устройство знают многие, а сыплются на продолжении: что будет при коллизиях, при росте и при доступе из двух потоков. Разбираем все три уточняющих.
«Расскажите, как устроен HashMap» — вопрос, который держится в роли разогревочного годами: инженеры, много лет ходившие по собеседованиям, описывают его именно так — как то, что не меняется, когда меняется всё остальное. В разборе двухсот сорока семи собеседований устройство HashMap встретилось в тридцати восьми, а контракт equals/hashCode — в сорока одном; это два самых частых вопроса выборки.
Стоит сказать прямо: устройство разобрано подробно и не в одном месте, и новых фактов здесь ждать неоткуда. Полезно другое — понимать, куда идёт разговор после первого ответа и где проходит граница между тем, что гарантировано, и тем, что просто так работает сегодня.
Всё, что сказано о поведении, относится к Java SE 21.
Как значение находит своё место
Ключ приходит в карту как объект, а корзину нужно выбрать числом — эту работу и делает hashCode. По числу выбирается корзина, а внутри корзины уже сравниваются ключи через equals. Отсюда следует то, что делает связку двух методов не формальностью, а условием работоспособности: hashCode решает, где искать, а equals — что именно нашли.
Из этого выводится самое неприятное последствие нарушенного контракта, и его стоит уметь показать на примере. Положили объект в карту, потом изменили у него поле, которое участвует в вычислении хеша, — и значение потерялось. Оно лежит в карте, оно не удалено, но искать его теперь будут в другой корзине. Тот же механизм объясняет, почему изменяемый объект — плохой ключ.
Коллизия — когда два разных ключа попадают в одну корзину — при этом не сбой, а нормальный режим. Гарантия javadoc сформулирована с оговоркой ровно про это: константное время get и put обещано при условии, что хеш-функция «должным образом рассеивает элементы по корзинам». Плохая хеш-функция никакого правила не нарушает — она просто съедает обещание производительности.
У этой оговорки есть предельный случай, на котором её смысл виден лучше всего: пусть hashCode всегда возвращает одно и то же число. Формально не сломается ничего — карта останется корректной, значения будут находиться. Но все ключи попадут в одну корзину, и поиск из константного превратится в перебор.
Что происходит при росте
Опубликованный разбор одного собеседования описывает эту часть по шагам: сначала спросили начальное число корзин, затем хеш-функцию, затем коэффициент заполняемости и политику расширения. Числа тут короткие и в документации названы прямо.
Конструктор без аргументов создаёт карту с начальной ёмкостью 16 и коэффициентом загрузки 0.75. Ёмкость — это число корзин; коэффициент загрузки — мера того, насколько карте позволено заполниться, прежде чем ёмкость увеличится автоматически.
Дальше работает правило, которое стоит формулировать точно, потому что в пересказах оно часто съезжает: когда число записей превышает произведение коэффициента загрузки на текущую ёмкость, таблица перестраивается так, чтобы корзин стало примерно вдвое больше. Не «когда карта заполнилась», а именно при превышении произведения — то есть при 16 корзинах порог наступает на тринадцатой записи, а не на семнадцатой.
Почему именно 0.75 — тоже документировано, и это вопрос размена: «значение по умолчанию даёт хороший компромисс между временем и памятью», а более высокие значения уменьшают расход памяти, но увеличивают стоимость поиска. Полезное практическое следствие: если объём известен заранее, ёмкость лучше задать в конструкторе — перестройка большой карты не бесплатна.
Из перестроек следует ещё одно свойство, о котором спрашивают отдельно: порядок обхода не гарантирован и не обязан оставаться постоянным во времени. Это записано в документации прямо, и опираться на порядок нельзя даже в пределах одного запуска.
Где кончается контракт и начинается реализация
Разборы собеседований описывают превращение длинной корзины в дерево как часть ожидаемого ответа — не как экзотику, до которой доходят единицы. И здесь есть тонкость, которая делает ответ заметно сильнее: документация класса этого не описывает.
Javadoc HashMap говорит про ёмкость, коэффициент загрузки, перестройку и отсутствие гарантий порядка. Про то, что лежит внутри корзины, он не говорит ничего — только обещает константное время при хорошем рассеивании. Структура корзины остаётся деталью конкретной реализации, а не обязательством, на которое можно опереться.
Отсюда способ отвечать, который применим ко всем вопросам такого рода: назвать поведение, а следом — его статус. Не «внутри связный список, который превращается в дерево», а «документация обещает константное время при хорошем рассеивании; в реализации HotSpot длинная корзина перестраивается в дерево, но это деталь реализации, а не часть контракта». Второй ответ не длиннее первого и содержит на один уровень больше.
Практическая ценность различения не в педантичности. Контракт — это то, что переживёт смену версии; деталь реализации — то, что может измениться без предупреждения, и строить на ней логику программы нельзя.
Что именно ломается в двух потоках
Ответ «не потокобезопасна» закрывает вопрос только на словах: в разборах собеседований этот заход доходит до замены и её свойств, а не останавливается на факте. Начать стоит с формулировки документации, которая точнее привычной: карта должна синхронизироваться извне, если несколько потоков обращаются к ней одновременно и хотя бы один изменяет её структурно.
Термин «структурная модификация» документация определяет отдельно, и это различение стоит знать: структурная модификация — это добавление или удаление записей; замена значения у уже существующего ключа структурной модификацией не является.
Дальше — место, где привычное изложение расходится с первоисточником. ConcurrentModificationException часто подаётся как защитный механизм, который сообщит о проблеме. Javadoc говорит обратное, и почти дословно: гарантировать fail-fast невозможно, исключение бросается «на основе лучших усилий», и писать программу, корректность которой зависит от этого исключения, было бы неправильно — оно предназначено только для обнаружения ошибок.
ConcurrentHashMap решает задачу иначе и имеет свою цену, которую полезно назвать вместе с преимуществом:
- чтения не берут блокировку и не блокируются, а чтение отражает результат последнего завершённого обновления;
- итераторы слабо согласованы: они отражают состояние таблицы в некоторый момент и
ConcurrentModificationExceptionне бросают вовсе; - у составных операций вроде
putAllиclearконкурентное чтение может увидеть только часть изменений; size(),isEmpty()иcontainsValue()документация относит к методам, полезным, когда карта не изменяется конкурентно; иначе они годятся для мониторинга и оценки, но не для управления логикой;nullне допускается ни как ключ, ни как значение — в отличие отHashMap.
И оговорка, без которой список читается неверно: конкурентная карта делает атомарной отдельную операцию, а не последовательность из двух. Пара «проверить и положить», написанная в два вызова, остаётся гонкой — для неё есть putIfAbsent и compute.

Backend-разработчик Java
Учебный маршрут backend-разработчика на Java: язык и JVM, коллекции и многопоточность, Spring, реляционные данные и JPA, Kafka, безопасность, продакшн-инженерия и архитектура.

Backend Developer Java
Глубокое понимание Java и JVM для backend-разработки. Курс разбирает не синтаксис, а семантику языка и поведение платформы: коллекции и их стоимость, Stream API, устройство JVM — память, сборку мусора, JIT, — модель памяти и многопоточность, внутренности Spring (контейнер, прокси, транзакции), JPA/Hibernate, Kafka и production-практики: тестирование, наблюдаемость, контейнеры, устойчивость. Цель — научиться видеть стоимость абстракций и поведение кода под нагрузкой, а не просто заставлять его работать.
Готовьтесь не вслепую
Возьмите маршрут по своей роли и отрабатывайте ответы на вопросы с AI-разбором.