go语言的map

go语言的map结构

数据结构

map的源码在runtime/map.go/hmap

// A header for a Go map.
type hmap struct {
    count     int   // 元素个数
    flags     uint8 //map状态,正在被读或者正在被写
    B         uint8 // 扩容常量相关字段B是buckets数组的长度的对数 2^B
    noverflow uint16 // 溢出的bucket个数
    hash0     uint32 // hash seed,hash函数
    buckets    unsafe.Pointer// buckets 数组指针,扩容的时候,buckets 长度会是 oldbuckets 的两倍
    oldbuckets unsafe.Pointer // 结构扩容的时候用于赋值的buckets数组
    nevacuate  uintptr // 指示扩容进度,小于此地址的 buckets 迁移完成
    extra *mapextra// 用于扩容的指针
}
  1. count 字段表征了 map 目前的元素数目, 当使用 len() 函数获取 map 长度时, 返回的便是 count 成员的值, 因此 len() 函数作用于 map 结构时, 其时间复杂度为 O(1)O(1)
  2. flag 字段标志 map 的状态, 如 map 当前正在被遍历或正在被写入
  3. B 是哈希桶数目以 2 为底的对数(正常桶的数目), 在 go map 中, 哈希桶的数目都是 2 的整数次幂(这样设计的好处是可以是用位运算来计算取余运算的值, 即 N mod M = N & (M-1))
  4. noverflow 是溢出桶的数目, 这个数值不是恒定精确的, 当其 B>=16 时为近似值
  5. hash0是随机哈希种子, map创建时调用 fastrand 函数生成的随机数, 设置的目的是为了降低哈希冲突的概率
  6. buckets 是指向当前哈希桶的指针
  7. oldbuckets 是当桶扩容时指向旧桶的指针
  8. nevacuate 是当桶进行调整时指示的搬迁进度, 小于此地址的 buckets 是以前搬迁完毕的哈希桶
  9. mapextra 则是表征溢出桶的变量

然后再介绍下bucket的数据结构
由于 go map 的 key 和 elem 可以有多种数据类型, 因此哈希桶的数据类型也会随着 key 和 elem 数据类型的不同而不同, 具体的数据类型是在编译期确定的, 因此 bmap 在 go 的源码中没有显式定义出来, 对 bmap 的操作也是通过计算地址偏移量来实现的, 具体的函数位于 src/cmd/compile/internal/gc/reflect.go, 通过该函数我们也可以还原出 bmap 的结构

type bmap struct {
   topbits  [8]uint8
   keys     [8]keytype
   elems    [8]elemtype
   //pad      uintptr(新的 go 版本已经移除了该字段, 我未具体了解此处的 change detail, 之前设置该字段是为了在 nacl/amd64p32 上的内存对齐)
   overflow uintptr
}
  1. topbits 是键哈希值的高 8 位
  2. keys 存放了哈希桶中所有键,
  3. elems(value) 存放了哈希桶中的所有值
  4. overflow 是一个 uintptr 类型指针, 存放了所指向的溢出桶的地址

go map 的每个哈希桶最多存放 8 个键值对, 桶里面会最多装 8 个 key,这些 key 之所以会落入同一个桶,是因为它们经过哈希计算后,哈希结果是“一类”的。在桶内,又会根据 key 计算出来的 hash 值的高 8 位来决定 key 到底落入桶内的哪个位置(一个桶内最多有8个位置)。当经由哈希函数映射到该地址的元素数超过 8 个时, 会将新的元素放到溢出桶中, 并使用 overflow 指针链向这个溢出桶, 这里有一个需要注意的点是在哈希桶中, 键值之间并不是相邻排列的, 这是为了保证内存对齐(存放顺序是key/key/key/…value/value/value).在某些情况下可以省略掉 padding 字段,节省内存空间如果按照 key/value/key/value/… 这样的模式存储,那在每一个 key/value 对之后都要额外 padding 7 个字节;而将所有的 key,value 分别绑定到一起,这种形式 key/key/…/value/value/…,则只需要在最后添加 padding。

每个 bucket 设计成最多只能放 8 个 key-value 对,如果有第 9 个 key-value 落入当前的 bucket,那就需要再构建一个 bucket ,通过 overflow 指针连接起来。
在这里插入图片描述

! 在这里插入图片描述
当一个 map 的 key 和 elem 都不含指针并且他们的长度都没有超过 128 时(当 key 或 value 的长度超过 128 时, go 在 map 中会使用指针存储), 该 map 的 bucket 类型会被标注为不含有指针, 这样 gc 不会扫描该 map, 这会导致一个问题, bucket 的底层结构 bmap 中含有一个指向溢出桶的指针(uintptr类型, uintptr指针指向的内存不保证不会被 gc free 掉), 当 gc 不扫描该结构时, 该指针指向的内存会被 gc free 掉, 因此在 hmap 结构中增加了 mapextra 字段, ( overflow 移动到 extra 字段来)

type mapextra struct {
    // If both key and elem do not contain pointers and are inline, then we mark bucket
    // type as containing no pointers. This avoids scanning such maps.
    // However, bmap.overflow is a pointer. In order to keep overflow buckets
    // alive, we store pointers to all overflow buckets in hmap.extra.overflow and hmap.extra.oldoverflow.
    // overflow and oldoverflow are only used if key and elem do not contain pointers.
    // overflow contains overflow buckets for hmap.buckets.
    // oldoverflow contains overflow buckets for hmap.oldbuckets.
    // The indirection allows to store a pointer to the slice in hiter.
    overflow    *[]*bmap
    oldoverflow *[]*bmap

    // nextOverflow holds a pointer to a free overflow bucket.
    nextOverflow *bmap
}
  1. overflow 是一个指向保存了所有hmap.buckets 的溢出桶地址的 slice 的指针
  2. oldoverflow 是指向保存了所有 hmap.oldbuckets 的溢出桶地址的 slice 的指针

只有当 map 的 key 和 elem 都不含指针时这两个字段才有效, 因为这两个字段设置的目的就是避免当 map 被 gc 跳过扫描带来的引用内存被 free 的问题, 当 map 的 key 和 elem 含有指针时, gc 会扫描 map, 从而也会获知 bmap 中指针指向的内存是被引用的, 因此不会释放对应的内存

创建map

func makemap(t *maptype, hint int64, h *hmap, bucket unsafe.Pointer) *hmap {
	// 省略各种条件检查...

	// 找到一个 B,使得 map 的装载因子在正常范围内
	B := uint8(0)
	for ; overLoadFactor(hint, B); B++ {
	}

	// 初始化 hash table
	// 如果 B 等于 0,那么 buckets 就会在赋值的时候再分配
	// 如果长度比较大,分配内存会花费长一点
	buckets := bucket
	var extra *mapextra
	if B != 0 {
		var nextOverflow *bmap
		buckets, nextOverflow = makeBucketArray(t, B)
		if nextOverflow != nil {
			extra = new(mapextra)
			extra.nextOverflow = nextOverflow
		}
	}

	// 初始化 hamp
	if h == nil {
		h = (*hmap)(newobject(t.hmap))
	}
	h.count = 0
	h.B = B
	h.extra = extra
	h.flags = 0
	h.hash0 = fastrand()
	h.buckets = buckets
	h.oldbuckets = nil
	h.nevacuate = 0
	h.noverflow = 0

	return h
}

注意,这个函数返回的结果:*hmap,它是一个指针,而我们之前讲过的 makeslice 函数返回的是 Slice 结构体
makemap 和 makeslice 的区别,带来一个不同点:当 map 和 slice 作为函数参数时,在函数参数内部对 map 的操作会影响 map 自身;而对 slice 却不会(之前讲 slice 的文章里有讲过)。

主要原因:一个是指针(*hmap),一个是结构体(slice)。Go 语言中的函数传参都是值传递,在函数内部,参数会被 copy 到本地。*hmap指针 copy 完之后,仍然指向同一个 map,因此函数内部对 map 的操作会影响实参。而 slice 被 copy 后,会成为一个新的 slice,对它进行的操作不会影响到实参。

哈希函数

map 的一个关键点在于,哈希函数的选择。在程序启动时,会检测 cpu 是否支持 aes,如果支持,则使用 aes hash,否则使用 memhash。这是在函数 alginit() 中完成,位于路径:src/runtime/alg.go 下。

哈希冲突

Go使用链地址法来解决键冲突。由 于每个bucket可以存放8个键值对,所以同一个bucket存放超过8个键值对时就会再创建一个键值对,用类似链表的方式将bucket连接起来。
当一个键值对被插入到map中时,如果该键已经存在,那么新的键值对会被添加到链表的头部。这意味着链表中的第一个元素是最近插入的键值对,而最后一个元素是最早插入的键值对。
在这里插入图片描述

负载因子

负载因子用于衡量一个哈希表冲突情况,公式为:

负载因子 = 键数量/bucket数量

例如,对于一个bucket数量为4,包含4个键值对的哈希表来说,这个哈希表的负载因子为1.
哈希表需要将负载因子控制在合适的大小,超过其阀值需要进行rehash
1.哈希因子过小,说明空间利用率低
2.哈希因子过大,说明冲突严重,存取效率低
再来说触发 map 扩容的时机:在向 map 插入新 key 的时候,会进行条件检测,符合下面这 2 个条件,就会触发扩容:

第一点:装载因子超过阈值,源码里定义的阈值是 6.5。
第二点:overflow 的 bucket 数量过多:当 B 小于 15,也就是 bucket 总数 2^B 小于 2^15 时,如果 overflow 的 bucket 数量超过 2^B;当 B >= 15,也就是 bucket 总数 2^B 大于等于 2^15,如果 overflow 的 bucket 数量超过 2^15。

gomap的查找

在 go 有两种写法

  1. 一种是直接取值 s := hash[key]
  2. 另一种是使用 s, ok := hash[key]

第一种写法无论 key 是否存在于 map 中, s 都会获取一个返回值, 而当 key 不存在时会返回对应类型的零值, 而第二种写法中, ok 变量可以标识此次是否从 map 中真正的获取到了 key 所对应的 elem, 在 go 语言底层, 这两种写法实际会调用两个不同函数, 它们都位于 src/runtime/map.go 中, 分别调用 mapaccess1 和 mapaccess2 函数, 这两个函数的内部逻辑几乎是一样的, 第二个相比于第一个仅仅多了一个是否查询到的标志位
在这里插入图片描述
上图中,假定 B = 5,所以 bucket 总数就是 2^5 = 32。首先计算出待查找 key 的哈希,使用低 5 位 00110,找到对应的 6 号 bucket,使用高 8 位 10010111,对应十进制 151,在 6 号 bucket 中寻找 tophash 值(HOB hash)为 151 的 key,找到了 2 号槽位,这样整个查找过程就结束了。

如果在 bucket 中没找到,并且 overflow 不为空,还要继续去 overflow bucket 中寻找,直到找到或是所有的 key 槽位都找遍了,包括所有的 overflow bucket。遍历完所有的溢出桶仍未找到目标元素, 此时返回该类型的零值

看源码:

func mapaccess1(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer {
  //...
  // 如果 h 什么都没有,返回零值
  if h == nil || h.count == 0 {
    return unsafe.Pointer(&zeroVal[0])
  }
  // 写和读冲突
  if h.flags&hashWriting != 0 {
    throw("concurrent map read and map write")
  }
  // 不同类型 key 使用的 hash 算法在编译期确定
  alg := t.key.alg
  // 计算哈希值,并且加入 hash0 引入随机性
  hash := alg.hash(key, uintptr(h.hash0))
  // 比如 B=5,那 m 就是31,二进制是全 1
  // 求 bucket num 时,将 hash 与 m 相与,
  // 达到 bucket num 由 hash 的低 8 位决定的效果
  m := bucketMask(h.B)
  // b 就是 bucket 的地址
  b := (*bmap)(add(h.buckets, (hash&m)*uintptr(t.bucketsize)))
  // oldbuckets 不为 nil,说明发生了扩容
  if c := h.oldbuckets; c != nil {
    // 如果不是同 size 扩容(看后面扩容的内容)
    // 对应条件 1 的解决方案
    if !h.sameSizeGrow() {
      // 新 bucket 数量是老的 2 倍
      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)
  //开始寻找key
  for ; b != nil; b = b.overflow(t) {
    // 遍历 8 个 bucket
    for i := uintptr(0); i < bucketCnt; i++ {
      // tophash 不匹配,继续
      if b.tophash[i] != top {
        continue
      }
      // tophash 匹配,定位到 key 的位置
      k := add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize))
      // key 是指针
      if t.indirectkey {
        // 解引用
        k = *((*unsafe.Pointer)(k))
      }
      // 如果 key 相等
      if alg.equal(key, k) {
        // 定位到 value 的位置
        v := add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.valuesize))
        // value 解引用
        if t.indirectvalue {
          v = *((*unsafe.Pointer)(v))
        }
        return v
      }
    }
  }
  return unsafe.Pointer(&zeroVal[0])
}

这里我们详细介绍下B的获取过程,以及key/value的获取过程

B的获取过程

在hmap中,buckets数组的元素数=2^B次方。 map的负载因子≥6.5时会自动扩容。 当前map的key/value元素数量为16。那计算B就变成了以下逻辑:

元素个数为16的情况下,分配几个bucket才能满足负载因子<6.5

即以下公式:

元素个数/bucket数量 ≤ 6.5

进一步演变成以下公式

元素个数 ≤ bucket数量*6.5

将bucket数量=2^B次方带入以上公式,则最终的公式为:

初始元素个数 ≤ 2^B * 6.5

当初始元素个数为16时,上述公式为:

16 ≤ 2^B * 6.5

那么,让B从0开始依次递增,直到遇到让该公式成立的最小B值即可
由此可知,当初始元素个数为16时,B为2,则计算出bucket的数量为2^2次方,为4个bucket。

key/value的获取过程

// key 定位公式
// b 就是 bucket 的地址
k :=add(unsafe.Pointer(b),dataOffset+i*uintptr(t.keysize))

// value 定位公式
v:= add(unsafe.Pointer(b),dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.valuesize))

//对于 bmap 起始地址的偏移:
dataOffset = unsafe.Offsetof(struct{
  b bmap
  v int64
}{}.v)

bucket 里 key 的起始地址就是 unsafe.Pointer(b)+dataOffset。第 i 个 key 的地址就要在此基础上跨过 i 个 key 的大小;而我们又知道,value 的地址是在所有 key 之后,因此第 i 个 value 的地址还需要加上所有 key 的偏移。

赋值操作

m := make(map[int 32]int 32)
m[0] = 111
  1. 校验和初始化
func mapassign(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer {
  //判断 hmap 是否已经初始化(是否为 nil)
    if h == nil {
    panic(plainError("assignment to entry in nil map"))
  }
  //...
    //判断是否并发读写 map,若是则抛出异常
  if h.flags&hashWriting != 0 {
    throw("concurrent map writes")
  }
    //根据 key 的不同类型调用不同的 hash 方法计算得出 hash 值
  alg := t.key.alg
  hash := alg.hash(key, uintptr(h.hash0))
    //设置 flags 标志位,表示有一个 goroutine 正在写入数据。因为 alg.hash 有可能出现 panic 导致异常
  h.flags |= hashWriting
    //判断 buckets 是否为 nil,若是则调用 newobject 根据当前 bucket 大小进行分配
    //初始化时没有初始 buckets,那么它在第一次赋值时就会对 buckets 分配
  if h.buckets == nil {
    h.buckets = newobject(t.bucket) // newarray(t.bucket, 1)
  }  
}
  1. 寻找可插入位和更新既有值
//根据低八位计算得到 bucket 的内存地址
  bucket := hash & bucketMask(h.B)
  //判断是否正在扩容,若正在扩容中则先迁移再接着处理
  if h.growing() {
    growWork(t, h, bucket)
  }
  //计算并得到 bucket 的 bmap 指针地址
  b := (*bmap)(unsafe.Pointer(uintptr(h.buckets) + bucket*uintptr(t.bucketsize)))
  //计算 key hash 高八位用于查找 Key
  top := tophash(hash)
  var inserti *uint8
  var insertk unsafe.Pointer
  var val unsafe.Pointer
  for {
    //迭代 buckets 中的每一个 bucket(共 8 个)
    for i := uintptr(0); i < bucketCnt; i++ {
      //对比 bucket.tophash 与 top(高八位)是否一致
      if b.tophash[i] != top {
        //若不一致,判断是否为空槽
        if b.tophash[i] == empty && inserti == nil {
          //有两种情况,第一种是没有插入过。第二种是插入后被删除
          inserti = &b.tophash[i]
          insertk = add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize))
          //把该位置标识为可插入 tophash 位置,这里就是第一个可以插入数据的地方
          val = add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.valuesize))
        }
        continue
      }
      //若是匹配(也就是原本已经存在),则进行更新。最后跳出并返回 value 的内存地址
      k := add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize))
      if t.indirectkey {
        k = *((*unsafe.Pointer)(k))
      }
      if !alg.equal(key, k) {
        continue
      }
      // already have a mapping for key. Update it.
      if t.needkeyupdate {
        typedmemmove(t.key, k, key)
      }
      val = add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.valuesize))
      goto done
    }
    //判断是否迭代完毕,若是则结束迭代 buckets 并更新当前桶位置
    ovf := b.overflow(t)
    if ovf == nil {
      break
    }
    b = ovf
  }
  //若满足三个条件:触发最大 LoadFactor(装载因子) 、存在过多溢出桶 overflow buckets、没有正在进行扩容。就会进行扩容动作(以确保后续的动作)
  if !h.growing() && (overLoadFactor(h.count+1, h.B) || tooManyOverflowBuckets(h.noverflow, h.B)) {
    hashGrow(t, h)
    goto again // Growing the table invalidates everything, so try again
  }
  1. 若没有找到可插入的位置,意味着当前的桶都满了,需要重新分配一个桶
//经过前面迭代寻找动作,若没有找到可插入的位置,意味着当前的所有桶都满了,将重新分配一个新溢出桶用于插入动作。最后再在上一步申请的新插入位置,存储键值对,返回该值的内存地址
  if inserti == nil {
    // all current buckets are full, allocate a new one.
    newb := h.newoverflow(t, b)
    inserti = &newb.tophash[0]
    insertk = add(unsafe.Pointer(newb), dataOffset)
    val = add(insertk, bucketCnt*uintptr(t.keysize))
  }
// store new key/value at insert position
  if t.indirectkey {
    kmem := newobject(t.key)
    *(*unsafe.Pointer)(insertk) = kmem
    insertk = kmem
  }
  if t.indirectvalue {
    vmem := newobject(t.elem)
    *(*unsafe.Pointer)(val) = vmem
  }
  typedmemmove(t.key, insertk, key)
  *inserti = top
  h.count++
done
    ...
  return val
  1. 写入
    第二三步最后返回的是内存地址。是怎么进行写入的呢?这是因为隐藏的最后一步写入动作(将值拷贝到指定内存区域)是通过底层汇编配合来完成的,在 runtime 中只完成了绝大部分的动作。 mapassign 函数和拿到值存放的内存地址,再将 value 这个值存放进该内存地址中。

扩容

map 扩容的时机:在向 map 插入新 key 的时候,会进行条件检测,符合下面这 2 个条件,就会触发扩容:

1、装载因子超过阈值,源码里定义的阈值是 6.5

2、overflow 的 bucket 数量过多

第 1 点:我们知道,每个 bucket 有 8 个空位,在没有溢出,且所有的桶都装满了的情况下,装载因子算出来的结果是 8。因此当装载因子超过 6.5 时,表明很多 bucket 都快要装满了,查找效率和插入效率都变低了。在这个时候进行扩容是有必要的。

第 2 点:是对第 1 点的补充。就是说在装载因子比较小的情况下,这时候 map 的查找和插入效率也很低,而第 1 点识别不出来这种情况。表面现象就是计算装载因子的分子比较小,即 map 里元素总数少,但是 bucket 数量多(真实分配的 bucket 数量多,包括大量的 overflow bucket)。

不难想像造成这种情况的原因:不停地插入、删除元素。先插入很多元素,导致创建了很多 bucket,但是装载因子达不到第 1 点的临界值,未触发扩容来缓解这种情况。之后,删除元素降低元素总数量,再插入很多元素,导致创建很多的 overflow bucket,但就是不会触犯第 1 点的规定,你能拿我怎么办?overflow bucket 数量太多,导致 key 会很分散,查找插入效率低得吓人,因此出台第 2 点规定。这就像是一座空城,房子很多,但是住户很少,都分散了,找起人来很困难。

对于命中条件 1,2 的限制,都会发生扩容。但是扩容的策略并不相同,毕竟两种条件应对的场景不同。

对于条件 1,元素太多,而 bucket 数量太少,很简单:将 B 加 1,bucket 最大数量(2^B)直接变成原来 bucket 数量的 2 倍。于是,就有新老 bucket 了。注意,这时候元素都在老 bucket 里,还没迁移到新的 bucket 来。新 bucket 只是最大数量变为原来最大数量的 2 倍(2^B*2) 。

对于条件 2,其实元素没那么多,但是 overflow bucket 数特别多,说明很多 bucket 都没装满。解决办法就是开辟一个新 bucket 空间,将老 bucket 中的元素移动到新 bucket,使得同一个 bucket 中的 key 排列地更紧密。这样,原来,在 overflow bucket 中的 key 可以移动到 bucket 中来。结果是节省空间,提高 bucket 利用率,map 的查找和插入效率自然就会提升。

由于 map 扩容需要将原有的 key/value 重新搬迁到新的内存地址,如果有大量的 key/value 需要搬迁,会非常影响性能。因此 Go map 的扩容采取了一种称为“渐进式”的方式,原有的 key 并不会一次性搬迁完毕,每次最多只会搬迁 2 个 bucket。插入或修改、删除 key 的时候,都会尝试进行搬迁 buckets 的工作。先检查 oldbuckets 是否搬迁完毕,具体来说就是检查 oldbuckets 是否为 nil。
hashGrow分配新的buckets,源码为:

func hashGrow(t *maptype, h *hmap) {
  // B+1 相当于是原来 2 倍的空间
  bigger := uint8(1)
  // 对应条件 2
  if !overLoadFactor(h.count+1, h.B) {
    // 进行等量的内存扩容,所以 B 不变
    bigger = 0
    h.flags |= sameSizeGrow
  }
  // 将老 buckets 挂到 buckets 上
  oldbuckets := h.buckets
  // 申请新的 buckets 空间
  newbuckets, nextOverflow := makeBucketArray(t, h.B+bigger, nil)
    //先把 h.flags 中 iterator 和 oldIterator 对应位清 0
    //如果 iterator 位为 1,把它转接到 oldIterator 位,使得 oldIterator 标志位变成1
    //可以理解为buckets 现在挂到了 oldBuckets 名下了,将对应的标志位也转接过去
  flags := h.flags &^ (iterator | oldIterator)
  if h.flags&iterator != 0 {
    flags |= oldIterator
  }
  // commit the grow (atomic wrt gc)
  h.B += bigger
  h.flags = flags
  h.oldbuckets = oldbuckets
  h.buckets = newbuckets
  // 搬迁进度为 0
  h.nevacuate = 0
  // overflow buckets 数为 0
  h.noverflow = 0
}

这里再介绍下
&^(按位置零)符号的含义

i := 1 &^ 0
fmt.Println("1 &^ 0 -- ",i)
i = 1 &^ 1
fmt.Println("1 &^ 1 -- ",i)
i = 0 &^ 1
fmt.Println("0 &^ 1 -- ",i)
i = 0 &^ 0
fmt.Println("0 &^ 0 -- ",i)

fmt.Println("")

j := 2 &^ 0
fmt.Println("2 &^ 0 -- ",j)
j = 2 &^ 2
fmt.Println("2 &^ 2 -- ",j)
j = 0 &^ 2
fmt.Println("0 &^ 2 -- ",j)
j = 0 &^ 0
fmt.Println("0 &^ 0 -- ",j)

结果为

1 &^ 0 --  1
1 &^ 1 --  0
0 &^ 1 --  0
0 &^ 0 --  0

2 &^ 0 --  2
2 &^ 2 --  0
0 &^ 2 --  0
0 &^ 0 --  0

结论:
z = x &^ y

如果y非零,则z为0
如果y为零,则z为x

还有一些其他常见的位运算符

运算符        描述
&      参与运算的两数各对应的二进位相与。(两位均为1才为1)
|      参与运算的两数各对应的二进位相或。(两位有一个为1就为1)
^      参与运算的两数各对应的二进位相异或,当两对应的二进位相异时,结果为1。(两位不一样则为1)
<<      左移n位就是乘以2的n次方。“a<<b”是把a的各二进位全部左移b位,高位丢弃,低位补0。
>>     右移n位就是除以2的n次方。“a>>b”是把a的各二进位全部右移b位。

growwork完成搬迁工作

func growWork(t *maptype, h *hmap, bucket uintptr) {
 // 搬迁正在使用的旧 bucket
 evacuate(t, h, bucket&h.oldbucketmask())
 // 再搬迁一个 bucket,以加快搬迁进程
 if h.growing() {
   evacuate(t, h, h.nevacuate)
 }
}

func (h *hmap) growing() bool {
 return h.oldbuckets != nil
}
type evacDst struct {
 b *bmap          // 表示bucket 移动的目标地址
 i int            // 指向 x,y 中 key/val 的 index
 k unsafe.Pointer // 指向 x,y 中的 key
 v unsafe.Pointer // 指向 x,y 中的 value
}

func evacuate(t *maptype, h *hmap, oldbucket uintptr) {
 // 定位老的 bucket 地址
 b := (*bmap)(add(h.oldbuckets, oldbucket*uintptr(t.bucketsize)))
 // 计算容量 结果是 2^B,如 B = 5,结果为32
 newbit := h.noldbuckets()
 // 如果 b 没有被搬迁过
 if !evacuated(b) {
   // 默认是等 size 扩容,前后 bucket 序号不变
   var xy [2]evacDst
   // 使用 x 来进行搬迁
   x := &xy[0]
   x.b = (*bmap)(add(h.buckets, oldbucket*uintptr(t.bucketsize)))
   x.k = add(unsafe.Pointer(x.b), dataOffset)
   x.v = add(x.k, bucketCnt*uintptr(t.keysize))

   // 如果不是等 size 扩容,前后 bucket 序号有变
   if !h.sameSizeGrow() {
     // 使用 y 来进行搬迁
     y := &xy[1]
     // y 代表的 bucket 序号增加了 2^B
     y.b = (*bmap)(add(h.buckets, (oldbucket+newbit)*uintptr(t.bucketsize)))
     y.k = add(unsafe.Pointer(y.b), dataOffset)
     y.v = add(y.k, bucketCnt*uintptr(t.keysize))
   }
   // 遍历所有的 bucket,包括 overflow buckets b 是老的 bucket 地址
   for ; b != nil; b = b.overflow(t) {
     k := add(unsafe.Pointer(b), dataOffset)
     v := add(k, bucketCnt*uintptr(t.keysize))
     // 遍历 bucket 中的所有 cell
     for i := 0; i < bucketCnt; i, k, v = i+1, add(k, uintptr(t.keysize)), add(v, uintptr(t.valuesize)) {
       // 当前 cell 的 top hash 值
       top := b.tophash[i]
       // 如果 cell 为空,即没有 key
       if top == empty {
         // 那就标志它被"搬迁"过
         b.tophash[i] = evacuatedEmpty
         continue
       }
       // 正常不会出现这种情况
       // 未被搬迁的 cell 只可能是 empty 或是
       // 正常的 top hash(大于 minTopHash)
       if top < minTopHash {
         throw("bad map state")
       }
       // 如果 key 是指针,则解引用
       k2 := k
       if t.indirectkey {
         k2 = *((*unsafe.Pointer)(k2))
       }
       var useY uint8
       // 如果不是等量扩容
       if !h.sameSizeGrow() {
         // 计算 hash 值,和 key 第一次写入时一样
         hash := t.key.alg.hash(k2, uintptr(h.hash0))
         // 如果有协程正在遍历 map 如果出现 相同的 key 值,算出来的 hash 值不同
         if h.flags&iterator != 0 && !t.reflexivekey && !t.key.alg.equal(k2, k2) {
           // useY =1 使用位置Y
           useY = top & 1
           top = tophash(hash)
         } else {
           // 第 B 位置 不是 0
           if hash&newbit != 0 {
             //使用位置Y
             useY = 1
           }
         }
       }

       if evacuatedX+1 != evacuatedY {
         throw("bad evacuatedN")
       }
       //决定key是裂变到 X 还是 Y
       b.tophash[i] = evacuatedX + useY // evacuatedX + 1 == evacuatedY
       dst := &xy[useY]                 // evacuation destination
       // 如果 xi 等于 8,说明要溢出了
       if dst.i == bucketCnt {
         // 新建一个 bucket
         dst.b = h.newoverflow(t, dst.b)
         // xi 从 0 开始计数
         dst.i = 0
         //key移动的位置
         dst.k = add(unsafe.Pointer(dst.b), dataOffset)
         //value 移动的位置
         dst.v = add(dst.k, bucketCnt*uintptr(t.keysize))
       }
       // 设置 top hash 值
       dst.b.tophash[dst.i&(bucketCnt-1)] = top // mask dst.i as an optimization, to avoid a bounds check
       // key 是指针
       if t.indirectkey {
         // 将原 key(是指针)复制到新位置
         *(*unsafe.Pointer)(dst.k) = k2 // copy pointer
       } else {
         // 将原 key(是值)复制到新位置
         typedmemmove(t.key, dst.k, k) // copy value
       }
       //value同上
       if t.indirectvalue {
         *(*unsafe.Pointer)(dst.v) = *(*unsafe.Pointer)(v)
       } else {
         typedmemmove(t.elem, dst.v, v)
       }
       // 定位到下一个 cell
       dst.i++
       dst.k = add(dst.k, uintptr(t.keysize))
       dst.v = add(dst.v, uintptr(t.valuesize))
     }
   }
   // Unlink the overflow buckets & clear key/value to help GC.

   // bucket搬迁完毕 如果没有协程在使用老的 buckets,就把老 buckets 清除掉,帮助gc
   if h.flags&oldIterator == 0 && t.bucket.kind&kindNoPointers == 0 {
     b := add(h.oldbuckets, oldbucket*uintptr(t.bucketsize))
     ptr := add(b, dataOffset)
     n := uintptr(t.bucketsize) - dataOffset
     memclrHasPointers(ptr, n)
   }
 }
 // 更新搬迁进度
 if oldbucket == h.nevacuate {
   advanceEvacuationMark(h, t, newbit)
 }
}

搬迁的目的就是将老的 buckets 搬迁到新的 buckets。而通过前面的说明我们知道,应对条件 1,新的 buckets 数量是之前的一倍,应对条件 2,新的 buckets 数量和之前相等。

对于条件 2,从老的 buckets 搬迁到新的 buckets,由于 bucktes 数量不变,因此可以按序号来搬,比如原来在 0 号 bucktes,到新的地方后,仍然放在 0 号 buckets。

对于条件 1,就没这么简单了。要重新计算 key 的哈希,才能决定它到底落在哪个 bucket。例如,原来 B = 5,计算出 key 的哈希后,只用看它的低 5 位,就能决定它落在哪个 bucket。扩容后,B 变成了 6,因此需要多看一位,它的低 6 位决定 key 落在哪个 bucket。这称为 rehash
扩容后,B 增加了 1,意味着 buckets 总数是原来的 2 倍,原来 1 号的桶“裂变”到两个桶,某个 key 在搬迁前后 bucket 序号可能和原来相等,也可能是相比原来加上 2^B(原来的 B 值),取决于 hash 值 第 6 bit 位是 0 还是 1 (0保持不变,1变)。原理看下图:
在这里插入图片描述

为什么遍历MAP是无序的

  1. 根据上面的结论可以得出,再进行搬迁后,有些key的位置 2^B,有的保持不变。而遍历的过程,就是按顺序遍历 bucket,同时按顺序遍历 bucket 中的 key。搬迁后,key 的位置发生了重大的变化,有些 key 飞上高枝,有些 key 则原地不动。这样,遍历 map 的结果就不可能按原来的顺序了。
  2. 当我们在遍历 map 时,并不是固定地从 0 号 bucket 开始遍历,每次都是从一个随机值序号的 bucket 开始遍历,并且是从这个 bucket 的一个随机序号的 cell 开始遍历。这样,即使你是一个写死的 map,仅仅只是遍历它,也不太可能会返回一个固定序列的 key/value 对了。
//runtime.mapiterinit 遍历时选用初始桶的函数
func mapiterinit(t *maptype, h *hmap, it *hiter) {
  ...
  it.t = t
  it.h = h
  it.B = h.B
  it.buckets = h.buckets
  if t.bucket.kind&kindNoPointers != 0 {
    h.createOverflow()
    it.overflow = h.extra.overflow
    it.oldoverflow = h.extra.oldoverflow
  }

  r := uintptr(fastrand())
  if h.B > 31-bucketCntBits {
    r += uintptr(fastrand()) << 31
  }
  it.startBucket = r & bucketMask(h.B)
  it.offset = uint8(r >> h.B & (bucketCnt - 1))
  it.bucket = it.startBucket
    ...

  mapiternext(it)
}

fastrand 部分,是一个生成随机数的方法:它生成了随机数。用于决定从哪里开始循环迭代
有一个特殊情况是:有一种 key,每次对它计算 hash,得到的结果都不一样。这个 key 就是 math.NaN() 的结果,它的含义是 not a number,类型是 float64。当它作为 map 的 key,在搬迁的时候,会遇到一个问题:再次计算它的哈希值和它当初插入 map 时的计算出来的哈希值不一样!

你可能想到了,这样带来的一个后果是,这个 key 是永远不会被 Get 操作获取的!当我使用 m[math.NaN()] 语句的时候,是查不出来结果的。这个 key 只有在遍历整个 map 的时候,才有机会现身。所以,可以向一个 map 插入任意数量的 math.NaN() 作为 key。

删除操作

它首先会检查 h.flags 标志,如果发现写标位是 1,直接 panic,因为这表明有其他协程同时在进行写操作。计算 key 的哈希,找到落入的 bucket。检查此 map 如果正在扩容的过程中,直接触发一次搬迁操作。删除操作同样是两层循环,核心还是找到 key 的具体位置。寻找过程都是类似的,在 bucket 中挨个 cell 寻找。找到对应位置后,对 key 或者 value 进行“清零”操作,将 count 值减 1,将对应位置的 tophash 值置成 Empty。
标记删除:删除key仅仅只是将其对应的tophash值置空,如果kv存储的是指针,那么会清理指针指向的内存,否则不会真正回收内存,内存占用并不会减少。

func mapdelete(t *maptype, h *hmap, key unsafe.Pointer) {
  if raceenabled && h != nil {
    callerpc := getcallerpc()
    pc := funcPC(mapdelete)
    racewritepc(unsafe.Pointer(h), callerpc, pc)
    raceReadObjectPC(t.key, key, callerpc, pc)
  }
  if msanenabled && h != nil {
    msanread(key, t.key.size)
  }
  if h == nil || h.count == 0 {
    return
  }
  if h.flags&hashWriting != 0 {
    throw("concurrent map writes")
  }

  alg := t.key.alg
  hash := alg.hash(key, uintptr(h.hash0))

  // Set hashWriting after calling alg.hash, since alg.hash may panic,
  // in which case we have not actually done a write (delete).
  h.flags |= hashWriting

  bucket := hash & bucketMask(h.B)
  if h.growing() {
    growWork(t, h, bucket)
  }
  b := (*bmap)(add(h.buckets, bucket*uintptr(t.bucketsize)))
  top := tophash(hash)
search:
  for ; b != nil; b = b.overflow(t) {
    for i := uintptr(0); i < bucketCnt; i++ {
      if b.tophash[i] != top {
        continue
      }
      k := add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize))
      k2 := k
      if t.indirectkey {
        k2 = *((*unsafe.Pointer)(k2))
      }
      if !alg.equal(key, k2) {
        continue
      }
      // Only clear key if there are pointers in it.
            // 对key清零
      if t.indirectkey {
        *(*unsafe.Pointer)(k) = nil
      } else if t.key.kind&kindNoPointers == 0 {
        memclrHasPointers(k, t.key.size)
      }
      v := add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.valuesize))
            // 对value清零
      if t.indirectvalue {
        *(*unsafe.Pointer)(v) = nil
      } else if t.elem.kind&kindNoPointers == 0 {
        memclrHasPointers(v, t.elem.size)
      } else {
        memclrNoHeapPointers(v, t.elem.size)
      }
            // 高位hash清零
      b.tophash[i] = empty
            // 个数减一
      h.count--
      break search
    }
  }

  if h.flags&hashWriting == 0 {
    throw("concurrent map writes")
  }
  h.flags &^= hashWriting
}

并发操作

map 并不是一个线程安全的数据结构。同时读写一个 map 是不安全的,如果被检测到,会直接 panic。

解决方法1:读写锁 sync.RWMutex。

type TestMap struct {
  M    map[int]string
  Lock sync.RWMutex
}

func main() {
  testMap := TestMap{}
  testMap.M = map[int]string{1: "lili"}
  go func() {
    i := 0
    for i < 10000 {
      testMap.Lock.RLock()
      fmt.Println(i, testMap.M[1])
      testMap.Lock.RUnlock()
      i++
    }
  }()

  go func() {
    i := 0
    for i < 10000 {
      testMap.Lock.Lock()
      testMap.M[1] = "lily"
      testMap.Lock.Unlock()
      i++
    }
  }()

    for {
    runtime.GC()
  }
}

解决方法2:使用golang提供的 sync.Map

func main() {
  m := sync.Map{}
  m.Store(1, 1)
  i := 0
  go func() {
    for i < 1000 {
      m.Store(1, 1)
      i++
    }
  }()

  go func() {
    for i < 1000 {
      m.Store(2, 2)
      i++
    }
  }()

  go func() {
    for i < 1000 {
      fmt.Println(m.Load(1))
      i++
    }
  }()

  for {
    runtime.GC()
  }
}

sync.map 适用于读多写少的场景。对于写多的场景,会导致 read map 缓存失效,需要加锁,导致冲突变多;而且由于未命中 read map 次数过多,导致 dirty map 提升为 read map,这是一个 O(N) 的操作,会进一步降低性能。(readmap相当于dirty map 的缓存)

同时 Map类型,还针对以下场景进行了性能优化:

  1. 当一个 key 只被写入一次但被多次读取时,例如在只会增长的缓存中,就存在这种业务场景
  2. 当多个 goroutines 读取、写入和覆盖不相干的 key 时
    这两种情况与 Go map 搭配单独的 Mutex 或 RWMutex 相比较,使用 sync.Map 类型可以大大减少锁的争夺(因为mutex实际上对 只读场景还是加锁,只不过是读锁,而sync.map不加锁)。

sync.map的结构为:

type Map struct {
 mu Mutex
 read atomic.Value // readOnly
 dirty map[interface{}]*entry
 misses int
}
 
// Map.read 属性实际存储的是 readOnly。
type readOnly struct {
 m       map[interface{}]*entry
 amended bool
}
  1. mu: 互斥锁,保护 read 和 dirty
  2. read: 只读数据,指出并发读取 (atomic.Value 类型) 。如果需要更新 read,需要加锁保护数据安全。read 实际存储的是 readOnly 结构体,内部是一个原生 map,amended 属性用于标记 read 和 dirty 的数据是否一致
  3. dirty: 读写数据,非线性安全的原生 map。包含新写入的 key,并且包含 read 中所有未被删除的 key。
  4. misses: 统计有多少次读取 read 没有被命中。每次 read 读取失败后,misses 的计数加 1

在这里插入图片描述
在 read 和 dirty 中,都涉及到的结构体:

type entry struct {
 p unsafe.Pointer // *interface{}
}

其中包含一个 p 指针,用于指向用户存储的元素(key)所指向的 value 值。看来,read 和 dirty 各自维护一套 key,key 指向的都是同一个 value。也就是说,只要修改了这个 entry,对 read 和 dirty 都是可见的。这个指针的状态有三种:
在这里插入图片描述
p 的三种状态

  1. 当 p == nil 时,说明这个键值对已被删除,并且 m.dirty == nil,或 m.dirty[k] 指向该 entry。

  2. 当 p == expunged 时,说明这条键值对已被删除,并且 m.dirty != nil,且 m.dirty 中没有这个 key。

  3. 其他情况,p 指向一个正常的值,表示实际 interface{} 的地址,并且被记录在 m.read.m[key] 中。如果这时 m.dirty 不为 nil,那么它也被记录在 m.dirty[key] 中。两者实际上指向的是同一个值。

当删除 key 时,并不实际删除。一个 entry 可以通过原子地(CAS 操作)设置 p 为 nil 被删除。如果之后创建 m.dirty,nil 又会被原子地设置为 expunged,且不会拷贝到 dirty 中。

如果 p 不为 expunged,和 entry 相关联的这个 value 可以被原子地更新;如果 p == expunged,那么仅当它初次被设置到 m.dirty 之后,才可以被更新。

map 中删除一个 key,它的内存会释放么?(常问)

如果删除的元素是值类型,如int,float,bool,string以及数组和struct,map的内存不会自动释放(如果把他们改成指针,就会回收子元素占用的内存)

如果删除的元素是引用类型,如指针,slice,map,chan等,map的内存会自动释放,但释放的内存是子元素应用类型的内存占用

将map设置为nil后,内存被回收。

总结:
sync.Map 的两个 map,当从 sync.Map 类型中读取数据时,其会先查看 read 中是否包含所需的元素:

若有,则通过 atomic 原子操作读取数据并返回。
若无,则会判断 read.readOnly 中的 amended 属性,他会告诉程序 dirty 是否包含 read.readOnly.m 中没有的数据;因此若存在,也就是 amended 为 true,将会进一步到 dirty 中查找数据。
sync.Map 的读操作性能如此之高的原因,就在于存在 read 这一巧妙的设计,其作为一个缓存层,提供了快路径(fast path)的查找。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值