map是Go中常用的数据结构之一,本文通过以下几个方面来探讨map在使用中常见的错误:
- map简介及底层数据结构
- map初始化时为什么要关注容量
- map中key的无序性
- nil-map写入会panic
- map是非并发安全的
- 哪些类型值可以做map的key
- 小结
01 map简介及底层数据结构
Go中的map是基于哈希表实现的,一个无序的键值对的数据结构。在Go中,定义map的形式如下:
代码语言:javascript复制map[KeyType]ValueType
因为Go中的map是基于哈希表实现的,所以底层其实就是一个hash表。hash表是根据关键码值(Key value)而直接进行访问的数据结构。也就是说,它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数叫做散列函数,存放记录的数组叫做散列表。
由此可见,hash表的底层本质上还是一个数组,只不过是通过散列函数(或hash函数)将key映射成数组的索引,并将值存储到对应数组索引的位置。如下图:
Go的map是基于hash表的,我们再来看下Go中map在底层的实际结构是如何存储数据的,这里只列出简图,详细的可参考我之前的文章:golang 中 map 的装载因子以及 B 的计算逻辑。
可以看到,Go中的map和hash表的图基本一致,只不过Go中的value做了变化,即每个数组中指向的不再是直接的value,而是一个变体,该变体在Go中称之为bucket,每个bucket中存有8个key-value值,看图中蓝色部分。
因为hash表本身是无序的,所以Go中的键和数据在map中也是无序的。也就是说:
- map中的数据不是按照key排序的
- map中的数据也不是按照加入map的顺序排序的。
初始化map的常用方式如下:
代码语言:javascript复制var m = make(map[int]string, 1000)
02 map初始化时为什么要关注容量
关注初始化容量本质上是为了尽量减少 内存分配次数 ,以提高程序性能。
map和slice一样,在容量不足时会进行自动扩容以及rehash操作。在自动扩容时,map是按照当前容量的2倍分配新的内存的。那满足什么条件时会触发扩容操作呢?
- 当buckets中的平均元素个数(我们称之为负载因子)大于某个特定的常量值,便会引发自动扩容和rehash操作。目前该常量值是6.5。该值是经过Go官方性能测试后得出的,后续的版本可能会改变。
- 或者有很多的bucket存在溢出时(即bucket里的元素个数超过8个)
例如,我们初始化一个map,该map的初始元素个数是100万个。如下:
代码语言:javascript复制m := make(map[string]int, 1000000)
该100万的意思是该map至少能存储100万个元素,当往map中添加的元素个数少于100万个的时候,同时又没有触发自动扩容的条件,那么该map则不需要再重新分配内存,从而提高了程序的性能。所以,在编程时,在提前知道map容量的前提下,则在使用make进行初始化时尽量指定该值。
以下是我们对初始化时指定元素个数以及没有指定元素个数的两个基准测试:
代码语言:javascript复制const n = 1_000_000
// make中没有指定元素个数
func BenchmarkMapWithoutSize(b *testing.B) {
for i := 0; i < b.N; i {
m := make(map[int]int)
for j := 0; j < n; j {
m[j] = j
}
}
}
// make中指定了元素个数
func BenchmarkMapWithSize(b *testing.B) {
for i := 0; i < b.N; i {
m := make(map[int]int, n)
for j := 0; j < n; j {
m[j] = j
}
}
}
在第一个基准测试中,因为我们没有在make中指定元素个数,所以在插入数据时,map会不断的扩容以能够存下所有的元素。相反,在第二个基准测试中,由于在make中指定了元素个数,提前分配好了对应的内存,所以在插入操作时,map不需要再额外扩容就能存下对应的元素。以下是基准测试的结果,可以发现后者的性能远远优于前者的性能:
代码语言:javascript复制goos: darwin
goarch: amd64
BenchmarkMapWithoutSize-4 6 227413490 ns/op
BenchmarkMapWithSize-4 13 91174193 ns/op
总之,在编程时,如果能够知道map的元素个数,最好在make初始化的时候就指定对应的个数,以便提前分配好内存。这样可以避免在后续的插入操作中进行扩容以满足存储元素的需要,在扩容后会同时触发rehash操作,这是相当耗费性能的操作。
03 map中key的无序性
在上面我们提到map中的key和数据是无序的。由于这种无序性,使我们在使用range遍历map的时候有两点特别容易造成错误,需要特别注意。
- 使用range遍历时,map的迭代项是不一样的。
- 在循环中对map进行更改或添加操作,新的key有可能会输出,也有可能不会输出。
我们基于以下示例来进行演示。假设我们有下面这样一个map。图示:
数组的每个索引都指向一个bucket:
- 数组索引0指向的bucket包含key:a和c
- 数组索引1指向的bucket不包含任何key
- 数组索引2指向的bucket包含key:z、d和e
- 数组索引3指向的bucket包含key:y
然后 通过range来循环遍历该map,并打印出key:
代码语言:javascript复制for k := range m {
fmt.Print(k)
}
我们运行该段代码,发现输出的key并不是按:aczdey的顺序输出的。而是随机的,下面是我运行的两次结果:
代码语言:javascript复制zdyaec
czyade
那map为什么会有这种无序性呢?上面我们提到map在某些条件下会自动扩容和重新hash所有的key以便存储更多的数据。因为散列值映射到数组索引上本身就是随机的,在重新hash前后,key的顺序自然就会改变了。所以Go的设计者们就对map增加了一种随机性,以确保开发者在使用map时不依赖于有序的这个特性**。
接下来我们在来看看如果在遍历过程中对map进行更新操作,会发生什么呢?
代码语言:javascript复制m := map[int]bool {
0: true,
1: false,
2: true,
}
for k, v := range m {
if v {
m[10 k] = true
}
}
fmt.Println(m)
上面这段代码的输出结果是不确定的。为什么呢?Go的官方文档中有这样的一段话:
If a map entry is created during iteration, it may be produced during the iteration or skipped. The choice may vary for each entry created and from one iteration to the next. – Go spec
大致的意思就是:
在遍历map期间,如果有一个新的key被创建,那么,在循环遍历过程中可能会被输出,也可能会被跳过。对于每一个创建的key在迭代过程中是选择输出还是跳过都是不同
也就是说,在迭代期间创建的key,有的可能会被输出,有的也可能会被跳过。这就是由于map中key的无序性造成的。
上面的代码运行多次的话,输出有可能会是这样的:
代码语言:javascript复制map[0:true 1:false 2:true 10:true 12:true 20:true 22:true 30:true]
map[0:true 1:false 2:true 10:true 12:true 20:true 22:true 30:true 32:true]
map[0:true 1:false 2:true 10:true 12:true 20:true]
map[0:true 1:false 2:true 10:true 12:true 22:true 32:true 42:true 52:true 62:true]
我们怎么解决上述问题,让输出结果变的是稳定的呢?最简单的方案就是使用复制:
代码语言:javascript复制m := map[int]bool{
0: true,
1: false,
2: true,
}
m2 := make(map[int]bool)
for k, v := range m {
m2[k] = v
if v {
m2[10 k] = true
}
}
fmt.Println(m2)
由此可知,我们通过一个新的map,将读和写分离。即从m中读,在m2中更新,这样就能保持稳定的输出结果:
代码语言:javascript复制map[0:true 1:false 2:true 10:true 12:true]
代码语言:javascript复制如果想让map有序输出该怎么办?如果我们想让map的key顺序输出,该怎么办呢?我们知道map本身是无序的,没法改变,所以需要借助另外一个数据结构:slice。即当往map中写入一个key时,同时也往slice中也加入该key,循环遍历的时候,遍历该slice中的key,从map中获取对应的数据即可:
var keys []int
var m map[int]int
for i := 0; i < 10; i {
m[i] = rand.Intn(100)
keys = append(keys, i)
}
for key := range keys {
fmt.Println(m[key])
}
下面我们先总结下:
当我们在使用map时,需要谨记以下几点:
- map的key是无序的,不会通过key保持data的顺序。
- map中的data不是按插入顺序存储的。
- 每次迭代循环map时,key的输出都是无序的
- 在迭代期间对map进行添加的新元素有可能被输出,也有可能被跳过。
- 在使用make函数初始化map时,指定元素个数,在操作中可以降低内存分配次数,提高性能。
04 nil-map的写入操作会引发panic
map是引用类型,如果只定义,但未经过make初始化,则其零值就是nil。如果往nil-map中进行写入操作则会引发panic。在Go官方文档中有如下说明:
This variable m is a map of string keys to int values:var m map[string]intMap types are reference types, like pointers or slices, and so the value of m above is nil; it doesn’t point to an initialized map. A nil map behaves like an empty map when reading, but attempts to write to a nil map will cause a runtime panic; don’t do that. To initialize a map, use the built in make function:m = make(map[string]int)
但同样为引用类型的slice在使用append 向nil slice追加新元素就可以,原因是append方法在底层为slice重新分配了相关数组让nil slice指向了具体的内存地址:
nil map doesn’t point to an initialized map. Assigning value won’t reallocate point address.The append function appends the elements x to the end of the slice s, If the backing array of s is too small to fit all the given values a bigger array will be allocated. The returned slice will point to the newly allocated array.
翻译过来的大致意思如下:
nil map没有指向初始化了的map。在赋值时不会对指向的地址进行扩容操作。而append在将元素添加到切片变量s的末尾时,如果s的底层数组容量太小而不足以容乃所有的元素,那么将会自动分配一个更大的数组以容乃所有的元素。返回的新切片将会指向新分配的数组。
所以,nil-map和nil-slice在添加元素时二者的关键区别在于slice使用的是内置的append,而append函数内部做了自动扩容操作。
05 map是非并发安全的
map的非并发安全是指不能同时对同一个map进行读和写。但多个线程同时读是可以的。为什么呢?我们引用Golang Blog中的内容:
Maps are not safe for concurrent use: it’s not defined what happens when you read and write to them simultaneously. If you need to read from and write to a map from concurrently executing goroutines, the accesses must be mediated by some kind of synchronization mechanism. One common way to protect maps is with sync.RWMutex
翻译后的大致意思就是:Map对于并发应用是不安全的:当同时对map进行读和写时,会出现未定义的行为,导致程序退出。如果需要对map进行同时读和写,那就必须要通过某种同步机制。其中一种同步机制就是使用sync.RWMutex。
那为什么不设计成并发安全的呢?Go FAQ中也有解释:
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. Therefore requiring that all map operations grab a mutex would slow down most programs and add safety to few. This was not an easy decision, however, since it means uncontrolled map access can crash the program
翻译后的大致意思就是:经过很长一段时间的讨论,map的主要使用场景并不包含并发安全的场景。并发安全的场景需要对整个map进行加锁,这样虽然增加了安全性,但会降低整个程序的性能。所以,map被设计成非并发安全的。
06 哪些类型值可以做map的key
map的key值也是我们需要关注的。任何可比较的类型都可以作为map的key。在Go文档中有详细的定义。简而言之,以下类型是可比较的:boolean、字符类型、字符串、指针、通道和接口类型以及只包含这些类型的结构体或数组。
不能作为map的key的类型:slice、map、function。因为这些类型是不可以通过 == 进行比较的。
小结
首先,map是基于hashtable实现的。hashtable的底层实际上是数组,所以,在通过make初始化map时,如果能提前预知map的容量,则需要指定容量,以降低内存分配次数,提高程序的性能。同时根据hashtable的特点,决定了map中key的无序性,这种无序性导致了我们在日常编程时容易出错的地方。其次,根据map的应用场景,map被设计成了非并发安全的,要想满足并发安全的场景,需要通过sync.RWMutex进行加锁同步。最后,提到了可作为map的key的类型必须是能够使用 == 操作符进行可比较的类型。