Maps

60 阅读8分钟

在Go语言中,理论上map的容量是无限的,仅受可用内存的限制。这就是为什么内置的cap函数不适用于map。

在官方标准的Go运行时实现中,map在内部是作为哈希表实现的。每个map/哈希表维护一个后备数组来存储map条目(键值对)。随着越来越多的条目被放入map中,可能会认为后备数组的大小太小,无法存储更多条目,因此会分配一个新的更大的后备数组,当前的条目(在旧的后备数组中)将被移动到新数组中,然后旧的后备数组将被丢弃。

在官方标准的Go运行时实现中,即使所有条目都从map中删除,map的后备数组也永远不会收缩。这是一种内存浪费形式。但在实际应用中,这很少成为问题,实际上通常对程序性能有益。 

清理 map entries

我们可以使用以下循环来清除map中的所有entries:

	for key := range aMap {
		delete(aMap, key)
	}

该循环经过特别优化(除非存在键为NaN的条目),因此执行速度非常快。然而,请注意,如上文所述,在循环之后,已清空的map的后备数组不会收缩。那么如何释放map的后备数组呢?有两种方法:

	aMap = nil
	// or
	aMap = make(map[K]V)

如果map的后备数组在其他地方没有被引用,那么后备数组在被释放后最终会被回收。

如果在map被清空后会有许多新条目放入其中,那么前一种方法更可取;否则,后一种(释放)方法更可取。

自Go 1.21起,有了一种更好的方法来完成这项工作。Go 1.21引入了一个新的内置函数clear,它可用于清除map中的所有条目,包括那些键为NaN的条目。

注意:目前(Go工具链1.24),使用内置的clear函数来清除至少有一个条目的map,所需时间与map的后备数组大小成正比。 

aMap[key]++ 比 aMap[key] = aMap[key] + 1 效率更高。

在语句aMap[key] = aMap[key] + 1中,键会被哈希两次,而在语句aMap[key]++ 中,键只被哈希一次。

类似地,aMap[key] += value比aMap[key] = aMap[key] + value效率更高。

这些可以通过以下基准测试代码来证明:

package maps

import "testing"

var m = map[int]int{}

func Benchmark_increment(b *testing.B) {
	for i := 0; i < b.N; i++ {
		m[99]++
	}
}

func Benchmark_plusone(b *testing.B) {
	for i := 0; i < b.N; i++ {
		m[99] += 1
	}
}

func Benchmark_addition(b *testing.B) {
	for i := 0; i < b.N; i++ {
		m[99] = m[99] + 1
	}
}

基准测试结果:

Benchmark_increment-4  11.31 ns/op
Benchmark_plusone-4    11.21 ns/op
Benchmark_addition-4   16.10 ns/op	

map中的指针

如果map的键类型和元素类型都不包含指针,那么在垃圾回收(GC)周期的扫描阶段,垃圾回收器将不会扫描该map的条目。这能节省大量时间。

这个技巧对Go中的其他类型容器同样适用,比如切片、数组和通道。 

使用字节数组而非短字符串作为key:

在内部,每个字符串都包含一个指针,该指针指向该字符串的底层字节。所以如果map的键或元素类型是字符串类型,那么在垃圾回收周期中,map的所有条目都需要被扫描。

如果我们能确保map条目中使用的字符串值有一个最大长度,并且这个最大长度很小,那么我们可以使用数组类型 [N]byte 来替代字符串类型(其中N是最大字符串长度)。如果map中的条目数量非常大,这样做将节省大量的垃圾回收扫描时间。

例如,在以下代码中,mapB的条目不包含指针,但mapA的(字符串)键包含指针。所以在垃圾回收周期的扫描阶段,垃圾回收器将跳过mapB。 

	var mapA = make(map[string]int, 1 << 16)
	var mapB = make(map[[32]byte]int, 1 << 16)

并且请注意,官方标准编译器对大小为4字节或8字节的map键哈希进行了特殊优化。因此,从节省CPU的角度来看,使用map[[8]byte]V 比使用map[[5]byte]V更好,使用map[int32]V比使用map[int16]V更好。

降低map元素的修改频率

在前面“字符串与字节切片”章节中提到过,在map元素检索表达式中作为索引键出现的字节切片到字符串的转换不会分配内存,但在左值map元素索引表达式中的此类转换会分配内存。

所以有时,我们可以降低在左值map元素索引表达式中使用此类转换的频率,以提升程序性能。

在下面的示例中,方法B(指针元素法)比方法A的性能更好。原因是方法B很少修改元素值。方法B中的元素是指针,一旦创建,就不会再改变。 

package maps

import "testing"

var wordCounterA = make(map[string]int)
var wordCounterB = make(map[string]*int)
var key = make([]byte, 64)

func IncA(w []byte) {
	wordCounterA[string(w)]++
}

func IncB(w []byte) {
	p := wordCounterB[string(w)]
	if p == nil {
		p = new(int)
		wordCounterB[string(w)] = p
	}
	*p++
}

func Benchmark_A(b *testing.B) {
	for i := 0; i < b.N; i++ {
		for i := range key {
			IncA(key[:i])
		}
	}
}

func Benchmark_B(b *testing.B) {
	for i := 0; i < b.N; i++ {
		for i := range key {
			IncB(key[:i])
		}
	}
}

基准测试结果:

Benchmark_A-4  11600 ns/op  2336 B/op  62 allocs/op
Benchmark_B-4   1543 ns/op     0 B/op   0 allocs/op

尽管方法B(指针元素法)消耗的CPU资源较少,但它会创建许多指针,这增加了垃圾回收(GC)周期中指针扫描的负担。但总体而言,方法B效率更高。

我们可以使用一个额外的计数器表(一个切片),并让映射记录该表的索引,以避免进行大量分配和创建许多指针,如下代码所示: 

var wordIndexes = make(map[string]int)
var wordCounters []int

func IncC(w []byte) {
	if i, ok := wordIndexes[string(w)]; ok {
		wordCounters[i]++
	} else {
		wordIndexes[string(w)] = len(wordCounters)
		wordCounters = append(wordCounters, 1)
	}
}

func Benchmark_C(b *testing.B) {
	for i := 0; i < b.N; i++ {
		for i := range key {
			IncC(key[:i])
		}
	}
}

基准测试结果:

Benchmark_A-4  11600 ns/op  2336 B/op  62 allocs/op
Benchmark_B-4   1543 ns/op     0 B/op   0 allocs/op
Benchmark_C-4   1609 ns/op     0 B/op   0 allocs/op

从短期来看,方法C的性能几乎与方法B相当。但由于它使用的指针少得多,从长远来看,它实际上比方法B更高效。

请注意,上述基准测试结果显示后两种方法的内存分配均为零。实际上并非如此。只是后两次基准测试运行平均每次分配的内存量小于一次,因此被截断为零。这是标准包中基准测试报告的有意设计。 

尽量一步扩大map的容量

如果在编码时我们能够预测将会放入一个map中的最大条目数量,我们应该使用make函数创建这个map,并将最大数量作为make调用的大小参数传入,以避免日后多次扩大map的容量。 

当键类型可能取值的集合很小时,使用索引表代替映射。

有些程序员喜欢使用键类型为bool的映射,以减少冗长的if - else代码块的使用。例如,以下代码:

	// Within a function ...
	var condition bool
	condition = evaluateCondition()
	...
	if condition {
		counter++
	} else {
		counter--
	}
	...
	if condition {
		f()
	} else {
		g()
	}
	...

可以替换为:

// Package-level maps.
var boolToInt = map[bool]int{true: 1, false: 0}
var boolToFunc = map[bool]func(){true: f, false: g}

	// Within a function ...
	var condition bool
	condition = evaluateCondition()
	...
	counter += boolToInt[condition]
	...
	boolToFunc[condition]()
	...

如果代码中有许多这样相同的if - else代码块,使用键为bool类型的map将减少大量样板代码,使代码看起来更简洁。对于大多数用例来说,这通常是不错的做法。然而,截至Go工具链v1.24.n,从代码执行性能的角度来看,使用map的方式并不是很高效。以下基准测试展示了性能差异: 

package maps

import "testing"

//go:noiline
func f() {}

//go:noiline
func g() {}

func IfElse(x bool) func() {
	if x {
		return f
	} else {
		return g
	}
}

var m = map[bool]func() {true: f, false: g}
func MapSwitch(x bool) func() {
	return m[x]
}

func Benchmark_IfElse(b *testing.B) {
	for i := 0; i < b.N; i++ {
		IfElse(true)()
		IfElse(false)()
	}
}

func Benchmark_MapSwitch(b *testing.B) {
	for i := 0; i < b.N; i++ {
		MapSwitch(true)()
		MapSwitch(false)()
	}
}

基准测试结果:

Benchmark_IfElse-4      4.155 ns/op
Benchmark_MapSwitch-4  47.46 ns/op

从基准测试结果可知,if - else代码块的方式比map - switch的方式性能要好得多。

对于那些对代码性能要求较高的用例,我们可以借助一个bool转int的函数,通过使用索引表来模拟一个键为bool类型的map,这样既能减少if - else的样板代码,又能保持map - switch方式的简洁性。以下基准测试展示了如何使用索引表的方式。 

func b2i(b bool) (r int) {
	if b {
		r = 1
	}
	return
}

var boolMap = [2]func(){g, f}

func Benchmark_BoolMap(b *testing.B) {
	for i := 0; i < b.N; i++ {
		boolMap[b2i(true)]()
		boolMap[b2i(false)]()
	}
}

从上述代码中,我们可以发现,尽管需要一个额外的小函数b2i ,但索引表方式的使用几乎和map - switch方式一样简洁。并且从以下基准测试结果可知,索引表方式和if - else代码块方式的性能相当: 

Benchmark_IfElse-4      4.155 ns/op
Benchmark_MapSwitch-4  47.46 ns/op
Benchmark_BoolMap-4     4.135 ns/op