当前位置: 首页 > news >正文

golang面经——map模块和sync.Map模块

一、面试题相关

1.map 使用注意的点,是否是并发安全的?

分析:

        考察map的线程安全,map在使用过程中主要是要注意并发读写不加锁会造成fatal error,让程序崩溃。并且这种错误是不能被recover捕获的

回答:

map 不是线程安全的。

        如果某个任务正在对map进行写操作,那么其他任务就不能对该 字典执行并发操作(读、写、删除),否则会导致进程崩溃。

        在查找、赋值、遍历、删除的过程中都会检测写标志,一旦发现写标志等于1,则直接 fatal退出程序。赋值和删除函数在检测完写标志是0之后,先将写标志改成1,才会进行之后的操作。

2.map 循环是有序的还是无序的?

分析:

        考察对map遍历的底层实现是否了解,map在每次遍历的时候都会选定一个随机桶号还有槽位,遍历从这个随机桶开始往后依次便利完所有的桶,在每个桶内,则是按照之前选定随机槽位开始遍历,回答的时候要突出随机桶号和槽位。

回答:

        map的遍历是无序的,map每次遍历,都会从一个随机值序号的桶,在每个桶中,再从按照之前选定随机槽位开始遍历,所以是无序的。

补充问题:为什么go语言的map要这样设计,要随机选定桶号和槽位进行随机遍历?

分析:

        因为map是可以动态扩容的,map 在扩容后,会发生 key 的搬迁,这样 key 的位置就会发生改变,那么如果顺序谝历key,在扩容前后顺序肯定会不一样,这道题回答一定要突出扩容会带来key的位置发生变化回顾一下双倍扩容,key的变化过程,双倍扩容,目标桶扩容后的位置可能在原位置也可能在原位置+偏移量处。

回答:

        因为map 在扩容后,会发生 key 的搬迁,原来落在同一个 bucket 中的 key,搬迁后,有些 key 的位置就会发生改变。而遍历的过程,就是按顺序遍历 bucket,同时按顺序遍历 bucket 中的 key。搬迁后,key 的位置发生了重大的变化,这样,遍历 map 的结果就不可能按原来的顺序了。所以,go语言,强制每次遍历都随机开始。

3.map 如何顺序读取?

分析:

        map本身的遍历是不能顺序执行的,所以我们要达到一个顺序遍历的目的就不能用原map的遍历方式,要想顺序遍历,显然需要对map的key进行排序,然后,我们按照这个排完序之后的key从map里面取出对应的数据即可。

回答:

        如果想顺序遍历map,先把key放到切片排序,再按照key的顺序遍历map

4. map 中删除一个 key,它的内存会释放么?

分析:

        考察map中key的删除原理,map删除key的时候是根据hash值找对对应的槽位,找对对应的key删除,将key置为空,并且将对应的tophash置为emptyOne,如果后面没有任何数据了,则再将emptyOne状态置为emptyReset,所以删除一个key,只是修改对应内存位置的值,并不会释放内存。

回答:

        不会释放,删除一个key,可以认为是标记删除,只是修改key对应内存位置的值为空,并不会释放内存,只有在置空这个map的时候,整个map的空间才会被垃圾回后释放。

5.怎么处理对 map 进行并发访问?有没有其他方案? 区别是什么?

分析:

        主要考察对加锁运用熟悉程度以及对go语言中内置的sync.map的了解,要使用线程安全的map,一般有这两种方式。

1.加锁;2.sync.map。

        同时,要明确这两种方式的性能比较,sync.map在性能上要优于map加锁,因为sync.map在底层使用了两个map,read和dirty来提升性能,对read的操作时原子操作不用加锁,只有在对read操作不能满足要求时才会加锁操作dirty,这样就减少了加锁的场景,锁竞争频率会减小,所以性能会高于单纯的map加锁,在回答的时候要突出sync.map的read和dirty,以及锁竞争的频率。

回答:

        对map进行加读写锁或者是使用sync.map。

        和原始map+RWLock读写锁的实现并发的方式相比,sync.map减少了加锁对性能的影响。它做了一些优化:可以无锁访问readmap,而且会优先操作read map,倘若只操作read map就可以满足要求,那就不用去加锁操作write map(dirty),所以在某些特定场景中它发生锁竞争的频率会远远小于map+RWLock的实现方式。

优点:

        适合读多写少的场景

缺点:

        写多的场景,会导致 read map 缓存失效,需要加锁,冲突变多,性能急剧下降。

6. nil map 和空 map 有何不同?

分析:

        主要考察细节对各种情况下的map的读写情况。

回答:

        1.未初始化的map为nil map:

                a.往值为nil的map添加值,会触发panic;

                b.读取值为nil的map,不会报错;

                c.删除值为nil的map,不会报错。

        2.已经初始化,没有任何元素的map为空map,对空map增删改查不会报错。

7.map 的数据结构是什么?是怎么实现扩容?

分析:

        map的底层实现其实是一个hmap的结构,其中包括一个buckets指针,指向一个bmap的数组,bmap数组每个元素是一个bmap结构,称之为桶,每个桶内存储着8个tophash和8个key-value的键值对,以及指向下一个溢出桶的指针。回答要突出hmap,bmap,tophash,以溢出指针overflow。

回答:

        Map的底层实现数据结构实际上是一个哈希表。在运行时表现为一个指向hmap结构的指针,hmap中有记录了桶数组指针buckets,溢出桶指针以及元素个数等字段。每个桶是一个bmap的数据结构,可以存储8个键值对和8个tophash以及指向下一个溢出桶的指针overflow。为了内存紧凑,采用的是先存8个key过后再存value。

map怎么实现扩容?

分析:

        这个问题作为上一个问题的补充,其实在回答的时候也要参考map的底层结构,回答扩容一定要涵盖扩容策略,扩容时机,扩容方式(渐进式扩容)

回答:

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

扩容条件:

        i.超过负载 map元素个数 >6.5(负载因子)*桶个数,触发双倍扩容;

        ii.溢出桶太多,触发等量扩容;当桶总数<2^15时,如果溢出桶总数>=桶总数,则认为溢出桶过多;当桶总数>2^15时,如果溢出桶总数>=2^15,则认为溢出桶过多。

扩容机制:

        双倍扩容:新建一个buckets数组,新的buckets数量大小是原来的2倍,然后旧buckets数据搬迁到新的buckets。

        等量扩容:并不扩大容量,buckets数量维持不变,重新做一遍类似双倍扩容的搬迁动作,把松散的键值对重新挂列一次,使得同一个 bucket 中的 key 排列地更紧密,节省空间,提高 bucket 利用率,进而保证更快的存取。

扩容方式:

        扩容过程并不是一次性进行的,而是采用的渐进式扩容,在插入修改删除key的时候,都会尝试进行搬迁桶的工作,每次都会检查oldbucket是否nil,如果不是nil则每次搬迁2个桶,蚂蚁搬家一样渐进式扩容。

8.map 的 key 为什么得是可比较类型的?

分析:

        本题主要考察go语言map中如何通过一个key计算得到它在桶中的位置。

        第一步:根据key来计算出一个hash值(64位的,当然与机器位数挂钩)。

        第二步:然后根据hash值的低B位锁定桶号(找到对应的bucket)。

        第三步:接着在桶中找到对应的槽位(找到对应的一个cell)。

        但是这里会存在一个hash冲突的问题,并不是找到了这个槽位就是当前key的位置,因为可能有其他的key和这个key计算出的hash值相同,那么显然槽位也就一样,

        所以还有第四步:进而比较key本身,来获取当前key的位置,所以key一定要是可比较的所以在回答时,一定要重点突出会存在hash冲突,然后会比较key本身

回答

        首先map 的 key、value 是存在 buckets 数组里的,而每个 bucket 又可以容纳8个 key 和 8个 value。.p眍骗戴沺赿嚢赑忪珗鸼颤挾漱瘛当要插入一个新的 key- value 时,会对 key 进行 hash 运算得到一个 hash 值,然后根据 hash 值 的低B位(取几位取决于桶的数量,比如一开始桶的数量是4,则取低2位)来决定命中哪个 bucket。

        在命中某个 bucket 后,又会根据 hash 值的高8位来决定是8个key 里的哪个位置。如果不巧,发生了 hash冲突,即该位置上已经有其他 key 存在了,则会去其他空位置寻找插入。如果全都满了,则使用 overflow 指针指向一个新的 bucket,重复刚刚的寻找步骤。

        以上面的流程可以看出,在判断 hash 冲突,即该位置是否已有其他 key 时,肯定是要进行比较的,所以 key 必须得是可比较类型的。像 slice、map、function 就不能作为 key。

9.sync.Map的底层原理

分析:

        对于sync.Map的底层原理,我们回答的核心点围绕,sync.Map如何保证并发安全,并减少锁操作的原理。

回答:

        空间换时间、数据的动态流转、entry状态的设计

        sync.Map采用 空间换取时间的取舍策略 以及 实时动态的数据流转策略,期望使用read map来尽量将读、更新、删除操作的流量用无锁化的操作挡下来,避免去加锁去访问拥有全量数据的dirty map

        sync.Map对于k-v对里面的v,还设计了两种删除状态,一种是为nil的软删除态,一种是为expunged的硬删除态

        nil态可以拦截删除操作在read map这一层

        expunged态可以正确标识dirty map中有没有对应的逻辑删除的key-entry

10.read map和dirty map之间的关联?

分析:

read map和dirty map作为sync.Map中的两个最重要的结构,他们互帮互助,read map为dirty map尽量用轻便的原子操作挡住读、更新、删的流量,而dirty map也为read map提供最终的兜底手段同时 read map和dirty map数据有互相流转的过程。

回答:

read 可以当做 dirty的保护层map,尽量用轻便的原子操作将流量拦截在read,防止加锁访问dirtydirty 当做read的兜底层map,如果在read 中没有完成的操作,最终需要加锁,然后尝试在dirty 完成兜底当因为miss read而访问dirty的次数等于dirty的长度时,需要将dirty map提升到read map,并置dirty为nil当dirty map为nil,会在Store里面触发dirtyLocked流程,这个流程会遍历read map,将所有非删除状态的kentry对写入到新dirty 里面去。

11.为什么要设计nil和expunged状态?

分析:

dirty map用于最终数据兜底,如果每次我们删除操作,直接删除dirty中对应k-entey对,但后面又对这个k进行写操作,那就导致多次加锁操作。

设计nil状态来标记k-entry对已经被逻辑删除了,但是k-entry还存在于read map和dirty map中,如果想对一个删除的key,再进行写,那么也可以通过在read map中解决。

而设计expunged状态是为了正确标识出key-entry对是否存在于dirty map中nil状态是软删除状态,代表逻辑上k-v被删除了,但是k-entry对还存在与read map和dirty map中expunged态是硬删除态,也是逻辑上k-v删除了,但是k-entey对只存在read map中。

回答:

nil态是软删除态,可以让删除操作的流量在read map层挡住,防止加锁,去删除dirty map中的数据expunged态是硬删除态,也是逻辑上k-v删除了,但是k-entey对只存在read map中,能正确标识出key-entry对是否存在于dirty map中。

12.sync.Map 适用的场景?

分析:

因为我们期望将更多的流量在read map这一层进行拦截,从而避免加锁访问dirty map对于更新,删除,读取,read map可以尽量通过一些原子操作,让整个操作变得无锁化,这样就可以避免进一步加锁访问dirty map倘若写操作过多,sync.Map 基本等价于一把互斥锁 + map,所以我们要尽可能避免写多的场景,场景应用贴合读多,更新多,删多。

回答:

sync.Map 是适用于读多、更新多、删多、写少的场景。

13.你认为sync.Map有啥不足吗?

分析:

对于sync.map,在dirtyLocked流程中,需要遍历整个read map,完成两步工作更新read map中的删除状态,将软删除态(nil)变成 硬删除态(expunged)’将read map中非删除态的key-entry对 写入到 dirty map中。dirtyLocked这整个流程是加锁的,如果在sync.map数据量比较大情况下,会引发性能抖动问题,因为这个时候其他goroutine想要访问dirty map拿锁就只能阻塞起来,存在很大的隐患也是因为这个原因,我们的雅哥九哥 开源项目htps://github.com/HDT3213/godis 在实现并发安全map的时候没有采用sync.map,最终选择的是分段锁map。

回答:

sync.Map不适用于写多的场景,因为写操作足够多的话,sync.Map就相当于一把Mutex+Map而且sync.Map中存在一个将read map数据流转到 dirty map的过程,这个过程是线性时间复杂度,当map中k-V数量较多的时候,容易导致程序性能抖动比如想要访问sync.Map拿锁操作的goroutine一直等待这个线性时间复杂度的过程完成。

14.补充知识--分段锁map是什么?

保证map的并非安全,最简单的做法就是直接用锁来进行保护,比如加读写锁保护,但是这样锁的粒度比较大,加锁直接锁住了整个map,性能很差

分段锁的核心思想:

1.数据分片:将整个Map划分为多个段,每个段包含独立的子Map和锁。

2.锁粒度细化:操作时仅锁定目标数据所在的段,其他段仍可并发访问,减少锁竞争。

适用写多或Key分布均匀的场景,在选择syncMap和分段锁map,优先考虑的就是应用场景下读写流量的比例,像svnc,Map只适用了读多写少的场景,如果读写流量中写流量占比较大 或者 无法在使用之初确定读写流量比例,那就可以直接选择使用分段锁map。

二、知识点总结

1、map中的桶和槽位是什么?和key、value是什么关系?

1) 桶(Bucket)
桶是map底层哈希表的基本存储单元,每个桶包含一组键值对(通常是8个槽位)。
当发生哈希冲突时(不同key哈希到同一位置),Go采用链地址法,将冲突的键值对存放在同一个桶内(或溢出桶中)。


2)槽位(Slot)
槽位是桶内的最小存储单元,每个槽位存储一个键值对(key-value)。
桶内的槽位按顺序排列,通过线性探测方式管理。


3)与Key-Value的关系
键值对(key-value) 是逻辑上的数据,而桶和槽位是物理存储结构。
例如:一个map的底层可能包含多个桶,每个桶有8个槽位,每个槽位存放一个key-value对。

// 逻辑上的map
map[string]int{"apple": 1, "banana": 2}// 底层存储可能的结构(简化):
Bucket0: [slot1: ("apple", 1), slot2: ("banana", 2), ...]
Bucket1: [slot1: empty, slot2: empty, ...]

关于链地址法的解释:

        1) 一个桶(Bucket)本身就是一个小型数组,可以容纳 8 个键值对(即 8 个槽位)。
2) 当键值对要存入 map 时,首先根据 key 的哈希值低几位确定它属于哪个桶。
3)桶内冲突解决:如果要插入的桶还有空槽位,就直接放入。
4)桶溢出解决:如果要插入的桶已经满了(8 个槽位都占了),就链上一个新的 溢出桶(Overflow Bucket),继续往这个新的溢出桶里存放。

        Go map 通过 “桶内数组 + 溢出桶链表” 的混合模式解决哈希冲突。它先尝试在桶内部的连续空间解决问题,这对于性能至关重要;只有在必要时才使用链表,这是一种在内存效率和查找性能之间取得的优秀平衡。

2、map的数据结构详解

        map底层实现是一个哈希表

        1)hmap结构(Map的头部)

type hmap struct {count     int    // 当前存储的键值对数量flags     uint8  // 状态标志(如是否正在写入)B         uint8  // 桶数量的对数(桶数量 = 2^B)noverflow uint16 // 溢出桶的大概数量hash0     uint32 // 哈希种子(用于防御Hash-DoS攻击)buckets    unsafe.Pointer // 指向桶数组的指针oldbuckets unsafe.Pointer // 扩容时指向旧桶数组nevacuate  uintptr        // 搬迁进度计数器extra *mapextra // 可选字段,用于优化小对象存储
}

2)bmap结构(桶结构)
每个桶可以存储最多8个键值对:

type bmap struct {tophash [8]uint8    // 8个哈希值高8位(用于快速比较)// 后面跟着8个key和8个value(内存中连续存储)// 最后是一个指向溢出桶的指针
}

实际的内存布局:

3)完整的map结构示意图:

tophash的作用
存储每个键哈希值的高8位
用于快速比较,避免直接比较可能很大的key
特殊值:

        0=空槽位(emptyRest):该槽位为空,且后面所有槽位都为空

        1=已删除槽位(emptyOne):仅该槽位为空,后面可能有非空槽位

溢出桶机制
当单个桶存储超过8个元素时,会创建溢出桶:

                主桶 → 溢出桶1 → 溢出桶2 → ...


每个溢出桶也是bmap结构,可以继续存储8个元素。

3、sync.Map如何使用

        sync.Map 提供了一套线程安全的方法来操作键值对,以下是其主要方法:

基础用法:

// 存储键值对
func (m *Map) Store(key, value interface{})// 根据键读取值,返回值和是否存在标志
func (m *Map) Load(key interface{}) (value interface{}, ok bool)// 删除指定键
func (m *Map) Delete(key interface{})// 遍历所有键值对
func (m *Map) Range(f func(key, value interface{}) bool)

高级用法:

// 如果键存在则返回对应的值,否则存储给定的值
func (m *Map) LoadOrStore(key, value interface{}) (actual interface{}, loaded bool)// 删除键并返回之前的值(如果存在)
func (m *Map) LoadAndDelete(key interface{}) (value interface{}, loaded bool)
package mainimport ("fmt""sync""time"
)// Session 表示用户会话
type Session struct {UserID    stringLoginTime time.TimeExpiresAt time.Time
}// 创建全局的会话存储(线程安全)
var sessionStore sync.Mapfunc main() {// 1. 存储会话(写操作)storeSessions()// 2. 并发读取会话(读操作)concurrentReadSessions()// 3. 更新会话updateSession()// 4. 删除会话deleteSession()// 5. 使用 LoadOrStoreloadOrStoreExample()// 6. 遍历所有会话rangeSessions()
}// 存储会话示例
func storeSessions() {fmt.Println("=== 存储会话 ===")// 存储几个用户会话session1 := &Session{UserID:    "user123",LoginTime: time.Now(),ExpiresAt: time.Now().Add(2 * time.Hour),}session2 := &Session{UserID:    "user456",LoginTime: time.Now(),ExpiresAt: time.Now().Add(1 * time.Hour),}// 使用 Store 方法存储sessionStore.Store("session_token_abc", session1)sessionStore.Store("session_token_xyz", session2)fmt.Println("会话存储完成")
}// 并发读取会话示例
func concurrentReadSessions() {fmt.Println("\n=== 并发读取会话 ===")var wg sync.WaitGroup// 启动多个 goroutine 并发读取for i := 0; i < 3; i++ {wg.Add(1)go func(workerID int) {defer wg.Done()// 尝试读取不同的会话tokens := []string{"session_token_abc", "session_token_xyz", "session_token_unknown"}for _, token := range tokens {if value, ok := sessionStore.Load(token); ok {session := value.(*Session) // 类型断言fmt.Printf("Worker %d: 找到会话 %s -> 用户 %s\n", workerID, token, session.UserID)} else {fmt.Printf("Worker %d: 会话 %s 不存在\n", workerID, token)}}}(i)}wg.Wait()
}// 更新会话示例
func updateSession() {fmt.Println("\n=== 更新会话 ===")// 更新 user123 的会话过期时间if value, ok := sessionStore.Load("session_token_abc"); ok {session := value.(*Session)session.ExpiresAt = time.Now().Add(3 * time.Hour) // 延长过期时间sessionStore.Store("session_token_abc", session)  // 重新存储fmt.Printf("已更新会话过期时间: %v\n", session.ExpiresAt)}
}// 删除会话示例
func deleteSession() {fmt.Println("\n=== 删除会话 ===")// 方法1: 使用 DeletesessionStore.Delete("session_token_xyz")fmt.Println("已删除 session_token_xyz")// 方法2: 使用 LoadAndDelete(同时获取被删除的值)if value, loaded := sessionStore.LoadAndDelete("session_token_abc"); loaded {session := value.(*Session)fmt.Printf("删除并获取会话: 用户 %s\n", session.UserID)}
}// LoadOrStore 示例
func loadOrStoreExample() {fmt.Println("\n=== LoadOrStore 示例 ===")// 如果键存在则返回现有值,否则存储新值newSession := &Session{UserID:    "user789",LoginTime: time.Now(),ExpiresAt: time.Now().Add(1 * time.Hour),}// 第一次调用:键不存在,会存储新值actual, loaded := sessionStore.LoadOrStore("new_token_123", newSession)if loaded {fmt.Println("键已存在,返回现有值")} else {fmt.Println("键不存在,已存储新值")}session := actual.(*Session)fmt.Printf("实际存储的会话用户: %s\n", session.UserID)// 第二次调用:键已存在,返回现有值actual, loaded = sessionStore.LoadOrStore("new_token_123", newSession)if loaded {fmt.Println("键已存在,返回现有值")} else {fmt.Println("键不存在,已存储新值")}
}// 遍历所有会话示例
func rangeSessions() {fmt.Println("\n=== 遍历所有会话 ===")// 重新存储一些会话用于演示sessionStore.Store("token1", &Session{UserID: "user1", LoginTime: time.Now()})sessionStore.Store("token2", &Session{UserID: "user2", LoginTime: time.Now()})sessionStore.Store("token3", &Session{UserID: "user3", LoginTime: time.Now()})fmt.Println("当前所有会话:")count := 0sessionStore.Range(func(key, value interface{}) bool {session := value.(*Session)fmt.Printf("  Token: %s -> 用户: %s\n", key, session.UserID)count++// 返回 true 继续遍历,返回 false 停止遍历return count < 5 // 限制最多显示5个,防止无限循环})fmt.Printf("总共遍历了 %d 个会话\n", count)
}/*执行结果:=== 存储会话 ===
会话存储完成=== 并发读取会话 ===
Worker 0: 找到会话 session_token_abc -> 用户 user123
Worker 0: 找到会话 session_token_xyz -> 用户 user456
Worker 0: 会话 session_token_unknown 不存在
Worker 1: 找到会话 session_token_abc -> 用户 user123
Worker 1: 找到会话 session_token_xyz -> 用户 user456
Worker 1: 会话 session_token_unknown 不存在
Worker 2: 找到会话 session_token_abc -> 用户 user123
Worker 2: 找到会话 session_token_xyz -> 用户 user456
Worker 2: 会话 session_token_unknown 不存在=== 更新会话 ===
已更新会话过期时间: 2023-10-01 14:30:00 +0800 CST=== 删除会话 ===
已删除 session_token_xyz
删除并获取会话: 用户 user123=== LoadOrStore 示例 ===
键不存在,已存储新值
实际存储的会话用户: user789
键已存在,返回现有值=== 遍历所有会话 ===
当前所有会话:Token: token1 -> 用户: user1Token: token2 -> 用户: user2Token: token3 -> 用户: user3
总共遍历了 3 个会话*/

4、sync.Map的底层原理

http://www.dtcms.com/a/457538.html

相关文章:

  • 【Pandas】pandas Index objects DatetimeIndex.dayofyear
  • 10BASE-T1S存在问题,还不能胜过CAN
  • 网站后台seo设置网站建设的安全性
  • 手机网站制作代理搜索引擎及门户网站介绍总结
  • MySQL、Nignx和Docker在Linux上的安装详解
  • Rust中的特征Trait
  • 《SaaS应用技术攻坚:从定制化到运维的六大核心实践拆解》
  • java-JDK8 日期时间类
  • 网站开发前途电影网站建设基本流程
  • 建网站怎么年赚网页设计网站页面搜索的代码
  • Echarts单轴坐标系散点图
  • t检验(t-test):统计学中的显著性检验方法
  • 音乐网站系统
  • Day17_最小文件系统
  • 参数迁移对迭代次数的影响
  • Coze源码分析-资源库-编辑数据库-后端源码-数据存储层
  • Python学习之Day07-08学习(Django网页Web开发)
  • STM32之IWDG-独立看门狗
  • Linux 系统编程:(一)从历史演进到 XShell 远程登录实操
  • 基于cherryusb自制daplink,并对stm32u575进行烧录过程,daplink的执行流进行trace分析
  • 洛阳瀍河建设局网站2021年10月新闻摘抄
  • 学习Java第三十四天——黑马点评48~60
  • 全功能按键非阻塞式实现
  • 学做网站的视频南京谷歌推广
  • iptables
  • STM32+8266+小程序智能家居【小白实战项目】
  • 如何部署一个Java项目
  • 联想乐享赋能笔记本选购新体验:智能解析五大系列,精准匹配用户需求
  • 西安网站设计报价怎样创建网站和网页
  • Go中使用反射的动态方法调用