Golang — map的使用心得和底层原理

 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实现核心其实就是以下几点:

  1. hash函数
  2. hash冲突的解决
  3. key对应着的value的查找过程

关于hash表,不是很懂的小伙伴可以查看这篇文章:

关于Hash表,你不得不知道的知识点icon-default.png?t=N7T8http://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 查

  1. 根据key值,计算对应的hash值
  2. 取hash值低八位与hmap.B取模来确定桶的位置,这就是桶定位操作。
  3. 取hash值的高八位,在tophash数组中查询,如果tophash[i]存储的hash值与当前key对应的hash值相等,则获取tophash[i]的key值进行比较。【不仅仅要hash值相同,对应的key值也要相同】
  4. 如果在当前bucket中没有找到,则依次从溢出的bucket中查找。
  5. 如果当前bucket正在搬迁的过程中,则优先从oldbuckets中进行查找,如果找不到,再去buckets中进行查找。
  6. 如果最后查询不到,则返回相应类型的零值。

4.2 增

  1. 根据key值算出hash值
  2. 取Hash值的低八位与hmap.B取模来进行桶定位,确定要插入元素的桶
  3. 查找该key是否已经存在,如果存在则直接更新值
  4. 如果不存在,则从给bucket中寻找空余位置并插入

如果当前map处于搬迁过程中,则新元素会直接添加到新的buckets数组中,但查找过程仍然从oldbuckets开始查找。

4.3 改

更改插操作实际上就是一种特殊的增加操作,如果元素不存在,更改操作等同于添加操作。

4.4 删

删除操作其实等同于查询操作,如果查找到该元素,则直接进行删除,如果查找不到,则执行一次空操作。

  • 17
    点赞
  • 32
    收藏
    觉得还不错? 一键收藏
  • 4
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值