GO 运行时内部原理

Go map 内部原理

Go 1.24 把内置 map 的实现整个换掉了:告别用了十几年的"桶 + 溢出链",改成了业界成熟的 Swiss Table 设计。这篇文章配了一个可以逐步播放的插入/查找/扩容模拟器,以及若干条直接引用自 Go 运行时源码的片段。

· 引用 Go 源码 commit 72aa6db7,路径 src/internal/runtime/maps/ · 源码遵循 BSD-3-Clause 协议,版权归 The Go Authors 所有,下文均为简短引用并附原文链接
分组 + 控制字节 · 8 个槽位打包在一起,一次比较扫完整组 H1 / H2 · 一个哈希拆成"去哪找"和"像不像"两部分 目录 + 可扩展哈希 · 满了只分裂一半,不用整体搬家

01 · 为什么要换掉用了十几年的实现

从桶 + 溢出链到 Swiss Table

老版本的 Go map(runtime/map.go 里的 hmap/bmap)用的是经典设计:一个桶装 8 个键值对,桶满了就挂一条"溢出桶"链表继续装。这个设计简单可靠,但有两个老毛病:溢出链一长,查找就退化成链表遍历;而且桶数组一旦扩容,旧数据要靠"渐进式搬迁"(每次写操作顺手搬一点)慢慢挪到新桶里,map 内部同时存在新旧两套桶,逻辑复杂,还占内存。

Go 1.24 把内置 map 换成了 Abseil Swiss Table 那一套思路的本土实现,搬进了新目录 internal/runtime/maps。核心变化是三个:

02 · 三个核心概念

分组 / 哈希拆分 / 目录

Group

8 个键值槽位 + 1 个控制字节数组。每个控制字节是三种状态之一:(1000 0000)、已删除(1111 1110)、占用(0 + 7 位 H2)。查找时把控制字整体和 H2 做匹配,一次搞定 8 个槽位的初筛。

H1 / H2

H1 是哈希值去掉低 7 位后的部分,决定从哪个分组开始探测;H2 是低 7 位,存进控制字节当"指纹"。只有指纹对上了,才会真的去比较 key 本身——多数不匹配的槽位在这一步就被过滤掉了。

目录 + 可扩展哈希

特别小的 map(≤7 个元素)甚至没有目录,直接是一个裸分组。长大之后,目录数组的每一项指向一个 table;table 满了就分裂成两个,必要时目录整体翻倍——这就是"可扩展哈希"。

group.go · L115-L123在 GitHub 上查看 ↗
// Each slot in the hash table has a control byte which can have one of three
// states: empty, deleted, and full. They have the following bit patterns:
//
//	  empty: 1 0 0 0 0 0 0 0
//	deleted: 1 1 1 1 1 1 1 0
//	   full: 0 h h h h h h h  // h represents the H2 hash bits
//
// TODO(prattmic): Consider inverting the top bit so that the zero value is empty.
type ctrl uint8
group.go · L248-L271在 GitHub 上查看 ↗
type groupReference struct {
	// data points to the group, which is described by typ.Group and has
	// layout depending on GOEXPERIMENT=mapsplitgroup:
	//
	// With mapsplitgroup (split arrays):
	// type group struct {
	// 	ctrls ctrlGroup
	// 	keys  [abi.MapGroupSlots]typ.Key
	// 	elems [abi.MapGroupSlots]typ.Elem
	// }
	//
	// Without (interleaved slots):
	// type group struct {
	// 	ctrls ctrlGroup
	// 	slots [abi.MapGroupSlots]struct {
	// 		key  typ.Key
	// 		elem typ.Elem
	// 	}
	// }
	//
	// In both cases, key(i) and elem(i) use the same formula via
	// typ.KeysOff/KeyStride and typ.ElemsOff/ElemStride.
	data unsafe.Pointer // data *typ.Group
}

控制字节和键值槽位是紧挨着存的同一块内存(ctrls + keys + elems),不是三个分开分配的数组——这样一个分组只需要一次内存访问就能把控制字节全部读进 CPU 缓存行,做位运算判断哪些槽位"看起来像"要找的 key。

map.go · L200-L211在 GitHub 上查看 ↗
// Extracts the H1 portion of a hash: the 57 upper bits.
// TODO(prattmic): what about 32-bit systems?
func h1(h uintptr) uintptr {
	return h >> 7
}

// Extracts the H2 portion of a hash: the 7 bits not used for h1.
//
// These are used as an occupied control byte.
func h2(h uintptr) uintptr {
	return h & 0x7f
}
map.go · L214-L265(Map 结构体节选)在 GitHub 上查看 ↗
type Map struct {
	// The number of filled slots (i.e. the number of elements in all
	// tables). Excludes deleted slots.
	// Must be first (known by the compiler, for len() builtin).
	used uint64

	// seed is the hash seed, computed as a unique random number per map.
	seed uintptr

	// The directory of tables.
	//
	// Normally dirPtr points to an array of table pointers
	//
	// dirPtr *[dirLen]*table
	//
	// The length (dirLen) of this array is `1 << globalDepth`. Multiple
	// entries may point to the same table. See top-level comment for more
	// details.
	//
	// Small map optimization: if the map always contained
	// abi.MapGroupSlots or fewer entries, it fits entirely in a
	// single group. In that case dirPtr points directly to a single group.
	//
	// dirPtr *group
	//
	// In this case, dirLen is 0. used counts the number of used slots in
	// the group. Note that small maps never have deleted slots (as there
	// is no probe sequence to maintain).
	dirPtr unsafe.Pointer
	dirLen int

	// The number of bits to use in table directory lookups.
	globalDepth uint8

	// The number of bits to shift out of the hash for directory lookups.
	// On 64-bit systems, this is 64 - globalDepth.
	globalShift uint8

	// writing is a flag that is toggled (XOR 1) while the map is being
	// written. Normally it is set to 1 when writing, but if there are
	// multiple concurrent writers, then toggling increases the probability
	// that both sides will detect the race.
	writing uint8

	// tombstonePossible is false if we know that no table in this map
	// contains a tombstone.
	tombstonePossible bool

	// clearSeq is a sequence counter of calls to Clear. It is used to
	// detect map clears during iteration.
	clearSeq uint64
}

注意 dirPtr 那段注释里的 small map optimization:如果一个 map 从头到尾都不超过 8 个元素,dirLen 永远是 0,dirPtr 直接指向唯一的那个分组,连目录这层间接寻址都省了。这也是为什么下面的模拟器一开始不会画出"目录"这一行——真的没有。

03 · 现场直播

插入 15 个 key,亲眼看着它长大

下面是一段真实模拟并逐条校验过的轨迹:依次插入 15 个编程语言名字作为 key。规则严格照抄上面(以及后面会引用)的源码:分组、H1/H2、探测序列、小 map 优化、目录翻倍、墓碑删除全部真实实现,我写了一个独立脚本,验证了插入结束后每个 key 都能通过模拟的 Get 正确查到,并且和一个朴素的 Python dict 逐项比对完全一致。唯一不真实的地方是哈希函数本身——用的是一个固定种子的 FNV-1a,不是 Go 运行时真正用的、每个进程随机加盐的硬件加速哈希,细节见文末说明。

key H1 H2
目录 Directory (小 map,没有目录)
点击"下一步"或"播放",从空 map 开始插入。
0 / 0
空槽位 已占用(显示 key + H2 指纹) 墓碑(已删除,但还占位) 此刻正在检查/改变

04 · 一次查找是怎么发生的

探测序列(probe sequence)

模拟器里几乎每一步都在做同一件事:算出 H1,从它对应的分组开始,沿着一条固定的"探测序列"逐组检查,直到找到 key 或者碰到一个真正的空槽位为止。这条序列不是随便走的,是二次探测(quadratic probing),保证只要分组数是 2 的幂,就一定会不重不漏地走遍每一个分组:

table.go · L1323-L1355在 GitHub 上查看 ↗

// probeSeq maintains the state for a probe sequence that iterates through the
// groups in a table. The sequence is a triangular progression of the form
// hash, hash + 1, hash + 1 + 2, hash + 1 + 2 + 3, ..., modulo mask + 1.
// The i-th term of the sequence is
//
//	p(i) := hash + (i^2 + i)/2 (mod mask+1)
//
// The sequence effectively outputs the indexes of *groups*. The group
// machinery allows us to check an entire group with minimal branching.
//
// It turns out that this probe sequence visits every group exactly once if
// the number of groups is a power of two, since (i^2+i)/2 is a bijection in
// Z/(2^m). See https://en.wikipedia.org/wiki/Quadratic_probing
type probeSeq struct {
	mask   uint64
	offset uint64
	index  uint64
}

func makeProbeSeq(hash uintptr, mask uint64) probeSeq {
	return probeSeq{
		mask:   mask,
		offset: uint64(hash) & mask,
		index:  0,
	}
}

func (s probeSeq) next() probeSeq {
	s.index++
	s.offset = (s.offset + s.index) & s.mask
	return s
}
table.go · L165-L223(getWithKey 节选)在 GitHub 上查看 ↗
func (t *table) getWithKey(typ *abi.MapType, hash uintptr, key unsafe.Pointer) (unsafe.Pointer, unsafe.Pointer, bool) {
	// To find the location of a key in the table, we compute hash(key). From
	// h1(hash(key)) and the capacity, we construct a probeSeq that visits
	// every group of slots in some interesting order. See [probeSeq].
	//
	// We walk through these indices. At each index, we select the entire
	// group starting with that index and extract potential candidates:
	// occupied slots with a control byte equal to h2(hash(key)). The key
	// at candidate slot i is compared with key; if key == g.slot(i).key
	// we are done and return the slot; if there is an empty slot in the
	// group, we stop and return an error; otherwise we continue to the
	// next probe index. Tombstones (ctrlDeleted) effectively behave like
	// full slots that never match the value we're looking for.
	//
	// The h2 bits ensure when we compare a key we are likely to have
	// actually found the object. That is, the chance is low that keys
	// compare false. Thus, when we search for an object, we are unlikely
	// to call Equal many times. This likelihood can be analyzed as follows
	// (assuming that h2 is a random enough hash function).
	//
	// Let's assume that there are k "wrong" objects that must be examined
	// in a probe sequence. For example, when doing a find on an object
	// that is in the table, k is the number of objects between the start
	// of the probe sequence and the final found object (not including the
	// final found object). The expected number of objects with an h2 match
	// is then k/128. Measurements and analysis indicate that even at high
	// load factors, k is less than 32, meaning that the number of false
	// positive comparisons we must perform is less than 1/8 per find.
	seq := makeProbeSeq(h1(hash), t.groups.lengthMask)
	h2Hash := h2(hash)
	for ; ; seq = seq.next() {
		g := t.groups.group(typ, seq.offset)

		match := g.ctrls().matchH2(h2Hash)

		for match != 0 {
			i := match.first()

			slotKey := g.key(typ, i)
			if typ.IndirectKey() {
				slotKey = *((*unsafe.Pointer)(slotKey))
			}
			if typ.Key.Equal(key, slotKey) {
				slotElem := g.elem(typ, i)
				if typ.IndirectElem() {
					slotElem = *((*unsafe.Pointer)(slotElem))
				}
				return slotKey, slotElem, true
			}
			match = match.removeFirst()
		}

		match = g.ctrls().matchEmpty()
		if match != 0 {
			// Finding an empty slot means we've reached the end of
			// the probe sequence.
			return nil, nil, false
		}
	}

这段注释里的概率分析很有意思:假设一次查找路上要跳过 k 个"不是它"的槽位,由于 H2 只有 7 位(128 种取值),期望只有 k/128 个槽位会因为指纹碰巧相同而被误判成"像是它",需要真的调用 Equal 比较一次。也就是说,H2 指纹几乎把"逐个比较 key"这件事完全挡在了外面。

05 · 从裸分组到 table

小 map 优化,以及并发写检测

模拟器插入到第 8 个 key(kotlin)时,单独的裸分组已经放满了 7 个(留 1 个空槽位保证探测能终止),这次插入直接触发"升级":新建一个有目录的正式 table,把原来 7 个 key 重新哈希搬进去,再放入第 8 个。这条路径,连同 Go map 一个经典的运行时保护——并发写检测——都在 PutSlot 里:

map.go · L505-L554(PutSlot 节选)在 GitHub 上查看 ↗
func (m *Map) PutSlot(typ *abi.MapType, key unsafe.Pointer) unsafe.Pointer {
	if m.writing != 0 {
		fatal("concurrent map writes")
	}

	hash := typ.Hasher(key, m.seed)

	// Set writing after calling Hasher, since Hasher may panic, in which
	// case we have not actually done a write.
	m.writing ^= 1 // toggle, see comment on writing

	if m.dirPtr == nil {
		m.growToSmall(typ)
	}

	if m.dirLen == 0 {
		elem := m.putSlotSmall(typ, hash, key)
		if elem == nil {
			// Can't fit another entry, grow to full size map.
			tab := m.growToTable(typ)

			elem = tab.uncheckedPutSlotForAssign(typ, hash, key)
			m.used++

			tab.checkInvariants(typ, m)
		}

		if m.writing == 0 {
			fatal("concurrent map writes")
		}
		m.writing ^= 1

		return elem
	}

	for {
		idx := m.directoryIndex(hash)
		elem, ok := m.directoryAt(idx).PutSlot(typ, m, hash, key)
		if !ok {
			continue
		}

		if m.writing == 0 {
			fatal("concurrent map writes")
		}
		m.writing ^= 1

		return elem
	}
}

m.writing ^= 1 这个异或翻转,就是 fatal("concurrent map writes") panic 的来源:写入开始时翻成 1,写入结束时再翻回 0;如果两个 goroutine 同时写同一个 map,大概率会有一次在对方还没翻回去之前就闯进来,检测到"应该是 0 却发现已经是 1(或者反过来)",直接 fatal 崩溃整个进程——这是故意设计成不可恢复的崩溃而不是 panic/recover,因为一旦发生数据竞争,map 内部状态已经不可信了。

06 · table 满了怎么办

分裂,而不是整体搬家

继续插入到第 15 个 key(zig)时,table 的 growthLeft 归零,触发分裂:按"比当前多用一位"的哈希位,把原 table 里的所有条目分流成两个新 table,原来指向旧 table 的目录项也跟着更新。如果目录已经没有多余的位可用(localDepth == globalDepth),就把整个目录翻倍,给新长出来的 table 腾位置:

map.go · L382-L414(installTableSplit 节选)在 GitHub 上查看 ↗
func (m *Map) installTableSplit(old, left, right *table) {
	if old.localDepth == m.globalDepth {
		// No room for another level in the directory. Grow the
		// directory.
		newDir := make([]*table, m.dirLen*2)
		for i := range m.dirLen {
			t := m.directoryAt(uintptr(i))
			newDir[2*i] = t
			newDir[2*i+1] = t
			// t may already exist in multiple indices. We should
			// only update t.index once. Since the index must
			// increase, seeing the original index means this must
			// be the first time we've encountered this table.
			if t.index == i {
				t.index = 2 * i
			}
		}
		m.globalDepth++
		m.globalShift--
		//m.directory = newDir
		m.dirPtr = unsafe.Pointer(&newDir[0])
		m.dirLen = len(newDir)
	}

	// N.B. left and right may still consume multiple indices if the
	// directory has grown multiple times since old was last split.
	left.index = old.index
	m.replaceTable(left)

	entries := 1 << (m.globalDepth - left.localDepth)
	right.index = left.index + entries
	m.replaceTable(right)
}

关键在于:分裂只发生在那一个装满的 table 身上,不会波及目录里其它已经安顿好的 table。这就是"可扩展哈希"这个名字的由来——目录会长大,但已经存在的数据不会被无谓地重新搬动。

07 · 删除为什么要留个"墓碑"

tombstone

模拟器里删除一个 key 时,如果它所在的分组还有空槽位,会直接把控制字节标成"空";但如果这个分组当时已经全满,直接标成空会切断经过它的探测链——后面的某个查找可能正是靠"这个分组没有空位,继续往下一个分组找"才能找到真正的目标,一旦提前看到空位就会误判成"没找到"。所以这种情况下会留一个特殊状态:墓碑(deleted),它在探测时表现得像"占用"(继续往下找),但在插入时可以被覆盖复用:

table.go · L520-L538在 GitHub 上查看 ↗
				// Only a full group can appear in the middle
				// of a probe sequence (a group with at least
				// one empty slot terminates probing). Once a
				// group becomes full, it stays full until
				// rehashing/resizing. So if the group isn't
				// full now, we can simply remove the element.
				// Otherwise, we create a tombstone to mark the
				// slot as deleted.
				var tombstone bool
				if g.ctrls().matchEmpty() != 0 {
					g.ctrls().set(i, ctrlEmpty)
					t.growthLeft++
				} else {
					g.ctrls().set(i, ctrlDeleted)
					tombstone = true
				}

				t.checkInvariants(typ, m)
				return tombstone

这次模拟里没有真的踩到墓碑分支:删除的 key 所在分组当时还有空位,所以直接标成了空,你在模拟器里会看到 tombstone: false。上面这段源码就是那个"分组已满"分支的真实逻辑——原理相同,只是这次插入的 15 个 key 不巧没有把某个分组正好填满就删除。

08 · 三个经典问题

顺手回答几个 FAQ

table.go · L710-L714在 GitHub 上查看 ↗
	// Randomize iteration order by starting iteration at a random slot
	// offset. The offset into the directory uses a separate offset, as it
	// must adjust when the directory grows.
	entryOffset uint64
	dirOffset   uint64

09 · 参考与说明

引用与这个模拟器做了哪些简化

☕ 如果这篇文章帮到你,可以请作者喝杯咖啡 · 爱发电