文章目录
Golang基本数据结构
相关链接:
https://www.topgoer.cn/docs/go-internals
https://www.topgoer.cn/docs/golang/
https://www.topgoer.cn/docs/golangxiuyang/
https://louyuting.blog.csdn.net/article/details/99699350
一、基本类型
Go中各种类型变量在内存中的布局:[int、float、数组、struct、指针、字符串]
数据类型的本质: 固定内存大小的别名。
数据类型的作用: 编译器预算对象(变量)分配的内存空间大小。
按照Go语言规范,任何类型在未初始化时都对应一个零值:布尔类型是false,整型是0,字符串是””,而指针,函数,interface,slice,channel和map的零值都是nil。
一个interface在没有进行初始化时,它的值是nil,但它自身不为nil。
string的空值是””,它是不能跟nil比较的。即使是空的string,它的大小也是两个机器字长的。slice也类似,它的空值并不是一个空指针,而是结构体中的指针域为空,空的slice的大小也是三个机器字长的。
channel跟string或slice有些不同,它在栈上只是一个指针,实际的数据在由指针所指向的堆中。
1、int
变量i属于类型int,在内存中用一个32位字长(word)表示。(32位内存布局方式)
i := 1234
变量j由于做了精确的转换,属于int32类型。尽管i和j有着相同的内存布局,但是它们属于不同的类型:赋值操作 i = j 是一种类型错误,必须写成更精确的转换方式:i = int(j)。
j := int32(1)
2、float
变量f属于float类型,Go语言当前使用32位浮点型值表示(float32)。它与int32很像,但是内部实现不同。
f := float32(3.14)
3、数组
变量bytes的类型是[5]byte,一个由5个字节组成的数组。它的内存表示就是连起来的5个字节,就像C的数组。
bytes := [5]byte{'h', 'e', 'l', 'l', 'o'}
类似地,变量arrays是4个int的数组。
arrays := [4]int{2, 3, 5, 7}
4、struct
只有相同类型的结构体才可以比较,结构体是否相同不但与属性值有关,还与属性顺序相关。
type Point struct{ X, Y int }
//p表示一个已初始化的Point类型。
p := Point{10, 20}
//对它进行取地址表示一个指向刚刚分配和初始化的Point类型的指针。
pp := &Point{10, 20}
// p和q是不同的指针,但是它们的值相同
fmt.Println( "&p == pp", &p == pp, "*(&p) == *pp", *(&p) == *pp)
// &p == pp false *(&p) == *pp true p == *pp true
struct类型 Point 表示内存中两个相邻的整数。
Point{10,20}表示一个已初始化的Point类型。对它进行取地址表示一个指向刚刚分配和初始化的Point类型的指针。前者在内存中是两个词,而后者是一个指向两个词的指针。
5、string
字符串在Go语言内存模型中用一个2个域的数据结构表示。它包含一个指向字符串存储数据的指针和一个长度数据。因为string类型是不可变的,对于多字符串共享同一个存储数据是安全的。
对string的切分操作str[i:j]会得到一个新的2个域长的结构,一个可能不同的但仍指向同一个字节序列(即上文说的存储数据)的指针和长度数据。这意味着字符串切分可以在不涉及内存分配或复制操作。这使得字符串切分的效率等同于传递下标。
s := "hello"
t := s[2:3]
数据类型的本质: 固定内存大小的别名。
数据类型的作用: 编译器预算对象(变量)分配的内存空间大小。
二、内存分区
go代码从硬盘load加载到内存后,在内存中占用分区如下:[由低地址→高地址]
代码区 | 数据区(初始化数据区,未初始化数据区,常量区) | 堆区 | 栈区(函数信息,局部变量)
栈区
空间较小,要求数据读写性能高,数据存放时间较短暂。由编译器自动分配和释放,存放函数的参数值、函数的调用流程方法地址、局部变量等(局部变量如果产生逃逸现象,可能会挂在在堆区) 。
最内层函数后进先出,最内层函数先执行后,释放内存,向上层传递结果。
函数return返回值将函数执行的结果保存下来,返回给调用者。
堆区
空间充裕,数据存放时间较久。一般由开发者分配及释放(但是Golang中会根据变量的逃逸现象来选择是否分配到栈上或堆上),启动Golang的GC由GC清除机制自动回收。
全局区
- 全局变量区
全局变量的开辟是在程序在main
之前就已经放在内存中。而且对外完全可见。即作用域在全部代码中,任何同包代码均可随时使用,在变量会搞混淆,而且在局部函数中如果同名称变量使用:=
赋值会出现编译错误。
全局变量最终在进程退出时,由操作系统回收。
我们在开发的时候,尽量减少使用全局变量的设计。
- 全局常量区
常量区也归属于全局区,常量为存放数值字面值单位,即不可修改。或者说常量是直接挂钩字面值的。
eg:const num=10
即num是字面量10的对等符号。在golang中,常量是无法取出地址的【会出现编译报错:Cannot take the address of ‘num’】,因为字面量符号并没有地址而言。
三、切片
切片slice是对数组某个部分的引用。
在内存中,它是一个包含3个域的结构体:指向slice中第一个元素的指针,slice的长度,以及slice的容量。
长度是下标操作的上界,如x[i]中i必须小于长度。容量是分割操作的上界,如x[i:j]中j不能大于容量。
切片的struct定义如下:代码位于 $GOROOT/src/pkg/runtime/slice.go
type slice struct {
array unsafe.Pointer // actual data
len int // number of elements
cap int // allocated number of elements
}
切片操作
在对slice进行append等操作时,可能会造成slice的自动扩容。其扩容时的大小增长规则是:
- 如果新的大小是当前大小2倍以上,则大小增长为新大小。
- 否则循环以下操作:如果当前大小小于1024,按每次2倍增长,否则每次按当前大小1/4增长。直到增长的大小超过或等于新大小。
s := make([]int, 10)
s = append(s, 1, 2, 3)
fmt.Println(s)
// [0 0 0 0 0 0 0 0 0 0 1 2 3]
s1 := []int{1, 2, 3}
s2 := []int{4, 5}
s1 = append(s1, s2...)
fmt.Println(s1)
// [1 2 3 4 5]
make和new
make返回的是slice、map以及channel这三个引用类型本身,是个结构体;而new返回的是指向类型的指针。
make(T, args)
返回一个普通的T
,而 new(T)
返回一个*T
。
二者都是内存的分配(堆上),但是make只用于slice、map以及channel的初始化(非零值)。
而new用于类型的内存分配,并且内存置为零。
四、map
Go中的map在底层是用 哈希表结构【数组+链表】实现的。代码位于 $GOROOT/src/pkg/runtime/map.go
map存储结构如下:
其中 buckets
是一个指针,指向实际存储的bmap数组的首地址。
oldbuckets
是一个指针,指向一个bmap数组,存储多个旧桶,用于扩容。
1、hmap
hmap是Go map的底层实现,每个hmap内都含有多个bucket桶(buckets桶[bmap桶]、oldbuckets旧桶、overflow溢出桶),既每个哈希表都由多个桶组成。
// A header for a Go map.
type hmap struct {
// Note: the format of the hmap is also encoded in cmd/compile/internal/reflectdata/reflect.go.
// Make sure this stays in sync with the compiler's definition.
count int // # live cells == size of map. Must be first (used by len() builtin)
flags uint8
//最多可以容纳loadFactor*2^B个bmap数组
B uint8 // log_2 of # of buckets (can hold up to loadFactor * 2^B items)
//溢出桶的近似数量
noverflow uint16 // approximate number of overflow buckets; see incrnoverflow for details
hash0 uint32 // hash seed
//2^B个Buckets的数组
buckets unsafe.Pointer // array of 2^B Buckets. may be nil if count==0.
//前一个buckets,只有当正在扩容时才不为空
oldbuckets unsafe.Pointer // previous bucket array of half the size, non-nil only when growing
nevacuate uintptr // progress counter for evacuation (buckets less than this have been evacuated)
extra *mapextra // optional fields
}
hash结构使用的是可扩展哈希算法,由hash值mod当前hash表大小决定某一个值属于哪个桶,而hash
表大小是2的指数,即上面结构体中的2^B
。每次扩容,会增大到上次大小的两倍。
结构体中的oldbuckets
是用来实现增量扩容的,正常情况下直接使用buckets
,而oldbuckets
为空。如果当前哈希表正在扩容中,则oldbuckets
不为空,并且buckets
大小是oldbuckets
大小的两倍。
2、bmap
bmap是存放 k-v 的地方,它的结构如下:【可以简单认为,bmap就是bucket,bucket就是bmap】
每个bmap桶中存放最多8个key/value对,如果多于8个,那么会申请一个新的bmap,并将它与之前的bmap链起来。多个bmap桶通过overflow指针相连,组成一个链表。
// A bucket for a Go map.
type bmap struct {
// tophash generally contains the top byte of the hash value
// for each key in this bucket. If tophash[0] < minTopHash,
// tophash[0] is a bucket evacuation state instead.
tophash [bucketCnt]uint8
// Followed by bucketCnt keys and then bucketCnt elems.
// NOTE: packing all the keys together and then all the elems together makes the
// code a bit more complicated than alternating key/elem/key/elem/... but it allows
// us to eliminate padding which would be needed for, e.g., map[int64]int8.
// Followed by an overflow pointer.
}
实际上,上面这个数据结构并不是 golang runtime 时的结构,在编译时候编译器会给它动态创建一个新的结构:
type bmap struct {
//元素hash值的高8位代表它在桶中的位置,如果tophash[0] < minTopHash,表示这个桶的搬迁状态
tophash [bucketCnt]uint8
//接下来是8个key、8个value,但是我们不能直接看到;为了优化对齐,go采用了key放在一起,value放在一起的存储方式。
keys [8]keytype //key单独存储
values [8]valuetype //value单独存储
pad uintptr
overflow uintptr //指向溢出桶的指针
}
bmap 就是我们常说的bucket
结构,每个 bucket 里面最多存储 8 个 key,这些 key 之所以会落入同一个桶,是因为它们经过哈希计算后,哈希结果是一类
的。在桶内,又会根据 key 计算出来的 hash 值的高 8 位来决定 key 到底落入桶内的哪个位置(一个桶内最多有8个位置)。
注意一个细节是Bucket中key/value的放置顺序,是将keys放在一起,values放在一起,为什么不将key和对应的value放在一起呢?【官网:可以省略掉 padding 字段,节省内存空间。】
如果那么做,存储结构将变成key1/value1/key2/value2… 设想如果是这样的一个map[int64]int8,考虑到字节对齐,会浪费很多存储空间。不得不说通过上述的一个小细节,可以看出Go在设计上的深思熟虑。
3、hash定位
对于 map 来说,最重要的就是根据key定位实际存储位置。key 经过哈希计算后得到哈希值,哈希值是 64 个 bit 位(针对64位机器)。先根据hash值的最后B个bit位来确定这个key落在哪个bucket桶。再用哈希值的高 8 位,找到此 key 在bucket中的位置。
eg:如果 B = 5,那么桶的数量,也就是 buckets 数组的长度是 2^5 = 32。 hash定位过程如下:首先计算出待查找 key 的哈希,使用低 5 位 00110,找到对应的 6 号 bucket,使用高 8 位 10010111,对应十进制 151,在 6 号 bucket 中 遍历bucket 寻找 tophash 值(HOB hash)为 151 的 key,找到了 2 号槽位,这就是整个查找过程。
如果在 bucket 中没找到,并且 overflow 不为空,还要继续去 overflow bucket 中寻找,直到找到或是所有的 key 槽位都找遍了,包括所有的 overflow bucket。
源码:
// mapaccess1 returns a pointer to h[key]. Never returns nil, instead
// it will return a reference to the zero object for the elem type if
// the key is not in the map.
// NOTE: The returned pointer may keep the whole map live, so don't
// hold onto it for very long.
// 函数返回 h[key] 的指针,如果 h 中没有此 key,那就会返回一个 key 相应类型的零值,不会返回 nil。
func mapaccess1(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer {
//一些校验逻辑
if raceenabled && h != nil {
callerpc := getcallerpc()
pc := abi.FuncPCABIInternal(mapaccess1)
racereadpc(unsafe.Pointer(h), callerpc, pc)
raceReadObjectPC(t.key, key, callerpc, pc)
}
if msanenabled && h != nil {
msanread(key, t.key.size)
}
if asanenabled && h != nil {
asanread(key, t.key.size)
}
//如果 h 什么都没有,返回value类型的零值
if h == nil || h.count == 0 {
if t.hashMightPanic() {
t.hasher(key, 0) // see issue 23734
}
return unsafe.Pointer(&zeroVal[0])
}
// 并发写冲突
if h.flags&hashWriting != 0 {
throw("concurrent map read and map write")
}
hash := t.hasher(key, uintptr(h.hash0))
// 求低 B 位的掩码。比如 B=5,那 m 就是31,低五位二进制是全1
m := bucketMask(h.B)
// b 就是 当前key对应的 bucket 的地址
b := (*bmap)(add(h.buckets, (hash&m)*uintptr(t.bucketsize)))
if c := h.oldbuckets; c != nil {
if !h.sameSizeGrow() {
// There used to be half as many buckets; mask down one more power of two.
m >>= 1
}
// 求出 key 在老的 map 中的 bucket 位置
oldb := (*bmap)(add(c, (hash&m)*uintptr(t.bucketsize)))
// 如果 oldb 没有搬迁到新的 bucket,那就在老的 bucket 中寻找
if !evacuated(oldb) {
b = oldb
}
}
// 计算出高 8 位的 hash。相当于右移 56 位,只取高8位
top := tophash(hash)
// 这里进入bucket的二层循环找到对应的kv(第一层是bucket,第二层是bucket内部的8个slot)
bucketloop:
for ; b != nil; b = b.overflow(t) {
//遍历bucket的8个slot
for i := uintptr(0); i < bucketCnt; i++ {
// tophash 不匹配
if b.tophash[i] != top {
// 标识当前bucket剩下的slot都是empty
if b.tophash[i] == emptyRest {
break bucketloop
}
continue
}
// 获取bucket的key
k := add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize))
if t.indirectkey() {
k = *((*unsafe.Pointer)(k))
}
if t.key.equal(key, k) {
//定位到 value 的位置
e := add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.elemsize))
// value 解引用
if t.indirectelem() {
e = *((*unsafe.Pointer)(e))
}
return e
}
}
}
// overflow bucket 也找完了,说明没有目标 key 。返回零值
return unsafe.Pointer(&zeroVal[0])
}
4、插入过程分析
- 根据key算出hash值,进而得出对应的bucket。
- 如果bucket在old table中,将其重新散列到new table中。
- 在bucket中,查找空闲的位置,如果已经存在需要插入的key,更新其对应的value。
- 根据table中元素的个数,判断是否grow table。
- 如果对应的bucket已经full,重新申请新的bucket作为overbucket。
- 将key/value pair插入到bucket中。
在扩容过程中,oldbucket是被冻结的,查找时会在oldbucket中查找,但不会在oldbucket中插入数据。如果在oldbucket是找到了相应的key,做法是将它迁移到新bucket后加入evalucated标记。并且还会额外的迁移另一个pair。然后就是只要在某个bucket中找到第一个空位,就会将key/value插入到这个位置。也就是位置位于bucket前面的会覆盖后面的(类似于存储系统设计中做删除时的常用的技巧之一,直接用新数据追加方式写,新版本数据覆盖老版本数据)。找到了相同的key或者找到第一个空位就可以结束遍历了。不过这也意味着做删除时必须完全的遍历bucket所有溢出链,将所有的相同key数据都删除。所以目前map的设计是为插入而优化的,删除效率会比插入低一些。
5、扩容
使用 key 的 hash 值可以快速定位到目标 key,然而,随着向 map 中添加的 key 越来越多,key 发生碰撞的概率也越来越大。bucket 中的 8 个 cell 会被逐渐塞满,查找、插入、删除 key 的效率也会越来越低。最理想的情况是一个 bucket 只装一个 key,这样,就能达到 O(1) 的效率,但这样空间消耗太大,用空间换时间的代价太高。
Go 语言采用一个 bucket 里装载 8 个 key,定位到某个 bucket 后,还需要再定位到具体的 key,这实际上又用了时间换空间。
当然,这样做,要有一个度,不然所有的 key 都落在了同一个 bucket 里,直接退化成了链表,各种操作的效率直接降为 O(n),是不行的。
因此,需要有一个指标来衡量前面描述的情况,这就是装载因子。
触发扩容的条件:【满足其一即触发】
- 1、装载因子超过阈值,源码里定义的阈值是 6.5。【源码中:Maximum average load of a bucket that triggers growth is 6.5】
- 2、overflow 的 bucket 数量过多,这也有两种情况:(1)当 B 大于15时,也就是 bucket 总数大于 2^15 时,如果overflow的bucket数量大于2^15 ,就触发扩容。(2)当B小于15时,如果overflow的bucket数量大于2^B 也会触发扩容。【有时候可能就是无法触发条件1】
扩容方式:对于条件1,将 B 加 1,bucket 最大数量 (2^B) 直接变成原来 bucket 数量的 2 倍。
对于条件2,其实元素没那么多,但是 overflow bucket 数特别多,说明很多 bucket 都没装满。解决办法就是开辟一个新 bucket 空间,将老 bucket 中的元素移动到新 bucket,使得同一个 bucket 中的 key 排列地更紧密。这样,原来,在 overflow bucket 中的 key 可以移动到 bucket 中来。结果是节省空间,提高 bucket 利用率,map 的查找和插入效率自然就会提升。
扩容机制:由于 map 扩容需要将原有的 key/value 重新搬迁到新的内存地址,如果有大量的 key/value 需要搬迁,在搬迁过程中map会阻塞,非常影响性能。因此 Go map 的扩容采取了一种称为 “渐进式” 的方式,原有的 key 并不会一次性搬迁完毕,每次最多只会搬迁 2 个bucket。
扩容源码:先分配,hashGrow()
函数实际上并没有真正地“搬迁”,它只是分配好了新的 buckets,并将老的 buckets 挂到了新的map的 oldbuckets
字段上。真正搬迁 buckets 的动作在 growWork()
函数中,而调用 growWork()
函数的动作是在 mapassign
和 mapdelete
函数中。也就是插入或修改、删除 key 的时候,都会尝试进行搬迁 buckets 的工作。先检查 oldbuckets
是否搬迁完毕,具体来说就是检查 oldbuckets
是否为 nil。
为什么遍历 map 是无序的?
map 在扩容后,会发生 key 的搬迁,原来落在同一个 bucket 中的 key,搬迁后,有些 key 就要远走高飞了(bucket 序号加上了 2^B)。而遍历的过程,就是按顺序遍历 bucket,同时按顺序遍历 bucket 中的 key。搬迁后,key 的位置发生了重大的变化,有些 key 飞上高枝,有些 key 则原地不动。这样,遍历 map 的结果就可能不按原来的顺序。
其实,为了避免不必要的误会,go语言中当我们在遍历 map 时,并不是固定地从 0 号 bucket 开始遍历,每次都是从一个随机值序号的 bucket 开始遍历,并且是从这个 bucket 的一个随机序号的 cell 开始遍历。这样,即使你是一个写死的 map,仅仅只是遍历它,也不太可能会返回一个固定序列的 key/value 对。
6、mapassign/mapdelete
向 map 中插入或者修改 key,最终调用的是 mapassign
函数。 删除操作底层的执行函数是 mapdelete
。
mapassign
有一个系列的函数【mapassign_fast32
、mapassign_fast64
、mapassign_faststr
】,根据 key 类型的不同,编译器会将其优化为相应的“快速函数”。类似的,mapdelete
也有一个系列的函数【mapdelete_fast32
、mapdelete_fast64
、mapdelete_faststr
】
整体来看,流程非常得简单:对 key 计算 hash 值,根据 hash 值按照之前的流程,找到要操作的位置(插入/更新/删除。 核心还是一个双层循环,外层遍历 bucket 和它的 overflow bucket,内层遍历整个 bucket 的各个 cell。 ),对相应位置进行操作。
无论是mapassign还是mapdelete,函数首先会检查 map 的标志位 flags。如果 flags 的写标志位此时被置 1 ,说明有其它协程在执行“写”操作,进而导致程序 panic。这也说明了 map 对协程是不安全的。
7、遍历
本来 map 的遍历过程比较简单:遍历所有的 bucket 以及它后面挂的 overflow bucket,然后挨个遍历 bucket 中的所有 cell。每个 bucket 中包含 8 个 cell,从有 key 的 cell 中取出 key 和 value,这个过程就完成了。
但是,现实并没有这么简单。 因为扩容过程不是一个原子的操作,它每次最多只搬运 2 个 bucket,所以如果触发了扩容操作,那么在很长时间里,map 的状态都是处于一个中间态:有些 bucket 已经搬迁到新家,而有些 bucket 还待在老地方。
-
map迭代先是调用
mapiterinit
函数初始化迭代器,然后循环调用mapiternext
函数进行 map 迭代。 -
前面已经提到过,即使是对一个写死的 map 进行遍历,每次出来的结果也是无序的。 源码:
// 生成随机数 r r := uintptr(fastrand()) if h.B > 31-bucketCntBits { r += uintptr(fastrand()) << 31 } // 从哪个 bucket 开始遍历 it.startBucket = r & bucketMask(h.B) it.offset = uint8(r >> h.B & (bucketCnt - 1)) //例如,B = 2,那 uintptr(1)<<h.B - 1 结果就是 3,低 8 位为 0000 0011,将 r 与之 & ,就可以得到一个0~3的 bucket 序号;bucketCnt - 1 等于 7,低 8 位为 0000 0111,将 r 右移 2 位后,与 7 相与,就可以得到一个 0~7 号的 cell。 //于是,在 mapiternext 函数中就会从 it.startBucket 的 it.offset 号的 cell 开始遍历,取出其中的 key 和 value,直到又回到起点 bucket,完成遍历过程。
扩容情况下的遍历:
假设我们有下图所示的一个 map,起始时 B = 1,有两个 bucket,后来触发了扩容(这里不要深究扩容条件,只是一个设定),B 变成 2。并且, 1 号 bucket 中的内容搬迁到了新的 bucket,1 号裂变成 1 号和 3 号;0 号 bucket 暂未搬迁。老的 bucket 挂在在 *oldbuckets 指针上面,新的 bucket 则挂在 *buckets 指针上面。
这时,我们对此 map 进行遍历。假设经过初始化后,startBucket = 3,offset = 2。于是,遍历的起点将是 3 号 bucket 的 2 号 cell,下面这张图就是开始遍历时的状态:
-
step1:因为 3 号 bucket 对应老的 1 号 bucket,因此先检查老 1 号 bucket 是否已经被搬迁过。
在本例中,老 1 号 bucket 已经被搬迁过了,因此只用遍历新的 3 号 bucket。
通过调用 mapiternext 函数依次找到了e、f、g
。 -
step2:新 3 号 bucket 遍历完之后,回到了新 0 号 bucket。0 号 bucket 对应老的 0 号 bucket,经检查,老 0 号 bucket 并未搬迁,因此对新 0 号 bucket 的遍历就改为遍历老 0 号 bucket。
老 0 号 bucket 在搬迁后将裂变成 2 个 bucket:新 0 号、新 2 号。而我们此时正在遍历的只是新 0 号 bucket。所以,我们只会取出老 0 号 bucket 中那些在裂变之后,分配到新 0 号 bucket 中的那些 key。 因此,lowbits == 00 的将进入遍历结果集e、f、g、b、c
。 -
step3:和之前的流程一样,按顺序开始遍历新 1 号 bucket,发现老 1 号 bucket 已经搬迁,只用遍历新 1 号 bucket 中现有的元素就可以了。结果集变成:
e、f、g、b、c、h
。 -
step4:继续遍历新 2 号 bucket,它来自老 0 号 bucket,因此需要在老 0 号 bucket 中那些会裂变到新 2 号 bucket 中的 key,也就是 lowbit == 10 的那些 key。 结果集变成:
e、f、g、b、c、h、a、d
。 -
step5:最后,继续遍历到新 3 号 bucket 时,发现所有的 bucket 都已经遍历完毕,整个迭代过程执行完毕。
map 遍历的核心在于理解 2 倍扩容时,老 bucket 会分裂到 2 个新 bucket 中去。而遍历操作,会按照新 bucket 的序号顺序进行,碰到老 bucket 未搬迁的情况时,要在老 bucket 中找到将来要搬迁到新 bucket 来的 key。
8、总结
- (1)可以边遍历边删除吗?
map 并不是一个线程安全的数据结构。同时读写一个 map 是未定义的行为,如果被检测到,会直接 panic。
如何保证线程安全:一般而言,这可以通过读写锁来解决:sync.RWMutex
。读之前调用 RLock()
函数,读完之后调用 RUnlock()
函数解锁;写之前调用 Lock()
函数,写完之后,调用 Unlock()
解锁。
另外,sync.Map
是线程安全的 map,可以直接使用。
- (2)key 可以是 float 型吗?
从语法上看,是可以的。Go 语言中只要是可比较的类型都可以作为 key。因此除了 slice,map,functions 这几种类型,其他类型都是 OK 的。具体包括:布尔值、数字、字符串、指针、通道、接口类型、结构体、只包含上述类型的数组。这些类型的共同特征是支持== 和 != 比较操作符,k1 == k2 时,可认为 k1 和 k2 是同一个 key。如果是结构体,则需要它们的字段值都相等,才被认为是相同的 key。
任何类型都可以作为 value,包括 map 类型。
结论:float 型虽然可以作为 key,但是由于精度的问题,会导致一些诡异的问题,慎用。
- (3)总结
总结一下,Go 语言中,通过哈希查找表实现 map,用链表法解决哈希冲突。
通过 key 的哈希值将 key 散落到不同的桶中,每个桶中有 8 个 cell。哈希值的低位决定桶序号,高位标识同一个桶中的不同 key。
当向桶中添加了很多 key,造成元素过多,或者溢出桶太多,就会触发扩容。相应的扩容分为等量扩容和 2 倍容量扩容。2倍扩容后,原来一个 bucket 中的 key 一分为二,会被重新分配到两个桶中。
扩容过程是渐进的,主要是防止一次扩容需要搬迁的 key 数量过多,引发性能问题。触发扩容的时机是增加了新元素,bucket 搬迁的时机则发生在赋值、删除期间,每次最多搬迁两个 bucket。