Golang基本数据结构

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、插入过程分析

  1. 根据key算出hash值,进而得出对应的bucket。
  2. 如果bucket在old table中,将其重新散列到new table中。
  3. 在bucket中,查找空闲的位置,如果已经存在需要插入的key,更新其对应的value。
  4. 根据table中元素的个数,判断是否grow table。
  5. 如果对应的bucket已经full,重新申请新的bucket作为overbucket。
  6. 将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_fast32mapassign_fast64mapassign_faststr】,根据 key 类型的不同,编译器会将其优化为相应的“快速函数”。类似的,mapdelete 也有一个系列的函数【mapdelete_fast32mapdelete_fast64mapdelete_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。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

进击的程序猿~

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值