19. Map под капотом. hmap, buckets и рост
В предыдущей статье 18. Map. Хеш-таблица в Go мы познакомились с синтаксисом и базовым поведением мап. Мы узнали, что мапа передается в функции как легковесный 8-байтный указатель. Но на какую именно структуру в памяти он указывает?
Реализация хэш-таблицы в рантайме Go (написанная Китом Рэндаллом и командой рантайма) — это подлинный триумф системной инженерии. Здесь органично переплелись криптографическая защита от сетевых атак, механическое сочувствие к кэшам процессора (L1/L2) и алгоритмы плавной дефрагментации памяти без остановки мира. Понимание того, как мапа устроена на уровне байтов, — один из главных маркеров Senior-инженера на технических собеседованиях.
В этой главе мы препарируем структуру hmap, разберем анатомию бакетов bmap, проследим путь поиска ключа и выясним, почему мапа в Go никогда не отдает захваченную память обратно операционной системе.
1. Структура hmap: Заголовок хеш-таблицы
Разделы 1–5 описывают классическую реализацию хэш-таблицы, которая служила Go с версии 1.0 по 1.23. С Go 1.24 мапа устроена иначе — на основе Swiss Tables (раздел 6), но многие идеи (хэш с солью, поиск по коротким «отпечаткам» хэша, рост с перераспределением ключей) живут и в новой модели.
Когда вы пишете make(map[string]int), классический рантайм Go аллоцирует в куче структуру hmap (Header Map). Она описана в исходниках рантайма в файле runtime/map.go и хранит управляющие метаданные:
// Упрощенная структура hmap из исходников рантайма
type hmap struct {
count int // Логическое количество элементов (len)
flags uint8 // Флаги состояния (например, hashWriting для защиты от гонок)
B uint8 // Логарифм от количества бакетов (2^B = кол-во бакетов)
noverflow uint16 // Примерное количество overflow-бакетов
hash0 uint32 // Рандомный seed (соль) для хеш-функции
buckets unsafe.Pointer // Указатель на массив текущих бакетов
oldbuckets unsafe.Pointer // Указатель на старый массив бакетов (при эвакуации)
// ... отладочные поля и счетчики
}
Ключевые поля структуры:
count: счетчик элементов. Именно это поле мгновенно читается за $O(1)$, когда вы вызываете функциюlen(m).B: показатель степени двойки для количества бакетов. Число бакетов всегда равно $2^B$. ЕслиB = 4, в мапе ровно $2^4 = 16$ бакетов.buckets: прямой указатель на непрерывный массив бакетов в оперативной памяти.
2. Структура bmap (Бакет) и Mechanical Sympathy
Хэш-таблица в Go — это массив бакетов (buckets). Каждый бакет представлен структурой bmap и спроектирован так, чтобы вмещать ровно 8 пар «ключ-значение»:
flowchart LR
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
HMAP["hmap"]:::process -->|"buckets"| BArray["Массив бакетов"]
BArray --> B0["Бакет 0"]
BArray --> B1["Бакет 1"]
BArray --> B2["Бакет 2"]
B1 -->|"Если бакет переполнен"| OF["Overflow Бакет"]
Если вы заглянете в runtime/map.go, определение bmap покажется обманчиво пустым: там объявлено лишь одно поле tophash. Причина в том, что конкретная структура бакета компилируется динамически на лету в зависимости от типов ключа и значения.
Логическая топология бакета bmap выглядит следующим образом:
flowchart LR
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
subgraph BMAP["Структура bmap (один бакет)"]
direction LR
T["tophash<br>Массив из 8 байт"]:::process --> K["Keys<br>8 ключей подряд"]
K --> V["Values<br>8 значений подряд"]
V --> O["Overflow ptr<br>Указатель на след. бакет"]
end
Mechanical Sympathy: Гениальная оптимизация памяти
Обратите внимание на физический порядок полей в памяти. Почему Go не хранит элементы парами K1, V1, K2, V2..., а группирует их монолитными блоками: K1, K2, K3... V1, V2, V3...?
Причина — выравнивание памяти (Memory Padding), о котором мы будем подробно говорить в главе 21. Struct. Пользовательские типы данных.
Представьте хэш-таблицу типа map[int64]int8:
- Ключ
int64занимает 8 байт (требует выравнивания по границе 8 байт). - Значение
int8занимает всего 1 байт.
Если бы они чередовались парами (int64, int8), процессору требовалось бы вставлять 7 байт пустого заполнителя (padding) после каждого однобайтового значения, чтобы следующий 8-байтный ключ начинался с кратного адреса. На одном бакете впустую сгорало бы 56 байт памяти!
Сгруппировав 8 ключей вместе, а следом разместив 8 значений, классический рантайм Go (до 1.23) полностью уничтожал паразитный padding. Бакет получался компактным, для небольших ключей и значений помещаясь в одну-две кэш-линии процессора (L1 Cache). (В Swiss Tables с Go 1.24 слоты по умолчанию снова чередуются — key, elem, key, elem…; раздельная раскладка включается только экспериментом GOEXPERIMENT=mapsplitgroup.)
3. Анатомия поиска: Как Go находит элемент?
Когда программа запрашивает элемент по ключу (val := m["alice"]), процессор выполняет пятишаговый алгоритм:
- Хеширование: Ключ
"alice"смешивается с сольюhash0и передается в хэш-функцию. На выходе получается 64-битный хэш. - Выбор бакета (Low bits): Рантайм берет младшие
Bбит хэша. ЕслиB = 4, берутся 4 младших бита (например,0011в двоичной системе = 3 в десятичной). Поиск направляется строго в Бакет №3. - TopHash (Быстрый фильтр): Рантайм берет старшие 8 бит хэша (High bits) — это значение
tophash. - Быстрый поиск внутри бакета: Процессор не сравнивает строки! Он проходит скалярным циклом по 8 байтам массива
tophashвнутри бакета, сравнивая однобайтовые «отпечатки». (Параллельное SIMD-сравнение 8 контрольных байт — уже свойство Swiss Tables, раздел 6.) - Финальное сравнение: Только если байт в
tophashсовпал со старшими битами искомого хэша, рантайм обращается к массиву ключей и выполняет честное сравнениеkey == "alice". Это исключает миллионы дорогостоящих сравнений тяжелых строк и структур.
4. Разрешение коллизий: Chaining (Цепочки)
Что происходит, когда в один и тот же бакет хэшируется 9-й элемент, а все 8 слотов уже заняты?
Go использует модифицированный метод связных списков (Separate Chaining). Рантайм выделяет в куче дополнительный бакет переполнения (Overflow Bucket) и записывает указатель на него в поле overflow текущего бакета, выстраивая цепочку.
5. Эвакуация (Рост мапы)
Решение о расширении таблицы рантайм Go принимает на основе двух строгих триггеров:
- Load Factor > 6.5. Фактор загрузки — это отношение общего числа элементов к числу бакетов (
count / 2^B). Если корзины заполнены в среднем более чем на 80% (6.5 из 8 слотов), вероятность коллизий резко возрастает. - Слишком много overflow-бакетов. Если фактор загрузки невелик, но создано аномально много overflow-бакетов (следствие частой вставки и удаления ключей), мапа страдает от острой фрагментации.
Инкрементальная эвакуация (Incremental Rehash)
Когда превышен Load Factor > 6.5, рантайм:
- Увеличивает
Bна 1 (общее число бакетов удваивается). - Выделяет новый массив бакетов.
- Сохраняет указатель на старый массив в поле
oldbuckets, а новый массив связывает сbuckets.
Но Go никогда не копирует все данные разом.
Если в вашей таблице содержится 10 миллионов элементов, полная блокировка сервиса (Stop-The-World) ради их переноса заморозила бы сервер на секунды, сокрушив все SLA по задержкам (latency).
Вместо этого Go применяет инкрементальную эвакуацию:
При каждом последующем вызове записи (m[k] = v) или удаления (delete) рантайм эвакуирует ровно один или два бакета из oldbuckets, пересчитывает для их 8 элементов новые адреса и аккуратно перемещает их в новый массив.
Эвакуация того же размера (Same-size Evacuation)
Если эвакуацию спровоцировало переполнение overflow-бакетов при низком Load Factor, удваивать емкость бессмысленно. Рантайм выделяет массив точно такого же размера ($2^B$) и переносит ключи туда.
Это чистая дефрагментация: разрозненные элементы из длинных overflow-цепочек уплотняются обратно в основные бакеты, освобождая «дыры», оставшиеся после операций delete.
6. Go 1.24+: Swiss Tables
С Go 1.24 мапа реализована по мотивам Swiss Table из библиотеки Abseil (Google). Ключевые отличия от классической модели:
- Группы вместо бакетов. Слоты собраны в группы по 8. У каждой группы есть контрольное слово (
ctrl, 8 байт — по байту на слот), где для занятого слота хранятся 7 младших бит хэша (H2), а остальные биты хэша (H1) выбирают, с какой группы начинать поиск. - Поиск. Рантайм сравнивает H2 искомого ключа сразу со всеми 8 контрольными байтами группы (на amd64 — SIMD-инструкциями, на других архитектурах — арифметикой над 64-битным словом) и честно сравнивает ключи только в совпавших слотах. Цепочек overflow-бакетов нет: при заполненной группе поиск продолжается в следующих группах (открытая адресация).
- Таблицы и директория. Одна таблица вмещает не больше 1024 слотов; большая мапа — это директория из нескольких таблиц. Таблица заполняется в среднем не больше чем на 7/8.
- Рост. Растёт не вся мапа сразу, а одна переполненная таблица: она расщепляется на две. Поэтому пауза на рост ограничена размером одной таблицы.
- Удаление. Удалённый слот помечается «надгробием» (tombstone), чтобы не разорвать цепочку поиска.
- Раскладка. Слоты по умолчанию чередуются (
key, elem); раздельная раскладка (KKKK…VVVV) — только сGOEXPERIMENT=mapsplitgroup.
7. Великая утечка памяти в Map
С мапами в Go связан фундаментальный архитектурный нюанс, о котором обязан помнить каждый бэкенд-разработчик:
Хэш-таблица в Go никогда не уменьшает свой размер (does not shrink).
Если под пиковой нагрузкой вы добавили в мапу 1 000 000 элементов, классический рантайм выделит под них $2^{18} = 262\,144$ бакета в куче (число бакетов — степень двойки, а при load factor 6,5 нужно не меньше 153 847) — около 54 МБ для map[string]int.
Если затем вы вызовете функцию delete для всех 1 000 000 ключей, счетчик count честно обнулится до 0. Однако физический массив из 262 144 бакетов (десятки мегабайт оперативной памяти, а для крупных значений — и больше) навсегда останется в памяти. Поле B никогда не уменьшается в меньшую сторону! Сборщик мусора бессилен, так как живая структура hmap продолжает удерживать массив корзин. Swiss Tables (Go 1.24+) в этом не отличаются: замер на Go 1.27 даёт те же ~56 МБ на миллион ключей map[string]int, и после удаления всех ключей память не освобождается.
Как бороться с этой утечкой?
Если вы используете мапу в качестве локального In-Memory кэша, который периодически раздувается, а затем сбрасывается, периодически пересоздавайте мапу заново:
// Плохой подход для In-Memory кэша (память не освобождается):
for k := range cache {
delete(cache, k)
}
// Идиоматичный подход (старая мапа уходит в Garbage Collector):
cache = make(map[string]Data)
Итог
hmap: структура хэш-таблицы в куче (~48 байт на 64-битной платформе), на которую ссылается 8-байтный указатель переменной мапы*hmap. Хранит счетчик элементовcount, логарифмический размер $2^B$ и случайную сольhash0для защиты от сетевых атак HashDOS.bmap(Бакет): Вмещает ровно 8 записей. В классической реализации (до Go 1.23) ключи и значения сгруппированы раздельно (K1..K8, V1..V8), исключая внутренний padding выравнивания; в Swiss Tables (Go 1.24+) слоты по умолчанию чередуются.- TopHash: 8-битный массив старших байт хэша, позволяющий мгновенно фильтровать несовпадающие ключи инструкциями CPU.
- Инкрементальный рост: При
Load Factor > 6.5емкость удваивается. Перенос данных происходит порциями по 1–2 бакета при операциях мутации, гарантируя низкие задержки (low latency). - Память не сжимается: Вызов
deleteудаляет значения, но не освобождает бакеты (и группы Swiss Tables тоже). Периодически пересоздавайте раздувающиеся мапы черезmake. - Swiss Tables (Go 1.24+): группы по 8 слотов с контрольным словом, таблицы до 1024 слотов, рост расщеплением одной таблицы; в современных версиях Go классической реализации больше нет.
Мы детально исследовали контейнеры данных: массивы, слайсы и хэш-таблицы. Однако самым частым ключом и самым популярным типом данных в бэкенде остается текст. Строки в Go обладают уникальной двойственной природой: с одной стороны, это неизменяемый монолит, с другой — легковесный дескриптор байтов.
В следующей статье — 20. Строки в Go. Immutable string и работа с Unicode — мы заглянем под капот строк, исследуем StringHeader, интернирование (interning) и разберем высокопроизводительную конкатенацию через strings.Builder.