Back to home

Go Internals - Built-in Types

10 min read
Cover Image for Go Internals - Built-in Types
Lucas LemosLucas Lemos

Introduction

In Go Internals - Garbage Collector we followed mark and sweep reclaim slots the allocator hands out. Before that, Memory already warned that a slice variable on the stack can still point at megabytes on the heap. This article is where those warnings become concrete layouts.

slice, string, and map look like single values in Go source. At runtime they are small headers — sometimes on the stack, sometimes escaped — pointing at backing storage the GC must trace. Slices and strings live in src/runtime/slice.go and string.go. Maps moved in Go 1.24: the real implementation is in src/internal/runtime/maps/ (map.go, table.go, group.go), with thin wrappers in runtime/map.go that the compiler still calls as mapaccess1, mapassign, and friends.

Headers are not the whole value

A variable of type []int is not an array. It is a triple the compiler treats like a struct:

// runtime/slice.go — not visible to user code
type slice struct {
  array unsafe.Pointer
  len   int
  cap   int
}

A string drops the capacity word:

// runtime/string.go
type stringStruct struct {
  str unsafe.Pointer
  len int
}

A map[K]V is a pointer to a maps.Map (see below). Copying a slice or string copies the header words, not the backing bytes. Two slice variables can share one array; subslicing is how that usually happens.

flowchart LR
  subgraph stack["Stack (often)"]
    H["slice header: ptr, len, cap"]
  end
  subgraph heap["Heap (when backing escapes)"]
    A["backing array elements"]
  end
  H -->|"array pointer"| A

Escape analysis from the Memory article decides whether the header, the backing store, or both land on the heap. The GC traces pointer words in headers and in backing arrays that contain pointers; noscan size classes from the Allocator article still apply to []byte backing stores.

Slice: indexing and subslicing

s[i] compiles to a bounds check plus address arithmetic: array + i*elemSize. No runtime call on the hot path when the compiler can prove indices are safe.

Subslicing s[low:high] builds a new header on the same backing array:

s := make([]int, 0, 8)
t := s[2:5] // len 3, cap 6 — shares s's array from index 2

len becomes high - low; cap becomes oldCap - low. That sharing is a feature until something mutates through one slice while another aliases the same memory — the language does not stop you; tests and code review do.

nil slice vs empty slice: both have len == 0, but nil has array == nil. Ranging and len behave the same; append on nil allocates a new backing array. A non-nil empty slice with cap > 0 can still reuse capacity on the next append without growth.

append and growslice

When len < cap, append writes into the existing backing array and bumps len in the header. When len == cap, the compiler calls growslice in runtime/slice.go.

Growth policy is in nextslicecap:

  • below 256 elements of capacity: double capacity
  • above that: smooth transition toward ~1.25× growth per step
func demo() []int {
  s := []int{1, 2, 3} // len 3, cap 3
  return append(s, 4) // growslice → new backing array, copy old elements
}

growslice allocates through mallocgc, copies the live prefix, and returns a new header. If the old backing array has no other references, it becomes garbage for the next GC cycle — a common source of "I appended once and RSS jumped" when you underestimate copy + retained old arrays.

append(s, s...) where s aliases itself can force a copy before mutation; the compiler/runtime detect that pattern. Appending to a slice that still shares a small parent backing array after many subslices is a classic way to retain memory you thought you dropped.

Pointer-heavy element types pay write-barrier work during copy when the GC is in mark phase; noscan element types skip scanning the new allocation's tail.

String layout and conversions

Strings are immutable byte sequences. The header points at read-only data — string literals live in the binary's read-only section; []byte("hello") allocates a mutable copy on the heap.

Substrings share storage:

s := "hello, world"
t := s[7:12] // "world" — same backing bytes as s, no copy

string(b) where b is []byte allocates and copies unless the compiler proves b does not escape. []byte(s) always copies because mutating the result would violate string immutability.

Comparison s == t is a length check plus memequal on the bytes — no allocation. Sorting or map keys with long strings moves those bytes through the same allocator/GC path as any other small object; the header is tiny, the payload is not.

Map: Swiss tables and groups

Go 1.26's map is a Swiss table design, based on Abseil's Swiss tables with Go-specific extensions. The package comment in internal/runtime/maps/map.go is the map:

// The map design is based on Abseil's "Swiss Table" map design
// ...
// - Group: A group of abi.MapGroupSlots (8) slots, plus a control word.
// - Table: A complete "Swiss Table" hash table.
// - Map: The top-level Map type consists of zero or more tables for storage.

The top-level header:

// internal/runtime/maps/map.go (Go 1.26)
type Map struct {
  used uint64 // # live entries — len(map) reads this
  seed uintptr
  dirPtr unsafe.Pointer // directory of tables, or single group (small map)
  dirLen int
  globalDepth uint8
  globalShift uint8
  writing uint8
  tombstonePossible bool
  clearSeq uint64
}

Each group holds 8 key/elem slots plus a control word — one byte per slot encoding empty, deleted (tombstone), or occupied. Occupied bytes also store the lower 7 bits of the hash (H2). Lookup compares all 8 H2 values in parallel against the key's hash, then does a real key equality check only on candidates — not one slot at a time like a naive linear-probed table.

flowchart TB
  M["maps.Map"] --> D["directory: 2^globalDepth table pointers"]
  D --> T0["table: groups array"]
  T0 --> G0["group: control word + 8 slots"]
  T0 --> G1["group ..."]

Small-map optimization: when a map never holds more than 8 entries, dirPtr points directly at a single group — no table allocation, no directory. That is why tiny maps are cheap and why makemap_small exists in runtime/map.go.

Hash bits split into H1 (upper bits — pick the starting group and drive quadratic probing) and H2 (lower 7 bits — filter slots inside a group). Probe walks groups until it finds a match or an empty slot; the table never fills to 100%, so every probe sequence terminates.

Lookup, insert, and growth

m[k] still compiles to mapaccess1 / mapaccess2 in runtime/map.go, which delegate into internal/runtime/maps. Missing keys return a pointer to the zero value for V (or false in comma-ok) — same semantics as before.

m[k] = v goes through mapassign. A nil map panics; make or a literal is required before writes. Concurrent writes without synchronization remain undefined — the runtime may print fatal error: concurrent map writes or corrupt data silently.

Growth is more layered than the old hmap + oldbuckets evacuation model:

  1. Single table, small: capacity doubles in place up to maxTableCapacity (1024 entries per table in table.go).
  2. Beyond that: the table splits into two; the map's directory grows (extendible hashing) so upper hash bits pick which sub-table owns a key.
  3. Deletion: slots in a full group become tombstones so probe sequences do not stop early; empty slots in a partially full group can be cleared directly.

Rehashing a whole table still happens at once for that table — probe sequences depend on group count — but a large map spreads work across many tables, so one grow is only a fraction of total entries. Iteration during grow is harder than the old model; the runtime keeps references to tables being walked and re-looks up keys in the new structure so spec semantics hold (no duplicate returns, deleted keys stay gone, modified values stay current). See the long iteration comment in map.go if you want the full state machine.

sequenceDiagram
  participant W as mutator write
  participant M as maps.Map
  participant T as table
  W->>M: mapassign hits load limit
  M->>T: grow — double or split table
  alt dir needs more bits
    M->>M: extend directory
  end
  Note over T: entries re-probed into new groups

Fast paths remain for common key types — mapaccess1_faststr, _fast64, _fast32 — in internal/runtime/maps/runtime_fast*.go.

Pre-1.24 maps used hmap with 8-slot buckets and overflow chains in runtime/map.go. If you diff against go1.23, that is the main structural change in this layer; slices and strings did not move.

range: what actually runs

for i, v := range s on a slice compiles to a loop over indices with bounds checks; no iterator struct.

for k, v := range m still builds an iterator on the stack and walks the map structure — now groups and tables in the directory rather than hiter over hmap.buckets. Iteration order is randomized per map. That is intentional — code must not depend on key order.

Growing a map while iterating is still defined behavior, but the implementation is heavier than before because iterators must track grows across table splits. clear(m) resets a map in place; prefer it over delete-in-range when you mean to drop everything.

Built-in map vs your hash table

In Data Structures with Golang - Part 3 you implemented a hash table with explicit chaining, load factor, and resize. The runtime map rhymes with that design but makes different trade-offs:

Topic Your HashTable Runtime map (Go 1.26)
Collision handling Chaining (linked nodes or slices) 8-slot groups + quadratic probing across groups
Growth Often one-shot rehash Per-table double or split; extendible hashing directory
Iteration order You chose (or documented unordered) Randomized per map
Key types Your interface{} or generics Compiler-specialized layouts per map[K]V
Concurrency You add mutexes Not safe — use sync.Map or external locking
Nil behavior Your API defined it nil map read returns zero; write panics

You controlled every resize policy. The runtime optimizes for generic map[K]V at any scale, SIMD-friendly group probing, and compiler integration. Neither design is "the textbook one" — they target different jobs.

Practical patterns

Preallocate slice capacity when you know n: make([]T, 0, n) avoids repeated growslice. Same idea as make(map[K]V, hint) — it does not pre-fill entries, but it can reduce early grows.

Copy when escaping subslices if you only need a small prefix: append([]T(nil), s[:k]...) forces a compact backing array independent of the parent.

Bytes ↔ string at a hot boundary: converting back and forth allocates. Keep one representation through the hot path; use unsafe only with extreme care and Go version awareness — not the default tool.

Map as cache with unbounded keys still grows until GC reclaims unreachable entries. A sync.Pool or explicit eviction policy from application code is still your responsibility; the runtime only implements the table.

Conclusion

Slices and strings are headers plus backing storage; maps are pointers to maps.Map tables built from 8-slot groups, control words, and a directory that grows with extendible hashing. Indexing and subslicing are cheap header math; append and map writes are where mallocgc, copying, and table growth show up in profiles.

Next in the series is Interfacesiface and eface, itabs, and what dynamic dispatch costs after these concrete layouts.