本文参考 公众号:Go语言中文网 微信号: studygolang 系列文章记录
Map
基本原理概述

Map 底层是通过hash table存储,每个元素是一个
bmap(下称为bucket)每个bucket的是一个容量为
bucketCnt=8的链表,用于解决冲突将key进行hash,获取到的数对应的二进制,低B位用于确定桶序号,高8位用于确定桶中的Tophash(用于标记key所在Cell)
- 若低B位冲突,则入桶后,计算Tophash放入桶链表的第一个空位中
- 若桶溢出,则通过桶的最后一个overflow指针指向overflow bucket
- 桶中存放kv的格式是kkk…vvv。将k和v分别存放;这样可以避免在使用kvkvkv组合交替存放时,需要填充一些位数(padding)来补齐数据以便查找的空间浪费
B通过负载因子(loadFactor)和map可容纳kv数量(count)决定,不超过15
count <= loadFactor * 2^B^
bucktCount = 2^B^
- loadFactor 内置为6.5,
loadFactor=6.5?若桶恰好全满时负载因子为8 (每个桶的8个位置全占满,则kv数量为桶的8倍),那么6.5就可以表时现在的负载状态已经进入有点拥挤状态,碰撞频发,性能可能开始下降,因此需要调整
B的大小;loadFactor太大,会导致桶溢出严重,性能下降;loadFactor太小又会导致整个桶过于稀疏,不仅浪费,在极端情况下还可能导致查询效率退化为线型。kv的查找,基本遵循上述原则,当查询出现桶号一样且Tophash相同,会进一步比较key;若在本bucket没找到,且overflow不为空则会去overflow bucket中找
为不破坏bucket不含指针的理念,overflow指针通过hmap.extra.[]overflow汇集所有bucket的overflow指针,初始化为2^B-4^个(B >=4时)
原生Map在读写删前会检查是否有并发写删在进行,因此不支持并发操作
在写Map时,会先计算插入桶的位置,并且检查,如果需要扩容,则会先进行扩容,然后重新计算插入的位置;最终确定可写再写kv
若可写入但是没有位置了,需要申请overflow buket和当前buket”串联”用于存放新key
- 写Map时可能导致扩容,扩容的先决条件是负载超过负载因子(kv数大于桶数的6.5倍)或者overflow桶过多(overflow桶的估计值大于等于桶的个数)
- Map的过程是对bucket搬迁的过程,首先通过B++来完成bucket扩容,并将老buckets挂入hmap对应指针,新buckets分配内存。而后,在每次写和删除kv时尝试对未完成的搬迁工程进行,每次最多搬迁2个bucket
- Map的遍历因为上述的底层存储形式(bucket => cell)和搬迁的存在导致遍历的存在无序可能;而又为了让用户意识到无序性的存在,在遍历Map时,起始bucket会是随机的,使得即使Map是个”常量”也是无序遍历,最终导致Map的无序遍历。
- 对Map的删除,只会将topHash清零,供未来使用,不会将已有的Cell往前怼(考虑效率怼不符合逻辑),因此如果进行一波大批量不超过负载因子的插入,再进行一大波删除,overflow会始终存在,使得kv存储的很分散。这就是为什么前面扩容也需要对overflow桶个数进行考虑。
- 基于上述过程,也就可以发现,在读写map时需要经过hash、查找、读写的流程,而这一套流程当出现并发场景时,就可能导致如协程A准备查找没有准备写入;A写入前B也进行查找,也没有,也进行写入,这就导致了数据覆盖。
Slice
- 一个Slice结构体中包含一个指向底层数组的指针、
len int、cap int,因此规定占24字节(64位机器)
1 | type slice struct { |
- 创建Slice
1 | func makeslice(et *_type, len, cap int) unsafe.Pointer { |
- 添加元素
1 | // append函数发生扩容时主要底层实现是growslice函数,需要将元素类型、老的slice和append后所需的新最小容量cap传入 |
- Slice 截取
- 由于上述
growslice的底层实现,在截取后,底层数组不会发生变化 - 因此如果直接修改元素会影响到公用底层数组的所有slice
- 仅当某次append执行了
growslice后同步影响才会消除 - 因此获取slice可以使用
newSlice :=append([]uint8{}, s[1:2]...)消除底层数组影响
- copy
1 | func slicecopy(to, fm slice, width uintptr) int { |
- slice的本质是结构体,因此在函数传参时,也是将
{arrayPointer, len, cap}这样一个结构体传递。如果函数操作对arrayPinter指向的底层数组操作变化,原slice也会变化;否则,不影响原slice。
Channel
不要用共享内存来通信,而用通信的方式来共享内存
chan的底层结构
1 | type hchan struct { |
chan的创建
- chan一般情况下都会通过make创建(只读、只写channel一般只用于传参)。
make函数在编译期间,会先编译成OMAKE节点,在类型检查阶段,会转化成OMAKECHAN,最终在SSA中间代码生成阶段之前被转换成makechan或者makechan64的函数调用。makechan64用于处理缓冲区大小大于 2 的 32 次方的情况,可以主要关注makechan方法(需要了解go编译方面的知识)
1 | func makechan(t *chantype, size int) *hchan { |
chan的写入(发送数据)
- 编译器讲发送数据语句
ch <- i,编译器会解析为OSEND节点,并最终转换为chansend1函数;而chansend1函数则直接调用的chansend函数
1 | func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool { |