Golang中的Map数据结构

在Go语言中,map是一种非常强大的数据结构,它提供了键值对的存储和快速查找功能。本文将详细介绍Go语言中map的数据结构、实现原理和常见操作。

一、Map的数据结构

Go语言中的map数据结构由runtime/map.go中的hmap定义:

type hmap struct {
    count     int           // 当前保存的元素个数
    B         uint8         // bucket的数量是2^B
    buckets   unsafe.Pointer // bucket数组指针,数组的大小为2^B
    ...
}

在Go语言中,map是一种哈希表的实现,它通过哈希函数将键映射到数组中的位置,以实现快速的查找和插入操作。hmap结构体是map的核心数据结构,包含了哈希表的基本信息和指向bucket数组的指针。

1.1 数据结构

每个bucket可以存储8个键值对,bucket的数据结构定义如下:

type bmap struct {
    tophash  [8]uint8 // 存储哈希值的高8位
    data     byte[1]  // key和value数据:key/key/key/.../value/value/value...
    overflow *bmap    // 溢出bucket的地址
}

1.2 详细说明

  • tophashtophash是一个长度为8的数组,用于存储每个键的哈希值的高8位。在哈希表中,多个键可能会映射到相同的bucket,这就会导致哈希冲突。为了快速判断一个键是否在当前bucket中,tophash数组存储了每个键的哈希值的高位部分。当我们查找一个键时,首先计算键的哈希值,然后取哈希值的高8位在tophash数组中查找匹配项。如果找到匹配项,再进一步比较键和值。

  • datadata区存放的是键值对的数据,存放顺序是key/key/key/.../value/value/value。这种存放方式可以节省字节对齐带来的空间浪费。因为在内存中,数据的对齐可能会浪费掉一些空间,通过这种紧凑的存储方式,可以更高效地利用内存。

  • overflowoverflow指针指向下一个溢出的bucket,用于处理哈希冲突。当一个bucket满了之后,新元素会存到溢出的bucket中。通过这种链式结构,可以处理多个哈希冲突的键值对。

1.3 负载因子和rehash

每个哈希表的实现对负载因子的容忍程度不同。负载因子是指哈希表中元素的数量与bucket数量的比值。例如,Redis在负载因子大于1时就会触发rehash,而Go在负载因子达到6.5时才会触发rehash。这是因为Redis的每个bucket只能存1个键值对,而Go的bucket可以存8个键值对,因此Go可以容忍更高的负载因子。

当负载因子过高时,哈希表的性能会下降,因为哈希冲突会增加,查找和插入操作的时间复杂度会变高。为了保持高效的性能,哈希表需要在负载因子达到一定阈值时进行扩容和rehash

二、Map的查找和插入

2.1 查找过程

map的查找过程如下:

  1. 计算哈希值:根据key值计算哈希值。Go语言使用了高效的哈希函数来计算键的哈希值,这个哈希值是一个固定长度的整数,它将键映射到哈希表中的某个位置。

  2. 确定bucket位置:取哈希值的低位与hmap.B取模,确定bucket的位置。哈希值的低位部分决定了键在bucket数组中的位置,这样可以快速定位到对应的bucket

  3. 查找tophash:取哈希值的高位,在tophash数组中查询。tophash数组存储了每个键的哈希值的高8位,通过查找tophash数组,可以快速判断键是否在当前bucket中。

  4. 比较键值:如果tophash[i]中的值与哈希值相等,则找到该bucket中的key值进行比较。如果键值相同,则查找成功。

  5. 处理溢出bucket:如果当前bucket没有找到,则继续从下一个overflowbucket中查找。通过链式结构,可以处理多个哈希冲突的键值对。

  6. 搬迁查找:如果当前处于搬迁过程,则优先从oldbuckets查找。在哈希表进行rehash时,可能会同时存在旧的bucket数组和新的bucket数组,此时需要优先从旧的bucket数组中查找。

注意:如果查找不到,不会返回空值,而是返回相应类型的零值。例如,如果查找一个不存在的整数键,返回值是0

2.2 插入过程

新元素的插入过程如下:

  1. 计算哈希值:根据key值计算哈希值。

  2. 确定bucket位置:取哈希值的低位与hmap.B取模,确定bucket的位置。

  3. 查找键值:查找该key是否已经存在,如果存在则直接更新值。

  4. 插入新键值:如果没找到key,则将key插入。如果当前bucket已满,则需要使用overflow链进行处理,将新元素插入到溢出的bucket中。

三、Map的创建和使用

3.1 创建方式

  1. 声明但不初始化

    var m1 map[int]string
    

    此时m1nil,不能存储数据,因为没有分配内存并初始化内部数据结构。如果尝试直接使用,会引发运行时错误。

  2. 使用字面量初始化

    m2 := map[int]string{}
    

    这种方式创建了一个空的map,可以直接存储数据。

  3. 使用make函数创建

    m3 := make(map[int]string)
    

    创建一个空的map,默认len=0

  4. 指定初始容量

    m4 := make(map[int]string, 10)
    

    创建时指定初始容量为10,这样可以避免在初始插入时多次扩容,提高性能。

3.2 初始化

  1. 使用字面量初始化

    var m map[int]string = map[int]string{1: "aa", 2: "bbb"}
    

    保证key彼此不重复。

  2. 使用简短声明初始化

    m := map[int]string{1: "aaa", 2: "bbb"}
    

    赋值过程中,如果新map元素的key与原mapkey相同,则替换;如果不同,则添加。

3.3 使用方法

遍历map

使用range关键字可以遍历map中的所有键值对:

for key, value := range m {
    fmt.Println(key, value)
}

如果只需要遍历键,可以省略值:

for key := range m {
    fmt.Println(key)
}

判断mapkey是否存在

map的下标运算返回两个值,第一个是value的值(如果不存在则为零值),第二个是key是否存在的布尔类型。

value, exists := m[key]
if exists {
    fmt.Println("Key exists with value", value)
} else {
    fmt.Println("Key does not exist")
}

删除map中的元素

使用delete()函数删除元素:

delete(m, key)

删除一个不存在的key不会报错:

delete(m, nonExistentKey)

map作为函数参数和返回值

map在作为函数参数和返回值时,传递的是引用。这意味着在函数内部对map的修改会影响到函数外部的map

func modifyMap(m map[int]string) {
    m[1] = "modified"
}

func main() {
    m := map[int]string{1: "original"}
    modifyMap(m)
    fmt.Println(m[1]) // 输出 "modified"
}

3.4 注意事项

  • 线程安全:Go语言中的map不是线程安全的。如果在多个协程中并发读写同一个map,需要使用互斥锁(sync.Mutex)或者读写锁(sync.RWMutex)来保证线程安全。
  • 键类型map的键类型必须是可比较的类型,例如整数、浮点数、字符串、指针、结构体等。不能使用切片、map、函数等不可比较的类型作为键。
  • 值类型map的值类型可以是任意类型,包括内置类型、自定义类型、切片、map、指针等。

四、总结

Go语言中的map是一种高效的键值对存储结构,通过哈希表实现快速查找和插入操作。它具有以下特点:

  • 灵活的创建方式:支持多种创建和初始化方式,适应不同的使用场景。
  • 高效的查找和插入:通过哈希值和bucket实现快速查找和插入操作,时间复杂度接近O(1)。
  • 处理哈希冲突:通过溢出bucket和链式结构解决哈希冲突,保证较高的性能。
  • 动态扩容:支持动态扩容和rehash操作,当负载因子过高时自动扩容,保持高效性能。

打 赏