Bootstrap

09 go语言(golang) - 数据类型:哈希表(map)及原理(一)

哈希表(map)

在Go语言中,map 是一种内置的数据结构,用于存储键值对。它类似于其他语言中的字典或哈希表。

特性

  1. 键值对存储

    • map 使用哈希表实现,可以通过键快速查找对应的值。
  2. 动态大小

    • 可以根据需要动态增加或删除元素。
  3. 键的类型要求

    • 键必须是可比较的类型,如字符串、数字等,不能使用切片、函数等不可比较类型作为键。

创建和初始化

  • 使用 make 函数创建
  • 使用字面量创建并初始化
func Test1(t *testing.T) {
	map1 := map[string]int{"a": 1}
	map2 := make(map[int]string)    // 默认容量为16,不像数组,不用太关注容量,只管高效的使用map即可
	map3 := make(map[int]string, 2) // 初始化容量为2

	fmt.Println(map1)
	fmt.Println(map2)
	fmt.Println(map3)
}

操作

  1. 添加和更新元素、删除元素

    func Test3(t *testing.T) {
    	map1 := map[int]string{1: "x", 2: "y"}
    	fmt.Println(map1)
    
    	// 更新键值对
    	map1[1] = "xxx"
    	fmt.Println(map1)
    	// 插入新键值对
    	map1[3] = "z"
    	fmt.Println(map1)
    	// 删除键值对
    	delete(map1, 2)
    	fmt.Println(map1)
    }
    

    输出:

    map[1:x 2:y]
    map[1:xxx 2:y]
    map[1:xxx 2:y 3:z]
    map[1:xxx 3:z]
    
  2. 访问元素并检查存在性

    /*
    获取键值对
    */
    func Test2(t *testing.T) {
    	var map1 map[string]int = map[string]int{"a": 1}
    
    	fmt.Println(map1)
    
    	fmt.Println(map1["a"])
    	fmt.Println(map1["b"])    // 不存在的键值对,返回类型零值
    	value, exist := map1["b"] // 获取键值对,第二个返回值表示键值对是否存在
    	if exist {
    		println(value)
    	} else {
    		fmt.Println("b键不存在!")
    	}
    }
    
  3. 遍历 map

    func Test4(t *testing.T) {
    	// key 为 int
    	// value 为 string 切片
    	map1 := map[int][]string{1: {"x", "x"}, 2: {"y", "y"}}
    
    	for key := range map1 {
    		fmt.Println(key)
    	}
    
    	for key, value := range map1 {
    		fmt.Println(key, value)
    	}
    }
    
  4. 获取长度

    func Test5(t *testing.T) {
    	map1 := map[string]int{"a": 1}
    	fmt.Println(len(map1))
    }
    

    map 并不像切片那样直接提供容量(capacity)的概念和相关函数。对于 map,我们只能获取当前存储的键值对数量,而无法直接查看或控制其底层容量。

注意事项

  • 如果访问一个不存在的键,返回该类型的零值。

  • 遍历时,顺序不固定,每次可能不同。

  • 并发读写不安全,需要同步机制(如 sync.RWMutex)保护。

  • 只声明的情况下,无法插入值 ,会报错:assignment to entry in nil map

    func Test6(t *testing.T) {
    	// 只声明,为nil
    	var map1 map[string]int
    	println(map1)
    
    	map1["x"] = 1 // 为nil插入一个值 ,会报错
    	fmt.Println(map1)
    }
    

底层原理

Go语言中的 map 是通过哈希表实现的。

基本组成(简单)

  1. 哈希表
    • map 使用哈希表来存储键值对,通过计算键的哈希值来确定存储位置。
  2. 桶(bucket)
    • 哈希表由多个桶组成,每个桶可以容纳多个键值对。
    • 每个桶有一个固定大小,通常为8个元素。
  3. 溢出桶(overflow bucket)
    • 当一个桶被填满时,会创建溢出桶来存放更多元素。
    • 溢出链可能会影响性能,因为需要遍历链条查找元素。

基本组成(详细)

源码位置在:src/runtime/map.go

  1. hmap结构体(哈希表)

    • hmap 是 Go 中表示一个 map 的主要数据结构。
    • 它包含了关于整个哈希表的信息,如桶数组、元素数量等。
    • hmap 结构的一些关键字段
      • count:当前 map 中的键值对数量。
      • B:用于计算bucket索引的shift值,len(buckets) = 2^B
      • buckets:指向第一个 bucket 数组的指针。
      • oldbuckets:用于渐进式扩容时保存旧的 bucket 数组。
      • nevacuate:记录已经迁移到新 buckets 的旧 buckets 数量,用于渐进式扩容控制。
    // A header for a Go map.
    type hmap struct {
    	// Note: the format of the hmap is also encoded in cmd/compile/internal/reflectdata/reflect.go.
    	// Make sure this stays in sync with the compiler's definition.
    	count     int // # live cells == size of map.  Must be first (used by len() builtin)
    	flags     uint8
    	B         uint8  // log_2 of # of buckets (can hold up to loadFactor * 2^B items)
    	noverflow uint16 // approximate number of overflow buckets; see incrnoverflow for details
    	hash0     uint32 // hash seed
    
    	buckets    unsafe.Pointer // array of 2^B Buckets. may be nil if count==0.
    	oldbuckets unsafe.Pointer // previous bucket array of half the size, non-nil only when growing
    	nevacuate  uintptr        // progress counter for evacuation (buckets less than this have been evacuated)
    
    	extra *mapextra // optional fields
    }
    
    
  2. bucket(桶,即 bmap

    • 哈希表由多个桶(bucket)组成,每个桶可以容纳多个键值对。
    • 每个桶有一个固定大小(通常为8个键值对),这有助于减少内存碎片并提高缓存命中率。
    • 每个 bucket 包含
      • tophash:用于存储每个键对应哈希值的一部分。这个数组帮助快速判断某个位置是否可能包含目标键。
      • 键和值存储
        • 键和值以紧凑形式存储在 bucket 中。
        • 每个 bucket 可以容纳多个键值对(通常为8),这有助于减少内存碎片并提高缓存命中率。
      • 溢出指针(overflow)
        • 当一个 bucket 被填满时,会使用溢出指针链接到下一个溢出 bucket。
        • 溢出链条用于处理哈希冲突和高负载情况。
    // A bucket for a Go map.
    type bmap struct {
    	// tophash generally contains the top byte of the hash value
    	// for each key in this bucket. If tophash[0] < minTopHash,
    	// tophash[0] is a bucket evacuation state instead.
    	tophash [abi.MapBucketCount]uint8
    	// Followed by bucketCnt keys and then bucketCnt elems.
    	// NOTE: packing all the keys together and then all the elems together makes the
    	// code a bit more complicated than alternating key/elem/key/elem/... but it allows
    	// us to eliminate padding which would be needed for, e.g., map[int64]int8.
    	// Followed by an overflow pointer.
    }
    

    注意:bmap 的确有一个结构体定义,但它只是包含了 tophash。键和值数组以及溢出指针(overflow)并没有直接在这个结构体中定义,而是通过内存布局和编译器生成的代码来实现。

  3. 溢出桶 (Overflow Buckets)

    当某个 bucket 因为碰撞过多而填满时,并且无法通过再散列来解决时,会使用溢出桶来存储额外的元素。这些溢出桶被动态地添加到相应基本 bucket 上以处理冲突。

初始化过程

当我们使用 make(map[string]string, 10) 来初始化一个 map 时,底层进行了一系列操作以配置和优化这个数据结构的性能。

第一步:创建一个 hmap 结构体对象

第二步:生成一个哈希因子 hash0 并赋值到 hmap 对象中

hash0 是用于生成 key 哈希值的随机种子。这个种子在 map 的整个生命周期内保持不变,并用于确保 hash 函数能够将键均匀分布在各个桶中,减少碰撞。

第三步:根据 hint=10 创建 B

这里 B 表示 buckets 数组中 bucket 的位数。如果 hint 为 10,则选择最小的 B 使得 2 B ≥ h i n t 2^B \geq hint 2Bhint。因此,则 B = 4 B = 4 B=4 因为 2 4 = 16 2^4 = 16 24=16 是大于或等于10且最接近10的2次幂数。

第四步:创建 buckets 和 bmaps

  • B < 4时, 桶数是 2 B 2^B 2B,由于数据较少、使用溢出桶的可能性较低,会省略创建的过程以减少额外开销;
  • B >= 4时,会额外创建 2 ( B − 4 ) 2^{(B-4)} 2(B4) 个溢出桶,所以桶数是 2 B + 2 ( B − 4 ) 2^B + 2^{(B-4)} 2B+2(B4)

注意:每个bmap中可以存储8个键值对,当不够存储时需要使用溢出桶,并将当前bmap中的overflow字段指向溢出桶的位置。

插入或更新值过程

当执行 info["name"] = "xxx" 来为一个 map 赋值时,底层会进行一系列操作以确保键值对正确存储。

第一步:计算 Key 的哈希值

首先,使用预先存储在 hmap 结构体中的哈希因子 hash0 来计算 key "name" 的哈希值。这个过程通常涉及到一个或多个快速的哈希函数,目的是将字符串 "name" 转换成一个整数型的哈希码。

第二步:确定桶位置

使用计算出来的哈希码来确定该键应该放置在哪个桶(bucket)。这通常通过取哈希码与当前桶数量减一之间的位与操作(例如,如果有 16 个桶,则为 hash & (16-1))来实现。这样可以保证得到一个介于 0 到 桶总数-1 的索引。

第三步:检查键是否存在

遍历目标 bucket 中所有已经存在的条目(即遍历该 bucket 对应的 bmap),查看 "name" 这个 key 是否已经存在:

  • 如果找到了相同的 key,则更新其对应 value 值为 "xxx".
  • 如果没有找到,则进行下一步。

第四步:添加新键值对

如果在当前 bucket 中没有找到相同 key,则先检查此 bucket 是否还有空间添加新条目

  • 如果有空间,则直接在此 bmap 中添加新条目。
  • 如果没有空间(即此 bmap 已满),则需要处理溢出情况:
    • 检查是否已经有溢出 bucket 存在。
      • 有则插入新条目
    • 如果没有溢出 bucket 或者溢出 buckets 也已满,则创建一个新的溢出 bmap 并链接到当前链表上。
      • 在适当位置插入新条目。
  • hmap的个数count++(map中的元素个数+1)

第五步:调整大小和重散列 (Rehashing)

每次添加操作后,都会检查 map 的负载因子(即元素数量与桶数量之比)。如果超过某个阈值,则可能触发 rehashing 过程:

  • 创建更大容量(通常翻倍)的新 buckets 数组。
  • 将所有旧 buckets 中内容重新散列并分配至新 buckets 中以均匀分布负载。

获取数据过程

当执行 value := info["name"] 来从一个 map 中获取值时,底层会进行一系列操作以确保能够正确地检索到对应的值。

第一步:计算 Key 的哈希值(与插入过程一样)

第二步:确定桶位置(与插入过程一样)

第三步:搜索键

遍历目标 bucket 中所有已经存在的条目(即遍历该 bucket 对应的 bmap),查看 "name" 这个 key 是否已经存在:

  • 如果找到了相同的 key,则读取其对应 value 值,并将其赋给变量 value
  • 如果没有找到相同 key,在基本 bucket 中未找到后还需要检查是否有溢出 bucket,并在溢出 buckets 中继续搜索。

第四步:处理未找到情况

如果在基本 bucket 及任何相关联溢出 buckets 中都没有找到指定 key,则返回 map 类型默认零值。对于 string 类型,默认零值是空字符串 “”。

;