map作为一种基础的数据结构,在算法和项目中有着非常广泛的应用,以下是自己总结的map使用心得、实现原理、扩容机制和增删改查过程。
1.使用心得:
1.1 当map为nil和map为空时,增删改查操作时会出现的不同情况
我们可以发现,当一个map为空或者为nil的时候,直接对其值进行打印输出并没有什么不同,都为map[ ]。但是当我们打印内存地址的时发现,map为空时,是有指针指向的一块内存空间的;map为nil时,是一个空指针,表示此时并没有进行内存空间的开辟。这也就导致了我们对值为nil的map做增、改操作时会触发panic,导致程序直接退出。
1.2 map初始化
map初始化有两种方法,一种是字面量初始化,一种是内置函数make()初始化。在使用内置函数make()初始化的时候,我们可以预先指定容量大小,减少后期map扩容带来的内存消耗。
1.3 map是无序的
map中存储的键值对,在取出的时候时没有顺序的,每次遍历取出的顺序都是不一致的,因此不要使用map存储一些顺序性的操作。如果需要进行顺序存储,请使用切片。
func main() {
map01 := make(map[int]int)
map01[1] = 1
map01[2] = 2
map01[3] = 3
map01[4] = 4
for key, value := range map01 {
fmt.Println(key, value)
}
/*
输出结果:
4 4
1 1
2 2
3 3
*/
}
1.4 并发读写不安全
由于map的增删改查的操作并不是原子性的,因此当多个协程并发访问map的时候,会导致读写冲突,引发panic导致程序中断。Go语言团队在设计map的时候,认为map在大多数场景下是没有并发读写需求的,如果为了实现并发读写,而在map中引入锁,会降低操作性能,得不偿失。虽然map没有实现并发读写机制,但是go语言团队在map中引入了并发检测机制,一旦发现多个协程并发读写map的时候,会立即panic,以免隐藏错误。如果实现需要在并发场景下使用map,可以使用sync.map,进行并发控制。
2.实现原理:
得Go语言中的map是基于hash表实现的,hash表是一种常见的数据结构,用来存储键值对类型的数据。我们通常将key经过哈希函数的运算之后到hash值,然后将value存储在hash值对应的内存地址上。通过hash函数我们实现了从key到hash值的映射,可以通过key来快速获取对应的value。
map实现核心其实就是以下几点:
- hash函数
- hash冲突的解决
- key对应着的value的查找过程
关于hash表,不是很懂的小伙伴可以查看这篇文章:
关于Hash表,你不得不知道的知识点http://t.csdnimg.cn/XigRT
2.1 hmap结构体
// Go map的头文件。
type hmap struct {
count int // 当前保存的元素个数
B uint8 // bucket数组的大小
noverflow uint16 // 溢出桶的大概数目
hash0 uint32 // 哈希种子
buckets unsafe.Pointer // bucket数组,数组长度为2^B,如果count=0的时候,桶可能为nil。
oldbuckets unsafe.Pointer // buckets桶的数量的一半,用于做map扩容是,存放旧的数据,一旦数据迁移完毕后,置为nil
....................
}
2.2 bmap结构体
// Go语言中map的桶
type bmap struct {
tophash [bucketCnt]uint8 //tophash通常包含哈希值的第一个字节(高8位)
//注意:把所有的键放在一起,然后把所有的元素放在一起
//采用key/elem/key/elem/…的形式,减少字节对齐带来的空间损耗。例如map[int64]int8,
//一个溢出指针,bmap类型的溢出指针
}
在bmap中有两个隐藏的字段,没有显式地在结构体中声明,根据运行时指针的偏移来访问这些虚拟成员。其中,两个虚拟成员的作用是:
一个是用来存放真实的key和value的,采用key/key/key……value/value/value……的形式进行存储,最多可以存储8个键值对。
另一个用来存储哈希冲突的溢出字段,用指针将所有的溢出字段连接在一起。
go语言中的map采用下图的结构组织起来。一个Hash表里面有多个bucket,每一个bucket保存了map中的一个或者一组键值对。其中,一组键值对最多有八个。
当有两个或以上数量的键被哈希到了同一个bucket时,我们称这些键发生了冲突。Go使用链地址法来解决键冲突。 由于每个bucket可以存放8个键值对,所以同一个bucket存放超过8个键值对时就会再创建一个键值对,用类似链表的方式将bucket连接起来。
3.扩容机制:
由于Hash冲突的存在,多个不同的key值,可能被放入少数bucket中,从而使hash值不均匀地分布桶中,导致bucket中使用了大量overflow指针来链接冲突的键值对,降低读取效率。
我们通常使用负载因子来衡量一个Hash表的冲突情况,其公式为:
负载因子 = 键数量 / bucket数量
例如,对于一个键数量为8,bucket数量为4的Hashb表来说,其负载因子为8/4=2.
负载因子过大过小都不理想:
- 负载因子过小,说明空间利用率低;
- 负载因子过大,说明哈希冲突严重,存取效率低,需要在多个overflow中进行链表查询操作。
负载因子过小,可能使预分配的空间太大,也可能是大部分的元素被删除造成的。随着元素不断添加到map中,负载因子会逐渐地升高。
当Hash表的负载因子过大时,需要申请更多的bucket,来降低负载因子;当负载因子过小时,Hash表中可能存在大量的overflow溢出桶,读取效率差。为了保证存取效率,会对所有的键值对进行重新组织,使其均匀地分布在这些bucket中,这个过程成为rehash。
3.1 扩容的条件:
Go语言会根据负载因子的大小,进行扩容操作,扩容有两种类型,一种是增量扩容,一种是等量扩容。增量扩容发生于bucket桶少,键值对多的情况,这时候增加桶的数量,即可降低负载因子。等量扩容发生在一个表进行了大量的删除操作,此时键值对零散地分布在各个溢出的桶中,我们为了提高存取效率,需要对hash表重新进行组织,删除一些overflow溢出桶。以下是Hash表的扩容条件:
- 当一个负载因子过大时,负载因子大于6.5,则需要进行增量扩容。
- 当一个负载因子过小时,overflow的数量超过2^min(B,15)时,则会进行等量扩容。
3.2 增量扩容:
当负载因子过大时,就新建一个bucket,新的bucket长度是原来的2倍,然后旧bucket数据搬迁到新的bucket。 考虑到如果map存储了数以亿计的key-value,一次性搬迁将会造成比较大的延时,Go采用逐步搬迁策略,即每次访问map时都会触发一次搬迁,每次搬迁2个键值对。
下图展示了包含一个bucket满载的map(为了描述方便,图中bucket省略了value区域):
当前map存储了6个键值对,只有1个bucket。此时负载因子为6。再次插入数据时将会触发扩容操作,扩容之后再将新插入键写入新的bucket。
当第7个键值对插入时,将会触发扩容,扩容后示意图如下:
hmap数据结构中oldbuckets成员指身原bucket,而buckets指向了新申请的bucket。新的键值对被插入新的bucket中。 后续对map的访问操作会触发迁移,将oldbuckets中的键值对逐步的搬迁过来。当oldbuckets中的键值对全部搬迁完毕后,删除oldbuckets。
搬迁完成后的示意图如下:
3.3 等量扩容:
所谓等量扩容,实际上并不是扩大容量,buckets数量不变,重新做一遍类似增量扩容的搬迁动作,把松散的键值对重新排列一次,以使bucket的使用率更高,进而保证更快的存取。 在极端场景下,比如不断的增删,而键值对正好集中在一小部分的bucket,这样会造成overflow的bucket数量增多,但负载因子又不高,从而无法执行增量搬迁的情况,如下图所示:
上图可见,overflow的buckt中大部分是空的,访问效率会很差。此时进行一次等量扩容,即buckets数量不变,经过重新组织后overflow的bucket数量会减少,即节省了空间又会提高访问效率。
4.增删改查过程:
4.1 查
- 根据key值,计算对应的hash值
- 取hash值低八位与hmap.B取模来确定桶的位置,这就是桶定位操作。
- 取hash值的高八位,在tophash数组中查询,如果tophash[i]存储的hash值与当前key对应的hash值相等,则获取tophash[i]的key值进行比较。【不仅仅要hash值相同,对应的key值也要相同】
- 如果在当前bucket中没有找到,则依次从溢出的bucket中查找。
- 如果当前bucket正在搬迁的过程中,则优先从oldbuckets中进行查找,如果找不到,再去buckets中进行查找。
- 如果最后查询不到,则返回相应类型的零值。
4.2 增
- 根据key值算出hash值
- 取Hash值的低八位与hmap.B取模来进行桶定位,确定要插入元素的桶
- 查找该key是否已经存在,如果存在则直接更新值
- 如果不存在,则从给bucket中寻找空余位置并插入
如果当前map处于搬迁过程中,则新元素会直接添加到新的buckets数组中,但查找过程仍然从oldbuckets开始查找。
4.3 改
更改插操作实际上就是一种特殊的增加操作,如果元素不存在,更改操作等同于添加操作。
4.4 删
删除操作其实等同于查询操作,如果查找到该元素,则直接进行删除,如果查找不到,则执行一次空操作。