一文读透GO语言的哈希表

最近一直远程办公,所以经常会从各种群邮件,钉钉等软件上收到一些重复的文件,突然发现原本不大的笔记本硬盘也会经常被这些文件所占据,而针对WINDOWS的重复文件清理软件如Duplicate Cleaner等笔者亲测也会带有一些捆绑式的安装,而其实这个需求没有那么难以解决,简单来说只需要建立 一个以文件名为索引,文件大小为键值的哈希表,然后遍历所有文件就能完成,在看过了所有典型的哈希表之后笔者发现GO语言的哈希表非常优秀,接下来就带大家一起来认识一下其中的奥秘,接下来笔者还会带大家一起来用GO语言实现一个文件去重的小工具。

 

在现代的系统研发尤其是web类系统中一般都使用负载均衡设备加分布式应用服务器的方式来完成,而同一客户的session一般都要保证始终发给一台应用服务器处理,负载设备每次需要通过session id迅速定位到服务器,那么session id和应用服务器的IP地址就是Map的典型应用。

GO语言中的Map源码非常值得一读下面给大家做一下简要介绍

1.map的数据结构

可以看到hmapmap的基础结构其中,其中记录了map的元素的数量,和bucket的数量,以及map现在是否正在迁移等状态信息详见以下注释说明。
 

type hmap struct {
	count     int // map的长度
	flags     uint8
	B         uint8  // map中的bucket的数量,
	noverflow uint16 // 
	hash0     uint32 // hash 种子

	buckets    unsafe.Pointer // 指向桶的指针
	oldbuckets unsafe.Pointer // 指向旧桶的指针,这里用于溢出
	nevacuate  uintptr        
	extra *mapextra // optional fields
}

// 在桶溢出的时候会用到extra
type mapextra struct {
	overflow    *[]*bmap
	oldoverflow *[]*bmap
	nextOverflow *bmap
}

type bmap struct {
	tophash [bucketCnt]uint8// Map中的哈希值的高8位为桶的地址

}
type hiter struct {//map中枚举结构

	key         unsafe.Pointer
	elem        unsafe.Pointer
	t           *maptype
	h           *hmap
	buckets     unsafe.Pointer
	bptr        *bmap          // 当前指向的bucket地址
	overflow    *[]*bmap       // 记录是否溢出
	oldoverflow *[]*bmap       //记录旧桶是否溢出
	startBucket uintptr      
	offset      uint8          
	wrapped     bool         
	B           uint8
	i           uint8
	bucket      uintptr
	checkBucket uintptr
}

 

Map创建时会初始化一个hmap结构体,在访问Map中的pair时,先计算key的哈希值,其中哈希的值的低8位定位到具体的桶(bucket),通过高8位在桶内定位到具体的位置。

2.map的创建

我们再来看makemap函数,如果首先看元素的个数,计算一个B值,如果map只需要一个桶(bucket)就可以直接创建在栈上,而不是在堆上如果 h.buckets 其指向的桶(bucket)可以作为第一个桶(bucket)来使用。详见以下代码注释

func makemap(t *maptype, hint int, h *hmap) *hmap {
    // 在 64 位系统上 hmap 结构体大小为 48 字节
    // 32 位系统上是 28 字节
    if sz := unsafe.Sizeof(hmap{}); sz != 8+5*sys.PtrSize {
        println("runtime: sizeof(hmap) =", sz, ", t.hmap.size =", t.hmap.size)
        throw("bad hmap size")
    }

    if hint < 0 || hint > int(maxSliceCap(t.bucket.size)) {
        hint = 0
    }

    // 初始化 hmap
    if h == nil {
        h = (*hmap)(newobject(t.hmap))
    }
    h.hash0 = fastrand()

    // 按照提供的元素个数,找一个可以放得下这么多元素的 B 值
    B := uint8(0)
    for overLoadFactor(hint, B) {
        B++
    }
    h.B = B

    // 分配初始的 hash table
    // 如果 B == 0,buckets 字段会由 mapassign 来 lazily 分配
    // 因为如果 hint 很大的话,对这部分内存归零会花比较长时间
    if h.B != 0 {
        var nextOverflow *bmap
        h.buckets, nextOverflow = makeBucketArray(t, h.B)
        if nextOverflow != nil {
            h.extra = new(mapextra)
            h.extra.nextOverflow = nextOverflow
        }
    }

    return h
}

3.Map中元素的访问

再来看访问元素的函数mapaccess,可以看到该函数会根据不同的key类型来选择不同的哈希算法,而且从这个函数也可以看到,map并不能保证并发安全的,因为在对外访问的时候map还可能正在扩容,一旦在扩容时发生并发访问可能会有潜在的问题,不过在gosync包下也有一个map的实现,这个map是协程安全的。mapaccess代码及注释如下

func mapaccess(t *maptype, h *hmap, key unsafe.Pointer) (unsafe.Pointer, bool) {
    // map 为空,或者元素数为 0,直接返回未找到
    if h == nil || h.count == 0 {
        return unsafe.Pointer(&zeroVal[0]), false
    }
    if h.flags&hashWriting != 0 {
        throw("concurrent map read and map write")
    }
    alg := t.key.alg
    // 不同类型的 key,所用的 hash 算法是不一样的
    // 具体可以参考 algarray
    hash := alg.hash(key, uintptr(h.hash0))
    // 如果 B = 3,那么结果用二进制表示就是 111
    // 如果 B = 4,那么结果用二进制表示就是 1111
    m := bucketMask(h.B)
    // 按位 &,可以 select 出对应的 bucket
    b := (*bmap)(unsafe.Pointer(uintptr(h.buckets) + (hash&m)*uintptr(t.bucketsize)))
    // 会用到 h.oldbuckets 时,说明 map 发生了扩容
    // 这时候,新的 buckets 里可能还没有老的内容
    // 所以一定要在老的里面找,否则有可能发生“消失”的诡异现象
    if c := h.oldbuckets; c != nil {
        if !h.sameSizeGrow() {
            // 说明之前只有一半的 bucket,需要除 2
            m >>= 1
        }
        oldb := (*bmap)(unsafe.Pointer(uintptr(c) + (hash&m)*uintptr(t.bucketsize)))
        if !evacuated(oldb) {
            b = oldb
        }
    }
    // tophash 取其高 8bit 的值
    top := tophash(hash)
    for ; b != nil; b = b.overflow(t) {
        // 一个 bucket 在存储满 8 个元素后,就再也放不下了
        // 这时候会创建新的 bucket
        // 挂在原来的 bucket 的 overflow 指针成员上
        for i := uintptr(0); i < bucketCnt; i++ {
            // 循环对比 bucket 中的 tophash 数组
            // 如果找到了相等的 tophash,那说明就是这个 bucket 了
            if b.tophash[i] != top {
                continue
            }
            k := add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize))
            if t.indirectkey {
                k = *((*unsafe.Pointer)(k))
            }
            if alg.equal(key, k) {
                v := add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.valuesize))
                if t.indirectvalue {
                    v = *((*unsafe.Pointer)(v))
                }
                return v, true
            }
        }
    }

    // 所有 bucket 都没有找到,返回零值和 false
    return unsafe.Pointer(&zeroVal[0]), false
}

 

3.通过分析源代码笔者有以下结论

首先map底层是hash实现,数据结构为hash数组 + 桶每个桶存储最多8key-value对,如果遇溢出,暨一个桶被分配到的元素不止8个,则加入溢出桶的操作。其次map查找是通过keyhash值的低8位定位到桶,再从桶中定位到得到具体的key。而且go map不支持在并发。插入、删除、搬迁等操作可能会使map进行迁移,这时并发访问会产生panic

从时间复杂度上分析,map正常的时间复杂度是O(1),如果所有数据都被集中到一个桶及溢出桶内,那么时间复杂度退化为O(n)

好的,有关GO的哈希表就是这些了,下一篇笔者计划介绍GO的切片。

发布了157 篇原创文章 · 获赞 4339 · 访问量 82万+

猜你喜欢

转载自blog.csdn.net/BEYONDMA/article/details/104752147