1. 引言
Map在golang开发的面试中,十次有八次就会被问到,那么如何去回答才能获得面试官的青睐呢。
这里是一个我自己的面试回答技巧的理解:
- 首先:简述map的基本概念及用途,介绍你在什么场景下会使用map,map有什么特点和缺点。
- 其次:从特点中引出map是少数达到时间复杂度O(1)的数据结构、遍历是无序的、并发不安全等
- 之后:从特点和缺点中讲底层(map的底层实现机制、map的自动扩容、map的底层数据结构等)
- 最后:说Map的性能优化与注意事项(避免不必要的map复制、警惕nil map的访问等)
下面是对map的理解,面试时使用自己的语言组织起来进行回答即可。
2. map的基本概念及用途
在Go语言中,map
是一种内置的数据结构,它存储了一组无序的键值对(key-value pairs)。其中,键(key)是唯一的,用于标识一个元素;值(value)是与键相关联的数据。你可以通过键来快速检索、更新或删除对应的值。
map
的键和值可以是任意类型,但键必须是可比较的类型(如整数、字符串、指针等),这样Go语言才能根据键来检索值。值则可以是任意类型,包括基本数据类型、结构体、切片甚至是另一个map
。
用途
map
在Go语言中有广泛的应用,其主要用途包括:
- 数据关联 :
map
非常适合用于存储和检索与特定键相关联的数据。例如,你可能想要存储一个用户ID到用户信息的映射,或者存储一个单词到其定义的映射。 - 快速查找 :由于
map
内部通过哈希表实现,所以它可以提供平均时间复杂度为O(1)的查找性能。这意味着无论map
中有多少元素,查找一个键的速度都是相对恒定的。 - 缓存机制 :
map
可以用作缓存来存储经常访问的数据,从而减少对数据库或外部服务的访问,提高应用程序的性能。 - 计数和统计 :你可以使用
map
来统计元素的频率或进行其他类型的聚合操作。例如,你可以统计文本中每个单词出现的次数。 - 构建数据结构 :
map
可以作为其他数据结构的基础组件,用于实现更复杂的数据结构,如集合、字典或图。 - 配置管理 :在应用程序中,
map
常用于存储配置选项,这些选项可以根据键快速检索和修改。
通过利用map
的这些特性,你可以更加高效地处理和组织数据,提高程序的性能和可维护性。
3. map的底层内存模型
在 golang 的源码中表示 map 的底层 struct 是 hmap,其实 hashmap 的缩写:
go
type hmap struct {
// map中存入元素的个数, golang中调用len(map)的时候直接返回该字段
count int
// 状态标记位,通过与定义的枚举值进行&操作可以判断当前是否处于这种状态
flags uint8
B uint8 // 2^B 表示bucket的数量, B 表示取hash后多少位来做bucket的分组
noverflow uint16 // overflow bucket 的数量的近似数
hash0 uint32 // hash seed (hash 种子) 一般是一个素数
buckets unsafe.Pointer // 共有2^B个 bucket ,但是如果没有元素存入,这个字段可能为nil
oldbuckets unsafe.Pointer // 在扩容期间,将旧的bucket数组放在这里, 新buckets会是这个的两倍大
nevacuate uintptr // 表示已经完成扩容迁移的bucket的指针, 地址小于当前指针的bucket已经迁移完成
extra *mapextra // optional fields
}
B 是 buckets 数组的长度的对数, 即 bucket 数组的长度是 2^B。bucket 的本质上是一个指针,指向了一片内存空间,其指向的 struct 如下所示:
go
// A bucket for a Go map.
type bmap struct {
tophash [bucketCnt]uint8
}
也就是说,桶是一个个的数组结构,但也并非简单的数组
但这只是表面(src/runtime/hashmap.go)的结构,编译期间会给它加料,动态地创建一个新的结构:
go
type bmap struct {
topbits [8]uint8
keys [8]keytype
values [8]valuetype
pad uintptr // 内存对齐使用,可能不需要
overflow uintptr // 当bucket 的8个key 存满了之后
}

4. 寻址过程
现在已经知道map的底层结构,那么map是如何进行寻址的呢?也就是说key经过哈希函数进行哈希运算之后,如何确定key是存放到那个位置?

这里也就解释了map 为什么可以做到O(1)
查询的。
当每次进行哈希运算,得到的是不同的哈希值,没有哈希冲突,这时的时间复杂度就是O(1)
,最坏情况下那就是O(n)
了。
Map扩容
上一个模块提出了哈希冲突,map的解决办法时在溢出桶上连接一个溢出桶(上面提到的bmap结构体)
而等量扩容就是和溢出桶有关,下面接着看:
扩容
在 golang 中 map 和 slice 一样都是在初始化时首先申请较小的内存空间,在 map 的不断存入的过程中,动态的进行扩容。扩容共有两种,增量扩容 与等量扩容(重新排列并分配内存)。下面我们来了解一下扩容的触发方式:
- 负载因子超过阈值,源码里定义的阈值是 6.5。(触发增量扩容)
- overflow 的 bucket 数量过多:当 B 小于 15,也就是 bucket 总数 2^B 小于 2^15 时,如果 overflow 的 bucket 数量超过 2^B;当 B >= 15,也就是 bucket 总数 2^B 大于等于 2^15,如果 overflow 的 bucket 数量超过 2^15。(触发等量扩容)
在哈希表(包括Map实现的哈希表)中,负载因子是已使用存储空间与总存储空间之间的比例。增量扩容和等量扩容是为了保持合适的负载因子,以防止哈希冲突过多,维护较好的性能。
- 增量扩容条件:(溢出因子超出预定阈值)
- 通常,当哈希表中的元素数量达到了某个阈值,使得负载因子超过了设定的阈值(比如0.75),就会触发增量扩容。
- 具体而言,当负载因子达到或超过设定的阈值时,哈希表会创建一个更大的存储空间,并将所有的元素重新散列到新的存储位置,以降低负载因子。
增量扩容时使用的是渐进式的迁移,这里不过多解释
- 等量扩容条件:(使用太多溢出桶)
- 有些哈希表的实现可能支持等量扩容,即在扩容时,新的哈希表的大小与原哈希表的大小相等。这样的扩容不会改变哈希表的大小,只是重新散列元素到新的位置。
- 等量扩容通常在负载因子较小时(远低于设定的阈值)触发,以减少重新哈希的开销。当负载因子过低时,哈希表可能浪费了一些存储空间,但由于哈希冲突较少,性能可能仍然较好。
等量扩容的机制是使用新旧指针,这里也不过多解释
5. map为什么不支持并发?
- 数据竞态 :并发读写
map
可能导致数据竞态(data race),即多个goroutine同时访问并修改同一个map
的状态,从而导致数据不一致或不确定的结果。这种竞态条件很难预测和调试,因此Go语言选择不在map
中提供内置的并发支持。 - 设计哲学 :Go语言的设计哲学鼓励开发者明确控制并发行为,并提供更精细的同步措施以适应不同的并发场景。通过显式使用同步原语(如
sync.Mutex
或sync.RWMutex
),开发者可以更加清晰地表达其并发意图,并更好地控制并发访问map
的行为。 - 性能考虑:内置并发支持通常意味着额外的开销,如锁的开销、内存分配的开销等。Go语言选择将并发控制的责任交给开发者,以便开发者根据具体需求优化性能。
- 简单性和一致性 :保持
map
的接口简单和一致也是Go语言设计的一个考虑因素。如果map
内置了并发支持,那么其接口和行为可能会变得更加复杂,这可能会增加学习成本和出错的可能性。
这里我觉得就答出会导致data race即可
如何并发使用map?
- 加锁(不过多解释)
- 除了加锁之外,Go 并发使用 Map 的其他常见解决方案包括使用 sync.Map 和使用并发安全的第三方 Map 库。
1、使用 sync.Map sync.Map 是 Go 1.9 新增的一种并发安全的 Map,它的读写操作都是并发安全的,无需加锁。使用 sync.Map 的示例代码如下:
dart
var m sync.Map
m.Store("name", "江江月")
value, ok := m.Load("name")
if ok {
fmt.Println(value)
}
// 输出江江月
2、使用并发安全的第三方 Map 库 除了使用 sync.Map,还可以使用其他第三方的并发安全的 Map 库,如 concurrent-map、ccmap 等。这些库的使用方式与 Go 标准库的 Map 类似,但它们都提供了更高效、更可靠的并发安全保证。
注意: 使用并发安全的第三方 Map 库可能会导致额外的依赖和复杂性,因此需要仔细评估是否值得使用。
sync.Map 和加锁的区别是什么?
- sync.Map 和使用锁的区别在于,sync.Map 不需要在并发访问时进行加锁和解锁操作。相比之下,使用锁需要在并发访问时显式地加锁和解锁,以避免竞争条件和数据竞争问题。
- 在使用锁时,当多个 goroutine 同时访问同一块数据时,必须通过加锁来避免竞争条件。这意味着只有一个 goroutine 能够访问该数据,并且在该 goroutine 完成工作后,其他 goroutine 才能够访问数据。这种方式可以确保数据的一致性,但是加锁会带来额外的开销,并且在高并发情况下可能会影响性能。
- 相比之下,sync.Map 使用了更高级的算法来避免竞争条件和数据竞争问题,而不需要显式地进行加锁和解锁。当多个 goroutine 同时访问 sync.Map 时,它会自动分配不同的段来存储数据,并且每个段都有自己的读写锁,以避免竞争条件。这种方式可以提高并发性能,减少开销,并且避免死锁等问题。
八股,背吧
6. Map的性能优化与注意事项
- Map 的键必须是可比较的类型,如整数、字符串和指针等,但是切片、函数和结构体等类型是不可比较的,因此不能用作键。
- Map 中的元素是无序的,这意味着遍历 Map 时,元素的顺序可能会随机改变。(for range)
- Map 的容量是动态变化的,它会自动调整容量以适应新的元素。 (扩容)
- 如果使用未初始化的 Map,会导致运行时错误。需要使用 make() 函数来初始化 Map。
- Map 在并发环境下不是安全的。如果需要在并发环境下使用 Map,需要使用 sync 包中提供的锁机制来保护 Map。(并发安全)
拓展两个map的题:
- Map 的 panic 能被 recover 吗?
- Map Rehash 的策略是怎样的?什么时机会发生 Rehash?
结尾
学习时做的笔记,今天重新整理以文档的方式产出,希望对看文章的你有所帮助~
日常分享学习中的教程,都看到这里了,点个赞再走吧~