Перейти к содержимому

Maps в Go — под капотом

Maps — хеш-таблицы в Go. Часто кажется, что вы их понимаете, пока не доходит до вопросов про buckets, evacuation, итерацию, concurrent access и sync.Map. Этот файл закрывает все типичные собеседовательные мины.

  1. Базовое определение и API
  2. Внутреннее устройство (под капотом)
  3. Тонкие моменты / Gotchas
  4. Производительность
  5. Типичные вопросы на собеседовании Junior
  6. Practice — задачки на проверку
  7. Источники и дополнительно

Map — это hash table, ассоциативный массив “ключ → значение”. В Go это встроенный тип. Не упорядочен, поиск O(1) в среднем.

var m map[string]int // nil map
m1 := make(map[string]int)
m2 := make(map[string]int, 100) // hint capacity (preallocation)
m3 := map[string]int{"a": 1, "b": 2}
m := map[string]int{"a": 1, "b": 2}
// Запись
m["c"] = 3
// Чтение
v := m["a"] // 1
v2 := m["zzz"] // 0 (zero value!)
// ok-idiom — проверка существования
v3, ok := m["a"]
fmt.Println(v3, ok) // 1 true
// Удаление
delete(m, "a")
// Длина
fmt.Println(len(m))
// Итерация (порядок РАНДОМНЫЙ!)
for k, v := range m {
fmt.Println(k, v)
}

Ключом может быть только comparable тип:

ТипМожно как ключ?
int, float, string, boolДа
arrayДа (если элементы comparable)
structДа (если все поля comparable)
pointerДа (сравнивает адреса)
interfaceДа (с runtime panic, если динамический тип не comparable)
chanДа (сравнивает по identity)
sliceНет
mapНет
funcНет
m1 := map[[3]int]string{} // OK
m2 := map[*int]int{} // OK
// m3 := map[[]int]string{} // ОШИБКА
// m4 := map[func()]int{} // ОШИБКА

Тип map[K]V в Go — это указатель на структуру hmap.

// runtime/map.go (упрощённо)
type hmap struct {
count int // # количество элементов
flags uint8 // флаги (iteration, write и т.д.)
B uint8 // log2 числа buckets: 2^B buckets
noverflow uint16 // приблизит. число overflow buckets
hash0 uint32 // seed для хеш-функции
buckets unsafe.Pointer // массив бакетов, 2^B штук
oldbuckets unsafe.Pointer // во время роста: старый массив бакетов
nevacuate uintptr // прогресс эвакуации при росте
extra *mapextra // overflow buckets и т.д.
}

Каждый bucket хранит до 8 пар ключ-значение. После 8 элементов — overflow bucket.

// runtime/map.go (упрощённо)
type bmap struct {
tophash [8]uint8 // верхние 8 бит хеша каждого элемента
// далее в памяти (генерируется компилятором):
// keys [8]K
// values [8]V
// overflow *bmap
}
hmap (header)
┌──────────────────────────────┐
│ count = 12 │
│ B = 2 (значит 4 buckets) │
│ hash0 = 0xDEADBEEF │
│ buckets ──────┐ │
└──────────────┘│ │
buckets array (2^B = 4 элементов):
┌─────────┬─────────┬─────────┬─────────┐
│ Bucket0 │ Bucket1 │ Bucket2 │ Bucket3 │
└────┬────┴─────────┴─────────┴─────────┘
Bucket0 (bmap):
┌────────────────────────────────────────────┐
│ tophash[0..7]: AB CD EF 12 34 .. .. .. │ ← 8 байт, верхние 8 бит хеша
│ keys[0..7]: k0 k1 k2 k3 k4 .. .. .. │ ← все ключи рядом
│ values[0..7]: v0 v1 v2 v3 v4 .. .. .. │ ← все значения рядом
│ overflow: ──┐ │
└──────────────────┼────────────────────────┘
Overflow bucket (если bucket переполнился)

⚠️ Важно: keys и values хранятся отдельными массивами (не как [K,V] пары). Это улучшает кэш-локальность при поиске по ключу.

Go использует memhash — низкоуровневую хеш-функцию для большинства встроенных типов. Для строк — оптимизирован через AES-инструкции на современных CPU.

// runtime/alg.go (упрощённо)
func memhash(p unsafe.Pointer, seed, s uintptr) uintptr
func strhash(a unsafe.Pointer, h uintptr) uintptr

Hash рандомизирован при инициализации программы через hash0 (seed). Это нужно для:

  • Защиты от hash-collision DoS атак.
  • Рандомизации итерации (см. ниже).
1. Вычисляем хеш ключа: h := hash(k, hmap.hash0)
2. Выделяем lower B бит h → индекс bucket: bIdx = h & (2^B - 1)
3. Идём в bucket по индексу. Берём tophash = (h >> 56) & 0xFF (верхние 8 бит)
4. Сравниваем tophash[i] с искомым для каждого i из 0..7
5. Если tophash совпал — сравниваем keys[i] с искомым ключом
6. Если ключ нашёлся — возвращаем values[i]
7. Если не нашли — идём в overflow bucket (если есть)
8. Если до конца не нашли — возвращаем zero value

Когда map становится плотным, происходит рост (увеличение числа buckets).

Триггер:

  • count > 6.5 * 2^B (load factor > 6.5)
  • ИЛИ слишком много overflow buckets

Алгоритм роста:

  1. Аллоцируется новый массив buckets вдвое больше (B+1 → 2^(B+1) buckets).
  2. Старый массив НЕ копируется сразу. Это было бы дорого.
  3. Каждая операция set/delete переносит 1-2 старых bucket в новый.
  4. get смотрит сначала в новый, потом в старый, если ещё не эвакуирован.

Это называется incremental rehash — амортизированная стоимость операций.

При росте каждый ключ из bucket i (старого массива) может попасть в bucket i или i + 2^B (нового массива) — в зависимости от того, был ли в его хеше включён очередной “новый бит”.

B=2 → 4 buckets (индексы 0..3)
B=3 → 8 buckets (индексы 0..7)
Старый bucket 0 → новые buckets 0 ИЛИ 4
Старый bucket 1 → новые buckets 1 ИЛИ 5

Go специально рандомизирует порядок итерации:

  • Начальный bucket выбирается случайно.
  • Стартовая позиция внутри bucket — тоже случайна.
m := map[string]int{"a":1, "b":2, "c":3}
for k := range m {
fmt.Println(k) // разный порядок каждый запуск
}

Зачем: чтобы разработчики не закладывались на порядок (как, например, в Python <3.7 с insertion order). Если бы Go давал стабильный порядок, при росте hash table порядок мог бы внезапно измениться, и старый код сломался бы.

✅ Если нужен стабильный порядок — отсортируйте ключи отдельно:

keys := make([]string, 0, len(m))
for k := range m { keys = append(keys, k) }
sort.Strings(keys)
for _, k := range keys { fmt.Println(k, m[k]) }

var m map[string]int // nil
v := m["a"] // OK, возвращает 0
v, ok := m["a"] // OK, v=0, ok=false
len(m) // OK, 0
for range m {} // OK, 0 итераций
m["a"] = 1 // PANIC: assignment to entry in nil map
delete(m, "a") // OK (no-op в Go 1.x)

✅ Всегда инициализируйте: m := make(map[string]int) или m := map[string]int{}.

m := map[string]int{}
v := m["nonexistent"] // 0 (zero value!)

Из-за этого нельзя отличить “ключ не существует” от “ключ есть, значение 0”. Используйте ok-idiom:

v, ok := m["key"]
if !ok { /* ключа нет */ }
m := map[string]int{"a":1, "b":2, "c":3}
for k := range m {
fmt.Print(k, " ") // каждый запуск разный порядок
}

⚠️ Не пишите тесты, ожидающие фиксированный порядок.

m := map[string]int{}
// Goroutine 1:
go func() { m["a"] = 1 }()
// Goroutine 2:
go func() { _ = m["a"] }()

Запуск с -race покажет data race. Без race detector — может быть panic: fatal error: concurrent map writes или concurrent map iteration and map write.

✅ Решения:

  • sync.Mutex / sync.RWMutex вокруг операций.
  • sync.Map — для специфичных паттернов.

sync.Map оптимизирован под:

  • Read-heavy доступ.
  • Disjoint sets: разные горутины пишут разные ключи.

Для других сценариев (write-heavy) — map + sync.RWMutex быстрее.

var sm sync.Map
sm.Store("k", 1)
v, ok := sm.Load("k")
sm.Delete("k")
sm.Range(func(k, v any) bool {
return true
})

⚠️ sync.Map использует any, теряете типобезопасность. С Go 1.18+ есть generics-обёртки.

m := map[string]int{"a": 1}
// p := &m["a"] // ОШИБКА компиляции

Причина: при росте map значение может переехать в другую память. Адрес стал бы невалидным.

✅ Workaround:

m := map[string]*int{"a": new(int)}
*m["a"] = 5 // OK
p := m["a"] // OK, это копия указателя, но указывает на ту же память
type Pt struct{ X, Y int }
m := map[string]Pt{"a": {1, 2}}
// m["a"].X = 10 // ОШИБКА: cannot assign to struct field m["a"].X

Причина: m["a"] — это копия значения. Изменение копии бессмысленно.

✅ Решения:

// 1. Получить, изменить, положить обратно:
p := m["a"]
p.X = 10
m["a"] = p
// 2. Использовать pointer:
m2 := map[string]*Pt{"a": {1, 2}}
m2["a"].X = 10 // OK
m := make(map[int]int)
for i := 0; i < 1_000_000; i++ {
m[i] = i
}
for i := 0; i < 1_000_000; i++ {
delete(m, i)
}
// len(m) == 0, но размер buckets остался прежним!

Map никогда не уменьшает число buckets. Память освобождается только при m = nil или новом make.

✅ Если нужно “сбросить”:

m = make(map[int]int)
// или Go 1.21+:
clear(m) // обнуляет все значения, но cap не меняется

⚠️ clear(m) ускоряет очистку, но не уменьшает аллоцированную память.

✅ Во время range можно:

  • Удалять текущий ключ (delete).
  • Удалять любой ключ.
  • Менять значение существующего ключа.

⚠️ Во время range рискованно:

  • Добавлять новые ключи — могут или не могут попасть в текущую итерацию (поведение не определено).
for k := range m {
if k == "trigger" {
m["new"] = 1 // увидим или нет — НЕ ОПРЕДЕЛЕНО
}
}

✅ Если нужно гарантированно итерировать, копируйте ключи:

keys := make([]string, 0, len(m))
for k := range m { keys = append(keys, k) }
for _, k := range keys {
// безопасно меняем m
}
m := map[float64]string{}
m[math.NaN()] = "x"
v, ok := m[math.NaN()]
fmt.Println(v, ok) // "" false (!!)

Причина: NaN != NaN в IEEE 754. Map по-разному хеширует, и сравнение на этапе lookup всегда возвращает false.

string ключи дороже хешировать (особенно длинные). Если возможно, используйте int/uint как ключи.

m := make(map[string]int, 1000)

Это подсказка компилятору сразу выделить достаточно buckets под ~1000 элементов. Это не лимит, а оптимизация — избегает многократного расширения.

type Stringer interface{ String() string }
m := map[Stringer]int{}
m[nil] = 0 // OK
var s Stringer = []int{1,2,3} // ошибка: []int не реализует Stringer

Если динамический тип интерфейса не comparable — будет runtime panic при вставке.

func makeMap() map[string]int {
return map[string]int{"a": 1}
}
m := makeMap()
m["b"] = 2 // влияет на оригинал, т.к. map — указатель

Map передаются “по ссылке” (как slice header, но проще — это указатель на hmap).

type Key interface { comparable }
m := map[any]int{} // OK с Go 1.20+
m[5] = 1
m[[]int{1,2,3}] = 2 // PANIC в runtime: hash of unhashable type

С Go 1.20 расширили comparable, но runtime-panic остался.

Длинные строки в качестве ключей могут попасть в один и тот же overflow bucket, если хешируются одинаково. Это редко, но deterministic hash может быть атакован → DoS.

✅ Go использует рандомизированный seed (hash0) при старте программы — защита от hash flood DoS.


// Плохо:
m := map[int]int{}
for i := 0; i < 1_000_000; i++ {
m[i] = i
}
// Хорошо:
m := make(map[int]int, 1_000_000)
for i := 0; i < 1_000_000; i++ {
m[i] = i
}

Выигрыш — избегаем многократных evacuation.

type Big struct { /* много полей */ }
// Вариант A: struct
mA := map[string]Big{}
// При m[k] = v — копируется вся структура
// Вариант B: pointer
mB := map[string]*Big{}
// При m[k] = v — копируется только указатель

Если Big большой — указатели быстрее. Минус: добавляет уровень indirection (плохо для cache).

// sync.Map — лучше при:
// - 90%+ операций — Load
// - Каждая горутина пишет в свой набор ключей
// map + sync.RWMutex — лучше при:
// - Mixed read/write
// - Малое число горутин
// - Нужна типобезопасность

Бенчмарки показывают: sync.Map может быть в 2-3x медленнее обычного map+Mutex при write-heavy.

Если у вас миллионы записей с одинаковыми/похожими строками, используйте string interning — кэширование уникальных строк:

var intern = map[string]string{}
func Intern(s string) string {
if v, ok := intern[s]; ok { return v }
intern[s] = s
return s
}

Экономит память.

// До 1.21:
for k := range m { delete(m, k) }
// С 1.21:
clear(m) // быстрее
// Литерал — одна аллокация:
m := map[string]int{"a":1, "b":2, "c":3}
// vs build:
m := make(map[string]int)
m["a"] = 1
m["b"] = 2
m["c"] = 3

Литерал немного быстрее для маленьких maps (компилятор оптимизирует).


A: Встроенная hash table, ассоциативный массив “ключ → значение”. Под капотом — указатель на hmap-структуру с массивом buckets (по 8 элементов в каждом). Поиск амортизированный O(1).

A: Нет. Slice не comparable. Ключ должен быть comparable: int, string, struct (если поля comparable), array, pointer, interface (с runtime check).

A: Zero value для типа значения. Для map[string]int — 0. Для map[string]string"". Чтобы отличить — используйте ok-idiom: v, ok := m[k].

A: Нет, panic: assignment to entry in nil map. Но читать из nil-map можно (вернёт zero value). len(nil-map) == 0, delete(nil-map, k) — no-op.

A: Один читает + один пишет (или 2 пишут) → race condition. Go’s runtime детектит это и панирует: fatal error: concurrent map writes. Защита: sync.Mutex/RWMutex или sync.Map.

A:

  • sync.Map потокобезопасен из коробки.
  • Использует any (нет типобезопасности).
  • Оптимизирован под read-heavy и disjoint keys.
  • Для write-heavy паттернов map + sync.RWMutex быстрее.

A: Нет. &m[k] — ошибка компиляции. Причина: при росте map значение может переехать. Решение — хранить указатели: map[K]*V.

A: Go специально рандомизирует, чтобы разработчики не закладывались на порядок. Это защита от bugs, когда поведение программы зависит от внутренней структуры map.

Q9: Можно ли менять значение struct в map напрямую?

Заголовок раздела «Q9: Можно ли менять значение struct в map напрямую?»

A: Нет. m["a"].Field = 1 — ошибка. Нужно: получить копию, изменить, положить обратно (p := m["a"]; p.Field = 1; m["a"] = p). Или хранить указатели: map[K]*V.

A: Нет. Map не уменьшает число buckets. Память освободится только при m = nil или новом make. С Go 1.21 есть clear(m) — обнуляет значения, но cap остаётся.

A: Отношение количества элементов к числу buckets × 8. Если > 6.5 — триггерится рост (удвоение buckets, постепенная эвакуация). Это компромисс между скоростью и памятью.

A: Bucket, к которому добавляется ещё один, когда основной заполнен (>8 элементов с тем же индексом). Создаёт linked list. При слишком многих overflow buckets — также триггерится рост.

m := map[int]string{1:"a", 2:"b", 3:"c"}
delete(m, 4)
fmt.Println(len(m))

A: 3. Удаление несуществующего ключа — no-op, паники нет.

m := make(map[string]int)
m["a"]++
fmt.Println(m["a"])

A: 1. m["a"] вернул 0 (zero value), ++ сделал 1, записал.

Q15: Можно ли использовать map ключом в другой map?

Заголовок раздела «Q15: Можно ли использовать map ключом в другой map?»

A: Нет, map не comparable. map[map[int]int]int{} — ошибка.

A: Атака, при которой клиент посылает много ключей, хешируемых в один bucket → деградация O(1) до O(n). Защита Go: рандомизированный hash0 при старте.

Q17: Как итерироваться по map в отсортированном порядке?

Заголовок раздела «Q17: Как итерироваться по map в отсортированном порядке?»

A: Скопировать ключи в slice, отсортировать, итерировать по slice.

keys := make([]string, 0, len(m))
for k := range m { keys = append(keys, k) }
sort.Strings(keys)
for _, k := range keys { fmt.Println(k, m[k]) }

A: Несколько десятков байт (зависит от Go version, обычно ~48-56). Сам hmap-struct содержит ~10 полей. Плюс buckets.

var m map[string]int
v, ok := m["a"]
fmt.Println(v, ok)

A: 0 false. nil-map читается нормально.

Q20: Безопасно ли удалять элементы во время range?

Заголовок раздела «Q20: Безопасно ли удалять элементы во время range?»

A: Да, спецификация гарантирует. Можно удалять текущий или любой другой ключ. Добавление новых ключей — undefined behavior (может попасть в итерацию или нет).

A: Стратегия роста map: старые buckets не копируются сразу, а переносятся постепенно во время операций. Это снижает worst-case latency операций. Особенность Go’s map.

A: Trick question: для map функция cap() не определена. Только len(). Capacity не доступна из user space.

m := map[string]int{}
go func() { m["a"] = 1 }()
go func() { _ = m["a"] }()
time.Sleep(time.Second)

A: С -race — race detector сообщит. Без race detector — может работать, может паниковать с fatal error: concurrent map read and map write.

A: Да, если все поля struct comparable. struct из примитивов — OK. struct со slice внутри — нет.

type Key struct { Name string; ID int }
m := map[Key]int{} // OK

A: Встроенная функция, очищающая map (удаляет все ключи). Эквивалент: цикл с delete, но быстрее. Также работает для slice (обнуляет элементы). Память buckets не освобождает.


Напишите функцию, считающую количество уникальных слов в тексте.

func CountUniqueWords(text string) int {
// ???
}

Решение:

import "strings"
func CountUniqueWords(text string) int {
words := strings.Fields(text)
seen := make(map[string]struct{}, len(words))
for _, w := range words {
seen[w] = struct{}{}
}
return len(seen)
}

struct{} — оптимальный value для set (0 байт). Можно map[string]bool, но struct{} экономнее.

Сгруппируйте слайс по результату функции.

func GroupBy[T any, K comparable](s []T, f func(T) K) map[K][]T {
// ???
}

Решение:

func GroupBy[T any, K comparable](s []T, f func(T) K) map[K][]T {
out := make(map[K][]T)
for _, v := range s {
k := f(v)
out[k] = append(out[k], v)
}
return out
}
// Использование:
nums := []int{1, 2, 3, 4, 5, 6}
byParity := GroupBy(nums, func(x int) string {
if x%2 == 0 { return "even" }
return "odd"
})

⚠️ Тут используется тот факт, что append к nil-slice работает. out[k] для нового k — это nil, и append создаёт slice.

func TopN(words []string, n int) []string {
// вернуть top-N самых частых, в порядке убывания частоты
}

Решение:

import "sort"
func TopN(words []string, n int) []string {
freq := make(map[string]int)
for _, w := range words {
freq[w]++
}
type pair struct {
word string
count int
}
pairs := make([]pair, 0, len(freq))
for w, c := range freq {
pairs = append(pairs, pair{w, c})
}
sort.Slice(pairs, func(i, j int) bool {
return pairs[i].count > pairs[j].count
})
if n > len(pairs) { n = len(pairs) }
res := make([]string, 0, n)
for _, p := range pairs[:n] {
res = append(res, p.word)
}
return res
}

Реализуйте thread-safe cache с TTL.

type Cache struct {
mu sync.RWMutex
data map[string]cacheItem
}
type cacheItem struct {
value any
exp time.Time
}
func (c *Cache) Set(k string, v any, ttl time.Duration) {
c.mu.Lock()
defer c.mu.Unlock()
if c.data == nil {
c.data = make(map[string]cacheItem)
}
c.data[k] = cacheItem{v, time.Now().Add(ttl)}
}
func (c *Cache) Get(k string) (any, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
item, ok := c.data[k]
if !ok || time.Now().After(item.exp) {
return nil, false
}
return item.value, true
}

⚠️ Это не cleanup истекших — для production добавьте отдельную goroutine.

m := map[string]int{"a": 1, "b": 2}
for k, v := range m {
fmt.Println(k, v)
delete(m, k)
m["c"] = 3 // добавляем!
}
fmt.Println(m)

Решение: Поведение не определено спецификацией для добавления новых ключей во время range.

Возможные исходы:

  • "a" 1, "b" 2 потом m = map[c:3] (нашли только начальные)
  • "a" 1, "b" 2, "c" 3 (новый ключ попал в итерацию)
  • Любой другой порядок.

Урок: не делайте так.


  1. Go Blog — “Go maps in action”https://go.dev/blog/maps — официальный гайд.
  2. runtime/map.gohttps://github.com/golang/go/blob/master/src/runtime/map.go — исходники с комментариями (must-read для понимания).
  3. Keith Randall — “Inside the Map Implementation” (GopherCon 2016) — https://www.youtube.com/watch?v=Tl7mi9QmLns — глубокое погружение.
  4. Dave Cheney — “How the Go runtime implements maps efficiently”https://dave.cheney.net/2018/05/29/how-the-go-runtime-implements-maps-efficiently-without-generics
  5. sync.Map docshttps://pkg.go.dev/sync#Map — обязательно прочитайте про use cases.
  6. Habr — “Maps в Go под капотом” — есть свежие статьи 2024-2025.
  7. “100 Go Mistakes and How to Avoid Them” (Teiva Harsanyi) — раздел про maps.
  8. “Concurrent Map Implementations Compared” — статьи сравнения sync.Map с альтернативами (e.g., concurrent-map).

Карта — это не словарь Python. Помнить про buckets, рост, рандомизацию итерации, concurrent access — must для джуна.