【Golang 面试基础题】每日 5 题(七)

✍个人博客:Pandaconda-CSDN博客
📣专栏地址:http://t.csdnimg.cn/UWz06

📚专栏简介:在这个专栏中,我将会分享 Golang 面试中常见的面试题给大家~
❤️如果有收获的话,欢迎点赞👍收藏📁,您的支持就是我创作的最大动力💪

 31. Go Map 查找

在 Go 语言中,使用 map 查找一个键值对的过程可以通过 map[key] 来完成,返回值是对应的值和一个表示是否存在的布尔值。

具体来说,如果 map 中存在该键,则返回对应的值和布尔值 true;如果不存在该键,则返回值类型的零值和布尔值 false。例如:

m := make(map[string]int)
m["apple"] = 1
value, ok := m["apple"]
if ok {
    fmt.Println(value) // 输出 1
}

另外,也可以直接使用一个值来获取键值对中的值,但是如果键值对中不存在该键,会返回该值类型的零值。例如:

m := make(map[string]int)
m["apple"] = 1
value := m["banana"]
fmt.Println(value) // 输出 0

需要注意的是,map 的键类型必须支持相等运算,例如,数字、字符串、指针、通道、接口类型、结构体类型等都是支持的,但是数组、切片、函数类型等不支持。

底层实现

在 Golang 中,Map 的查找是通过哈希表实现的。当程序执行 map 查找操作时,会先根据哈希函数将 key 转换成一个哈希值,然后在哈希表中查找该哈希值对应的桶 (bucket),再在桶中查找对应的键值对。

具体来说,当 Map 中的键值对数量超过一定阈值时,会触发自动扩容操作。扩容操作会重新分配更大的桶数组,并将原有的键值对重新哈希分布到新的桶中。

在查找时,Golang 的 Map 会先通过哈希值定位到对应的桶 (bucket),然后在桶中遍历链表(每个桶可能对应多个键值对)查找对应的键值对。在遍历链表的过程中,如果发现某个键值对的 key 与要查找的 key 相等,则返回该键值对的 value。

需要注意的是,如果 Map 中的键值对过多,桶中的链表会很长,查找时效率会降低,因此需要根据实际情况合理设置 Map 的容量和哈希函数,以充分利用哈希表的优势。同时,当 Map 中的键值对类型为复杂类型(如结构体)时,需要重载对应的哈希函数和比较函数,以确保哈希表的正确性。

32. Go Ma p 如何查找?

Go 语言中读取 map 有两种语法:带 comma 和 不带 comma。当要查询的 key 不在 map 里,带 comma 的用法会返回一个 bool 型变量提示 key 是否在 map 中;而不带 comma 的语句则会返回一个 value 类型的零值。如果 value 是 int 型就会返回 0,如果 value 是 string 类型,就会返回空字符串。

// 不带 comma 用法
value := m["name"]
fmt.Printf("value:%s", value)

// 带 comma 用法
value, ok := m["name"]
if ok {
    fmt.Printf("value:%s", value)
}

map 的查找通过生成汇编码可以知道,根据 key 的不同类型/返回参数,编译器会将查找函数用更具体的函数替换,以优化效率:

key 类型查找
uint32mapaccess1_fast32(t maptype, h hmap, key uint32) unsafe.Pointer
uint32mapaccess2_fast32(t maptype, h hmap, key uint32) (unsafe.Pointer, bool)
uint64mapaccess1_fast64(t maptype, h hmap, key uint64) unsafe.Pointer
uint64mapaccess2_fast64(t maptype, h hmap, key uint64) (unsafe.Pointer, bool)
stringmapaccess1_faststr(t maptype, h hmap, ky string) unsafe.Pointer
stringmapaccess2_faststr(t maptype, h hmap, ky string) (unsafe.Pointer, bool)

查找流程

  

 1. 写保护监测

函数首先会检查 map 的标志位 flags。如果 flags 的写标志位此时被置 1 了,说明有其他协程在执行“写”操作,进而导致程序 panic,这也说明了 map 不是线程安全的。

if h.flags&hashWriting != 0 {
    throw("concurrent map read and map write")
}

2. 计 算 hash 值

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

key 经过哈希函数计算后,得到的哈希值如下(主流 64 位机下共 64 个 bit 位),不同类型的 key 会有不同的 hash 函数。

 10010111 | 000011110110110010001111001010100010010110010101010 │ 01010

3. 找 到 hash 对应的 bucket

bucket 定位:哈希值的低 B 个 bit 位,用来定位 key 所存放的 bucket。

如果当前正在扩容中,并且定位到的旧 bucket 数据还未完成迁移,则使用旧的 bucket(扩容前的 bucket)。

hash := t.hasher(key, uintptr(h.hash0))
// 桶的个数m-1,即 1<<B-1,B=5时,则有0~31号桶
m := bucketMask(h.B)
// 计算哈希值对应的bucket
// t.bucketsize为一个bmap的大小,通过对哈希值和桶个数取模得到桶编号,通过对桶编号和buckets起始地址进行运算,获取哈希值对应的bucket
b := (*bmap)(add(h.buckets, (hash&m)*uintptr(t.bucketsize)))
// 是否在扩容
if c := h.oldbuckets; c != nil {
  // 桶个数已经发生增长一倍,则旧bucket的桶个数为当前桶个数的一半
    if !h.sameSizeGrow() {
        // There used to be half as many buckets; mask down one more power of two.
        m >>= 1
    }
    // 计算哈希值对应的旧bucket
    oldb := (*bmap)(add(c, (hash&m)*uintptr(t.bucketsize)))
    // 如果旧bucket的数据没有完成迁移,则使用旧bucket查找
    if !evacuated(oldb) {
        b = oldb
    }
}

4. 遍 历 bucket 查找

tophash 值定位:哈希值的高 8 个 bit 位,用来快速判断 key 是否已在当前 bucket 中(如果不在的话,需要去 bucket 的 overflow 中查找)。

用步骤 2 中的 hash 值,得到高 8 个 bit 位,也就是 10010111,转化为十进制,也就是 151

top := tophash(hash)
func tophash(hash uintptr) uint8 {
    top := uint8(hash >> (goarch.PtrSize*8 - 8))
    if top < minTopHash {
        top += minTopHash
    }
    return top
}

上面函数中 hash 是 64 位的,sys.PtrSize 值是 8,所以 top := uint8(hash >> (sys.PtrSize*8 - 8)) 等效 top = uint8(hash >> 56),最后 top 取出来的值就是 hash 的高 8 位值。

在 bucket 及 bucket 的 overflow 中寻找 tophash 值(HOB hash)为 151* 的 槽位,即为 key 所在位置,找到了空槽位或者 2 号槽位,这样整个查找过程就结束了,其中找到空槽位代表没找到。

for ; b != nil; b = b.overflow(t) {
        for i := uintptr(0); i < bucketCnt; i++ {
            if b.tophash[i] != top {
              // 未被使用的槽位,插入
                if b.tophash[i] == emptyRest {
                    break bucketloop
                }
                continue
            }
            // 找到tophash值对应的的key
            k := add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize))
            if t.key.equal(key, k) {
                e := add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.elemsize))
                return e
            }
        }
    }

  

5. 返回 key 对应的指针

如果通过上面的步骤找到了 key 对应的槽位下标 i,我们再详细分析下 key/value 值是如何获取的:

// keys的偏移量
dataOffset = unsafe.Offsetof(struct{
  b bmap
  v int64
}{}.v)

// 一个bucket的元素个数
bucketCnt = 8

// key 定位公式
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))

bucket 里 keys 的起始地址就是 unsafe.Pointer(b)+dataOffset。

第 i 个下标 key 的地址就要在此基础上跨过 i 个 key 的大小;

而我们又知道,value 的地址是在所有 key 之后,因此第 i 个下标 value 的地址还需要加上所有 key 的偏移。

33. Go Map 冲突的解决方式?

比较常用的 Hash 冲突解决方案有链地址法和开放寻址法:

1. 链地址法

当哈希冲突发生时,创建新单元,并将新单元添加到冲突单元所在链表的尾部。

2. 开放寻址法

当哈希冲突发生时,从发生冲突的那个单元起,按照一定的次序,从哈希表中寻找一个空闲的单元,然后把发生冲突的元素存入到该单元。开放寻址法需要的表长度要大于等于所需要存放的元素数量

开放寻址法有多种方式:线性探测法、平方探测法、随机探测法和双重哈希法。这里以线性探测法来帮助读者理解开放寻址法思想。

线性探测法

Hash(key) 表示关键字 key 的哈希值, 表示哈希表的槽位数(哈希表的大小)。

线性探测法则可以表示为:

如果 Hash(x) % M 已经有数据,则尝试 (Hash(x) + 1) % M ;

如果 (Hash(x) + 1) % M 也有数据了,则尝试 (Hash(x) + 2) % M ;

如果 (Hash(x) + 2) % M 也有数据了,则尝试 (Hash(x) + 3) % M ;

两种解决方案比较

对于链地址法,基于数组 + 链表进行存储,链表节点可以在需要时再创建,不必像开放寻址法那样事先申请好足够内存,因此链地址法对于内存的利用率会比开方寻址法高。链地址法对装载因子的容忍度会更高,并且适合存储大对象、大数据量的哈希表。而且相较于开放寻址法,它更加灵活,支持更多的优化策略,比如可采用红黑树代替链表。但是链地址法需要额外的空间来存储指针。

对于开放寻址法,它只有数组一种数据结构就可完成存储,继承了数组的优点,对 CPU 缓存友好,易于序列化操作。但是它对内存的利用率不如链地址法,且发生冲突时代价更高。当数据量明确、装载因子小,适合采用开放寻址法。

总结

在发生哈希冲突时,Python 中 dict 采用的开放寻址法,Java 的 HashMap 采用的是链地址法,而 Go map 也采用链地址法解决冲突,具体就是插入 key 到 map 中时,当 key 定位的桶填满 8 个元素后(这里的单元就是桶,不是元素),将会创建一个溢出桶,并且将溢出桶插入当前桶所在链表尾部。

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)
    elem = add(insertk, bucketCnt*uintptr(t.keysize))
}

34. Go Map 的负载因子为什么是 6.5?

什么是负载因子?

负载因子(load factor),用于衡量当前哈希表中空间占用率的核心指标,也就是每个 bucket 桶存储的平均元素个数。

负载因子 = 哈希表存储的元素个数/桶个数

另外负载因子与扩容、迁移等重新散列(rehash)行为有直接关系:

  • 在程序运行时,会不断地进行插入、删除等,会导致 bucket 不均,内存利用率低,需要迁移。

  • 在程序运行时,出现负载因子过大,需要做扩容,解决 bucket 过大的问题。

负载因子是哈希表中的一个重要指标,在各种版本的哈希表实现中都有类似的东西,主要目的是为了平衡 buckets 的存储空间大小和查找元素时的性能高低

在接触各种哈希表时都可以关注一下,做不同的对比,看看各家的考量。

为什么是 6.5?

为什么 Go 语言中哈希表的负载因子是 6.5,为什么不是 8 ,也不是 1。这里面有可靠的数据支撑吗?

测试报告

实际上这是 Go 官方的经过认真的测试得出的数字,一起来看看官方的这份测试报告。

报告中共包含 4 个关键指标,如下:

loadFactor%overflowbytes/entryhitprobemissprobe
42.1320.7734
4.54.0517.33.254.5
56.8514.773.55
5.510.5512.943.755.5
615.2711.6746
6.520.910.794.256.5
727.1410.154.57
7.534.039.734.757.5
841.19.458

  • loadFactor:负载因子,也有叫装载因子。

  • %overflow:溢出率,有溢出 bukcet 的百分比。

  • bytes/entry:平均每对 key/value 的开销字节数.

  • hitprobe:查找一个存在的 key 时,要查找的平均个数。

  • missprobe:查找一个不存在的 key 时,要查找的平均个数。

选择数值

Go 官方发现:装载因子越大,填入的元素越多,空间利用率就越高,但发生哈希冲突的几率就变大。反之,装载因子越小,填入的元素越少,冲突发生的几率减小,但空间浪费也会变得更多,而且还会提高扩容操作的次数。

根据这份测试结果和讨论,Go 官方取了一个相对适中的值,把 Go 中的 map 的负载因子硬编码为 6.5,这就是 6.5 的选择缘由。

这意味着在 Go 语言中,当 map存储的元素个数大于或等于 6.5 * 桶个数 时,就会触发扩容行为

35. Go Map 的底层实现原理

Go 中的 map 是一个指针,占用 8 个字节,指向 hmap 结构体。

源码包中 src/runtime/map.go 定义了 hmap 的数据结构:

hmap 包含若干个结构为 bmap 的数组,每个 bmap 底层都采用链表结构,bmap 通常叫其 bucket。

  

hmap 结构体

// A header for a Go map.
type hmap struct {
    count     int 
    // 代表哈希表中的元素个数,调用len(map)时,返回的就是该字段值。
    flags     uint8 
    // 状态标志(是否处于正在写入的状态等)
    B         uint8  
    // buckets(桶)的对数
    // 如果B=5,则buckets数组的长度 = 2^B=32,意味着有32个桶
    noverflow uint16 
    // 溢出桶的数量
    hash0     uint32 
    // 生成hash的随机数种子
    buckets    unsafe.Pointer 
    // 指向buckets数组的指针,数组大小为2^B,如果元素个数为0,它为nil。
    oldbuckets unsafe.Pointer 
    // 如果发生扩容,oldbuckets是指向老的buckets数组的指针,老的buckets数组大小是新的buckets的1/2;非扩容状态下,它为nil。
    nevacuate  uintptr        
    // 表示扩容进度,小于此地址的buckets代表已搬迁完成。
    extra *mapextra 
    // 存储溢出桶,这个字段是为了优化GC扫描而设计的,下面详细介绍
 }

bmap 结构体

bmap 就是我们常说的 “桶”,一个桶里面会最多装 8 个 key,这些 key 之所以会落入同一个桶,是因为它们经过哈希计算后,哈希结果的低 B 位是相同的,关于 key 的定位我们在 map 的查询中详细说明。在桶内,又会根据 key 计算出来的 hash 值的高 8 位来决定 key 到底落入桶内的哪个位置(一个桶内最多有 8 个位置)。

// A bucket for a Go map.
type bmap struct {
    tophash [bucketCnt]uint8        
    // len为8的数组
    // 用来快速定位key是否在这个bmap中
    // 一个桶最多8个槽位,如果key所在的tophash值在tophash中,则代表该key在这个桶中
}

上面 bmap 结构是静态结构,在编译过程中 runtime.bmap 会拓展成以下结构体:

type bmap struct{
    tophash [8]uint8
    keys [8]keytype 
    // keytype 由编译器编译时候确定
    values [8]elemtype 
    // elemtype 由编译器编译时候确定
    overflow uintptr 
    // overflow指向下一个bmap,overflow是uintptr而不是*bmap类型,保证bmap完全不含指针,是为了减少gc,溢出桶存储到extra字段中
}

tophash 就是用于实现快速定位 key 的位置,在实现过程中会使用 key 的 hash 值的高 8 位作为 tophash 值,存放在 bmap 的 tophash 字段中。

tophash 字段不仅存储 key 哈希值的高 8 位,还会存储一些状态值,用来表明当前桶单元状态,这些状态值都是小于 minTopHash 的。

为了避免 key 哈希值的高 8 位值和这些状态值相等,产生混淆情况,所以当 key 哈希值高 8 位若小于 minTopHash 时候,自动将其值加上 minTopHash 作为该 key 的 tophash。桶单元的状态值如下:

emptyRest      = 0 // 表明此桶单元为空,且更高索引的单元也是空
emptyOne       = 1 // 表明此桶单元为空
evacuatedX     = 2 // 用于表示扩容迁移到新桶前半段区间
evacuatedY     = 3 // 用于表示扩容迁移到新桶后半段区间
evacuatedEmpty = 4 // 用于表示此单元已迁移
minTopHash     = 5 // key的tophash值与桶状态值分割线值,小于此值的一定代表着桶单元的状态,大于此值的一定是key对应的tophash值

func tophash(hash uintptr) uint8 {
    top := uint8(hash >> (goarch.PtrSize*8 - 8))
    if top < minTopHash {
        top += minTopHash
    }
    return top
}

mapextra 结构体

当 map 的 key 和 value 都不是指针类型时候,bmap 将完全不包含指针,那么 gc 时候就不用扫描 bmap。bmap 指向溢出桶的字段 overflow 是 uintptr 类型,为了防止这些 overflow 桶被 gc 掉,所以需要 mapextra.overflow 将它保存起来。如果 bmap 的 overflow 是 *bmap 类型,那么 gc 扫描的是一个个拉链表,效率明显不如直接扫描一段内存 (hmap.mapextra.overflow)。

type mapextra struct {
    overflow    *[]*bmap
    // overflow 包含的是 hmap.buckets 的 overflow 的 buckets
    oldoverflow *[]*bma
   // oldoverflow 包含扩容时 hmap.oldbuckets 的 overflow 的 bucket
    nextOverflow *bmap 
     // 指向空闲的 overflow bucket 的指针
}

总结

bmap(bucket)内存数据结构可视化如下:

注意到 key 和 value 是各自放在一起的,并不是 key/value/key/value/... 这样的形式,当 key 和 value 类型不一样的时候,key 和 value 占用字节大小不一样,使用 key/value 这种形式可能会因为内存对齐导致内存空间浪费,所以 Go 采用 key 和 value 分开存储的设计,更节省内存空间。

  • 12
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值