Go进阶之map

18 阅读7分钟

1.初始化:

1.1字面量初始化:

func main() {
    m := map[string]string{
       "hello":  "world",
       "hello2": "world2",
    }

    for k, v := range m {
       fmt.Printf("%s-%s\n", k, v)
    }
}

1.2内置函数make初始化:

func main() {
    m2 := make(map[string]int, 10)
    m2["hello"] = 1
    m2["hello2"] = 2
    for k, v := range m2 {
       fmt.Printf("%s-%d\n", k, v)
    }
}

使用make内置函数初始化map时可以指定容量(也可以不指定).指定容量可以有效的减少分配内存次数.有利于提升性能.

1.3map的增删改查:

func main() {
    m2 := make(map[string]int, 10)

    for k, v := range m2 {
       fmt.Printf("%s-%d\n", k, v)
    }

    //添加.
    m2["hello"] = 1
    m2["hello2"] = 2
    m2["add"] = 3
    //修改.
    m2["hello"] = 001
    //删除
    delete(m2, "hello2")
    //查询
    v, exist := m2["add"]
    if exist {
       fmt.Println(v)
    }
}

注:

修改操作中.如果键不存在.则map会创建一个新的键值对并存储.等同于添加元素.

删除元素使用内置函数delete()完成.没有返回值.在map为nil或指定键值不存在的话.也不会报错.相当于空操作.

查询操作中.最多可以给两个变量赋值.第一个为值.第二个为bool类型变量.用于表示是否存在指定键.如果不存在.对应的值为相应类型的零值.如果指定一个变量.那么该变量仅表示该键对应的值.如果键不存在.该值同样为相应类型的零值.

内置函数len()可以查询map的长度.该长度为map中键值对的数量.

2.危险操作:

2.1并发读写:

map操作不是原子的.这意味这多协程操作map时有可能产生读写冲突.读写冲突会发生panic从而导致程序退出.

2.2空map:

未初始化的map值为nil.在向值为nil的map添加元素的时候会发生panic.

值为nil的map和长度为空的map长度一致.

func main() {
    var m1 map[string]int
    m := make(map[string]int)
    if len(m1) == len(m) {
       fmt.Println("长度一致")
    }
}

3.实现原理:

3.1数据结构:

Go语言的map底层使用的是hash实现.一个hash表里可以有多个bucket.而每个bucket保存了map中的一个或一组键值对.

源码位置:runtime/map_noswiss.go:hmap(go版本1.24.0)

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
    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

    buckets    unsafe.Pointer // array of 2^B Buckets. may be nil if count==0.
    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)
    clearSeq   uint64

    extra *mapextra // optional fields
}

count   当前保存元素的个数.

B   bucket数组大小.

buckets   bucket数组.数组长度为2的b次方.

oldbuckets   旧bucket数组.用于扩容.

拥有4个bucket的map.

3.2bucket结构:

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 [abi.OldMapBucketCount]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.
}

tophash 存储hash值得高八位.从下面源码可以看出.值为8.

const (
    // Maximum number of key/elem pairs a bucket can hold.
    OldMapBucketCountBits = 3 // log2 of number of elements in a bucket.
    OldMapBucketCount     = 1 << OldMapBucketCountBits

    // Maximum key or elem size to keep inline (instead of mallocing per element).
    // Must fit in a uint8.
    // Note: fast map functions cannot handle big elems (bigger than MapMaxElemBytes).
    OldMapMaxKeyBytes  = 128
    OldMapMaxElemBytes = 128 // Must fit in a uint8.
)

tophash 是一个长度为8的整型数组.hash值相同的键.(准确的说是hash值低八位相同的键)存入当前bucket时会将hash值的高八位存储在该数组中.方便后续匹配.

3.3hash冲突:

当有两个或两个以上的key被hash到通一个bucket时.我们称这些键发生了hash冲突.Go使用地址法来解决hash冲突.

一个桶可以放8个键值对.如果超过了8个会重新在创建一个bucket.用类似链表的方式连接起来.

注:hash冲突造成了存取效率低下.

3.4负载因子:

负载因子用于衡量一个hash表冲突情况.公式如下:

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

示例:

一个bucket数量为4.包含4个key-value的hash表.负载因子为1.

总结:

负载因子过小.说明空间利用率低.

负载因子过小.可能是预分配的空间过大.也可能是大部分元素被删除造成的.

负载因子过大.说明冲突严重.存取效率低.

当hash表中负载因子过大.需要申请更多地bucket.并对所有键值对重新组织.使其均匀的分配到bucket中.这个过程称为rehash.

3.5扩容:

降低负载因子常用的手段就是扩容.为了保证访问效率.当新元素加入的时候会检查是否需要扩容.扩容就是通过空间来换取时间的手段.

3.5.1扩容条件:

负载因子大于6.5.即平均每个桶存储的键值对达到6.5个以上.

overflow的数量达到2的min(15,b).

3.5.2增量扩容:

当负载因子过大时.新建一个bucket数组.新的bucket数组是原来的2倍.然后旧的bucket数组搬迁到新的bucket数组.如果map中存储了数以亿计的键值对.一次性搬迁会造成较大的延时.Go采用了逐步搬迁的策略.即每次访问map的时候都会进行搬迁.每次搬迁两个桶.

负载因子为7/1=7.

扩容的时候先让oldbuckets指向原bucket数组.然后申请新的数组(长度为原来的倍).并将数组指针保存到bucket.等后续迁移完了安全释放oldbuckets.迁移完成如下.

3.5.3等量扩容:

等量扩容并不是扩大容量.bucket数量不变.重新做一遍类似增量扩容的搬迁操作.把松散的键值对重新排列一次.使bucket使用效率更高.保证存取速度.

4增删改查:

无论元素的添加还是查询操作.都需要根据键的hash值确定一个bucket.并查询该bucket中是否存在指定的键.

查询操作:

查到指定的键后获取值就返回.否则返回对应类型的空值.

添加操作:

查到指定的键意味着当前操作实际上是更新操作.否则在bucket中查找一个空余位置插入.

4.1查找过程:

1).根据key值计算hash值.

2).取hash值低位与hmap.B取模来确定bucket的位置.

3).取hash值高位.在tophash数组中查询.

4).如果tophash[i]中存储的hash值与当前key的hash值相等.则获取tophash[i]的key值进行比较.

5).如果当前bucket中没有找到.则依次从溢出的bucket中查找.

6).如果还是找不到不是返回nil.而是返回对应类型的零值.

4.2添加过程:

1).根据key值计算出hash值.

2).取hash值低位与hmp.B取模来确定bucket位置.

3).查找key是否存在.如果存在则直接更新值.

4).如果key不存在,则从该bucket中寻找空余位置并插入.

注:如果当前map处于搬迁过程中.则元素会直接添加到新的bucket数组中.查找过程仍从就数组开始.

4.3删除过程:

删除元素实际上是先查找元素.如果元素存在从相应的bucket中清除.如果不存在则什么也不做.

眼里倒映着转身.



如果大家喜欢我的分享的话.可以关注我的微信公众号

念何架构之路