前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >深入理解Go语言中的map:结构、性能与最佳实践

深入理解Go语言中的map:结构、性能与最佳实践

作者头像
windealli
发布2024-03-22 12:27:21
2240
发布2024-03-22 12:27:21
举报
文章被收录于专栏:windealliwindealli

一、引言

哈希表和数组是最常见的数据结构,几乎所有的语言都会有数组和哈希表两种容器类型 。哈希表表示的是键值对之间映射关系,在Go语言中,通过map来表示哈希表。本文将深入浅出介绍map的概念、使用方式、底层结构、性能、最佳实现等话题,帮助开发更好的理解和使用map

二、map的基本概念和使用

1. 什么是map

在Go语言中,map是一种内置的数据结构,用于存储键值对。Go语言中的map有如下特点

  • 内置数据结构map是Go语言内置的数据结构,它是一种无序的键值对集合,其中键是唯一的。Go语言在语言级别支持map, 使用方便。
  • 快速查找map提供了非常快速的查找、插入和删除操作,这些操作的平均时间复杂度为O(1)。这使得map非常适合用于需要快速访问数据的场景。
  • 动态性map是动态的,可以在运行时动态地增加或删除键值对,而不需要预先声明大小。
  • 键的多样性:Map的键可以是任何可比较的类型,例如整数、字符串等。这为存储和检索各种类型的数据提供了灵活性。
  • 非并发安全:标准的Map在Go中并不是并发安全的。如果需要在多个goroutine中并发访问Map,需要使用sync包中的Mutex或RWMutex来保证并发安全,或者使用并发安全的数据结构,如sync.Map。
  • 应用场景:Map在Go中被广泛应用于各种场景,如数据库查询结果的存储、配置项的读取、缓存的实现等。

2. map的定义和初始化

初始化Map有几种方式:

代码语言:javascript
复制
// 方式一: 使用var关键字声明Map,然后使用make函数初始化
var myMap map[string]int
myMap = make(map[string]int)

// 方式二: 使用make函数直接声明并初始化Map
myMap := make(map[string]int)

// 方式三: 使用Map字面量初始化Map,这在创建预填充的Map时非常有用
myMap := map[string]int{
    "apple":  5,
    "banana": 10,
}

注意:使用Map时,如果没有初始化(即值为nil),直接赋值会导致运行时错误。

3. map基本操作:增、删、改、查

下面是对map进行增、删、改、查的基本方法

代码语言:javascript
复制
// 增(Insert): 向Map中添加新的键值对; 如果key已存在,则更新value
myMap["orange"] = 15

// 删(Delete): 从Map中删除键值对; 如果key不存在,delete函数不会执行任何操作。
delete(myMap, "apple")

// 改(Update): 更新Map中的键值对; 如果key已经存在,这将替换原来的值。如果key不存在,这将添加一个新的键值对
myMap["banana"] = 20

// 查(Lookup):查找Map中的键对应的值;
value, exists := myMap["banana"] // 这里value是与键关联的值,exists是一个布尔值,如果键存在于Map中,则为true;如果键不存在,则为false,并且value将是类型的零值。
value := myMap["banana"] // 如果你只关心值,可以忽略第二个返回值

注意:map在并发环境下不是线程安全的,如果需要在多个goroutine中使用Map,应该使用互斥锁(sync.Mutex)或者使用sync.Map

4. map的遍历

在Go语言中,可以使用for循环和range关键字来遍历Map。遍历时,range表达式返回两个值:键和对应的值。这里是一个基本的例子:

代码语言:javascript
复制
myMap := map[string]int{
    "apple":  5,
    "banana": 10,
    "cherry": 15,
}

for key, value := range myMap {
    fmt.Printf("Key: %s, Value: %d\n", key, value)
}

在上面的代码中,key变量会遍历Map中的所有键,而value变量会遍历对应的值。每次迭代时,keyvalue都会更新为当前遍历到的键值对。

可以忽略第二个变量

代码语言:javascript
复制
for key := range myMap {

如果你只需要值,可以使用下划线_来忽略键:

代码语言:javascript
复制
for _, value := range myMap {

**需要注意的是,Map的遍历顺序是随机的,每次遍历的顺序可能都不一样。**这是因为Go的Map类型是故意设计为无序的,以避免依赖特定的遍历顺序,这可能会导致程序中的一些隐蔽的bug。

此外,虽然可以在遍历过程中删除或修改Map中的键值对,但是添加键值对可能会导致未定义的行为。如果需要在遍历时修改Map,建议先记录需要做出的更改,然后在遍历结束后进行。

三、哈希函数简介

在Go语言中,map本质上是哈希表(hash table)。哈希表是一种使用哈希函数组织数据,以支持快速插入和搜索的数据结构。实现哈希表的两个关键是哈希函数和解决哈希冲突。

哈希函数

哈希函数,也被称为散列函数,是一种将任意长度的输入(如字符串)通过特定的散列算法,变换成固定长度的输出(即哈希值或消息摘要)的函数。通常,会使用类似数组(连续内存)的形式来存储哈希值,从而保证哈希值的访问性能。

哈希函数应该尽可能保证不同的输入有相同的输出。

如上图所示,哈希函数的结果分布较为均匀,哈希值的增删改查的时间复杂度为O(1)

哈希冲突

在实际场景中,因为可能的输入范围通常是远超映射范围(输出的范围,受存储空间的影响)。因而难免会出现不同的输入得到相同的输出,即发生哈希冲突。

基于拉链法实现的哈希函数

大多数的编程语言都用拉链法实现哈希表, 拉链法通常使用数组和链表作为底层数据结构。

哈希值使用数组将哈希值HashValue相同的Key对应的Value通过链表数组进行维护

哈希函数将哈希键Key映射到数组的索引,数组的每一个元素都有一个Value桶,使用链表进行维护。

四、map的底层数据结构

源码分析

在Go语言中,map使用类似拉链法的方式实现哈希表,Go语言运行时同时使用了多个数据结构组合表示哈希表。

代码语言:javascript
复制
// runtime/map.go
// A header for a Go map.
type hmap struct {
 count     int // 当前哈希表中的元素数量
 flags     uint8
 B         uint8  // 当前哈希表持有的 buckets 数量, 因为哈希表中桶的数量都按2倍扩容,改字段存储对数,也就是 len(buckets) == 2^B
 noverflow uint16 // 溢出桶的大致数量
 hash0     uint32 // hash seed

 buckets    unsafe.Pointer // 存储 2^B 个桶的数组
 oldbuckets unsafe.Pointer // 扩容时用于保存之前 buckets 的字段 , 大小事buckets的一般
 nevacuate  uintptr        // progress counter for evacuation (buckets less than this have been evacuated)

 extra *mapextra // optional fields
}

// mapextra 主要维护,当hmap中的buckets中有一些桶发生溢出,但有达不到扩容阈值时,存储溢出的桶
type mapextra struct {
 overflow    *[]*bmap
 oldoverflow *[]*bmap

 // nextOverflow holds a pointer to a free overflow bucket.
 nextOverflow *bmap
}

runtime.hmap结构体中,buckets字段是一个unsafe.Pointer , 因为go语言中支持不同类型的键值对,需要在编译时才能确定map的类型。

可以查看编译时如何重建hmap类型reflectdata.MapType()

代码语言:javascript
复制
func MapType(t *types.Type) *types.Type {
 if t.MapType().Hmap != nil {
  return t.MapType().Hmap
 }

 bmap := MapBucketType(t)  // 构建bmap类型

 fields := []*types.Field{
  makefield("count", types.Types[types.TINT]),
  makefield("flags", types.Types[types.TUINT8]),
  makefield("B", types.Types[types.TUINT8]),
  makefield("noverflow", types.Types[types.TUINT16]),
  makefield("hash0", types.Types[types.TUINT32]), // Used in walk.go for OMAKEMAP.
  makefield("buckets", types.NewPtr(bmap)),       // Used in walk.go for OMAKEMAP.
  makefield("oldbuckets", types.NewPtr(bmap)),
  makefield("nevacuate", types.Types[types.TUINTPTR]),
  makefield("extra", types.Types[types.TUNSAFEPTR]),
 }

 hmap := types.NewStruct(types.NoPkg, fields)
 hmap.SetNoalg(true)
 types.CalcSize(hmap)

 // The size of hmap should be 48 bytes on 64 bit
 // and 28 bytes on 32 bit platforms.
 if size := int64(8 + 5*types.PtrSize); hmap.Size() != size {
  base.Fatalf("hmap size not correct: got %d, want %d", hmap.Size(), size)
 }

 t.MapType().Hmap = hmap
 hmap.StructType().Map = t
 return hmap
}

这里可以看出buckets是指向bmap的指针, bmap也是在编译时通过bmap := MapBucketType(t) 进行构建的,而非runtime.bmap中的定义那般简单:

代码语言:javascript
复制
// runtime.bmap
type bmap struct {
 tophash [bucketCnt]uint8
}

可以查看

代码语言:javascript
复制
// MapBucketType makes the map bucket type given the type of the map.
func MapBucketType(t *types.Type) *types.Type {
 if t.MapType().Bucket != nil {
  return t.MapType().Bucket
 }

 keytype := t.Key()
 elemtype := t.Elem()
 types.CalcSize(keytype)
 types.CalcSize(elemtype)
 if keytype.Size() > MAXKEYSIZE {
  keytype = types.NewPtr(keytype)
 }
 if elemtype.Size() > MAXELEMSIZE {
  elemtype = types.NewPtr(elemtype)
 }

 field := make([]*types.Field, 0, 5)

 // The first field is: uint8 topbits[BUCKETSIZE].
 arr := types.NewArray(types.Types[types.TUINT8], BUCKETSIZE)
 field = append(field, makefield("topbits", arr))

 arr = types.NewArray(keytype, BUCKETSIZE)
 arr.SetNoalg(true)
 keys := makefield("keys", arr)
 field = append(field, keys)

 arr = types.NewArray(elemtype, BUCKETSIZE)
 arr.SetNoalg(true)
 elems := makefield("elems", arr)
 field = append(field, elems)

 // If keys and elems have no pointers, the map implementation
 // can keep a list of overflow pointers on the side so that
 // buckets can be marked as having no pointers.
 // Arrange for the bucket to have no pointers by changing
 // the type of the overflow field to uintptr in this case.
 // See comment on hmap.overflow in runtime/map.go.
 otyp := types.Types[types.TUNSAFEPTR]
 if !elemtype.HasPointers() && !keytype.HasPointers() {
  otyp = types.Types[types.TUINTPTR]
 }
 overflow := makefield("overflow", otyp)
 field = append(field, overflow)

 // link up fields
 bucket := types.NewStruct(types.NoPkg, field[:])
 bucket.SetNoalg(true)
 types.CalcSize(bucket)
 
 // ......
 
 return bucket
}

重建后的结构体如下所示:

代码语言:javascript
复制
type bmap struct {
    topbits  [8]uint8
    keys     [8]keytype
    elems    [8]elemtype
    overflow uintptr
}

示意图

综上所示,map的底层结构如下图所示:

五、map的操作性能分析

1. 时间复杂度:最好和最坏情况

  • 最好情况: 每个键都映射到不同的桶中,没有发生哈希冲突。此时,Map的插入、查找和删除操作的时间复杂度都是O(1)。
  • 最坏情况: 所有的键都映射到同一个桶中,发生了极端的哈希冲突。此时,Map中的操作可能需要遍历整个链表,时间复杂度变为O(n)。不过,由于Go的Map实现会自动扩容,并重新分配键值对,这种情况在实践中很少发生。

2. 空间复杂度

Map的空间复杂度取决于存储的键值对数量以及哈希桶的数量。在Go中,Map的空间复杂度通常可以认为是O(n),其中n是键值对的数量。随着键值对数量的增加,Map可能会进行扩容,增加哈希桶的数量,以保持操作的效率。

3. 性能优化技巧

  • 合理估计Map大小:如果你预先知道将要存储的键值对的大致数量,可以在创建Map时指定一个初始容量,这有助于减少自动扩容的次数,从而提高性能。
代码语言:javascript
复制
myMap := make(map[string]int, initialCapacity)
  • 选择合适的键类型:使用较小的、可比较性能较高的键类型,可以减少哈希计算的开销。例如,int类型通常比string类型作为键更高效。
  • 避免复杂的键结构:如果键是一个复杂的结构体,那么比较和哈希计算的开销会更大。如果可能,尝试将复杂的键简化,或者使用能够唯一表示键的简单类型。
  • 使用并发安全的Map:如果需要在多个goroutine中并发访问Map,使用sync.Mapsync.Map提供了一些优化,不需要开发者自己实现同步,可以在并发环境中提供更好的性能。
  • 避免频繁的内存分配:在Map的使用过程中,尽量避免频繁地增加和删除键值对,因为这可能导致频繁的内存分配和垃圾回收。
  • 监控性能指标:在性能关键的应用中,监控Map的大小和性能指标,及时调整策略,可以帮助发现潜在的性能问题。

六、map的扩容策略

在Go语言中,Map的扩容是一个自动的过程,旨在维护Map的性能,特别是插入和查找操作的时间复杂度。

1. 扩容的出发条件

map在写入哈希键值对时runtime.mapassign, 会判断是否需要扩容:

代码语言:javascript
复制
func mapassign(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer {
...
 // If we hit the max load factor or we have too many overflow buckets,
 // and we're not already in the middle of growing, start growing.
 if !h.growing() && (overLoadFactor(h.count+1, h.B) || tooManyOverflowBuckets(h.noverflow, h.B)) {
  hashGrow(t, h)
  goto again // Growing the table invalidates everything, so try again
 }
...
}

// overLoadFactor reports whether count items placed in 1<<B buckets is over loadFactor.
func overLoadFactor(count int, B uint8) bool {
 return count > bucketCnt && uintptr(count) > loadFactorNum*(bucketShift(B)/loadFactorDen)
}

func tooManyOverflowBuckets(noverflow uint16, B uint8) bool {
 if B > 15 {
  B = 15
 }
 return noverflow >= uint16(1)<<(B&15)
}

可以看出扩容的两个条件:

  • 装载因子超过阈值6.5:overLoadFactor(h.count+1, h.B) , 装载因子:=元素数量÷桶数量
  • 使用了太多溢出桶:tooManyOverflowBuckets(h.noverflow, h.B))

h.growing() 用于避免并发扩容,导致混乱。

2. 扩容过程

当Map需要扩容时,Go运行时会进行以下步骤:

  1. 新桶数组:分配一个新的、更大的桶数组。新数组的大小通常是原来大小的两倍,这有助于分散键值对,减少冲突。
  2. 重新哈希:遍历旧的桶数组中的所有键值对,并使用哈希函数重新计算每个键的位置,将它们插入到新的桶数组中。
  3. 逐步迁移:为了避免在扩容时暂停整个程序,Go的Map实现可能会选择逐步迁移键值对。这意味着在扩容期间,旧的桶数组和新的桶数组会同时存在,新插入的键值对会直接放入新的桶中,而对旧桶的访问会触发迁移操作。
  4. 更新内部状态:一旦所有键值对都迁移到新的桶数组中,Map的内部状态会更新,以反映新的结构。
代码语言:javascript
复制
func hashGrow(t *maptype, h *hmap) {
 ...
 // 原有桶设置给oldbuckets
 oldbuckets := h.buckets   
 // 创建新桶
 newbuckets, nextOverflow := makeBucketArray(t, h.B+bigger, nil)

 flags := h.flags &^ (iterator | oldIterator)
 if h.flags&iterator != 0 {
  flags |= oldIterator
 }
 // commit the grow (atomic wrt gc)
 h.B += bigger
 h.flags = flags
 h.oldbuckets = oldbuckets
 h.buckets = newbuckets
 h.nevacuate = 0
 h.noverflow = 0

 ...
}

键值对的迁移并摆在hashGrow中进行,而是在runtime.mapassign中逐步迁移的。

代码语言:javascript
复制
func mapassign(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer {
...
 if h.growing() {
  growWork(t, h, bucket)
 }
...
}

runtime.growWork中执行迁移的具体工作

代码语言:javascript
复制
func growWork(t *maptype, h *hmap, bucket uintptr) {
 // make sure we evacuate the oldbucket corresponding
 // to the bucket we're about to use
 evacuate(t, h, bucket&h.oldbucketmask())

 // evacuate one more oldbucket to make progress on growing
 if h.growing() {
  evacuate(t, h, h.nevacuate)
 }
}

3. 扩容对性能的影响

扩容是一个昂贵的操作,它涉及到内存分配和键值对的重新哈希。在扩容期间,Map的性能可能会暂时下降,特别是在插入新元素时。然而,扩容完成后,由于减少了哈希冲突,Map的性能通常会得到提升。

七、map与并发安全

1. Map的并发问题

在Go语言中,标准的Map不是并发安全的。这意味着如果多个goroutine同时对同一个Map进行读写操作,可能会导致竞态条件(race condition),从而引发不可预知的行为,如Map的内部结构损坏、程序崩溃等。

并发问题主要发生在以下情况:

  1. 同时写入:当两个或更多的goroutine尝试同时写入Map时,可能会导致数据冲突和不一致。
  2. 读写同时进行:当一个goroutine在读取Map,而另一个goroutine在写入Map时,读取操作可能会遇到不完整或不一致的数据。

为了避免这些问题,需要采取措施来确保对Map的并发访问是安全的。

2. 使用sync.Map

sync.Map是Go语言在sync包中提供的一个并发安全的Map类型。它使用了一些优化技术来减少锁的粒度,提高并发性能。

sync.Map提供了以下几个主要的方法:

  • Store(key, value):存储键值对。
  • Load(key):根据键获取值。
  • LoadOrStore(key, value):获取或存储键值对。
  • Delete(key):删除键值对。
  • Range(f func(key, value interface{}) bool):遍历Map。

sync.Map适用于以下使用场景:

  1. 键值对的写入操作比读取操作少得多sync.Map在这种场景下性能较好,因为它减少了锁的竞争。
  2. Map中的键值对数量很大,且键的集合相对稳定:在这种情况下,sync.Map可以有效地减少锁的粒度,提高并发访问的性能。
  3. 多个goroutine读取同一个键值对sync.Map可以在不加锁的情况下安全地返回同一个键的值。

关于sync.Map的更多介绍,参考《深入理解Go语言sync.Map》

八、最佳实践与常见问题

1. 何时使用Map

Map适用于以下场景:

  1. 快速查找:当需要快速根据键查找值时,Map提供了平均时间复杂度为O(1)的查找性能。
  2. 去重:当需要存储唯一键时,Map的键不允许重复,自然可以实现去重功能。
  3. 关联数据:当数据以键值对的形式存在,并且需要经常更新或查询时,Map是一个很好的选择。
  4. 动态集合:当需要动态地添加或删除键值对时,Map提供了灵活的操作。

2. Map的性能陷阱

  1. 并发使用:在多个goroutine中使用Map时,如果没有正确的同步机制,会导致竞态条件。
  2. 大量删除操作:频繁的插入和删除操作可能会导致Map的性能下降,因为这可能会引起频繁的内存分配和垃圾回收。
  3. 迭代效率:虽然Map的迭代操作简单,但如果Map很大,迭代可能会比预期慢,尤其是在Map扩容时。
  4. 内存使用:Map的内存使用可能比预期高,特别是当存储大量小对象时,因为每个键值对都有一定的存储开销。

3. 调试与优化Map性能的技巧

  1. 预分配大小:如果能预估Map的大小,使用make(map[KeyType]ValueType, size)预分配足够的空间可以减少扩容带来的性能损耗。
  2. 避免大键:使用较小的键类型,如intint64,可以减少哈希计算的开销。
  3. 使用结构体指针:如果值是大型结构体,使用指向这些结构体的指针作为值,可以减少内存使用和复制开销。
  4. 并发控制:对于并发访问,使用sync.Map或自行实现的并发安全Map,确保使用适当的锁策略。
  5. 性能分析:使用Go的pprof工具进行性能分析,找出热点函数和性能瓶颈。
  6. 监控内存使用:使用内存分析工具监控Map的内存使用情况,及时发现潜在的内存问题。
  7. 适时清理:对于不再需要的键值对,及时删除可以帮助减少内存压力。

通过遵循这些最佳实践和技巧,可以有效地使用Map,并优化其性能。在实际开发中,应该根据具体的应用场景和需求来选择和调整策略。

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2024-03-21,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 海天二路搬砖工 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 一、引言
  • 二、map的基本概念和使用
    • 1. 什么是map
      • 2. map的定义和初始化
        • 3. map基本操作:增、删、改、查
          • 4. map的遍历
          • 三、哈希函数简介
            • 哈希函数
              • 哈希冲突
                • 基于拉链法实现的哈希函数
                • 四、map的底层数据结构
                  • 源码分析
                    • 示意图
                    • 五、map的操作性能分析
                      • 1. 时间复杂度:最好和最坏情况
                        • 2. 空间复杂度
                          • 3. 性能优化技巧
                          • 六、map的扩容策略
                            • 1. 扩容的出发条件
                              • 2. 扩容过程
                                • 3. 扩容对性能的影响
                                • 七、map与并发安全
                                  • 1. Map的并发问题
                                    • 2. 使用sync.Map
                                    • 八、最佳实践与常见问题
                                      • 1. 何时使用Map
                                        • 2. Map的性能陷阱
                                          • 3. 调试与优化Map性能的技巧
                                          相关产品与服务
                                          容器服务
                                          腾讯云容器服务(Tencent Kubernetes Engine, TKE)基于原生 kubernetes 提供以容器为核心的、高度可扩展的高性能容器管理服务,覆盖 Serverless、边缘计算、分布式云等多种业务部署场景,业内首创单个集群兼容多种计算节点的容器资源管理模式。同时产品作为云原生 Finops 领先布道者,主导开源项目Crane,全面助力客户实现资源优化、成本控制。
                                          领券
                                          问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档