Hash Table Architecture
Go maps are implemented as hash tables with separate chaining using arrays rather than traditional linked lists. This design choice provides significant performance benefits through better cache locality and memory access patterns.
The core structure is the hmap (hash map) in the Go runtime:
type hmap struct {
count int // number of live cells (len())
flags uint8 // iterator flags, growing flag, etc.
B uint8 // log_2 of # of buckets (can hold up to 6.5 * 2^B items)
noverflow uint16 // approximate number of overflow buckets
hash0 uint32 // hash seed
buckets unsafe.Pointer // array of 2^B buckets
oldbuckets unsafe.Pointer // previous bucket array during growth
nevacuate uintptr // progress counter for evacuation
extra *mapextra // overflow buckets and old overflow buckets
}
Why this structure? Each field serves a critical purpose:
count: Enables O(1) len() operationsB: Determines bucket array size as power of 2 for fast bit maskinghash0: Security feature preventing hash DoS attacksbuckets/oldbuckets: Dual arrays enable incremental growth without blocking
Bucket Structure
Each bucket can hold exactly 8 key-value pairs, and this isn’t arbitrary - it’s optimized for cache line efficiency:
type bmap struct {
tophash [8]uint8 // high 8 bits of hash for each key
// followed by 8 keys
// followed by 8 values
// followed by overflow pointer
}
The memory layout innovation: Keys and values are stored separately!
Traditional layout: [key1][val1][key2][val2][key3][val3]...
Go's layout: [key1][key2][key3]...[val1][val2][val3]...
Why separate storage?
- Cache locality: When scanning for keys, we don’t load unnecessary values
- Alignment optimization: Values can be properly aligned regardless of key size
- SIMD potential: Multiple tophash values can be compared simultaneously
Hash Function Deep Dive
Hash Calculation Process
- Hash the key → get 64-bit hash value using type-specific hash function
- Split the hash strategically:
- Lower B bits determine bucket index:
bucket = hash & (1<<B - 1) - Top 8 bits become tophash:
tophash = uint8(hash >> 56) - Middle bits used during growth for redistribution
- Lower B bits determine bucket index:
Type-Specific Hash Functions
Go uses different hash functions based on key type for optimal performance
String Hashing (with AES optimization):
func stringHash(s string, seed uintptr) uintptr {
if len(s) == 0 {
return seed
}
// Use AES-based hash if CPU supports it (modern CPUs)
if useAeshash {
return aeshashstr(noescape(unsafe.Pointer(&s)), seed)
}
// Fallback to memhash for older CPUs
return memhashString(s, seed)
}
Integer Hashing (multiplication-based with seed mixing):
func int64Hash(i uint64, seed uintptr) uintptr {
// Mix the seed with the value
i ^= uint64(seed)
i ^= i >> 33
i *= 0xff51afd7ed558ccd // Large prime multiplier
i ^= i >> 33
i *= 0xc4ceb9fe1a85ec53 // Another large prime
i ^= i >> 33
return uintptr(i)
}
Others …
The Lookup Process
Here’s the actual lookup algorithm (simplified from Go runtime):
func mapaccess(h *hmap, key unsafe.Pointer) unsafe.Pointer {
hash := h.hash(key)
bucket := hash & (1<<h.B - 1) // Extract lower B bits
tophash := uint8(hash >> 56) // Extract top 8 bits
// Find the bucket in memory using pointer arithmetic
b := (*bmap)(add(h.buckets, bucket*uintptr(h.bucketsize)))
// Search bucket chain (main bucket + overflow buckets)
for ; b != nil; b = b.overflow {
for i := 0; i < 8; i++ {
// Fast tophash comparison first!
if b.tophash[i] == tophash && h.key_equal(key, b.key(i)) {
return b.value(i)
}
}
}
return nil // Return zero value if not found
}
Performance optimizations in this code:
- Bit masking instead of modulo:
hash & (1<<h.B - 1)is much faster thanhash % bucket_count - Tophash pre-filtering: 8-bit comparison before expensive key comparison
- Pointer arithmetic: Direct memory access without array bounds checking
- Cache-friendly traversal: Linear scan through bucket slots
Growth and the Evacuation Algorithm
This is where Go maps get truly sophisticated. The challenge: How do you resize a hash table without blocking all operations?
Growth Triggering Conditions
Load Factor Calculation:
func overLoadFactor(count int, B uint8) bool {
return count > bucketCnt && uintptr(count) > loadFactorNum*(bucketShift(B)/loadFactorDen)
// where bucketCnt = 8, loadFactorNum = 13, loadFactorDen = 2
// So threshold = 6.5 * 2^B
}
Why 6.5? This comes from extensive hash table research:
- Below 6.5: Wastes memory with mostly empty buckets
- Above 6.5: Too many collisions, performance degrades exponentially
- 6.5: Sweet spot balancing memory usage and lookup performance
Growth is triggered when EITHER condition is met:
if !h.growing() && (overLoadFactor(h.count+1, h.B) || tooManyOverflowBuckets(h.noverflow, h.B)) {
hashGrow(h) // Trigger growth: B = B + 1
}
Too Many Overflow Buckets:
func tooManyOverflowBuckets(noverflow uint16, B uint8) bool {
// If B >= 15: threshold = 2^15 overflow buckets
// If B < 15: threshold = 2^B overflow buckets
if B > 15 {
return noverflow >= uint16(1)<<15
}
return noverflow >= uint16(1)<<B
}
The B Value Determination Algorithm
func makemap(t *maptype, h *hmap, hint int) *hmap {
// Calculate optimal B based on expected size
B := uint8(0)
for overLoadFactor(hint, B) {
B++ // Keep increasing until load factor ≤ 6.5
}
// B now represents log₂(optimal_bucket_count)
h.B = B
h.buckets = newarray(t.bucket, int(bucketShift(B)))
}
The Incremental Evacuation Algorithm
The Problem: Naive approach would rehash everything at once:
// BAD: Would cause massive pause
func naiveGrow(h *hmap) {
newBuckets := allocate_new_buckets(2 * current_size)
for each key-value in old buckets {
newBucket := hash(key) & new_mask
insert into newBuckets[newBucket]
}
// PAUSE: Could take milliseconds for large maps!
}
Go’s Solution: Incremental evacuation during normal operations:
func evacuate(h *hmap, oldbucket uintptr) {
// Get the old bucket to evacuate
b := (*bmap)(add(h.oldbuckets, oldbucket*uintptr(h.bucketsize)))
newbit := h.noldbuckets() // This is 2^B - the new bit that determines split
// Two destination buckets for the split
var x, y evacDst // x = lower half, y = upper half
x.b = (*bmap)(add(h.buckets, oldbucket*uintptr(h.bucketsize)))
y.b = (*bmap)(add(h.buckets, (oldbucket+newbit)*uintptr(h.bucketsize)))
// Process all key-value pairs in the old bucket chain
for ; b != nil; b = b.overflow(t) {
k := add(unsafe.Pointer(b), dataOffset) // Pointer to first key
v := add(k, h.bucketsize-h.valuesize*8) // Pointer to first value
for i := 0; i < bucketCnt; i++ {
if b.tophash[i] == emptyOne || b.tophash[i] == emptyRest {
continue // Skip empty slots
}
// THE MAGIC: Recalculate hash to determine new bucket
hash := h.hash(k)
// Use the new bit (bit B) to decide destination
var dst *evacDst
if hash&newbit == 0 {
dst = &x // Stays in lower half (same index)
} else {
dst = &y // Moves to upper half (index + 2^B)
}
// Move key-value to destination
dst.b.tophash[dst.i] = b.tophash[i]
typedmemmove(h.keytype, dst.k, k)
typedmemmove(h.valuetype, dst.v, v)
// Advance destination pointers
dst.i++
dst.k = add(dst.k, h.keysize)
dst.v = add(dst.v, h.valuesize)
// If destination bucket full, allocate overflow bucket
if dst.i == bucketCnt {
dst.b = h.newoverflow(dst.b)
dst.i = 0
dst.k, dst.v = dst.b.keys(), dst.b.values()
}
}
// Move to next key-value pair in current bucket
k = add(k, h.keysize)
v = add(v, h.valuesize)
}
// Mark old bucket as evacuated
b.tophash[0] = evacuatedX // or evacuatedY depending on bucket type
}
The Binary Split Mathematics
The brilliant insight: When growing from 2^B to 2^(B+1) buckets:
Old hash: [remaining bits][B bits for bucket]
New hash: [remaining bits][new bit][B bits for bucket]
The new bit determines redistribution:
- New bit = 0: Key stays at same bucket index
- New bit = 1: Key moves to bucket index + 2^B
Concrete Example (B=2 growing to B=3):
Old: 4 buckets (indices 0,1,2,3)
New: 8 buckets (indices 0,1,2,3,4,5,6,7)
Old bucket 0 → New bucket 0 (new bit=0) or 4 (new bit=1)
Old bucket 1 → New bucket 1 (new bit=0) or 5 (new bit=1)
Old bucket 2 → New bucket 2 (new bit=0) or 6 (new bit=1)
Old bucket 3 → New bucket 3 (new bit=0) or 7 (new bit=1)
Evacuation Triggering and Progress
func mapassign(h *hmap, key unsafe.Pointer) unsafe.Pointer {
// ... hash calculation and bucket finding ...
if h.growing() {
growWork(h, bucket) // Evacuate during normal operations!
}
// ... continue with assignment ...
}
func growWork(h *hmap, bucket uintptr) {
// Evacuate the bucket we're working on
evacuate(h, bucket&h.oldbucketmask())
// Make additional progress - evacuate one more bucket
if h.nevacuate < h.noldbuckets() {
evacuate(h, h.nevacuate)
h.nevacuate++ // Track progress
}
}
Key Properties:
- Every map operation evacuates 1-2 buckets during growth
nevacuatecounter tracks which buckets have been evacuated- Growth completes when
nevacuate == noldbuckets() - Amortized O(1) - growth work spread across many operations
Lookup During Evacuation - The Tricky Part
The challenge: Some buckets are evacuated, others aren’t. Lookups must work correctly:
func mapaccess(h *hmap, key unsafe.Pointer) unsafe.Pointer {
hash := h.hash(key)
bucket := hash & (1<<h.B - 1) // New bucket index
if h.growing() {
oldbucket := bucket & h.oldbucketmask() // Corresponding old bucket
b := (*bmap)(add(h.oldbuckets, oldbucket*uintptr(h.bucketsize)))
if !evacuated(b) {
// Old bucket not evacuated yet - search there!
return searchBucket(b, key, hash)
}
}
// Search in new buckets (either not growing or bucket evacuated)
b := (*bmap)(add(h.buckets, bucket*uintptr(h.bucketsize)))
return searchBucket(b, key, hash)
}
Hash Seed (hash0) - Security Through Randomness
Seed Generation and Lifecycle
Generation Process:
func makemap(t *maptype, h *hmap, hint int) *hmap {
// ... other initialization ...
h.hash0 = fastrand() // Generated ONCE, never changes!
}
fastrand() Implementation:
// Each goroutine has its own random state
type g struct {
// ... other fields ...
rand uint64 // Per-goroutine random state
}
func fastrand() uint32 {
gp := getg() // Current goroutine
// Linear Feedback Shift Register (LFSR) algorithm
gp.rand = gp.rand*1103515245 + 12345
return uint32(gp.rand >> 32)
}
Security Properties and Hash DoS Prevention
Without Random Seed (Vulnerable):
// Predictable hash function allows targeted attacks
func badHash(key string) int {
return simpleHash(key) % bucketCount
}
// Attacker crafts keys that all collide:
// "evil1", "evil2", "evil3"... all hash to bucket 0
// Creates O(n) lookup time instead of O(1)
With Random Seed (Protected):
func goodHash(key string, seed uint32) int {
return complexHash(key, seed) % bucketCount
}
// Attacker cannot predict bucket distribution
// Maintains O(1) average performance
Real-world Attack Example:
// Without randomization, attacker could send HTTP POST data:
// field1=hash_to_0&field2=also_hash_to_0&field3=still_hash_to_0...
// Causing server map operations to degrade to O(n)
Seed Immutability
// CORRECT: Seed never changes after map creation
m := make(map[string]int) // hash0 = 0x12345678
m["hello"] = 42 // Uses 0x12345678
// ... map grows, keys redistribute ...
value := m["hello"] // Still uses 0x12345678 - finds key!
Why immutability is crucial:
// CATASTROPHIC if this happened:
m := make(map[string]int)
m["hello"] = 42 // Stored with seed A in bucket 3
// Imagine seed changed to B:
value := m["hello"] // Looks with seed B in bucket 7 - KEY NOT FOUND!
Flags Field - Runtime State and Race Detection
Flag Definitions and Usage
const (
iterator = 1 // there may be an iterator using buckets
oldIterator = 2 // there may be an iterator using oldbuckets
hashWriting = 4 // a goroutine is writing to the map
sameSizeGrow = 8 // current growth is compaction, not expansion
)
Concurrent Access Detection (Best-Effort)
func mapassign(h *hmap, key unsafe.Pointer) unsafe.Pointer {
// Check for concurrent write
if h.flags&hashWriting != 0 {
throw("concurrent map writes") // Runtime panic!
}
h.flags ^= hashWriting // Set writing flag (XOR toggle)
// ... perform map modification ...
h.flags &^= hashWriting // Clear writing flag (AND NOT)
return result
}
The Race Condition in Flag Operations
Flag operations are not atomic, which creates a window for concurrent access.
// RACE WINDOW EXISTS:
if h.flags&hashWriting != 0 { ... } // ← Read operation
h.flags ^= hashWriting // ← Write operation (NOT ATOMIC!)
Problematic sequence:
- Goroutine A: reads
flags, seeshashWriting = 0✓ - Goroutine B: reads
flags, seeshashWriting = 0✓ - Goroutine A: sets
hashWriting = 1 - Goroutine B: sets
hashWriting = 1(overwrites A’s flag) - Both proceed to modify map concurrently!
Why Go Doesn’t Use Atomic Operations
- Performance First: Atomic operations add overhead to every single map operation, even in single-threaded programs.
- Best-Effort Detection: Go’s approach is “best-effort race detection” rather than “foolproof race detection”. The goal isn’t to make maps thread-safe, but to catch common mistakes during development.
Iterator Safety Flags
func mapiterinit(h *hmap, it *hiter) {
// Detect concurrent modification during iteration
if h.flags&hashWriting != 0 {
throw("concurrent map iteration and map write")
}
// Mark that iterator exists
h.flags |= iterator
if old := h.oldbuckets; old != nil {
h.flags |= oldIterator // Iterator needs to see old buckets too
}
}
Growth Type Tracking
func hashGrow(h *hmap) {
bigger := uint8(1) // Default: double the buckets
if !overLoadFactor(h.count+1, h.B) {
// Same-size growth (compaction due to overflow buckets)
bigger = 0
h.flags |= sameSizeGrow
}
// Allocate new bucket array
oldbuckets, buckets := h.buckets, newarray(t.bucket, int(bucketShift(h.B+bigger)))
// ...
}
Memory Management and Performance Optimizations
Bucket Array Allocation
// Buckets allocated as single contiguous memory block
bucketsize = unsafe.Sizeof(bmap{}) + keysize*8 + valuesize*8 + ptrsize
buckets = newarray(buckettype, 1<<B) // 2^B buckets in one allocation
// Benefits:
// - Better cache locality
// - Reduces memory fragmentation
// - Enables fast pointer arithmetic for bucket access
Overflow Bucket Management
type mapextra struct {
overflow *[]*bmap // Overflow buckets in use
oldoverflow *[]*bmap // Old overflow buckets during growth
nextOverflow *bmap // Next free overflow bucket
}
// Overflow buckets are pooled and reused to reduce allocations
Cache-Friendly Design Decisions
Separate Key-Value Storage:
// Cache-friendly for key scanning:
for i := 0; i < 8; i++ {
if tophash[i] == target_tophash {
// Only load key if tophash matches
// Avoids loading unnecessary values
}
}
Reference: Faster Go Maps With Swiss Tables