Go开发工程师-Golang基础知识篇

发布于:2025-06-29 ⋅ 阅读:(16) ⋅ 点赞:(0)

开篇

我们尝试从2个方面来进行介绍:

1. 社招实际面试问题

2. 问题涉及的基础点梳理

社招面试题

米哈游

1. Go 里面使用 Map 时应注意问题和数据结构

2. Map 扩容是怎么做的?
3. Map 的 panic 能被 recover 掉吗?了解 panic 和 recover 的机制吗?

4. Map 怎么知道自己处于竞争状态?是 Go 编码实现的还是底层硬件实现的?

5. 有对比过 sync.Map 和加锁的区别吗?

富途牛牛

1. Golang 标准库中 map 的底层数据结构是什么样子的

2. Map 的查询时间复杂度如何分析?

3. 极端情况下有很多哈希冲突,Golang 标准库如何去避免最坏的查询时间复杂度?

4. Golang map Rehash 的策略是怎样的?什么时机会发生 Rehash?

5. Rehash 具体会影响什么?哈希结果会受到什么影响?

6. Rehash 过程中存放在旧桶的元素如何迁移?

7. sync.Map 的 Load() 方法流程?

8. sync.Map Store() 如何保持缓存层和底层 Map 数据是相同的? 是不是每次执行修改都需要去加锁?

基础点梳理

常见的基础数据结构,包括数组、map,链表、栈和队列、堆、树、图,不过在Golang日常的使用和面试问题中,其实主要就只有slice和map。

在面试场景中更重中之重,几乎一定会考察的就是map的数据结构,一方面是在Golang应用开发中比较广泛,另一方面是这里有几个相对好考察的点,基本上我们掌握如下一些问题,对于面试来说基本也就够了,毕竟就算大厂面试,一般最多也就一个小时,这还包括算法题,所以大体上不会在这方面非常的细节。

Slice

一个slice是一个数组某个部分的引用。在内存中,它是一个包含3个域的结构体:指向slice中第一个元素的指针,slice的长度,以及slice的容量。

理解了这个概念,我们大体去了解这么几件事:

1. 数组是定长的,但是切片是非常灵活的,所以可以动态扩容,那动态扩容如何去扩,可以直接参考这里的文章。

切片的容量是怎样增长的 | Go 程序员面试笔试宝典

2. 很多人在写函数传参中,很多人知道Golang所有的函数参数传递都是值传递,那我们不免有一个担心,如果我们直接把slice传入进去到底会不会因为数据量过大,导致出问题。

其实不会,因为对于传参来说,就是一个普通的结构体,包括三个参数,长度/容量/底层数据的地址,所以这几个拷贝是比较快的。

Map

Golang面试数据结构中,Map是重中之重,所以我们必须把这里的问题都弄清楚,

1. Map的底层数据结构是什么样子的

2. Map的日常使用中需要注意哪些问题

3. Map的扩容是怎么做的

4. sync.Map的实现

我们总结下来,关于Map基本上就是如下几个场景:

1. 先说说Map的底层数据结构:

map 的实现原理 | Go 程序员面试笔试宝典

map的实现 · 深入解析Go

这两篇文章都可以参考:

重点是对这张图有一些概念,map的底层实现就是Hash,bmap就是我们常说的桶,桶里面会最多装8个key,这些 key 之所以会落入同一个桶,是因为它们经过哈希计算后,哈希结果是“一类”的。在桶内,又会根据 key 计算出来的 hash 值的高 8 位来决定 key 到底落入桶内的哪个位置。

另外注意一个细节是Bucket中key/value的放置顺序,是将keys放在一起,values放在一起,为什么不将key和对应的value放在一起呢?如果那么做,存储结构将变成key1/value1/key2/value2… 设想如果是这样的一个map[int64]int8,考虑到字节对齐,会浪费很多存储空间。不得不说通过上述的一个小细节,可以看出Go在设计上的深思熟虑。

2. Map的日常使用中需要注意哪些问题

1. 未初始化时写操作会panic,记得使用make方法进行初始化

2. 并发读写导致panic,这个是map日常非常需要注意的地方,尤其是在日常开发中,因为很多时候我们可能不会刻意为之,但是不经意的会在程序中出现这个问题。

3. Map的扩容是怎么做的

扩容触发条件
  • 负载因子 > 6.5(平均每个桶元素超过6.5个)

  • 溢出桶过多(当B < 15时溢出桶超过2^B;当B >= 15时溢出桶超过2^15

负载因子 = 元素总数量(map元素个数) / 桶数量(Bucket数量)
  1. 桶数量(Bucket Count)
    hmap结构体的B字段决定,桶数量 = 2^B

    • 例:B=3 → 桶数量 = 8

  2. 元素数量(Element Count)
    即当前map中存储的键值对总数,对应hmap.count

  3. 负载因子阈值
    默认阈值 = 6.5(硬编码在Go源码中)

    • 当 负载因子 > 6.5 时触发增量扩容(桶数量翻倍)

  4. 为什么阈值是6.5?

  5. 平衡空间与性能

    • 过低(如4):扩容频繁 → 内存浪费

    • 过高(如10):哈希冲突激增 → 查找性能下降

  6. 经验值
    经过性能测试,6.5能在冲突率和内存占用间取得最佳平衡。

hash的扩容过程:https://golang.design/go-questions/map/extend/

核心点在于:

扩容流程(增量扩容为例):
  1. 分配新桶数组(大小为原来的2倍)

  2. 逐步迁移旧桶数据到新桶(每次写操作迁移1-2个桶)

  3. 迁移期间读取:优先查旧桶,未找到再查新桶

  4. 迁移完成后释放旧桶

4. sync.Map的实现

type Map struct {
    mu Mutex               // 写操作锁(保护 dirty 字段)
    read atomic.Value      // 只读缓存(atomic 访问,无锁)
    dirty map[any]*entry   // 写操作目标(需 mu 保护)
    misses int             // read 未命中次数(触发 dirty 提升)
}

type entry struct {
    p unsafe.Pointer  // 指向实际值(特殊标记:nil、expunged)
}

最核心的优化点,主要是用来了一个dirty的map和一个只读的map.

read好比整个sync.Map的一个“高速缓存”,当goroutine从sync.Map中读数据时,sync.Map会首先查看read这个缓存层是否有用户需要的数据(key是否命中),如果有(key命中),则通过原子操作将数据读取并返回,这是sync.Map推荐的快路径(fast path),也是sync.Map的读性能极高的原因。

  • 操作:直接写入dirty(负责写的map)
  • 操作:先读read(负责读操作的map),没有再读dirty(负责写操作的map)

sync.Map 的实现原理可概括为:

  1. 通过 read 和 dirty 两个字段实现数据的读写分离,读的数据存在只读字段 read 上,将最新写入的数据则存在 dirty 字段上
  2. 读取时会先查询 read,不存在再查询 dirty,写入时则只写入 dirty
  3. 读取 read 并不需要加锁,而读或写 dirty 则需要加锁
  4. 另外有 misses 字段来统计 read 被穿透的次数(被穿透指需要读 dirty 的情况),超过一定次数则将 dirty 数据更新到 read 中(触发条件:misses=len(dirty))

与 Mutex+Map 的底层差异

特性 sync.Map Mutex + Map
读性能 ⭐⭐⭐ 无锁(atomic) ⭐ 每次读需加锁
写性能 ⭐ 需锁升级(可能触发 dirty 提升) ⭐⭐ 单一锁保护
内存占用 高(双份数据 + entry 包装) 低(直接存储)
删除开销 低(逻辑删除 + 延迟清理) 高(立即删除 + 缩容开销)
适用场景 读 >> 写(如配置存储、缓存) 读写均衡或写多读少

再回头看一下这些面试题:

1. Golang 标准库中 map 的底层数据结构是什么样子的

 这个上面已经介绍过了,不再介绍

2. Map 的查询时间复杂度如何分析?

  • 平均情况:O(1)

    • 通过哈希值定位桶:O(1)

    • 桶内遍历(最多8个元素+溢出桶):O(1)

  • 最坏情况:O(N)

    • 所有元素哈希冲突到同一桶链

    • 需遍历整个桶链(N为元素总数)

这道问题主要考察的就是Map的底层数据结构,了解的话,其实这个很容易得知。

3. 极端情况下有很多哈希冲突,Golang 标准库如何去避免最坏的查询时间复杂度?

  • 桶分裂策略

    • 每个桶最多存8个键值对,超限创建溢出桶

  • 渐进式扩容

    • 负载因子>6.5时触发扩容(桶数量×2)

  • 哈希随机化

    // 初始化时生成随机种子
    h.hash0 = fastrand()
  • SIMD 优化

    • 使用AVX2指令并行比对tophash(Go 1.20+)

4. Golang map Rehash 的策略是怎样的?什么时机会发生 Rehash?

这个上面也说过了,不再赘述,重要的点,还是理解了底层结构是hash,所以我们就知道必须去做Rehash的原因

5. Rehash 具体会影响什么?哈希结果会受到什么影响?

6. Rehash 过程中存放在旧桶的元素如何迁移?

7. sync.Map 的 Load() 方法流程?

func (m *Map) Load(key any) (value any, ok bool) {
    // Step 1: 原子读取readOnly
    read, _ := m.read.Load().(readOnly)
    e, ok := read.m[key]
    
    if !ok && read.amended { // read不存在且dirty有数据
        m.mu.Lock()
        // Step 2: 双检查(避免锁竞争期间read更新)
        read, _ = m.read.Load().(readOnly)
        e, ok = read.m[key]
        
        if !ok && read.amended {
            // Step 3: 查询dirty(加锁)
            e, ok = m.dirty[key]
            // Step 4: 更新miss计数器
            m.missLocked() 
        }
        m.mu.Unlock()
    }
    
    if !ok { return nil, false }
    // Step 5: 从entry加载实际值
    return e.load() 
}

8. sync.Map Store() 如何保持缓存层和底层 Map 数据是相同的? 是不是每次执行修改都需要去加锁?

func (m *Map) Store(key, value any) {
    // 情况1:read中存在可直接更新的条目
    if e, ok := read.m[key]; ok && e.tryStore(&value) {
        return // 无锁CAS更新
    }

    m.mu.Lock()
    // 情况2:条目在read中但被标记删除
    if e.unexpungeLocked() { 
        m.dirty[key] = e // 重新加入dirty
    }
    // 情况3:新增条目
    if !read.amended {
        m.dirtyLocked() // 惰性初始化dirty
        m.read.Store(readOnly{
            m: read.m, 
            amended: true // 标记dirty有额外数据
        })
    }
    m.dirty[key] = newEntry(value) // 写入dirty
    m.mu.Unlock()
}

总结

作为一名工程师,其实需要对各类数据结构都应该有深入的一些理解,这个算是一个基本功,包括树、图等结构体。但是从大环境来说,一般业务上用到树和图的场景也是比较少,用的比较多的就是slice+map,考察点上,基本也就是上面的问题,把这些弄清楚,面试上基本不会有太大问题。但是日常来说还有一些是需要注意的:
1. slice的范围判断,避免出现panic问题。

2. slice和map有很多常用的使用方式,可以进行统一封装

希望大家面试顺利


网站公告

今日签到

点亮在社区的每一天
去签到