Chapter 18

Map Internals: Hash Table, Growth and Concurrency

Chapter 18: Map Internals: Hash Table, Growth and Concurrency

Go's map is one of the most used โ€” and most frequently misused โ€” data structures in the standard library. Most programmers know its interface cold: m[key] = value, delete(m, key), v, ok := m[key]. But when a production service panics with concurrent map read and map write, or when you wonder why map iteration order differs on every run, or when a profiler reveals map operations as a hot spot, you realize your understanding of map stops at the interface layer rather than the implementation layer.

This chapter dives into Go's hash table implementation, examining the layout of hmap and bmap, explaining why maps are not safe for concurrent use (and why Go deliberately made this choice), and then presenting real-world concurrent-safe solutions: from sync.RWMutex to sync.Map to the sharded map pattern.


Level 1: Philosophy โ€” Why Maps Are Not Concurrency-Safe

A Deliberate Design Decision

Go's map is not concurrency-safe. This is not an oversight; it is a deliberate, carefully considered design decision. The official Go blog explained this choice explicitly:

"After long discussion it was decided that the typical use of maps did not require safe access from multiple goroutines, and in those cases where it did, the map was probably part of some larger data structure or computation that was already synchronized."

In typical use cases, maps do not need concurrent multi-goroutine access; in those cases where they do, the map is usually already part of a larger, already-synchronized data structure.

The cost of this decision: concurrent reads and writes to a map cause a panic (runtime detection added in Go 1.6+). The benefit: when used from a single goroutine, maps carry zero overhead โ€” no atomic operations, no locking, just direct array reads and writes. If maps were built-in concurrency-safe, every access would pay the cost of atomic operations, even though 99% of map usage never requires that guarantee.

This reflects a core Go philosophy: you don't pay for what you don't use.

What Is a Data Race

Before diving into map internals, let's pin down a concept: a data race occurs when two goroutines concurrently access the same memory location, at least one access is a write, and there is no synchronization mechanism guaranteeing the order of access.

// Classic data race
m := map[string]int{"a": 1}
go func() { m["a"] = 2 }()  // goroutine 1 writes
go func() { _ = m["a"] }()  // goroutine 2 reads
// Result: undefined behavior โ€” may panic, may return wrong values

For simple types like int, a data race might only result in reading a "stale" or "new" value and appear harmless. But for a complex data structure like a map, concurrent writes corrupt internal pointer structures, causing segfaults or infinite loops. This is why Go chose to detect and panic at runtime rather than silently producing wrong results.


Level 2: Internals โ€” The hmap and bmap Layouts

hmap: The Control Structure

Every make(map[K]V) call (or map literal) allocates an hmap struct on the heap (runtime/map.go):

type hmap struct {
    count      int            // number of key-value pairs (returned by len(m))
    flags      uint8          // state flags (concurrent read/write detection here)
    B          uint8          // log2 of number of buckets (2^B buckets total)
    noverflow  uint16         // approximate count of overflow buckets
    hash0      uint32         // hash seed (randomized, guards against HashDoS)
    buckets    unsafe.Pointer // pointer to bucket array (2^B bmap elements)
    oldbuckets unsafe.Pointer // during growth, points to old bucket array
    nevacuate  uintptr        // incremental migration progress counter
    extra      *mapextra      // overflow bucket linked list, etc.
}

The B field is the key to understanding map layout: a map has 2^B buckets, each capable of storing up to 8 key-value pairs. Initially B may be 0 (1 bucket) or larger (pre-allocated multiple buckets).

bmap: The Fine-Grained Bucket Layout

Each bucket corresponds to a bmap struct, but its actual memory layout is more intricate than the struct definition suggests:

bmap (bucket) memory layout:

  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
  โ”‚ tophash [8]uint8                   โ”‚  โ† high 8 bits of hash for each of 8 slots
  โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
  โ”‚ key[0]  key[1]  ...  key[7]        โ”‚  โ† 8 keys stored contiguously
  โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
  โ”‚ val[0]  val[1]  ...  val[7]        โ”‚  โ† 8 values stored contiguously
  โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
  โ”‚ overflow *bmap                     โ”‚  โ† pointer to next overflow bucket
  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Notice: keys and values are stored separately (all keys first, then all values), not interleaved as in []struct{K, V}. This layout's advantage: when values are much larger than keys, scanning keys does not unnecessarily load value data into cache lines; when keys or values are not alignment-sized, less padding is wasted.

The tophash Optimization: Avoiding Full Key Comparisons

The tophash array is a critical performance optimization for map lookups. Each slot stores the high 8 bits of the corresponding key's hash:

Looking up key "hello" (assume hash(hello) = 0xABCD1234):

1. Compute bucket index: hash & (2^B - 1) = 0x34 & 0x07 = bucket #4
2. Compute tophash: hash >> (64-8) = 0xAB

3. Search bucket #4's tophash array for 0xAB:
   tophash: [0xAB, 0x12, 0xAB, 0x00, ...]
              โ†‘              โ†‘
           match!         also matches!

4. Only perform full key comparison for slots where tophash matches
   (most slots require comparing just 1 byte, not the full key)

tophash also uses several special sentinel values to represent slot states (empty, deleted, etc.), eliminating the need for a separate bitmap to track slot validity.

Load Factor and Growth Trigger

A map triggers growth when either of the following conditions is met:

Condition 1: Load factor too high (count / 2^B > 6.5)

6.5 is an empirically determined threshold from benchmarking: at this value, the trade-off between hash collision rate and memory utilization is well balanced. When exceeded, each bucket averages more than 6.5 key-value pairs and lookup performance degrades significantly.

Trigger Condition 1: count > 6.5 * 2^B

With B=3 (8 buckets), triggers when count > 52
New B becomes 4 (16 buckets), capacity doubles

Condition 2: Too many overflow buckets

Even if the load factor is acceptable, if the number of overflow buckets exceeds a threshold (when B < 15, more than 2^B overflow buckets exist), growth is triggered. This happens after many insertions followed by many deletions: the element count is low, but buckets contain many "tombstones" (deleted slots), causing lookups to traverse long overflow chains. In this case, growth (same-size growth, B unchanged) reorganizes memory and eliminates fragmentation.

Incremental Rehashing

When the hash table grows, all old bucket data cannot be migrated to new buckets at once โ€” that would cause large latency spikes. Go uses an incremental migration strategy:

Growth triggered (B: 3โ†’4):
  oldbuckets = buckets (points to old 8 buckets)
  buckets = newly allocated 16 buckets
  nevacuate = 0 (start migrating from old bucket #0)

  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
  โ”‚ buckets (new, 16 buckets)                โ”‚
  โ”‚  [0][1][2]...[7][8][9]...[15]            โ”‚
  โ”‚   โ†‘ gradually filled                     โ”‚
  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
  โ”‚ oldbuckets (old, 8 buckets)              โ”‚
  โ”‚  [0][1][2][3][4][5][6][7]                โ”‚
  โ”‚   โ†‘ gradually drained                    โ”‚
  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Each map write additionally migrates 1-2 old buckets.
When nevacuate == 8 (all old buckets migrated),
oldbuckets is set to nil and GC reclaims old memory.

Each write to the map (mapassign or mapdelete) additionally migrates one or two old buckets. This amortizes the O(n) bulk migration cost across subsequent O(1) regular operations, guaranteeing amortized O(1) complexity for map operations.

During growth, read operations check both oldbuckets and buckets to ensure correct data access while migration is in progress.

Hash Seed: Defending Against HashDoS

hmap.hash0 is a random hash seed, initialized from OS randomness when make(map) is called. This is a defense against HashDoS (Hash Denial of Service) attacks, introduced in Go 1.4.

HashDoS attack mechanism: if the hash function is deterministic, an attacker can construct many keys with identical hash values, forcing them all into the same bucket and degrading lookup complexity from O(1) to O(n), exhausting server resources. With a random seed, even if an attacker knows the key content, they cannot predict the resulting hash values, neutralizing the attack.

The cost: map iteration order differs across runs (because identical keys have different hash values in different process instances, landing in different buckets, which are visited in order during iteration). The Go runtime additionally randomizes the starting bucket and slot for iteration, further ensuring that developers cannot rely on iteration order.


Level 3: Code Patterns

Map Iteration Order Randomness

m := map[string]int{"a": 1, "b": 2, "c": 3, "d": 4}

// Iteration order may differ each run
for k, v := range m {
    fmt.Printf("%s=%d ", k, v)
}
// May print: a=1 c=3 b=2 d=4
// Or:        b=2 d=4 a=1 c=3

// For ordered iteration:
keys := make([]string, 0, len(m))
for k := range m {
    keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
    fmt.Printf("%s=%d ", k, m[k])
}

The root cause of non-deterministic iteration order: map iteration starts at a random bucket at a random slot, then traverses buckets in order. Even if the bucket distribution of key-value pairs is fixed (the hash seed does not change within a single process), the starting position is randomized.

Using Structs as Map Keys

Go requires map key types to be comparable โ€” they must support the == operator. A struct is comparable if all its fields are comparable:

type Point struct {
    X, Y int
}

// Point's fields are all int (comparable), so Point can be a key
m := map[Point]string{
    {1, 2}: "origin-ish",
    {0, 0}: "origin",
}

fmt.Println(m[Point{1, 2}])  // "origin-ish"

// Structs with slice fields cannot be keys
type Bad struct {
    Data []int  // slice is not comparable
}
// m2 := map[Bad]string{}  // compile error: invalid map key type Bad

Performance note: comparing struct keys requires field-by-field comparison across all fields. Larger structs mean slower comparisons. For large structs, consider using a hash or string representation as the key:

// Large struct: use string representation as key
type Config struct {
    Host    string
    Port    int
    Timeout time.Duration
    // ... many fields
}

func configKey(c Config) string {
    return fmt.Sprintf("%s:%d:%v", c.Host, c.Port, c.Timeout)
}
cache := map[string]Result{}
cache[configKey(cfg)] = result

Map vs Slice Performance Benchmark

// Scenario: integer-key lookup, comparing map[int]T vs []T
func BenchmarkMapLookup(b *testing.B) {
    m := make(map[int]int, 1000)
    for i := 0; i < 1000; i++ {
        m[i] = i * 2
    }
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        _ = m[i%1000]
    }
}

func BenchmarkSliceLookup(b *testing.B) {
    s := make([]int, 1000)
    for i := 0; i < 1000; i++ {
        s[i] = i * 2
    }
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        _ = s[i%1000]
    }
}

// Typical results:
// BenchmarkMapLookup-8    50000000    28.5 ns/op
// BenchmarkSliceLookup-8  500000000    2.1 ns/op
// slice is ~13x faster

When the key is a contiguous integer (or can be mapped to one), slice lookup is roughly an order of magnitude faster than map. Reason: a slice lookup is direct memory indexing (pointer + offset), while a map lookup requires computing a hash, locating a bucket, comparing tophash values, and comparing the full key โ€” multiple memory indirections.

Selection Principles:

sync.Map: Applicable Scenarios and Limitations

Go 1.9 introduced sync.Map, a concurrency-safe map optimized for specific access patterns:

var m sync.Map

// Write
m.Store("key", "value")

// Read
if v, ok := m.Load("key"); ok {
    fmt.Println(v.(string))
}

// Atomic read-or-write
actual, loaded := m.LoadOrStore("key", "default")
fmt.Println(actual, loaded)

// Delete
m.Delete("key")

// Iterate (order not guaranteed)
m.Range(func(k, v interface{}) bool {
    fmt.Println(k, v)
    return true  // return false to stop
})

sync.Map's internal structure contains two maps:

sync.Map {
    mu     Mutex           // protects dirty
    read   atomic.Value    // read-only map (atomic reads, lock-free)
    dirty  map[any]*entry  // writable map with all keys (requires lock)
    misses int             // read miss counter
}

A read operation first attempts to read from read (read-only view) without locking. Only when read doesn't contain the key does it lock and check dirty. When misses exceeds a threshold, dirty is promoted to become the new read, and dirty is cleared (lazy promotion).

sync.Map suitable for:

  1. Read-heavy, write-light workloads (keys written once, then read many times)
  2. Different goroutines reading/writing different keys (reduces lock contention)

sync.Map not suitable for:

  1. Frequent writes to the same key set (dirty and read sync too often, performance degrades)
  2. When len() is needed (sync.Map has no O(1) len; requires traversal)
  3. Frequent iteration (Range is less efficient than a standard map)

Level 4: Advanced โ€” Race Detection, Read-Only Maps and Sharded Maps

Runtime Concurrency Detection and the -race Flag

Go 1.6 introduced runtime detection of concurrent map reads and writes. However, this detection is not based on the happens-before memory model โ€” it is a lightweight check on the hmap.flags field:

// Simplified from runtime/map.go
func mapaccess1(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer {
    if h.flags&hashWriting != 0 {
        throw("concurrent map read and map write")
    }
    // ...
}

func mapassign(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer {
    if h.flags&hashWriting != 0 {
        throw("concurrent map writes")
    }
    h.flags ^= hashWriting
    // ... write operations ...
    h.flags &^= hashWriting
}

This detection catches most concurrent read/write patterns, but it is not perfectly reliable: under extreme race conditions, it can miss cases where two goroutines' flag checks and flag sets interleave at exactly the wrong moment.

For precise data race detection, use the -race flag:

go test -race ./...
go run -race main.go
go build -race -o myapp main.go

-race uses the ThreadSanitizer (TSan) algorithm, tracking every memory access with shadow memory to precisely detect all data races. The cost: programs run 5-10x slower and use 5-10x more memory. Typically only enabled during testing and debugging.

// What -race catches: concurrent map read/write
func TestRace(t *testing.T) {
    m := map[string]int{}
    var wg sync.WaitGroup
    wg.Add(2)
    go func() {
        defer wg.Done()
        m["a"] = 1  // write
    }()
    go func() {
        defer wg.Done()
        _ = m["a"]  // read
    }()
    wg.Wait()
    // go test -race reports: DATA RACE on map
}

Protecting a Map with sync.RWMutex

The most direct concurrency-safe map implementation: wrap a standard map with sync.RWMutex.

type SafeMap struct {
    mu sync.RWMutex
    m  map[string]int
}

func NewSafeMap() *SafeMap {
    return &SafeMap{m: make(map[string]int)}
}

func (s *SafeMap) Get(key string) (int, bool) {
    s.mu.RLock()
    defer s.mu.RUnlock()
    v, ok := s.m[key]
    return v, ok
}

func (s *SafeMap) Set(key string, val int) {
    s.mu.Lock()
    defer s.mu.Unlock()
    s.m[key] = val
}

func (s *SafeMap) Delete(key string) {
    s.mu.Lock()
    defer s.mu.Unlock()
    delete(s.m, key)
}

func (s *SafeMap) Len() int {
    s.mu.RLock()
    defer s.mu.RUnlock()
    return len(s.m)
}

RWMutex characteristics: multiple goroutines may hold a read lock simultaneously (RLock), but a write lock (Lock) is exclusive. In read-heavy workloads, RWMutex significantly outperforms a plain Mutex.

Note: sync.RWMutex write operations can suffer starvation under high contention โ€” a flood of read locks can prevent a write lock from being acquired for a long time. Go 1.14 improved the RWMutex implementation, giving write locks higher priority: when a write lock is waiting, new read lock requests are blocked.

Sharded Map: The High-Concurrency Tool

When concurrency is very high, a single lock protecting the entire map becomes a bottleneck. The solution is sharding: split the map into multiple independent shards, each with its own lock, mapping different keys to different shards to dramatically reduce lock contention.

const numShards = 32  // typically a power of 2

type ShardedMap struct {
    shards [numShards]mapShard
}

type mapShard struct {
    mu sync.RWMutex
    m  map[string]int
    _  [56]byte  // cache line padding to prevent false sharing
}

func NewShardedMap() *ShardedMap {
    sm := &ShardedMap{}
    for i := range sm.shards {
        sm.shards[i].m = make(map[string]int)
    }
    return sm
}

func (sm *ShardedMap) shardIndex(key string) int {
    // Map key to shard index using FNV hash
    h := fnv.New32a()
    h.Write([]byte(key))
    return int(h.Sum32()) % numShards
}

func (sm *ShardedMap) Get(key string) (int, bool) {
    shard := &sm.shards[sm.shardIndex(key)]
    shard.mu.RLock()
    defer shard.mu.RUnlock()
    v, ok := shard.m[key]
    return v, ok
}

func (sm *ShardedMap) Set(key string, val int) {
    shard := &sm.shards[sm.shardIndex(key)]
    shard.mu.Lock()
    defer shard.mu.Unlock()
    shard.m[key] = val
}

func (sm *ShardedMap) Delete(key string) {
    shard := &sm.shards[sm.shardIndex(key)]
    shard.mu.Lock()
    defer shard.mu.Unlock()
    delete(shard.m, key)
}

// Len must aggregate all shards
func (sm *ShardedMap) Len() int {
    total := 0
    for i := range sm.shards {
        sm.shards[i].mu.RLock()
        total += len(sm.shards[i].m)
        sm.shards[i].mu.RUnlock()
    }
    return total
}

Choosing numShards:

Performance comparison (high concurrency, 100 goroutines reading and writing simultaneously):

Approach Throughput (ops/s) Notes
sync.Mutex + map ~2M Heavy lock contention
sync.RWMutex + map ~8M Good read concurrency, writes still serial
sync.Map ~12M Best for read-heavy, write-light
ShardedMap (32) ~25M Best for balanced read/write

Go 1.21+ maps Package

Go 1.21 introduced the maps standard library package with a collection of commonly needed map utility functions:

import "maps"

m1 := map[string]int{"a": 1, "b": 2}
m2 := map[string]int{"b": 3, "c": 4}

// Clone a map
cloned := maps.Clone(m1)

// Copy all key-value pairs from m2 into m1 (m2 overwrites m1 on conflict)
maps.Copy(m1, m2)
// m1 is now: {"a": 1, "b": 3, "c": 4}

// Delete key-value pairs satisfying a predicate
maps.DeleteFunc(m1, func(k string, v int) bool {
    return v < 2  // delete pairs where value < 2
})

// Check equality of two maps
fmt.Println(maps.Equal(m1, m2))

// Iterate keys (Go 1.23+ range over func iterator)
for k := range maps.Keys(m1) {
    fmt.Println(k)
}

maps.Clone performs a shallow copy: both keys and values are copied, but if a value is a pointer or contains pointers, both maps' values point to the same underlying objects after cloning.

// The shallow copy trap
m1 := map[string]*[]int{
    "nums": {1, 2, 3},
}
m2 := maps.Clone(m1)
(*m2["nums"])[0] = 99  // mutates the slice m1["nums"] also points to!
fmt.Println(*m1["nums"])  // [99 2 3] โ€” unexpectedly modified

Read-Only Map Pattern

Sometimes you have a configuration map that is populated at initialization and never modified afterward, and you want multiple goroutines to safely read it concurrently. For truly read-only access, a standard map is concurrency-safe (concurrent reads are safe; only concurrent reads and writes are dangerous):

// Initialization phase (single goroutine)
config := map[string]string{
    "host": "localhost",
    "port": "8080",
}

// Passing map to multiple goroutines for read-only use โ€” this is safe
for i := 0; i < 10; i++ {
    go func() {
        host := config["host"]  // pure read, no race
        _ = host
    }()
}

If you want to explicitly express "this map is immutable after initialization," encapsulate it through an interface or function type:

type ReadOnlyMap struct {
    m map[string]string
}

func NewReadOnlyMap(m map[string]string) ReadOnlyMap {
    // Defensive copy โ€” prevents caller from modifying the original map later
    copied := make(map[string]string, len(m))
    for k, v := range m {
        copied[k] = v
    }
    return ReadOnlyMap{m: copied}
}

func (r ReadOnlyMap) Get(key string) (string, bool) {
    v, ok := r.m[key]
    return v, ok
}

// No Set/Delete methods โ€” compile-time guarantee of read-only access

Summary

Go's map is an elegantly designed hash table. The two-level design of hmap control structure plus bmap bucket array, the tophash fast filter, incremental growth's amortized cost control, and hash seed security protection โ€” each design decision reflects a concrete engineering trade-off.

Maps being non-concurrency-safe is a deliberate design: zero overhead for single-goroutine use, and a choice of sync.Mutex, sync.RWMutex, sync.Map, or sharded maps for concurrent scenarios. Understanding when to apply each approach and its performance characteristics is essential knowledge for writing high-performance Go programs.

Remember: iteration order is undefined, zero-value reads are safe (returning the type's zero value), concurrent writes cause a panic.

Rate this chapter
4.7  / 5  (15 ratings)

๐Ÿ’ฌ Comments