Go 语言的字典类型其实是一个哈希表(hash table)的特定实现,它能存储的不是单一值的集合,而是键-元素对的集合。在这个实现中,键和元素的最大不同在于,前者的类型是受限的,而后者却可以是任意类型的。
带着问题
- golang中map是指针还是引用?
- map的底层结构是什么样的?
- map的键值映射过程?
- 键不能是哪些类型?为什么?
- map是什么情况下扩容?
- for range map为什么是无序的?
map是指针
golang中,map作为函数参数是如何传递的
当你声明一个map的时候:
m := make(map[int]int)
编译器会调用 runtime.makemap:
// makemap implements a Go map creation make(map[k]v, hint)
// If the compiler has determined that the map or the first bucket
// can be created on the stack, h and/or bucket may be non-nil.
// If h != nil, the map can be created directly in h.
// If bucket != nil, bucket can be used as the first bucket.
func makemap(t *maptype, hint int64, h *hmap, bucket unsafe.Pointer) *hmap
所以实际上是返回一个hmap的指针。
如何验证呢?
func main(){
m := make(map[int]int)
m[1] = 1
fmt.Printf("原始map的内存地址是:%p\n", m)
modify(m)
fmt.Println("map值被修改了,新值为:", m)
}
func modify(m interface{}){
fmt.Printf("函数里接收到map的内存地址是:%p\n", p)
m := p.(map[int]int)
m[1] = 2
}
输出结果:
原始map的内存地址是:0xc00009e030
函数里接收到map的内存地址是:0xc00009e030
map值被修改了,新值为: map[1:2]
在main函数中,m是个指针变量,它保存的值是:0xc00009e030。
在modify函数中,m也是个指针变量,保存的值也是:0xc00009e030。
说明初始化map后,返回的是指针变量,在函数之间,传递的是map的地址。
map和channel是相似的。
那么为什么不是 *map[key]value呢,这样不是更直观?
Ian Taylor answered this recently in a golang-nuts 原话是:
In the very early days what we call maps now were written as pointers, so you wrote *map[int]int. We moved away from that when we realized that no one ever wrote map without writing \*map.
意思是,一开始也是写作 *map[key]value,后来发现所有的map都是当作指针来用的,于是就省略简写了。
map基础数据结构
map底层源码位置:$GOROOT/src/runtime/hashmap.go
map的底层结构是hmap(即hashmap的缩写),核心元素是一个由若干个桶(bucket,结构为bmap)组成的数组,每个bucket可以存放若干元素(通常是8个),key通过哈希算法被归入不同的bucket中。当超过8个元素需要存入某个bucket时,hmap会使用extra中的overflow来拓展该bucket(链地址法)。
- hmap结构
type hmap struct {
count int // map元素的个数
flags uint8 //状态表示,并发读写的判断条件之一
B uint8 // 桶,可以最多容纳 6.5 * 2 ^ B 个元素,6.5为装载因子,B可以理解为已扩容的次数
noverflow uint16 // 溢出的bucket个数
hash0 uint32 // 哈希种子
buckets unsafe.Pointer //当前桶数据的地址
oldbuckets unsafe.Pointer // 旧桶的地址,用于扩容,只有扩容的时候才不为nil
nevacuate uintptr // 搬迁进度,小于nevacuate的已经搬迁
extra *mapextra //原有 buckets满载后,会发生扩容动作,在 Go 中是增量扩容
}
type mapextra struct {
overflow *[]*bmap //为 hmap.buckets(当前)溢出桶的指针地址
oldoverflow *[]*bmap //为 hmap.oldbuckets(旧)溢出桶的指针地址
nextOverflow *bmap //存储一个指针值,指向一个空闲溢出桶
}
- 哈希桶(bucket)结构
// A bucket for a Go map.
type bmap struct {
// 每个key的hash值的高8位,如果tophash[0] < minTopHash,表示这个桶的搬迁状态,不懂?
tophash [bucketCnt]uint8 //bucketCnt是常量,值为8, 即最多存8个key,元素为key的hash值高的8位数值
//接下来,将所有键打包在一起,keys,然后将所有值打包在一起,values;
//为了优化对齐,go采用了key放在一起,value放在一起的存储方式,
//kv的存储形式为”key0key1key2key3…key7val1val2val3…val7″,这样做的好处是:
//在key和value 的长度不同的时候,节省填充空间。
//源码给的一个例子,在map[int64]int8中,4个相邻的int8可以储在同一个内存单元中。如果使用kv
//交错存储的话,由于字节对齐,每个int8都只占一个字节,却需要7个字节来补齐占用单独
// 再接下来是hash冲突发生时,下一个溢出桶的地址
}
bmap 就是我们常说的“bucket”结构,每个 bucket 里面最多存储 8 个 key,这些 key 之所以会落入同一个桶,是因为它们经过哈希计算后,哈希结果是“一类”的。在桶内,又会根据 key 计算出来的 hash 值的高 8 位来决定 key 到底落入桶内的哪个位置(一个桶内最多有8个位置)
原文链接:https://blog.csdn.net/u010853261/article/details/99699350
- 整体结构
溢出桶就是指扩容的buket。
详情参见:https://segmentfault.com/a/1190000018387055
https://segmentfault.com/a/1190000018632347
映射过程
- 先把键值作为参数传给这个哈希表。哈希表会先用哈希函数把键值转换为哈希值,通常是一个无符号整数。
- 一个哈希表会持有一定数量的桶(bucket),也可称之为哈希桶,这些哈希桶会均匀地储存其所属哈希表收纳的那些键 - 元素对。
- 利用哈希值的低B位寻找当前key属于hmap中的哪个bucket。
- 利用哈希值的高位,遍历bucket中所有key对应的高位哈希值,看是否相等,若找不到,表示没有要查找的值
- 若有相等的,再用键值本身去对比一次。为什么还要对比?原因是,不同值的哈希值是可能相同的(哈希碰撞)
- 最后,只有键的哈希值和键值都相等,才能说明查找到了匹配的键 - 元素对。
键类型受限
Go 语言字典的键类型不可以是函数类型、字典类型和切片类型。
- Go 语言规范规定,键类型的值必须要支持判等操作。
- 函数类型、字典类型和切片类型的值并不支持判等操作。
package main
import "fmt"
func main() {
//切片类型
//var badMap1 = map[interface{}]int{
// "1": 1,
// []int{2}: 2, // 这里会引发panic:runtime error: hash of unhashable type func()
// 3: 3,
//}
//fmt.Println(len(badMap1))
//函数类型
//var badMap2 = map[interface{}]int{
// "1":1,
// func(){}:2, //这里会引发panic
//}
//fmt.Println(len(badMap2))
//字典类型
var badMap3 = map[interface{}]int{
"1":1,
map[int]int{1:2,}:3, //这里会引发panic
}
fmt.Println(len(badMap3))
}
- 更深层原因,在上边map映射过程讲解中,由于哈希碰撞的存在,哈希值一样,键值也不一定一样。如果键类型的值之间无法判断相等,那么此时这个映射的过程就没办法继续下去了。
- 从映射过程得出,求哈希和判等操作的速度越快,对应的类型就越适合作为键类型
- 优先选用数值类型和指针类型,通常情况下类型的宽度越小越好。若非要选择字符串类型,最好对键值的长度进行额外的约束
map扩容
随着元素的增加,在一个bucket链中寻找特定的key会变得效率低下,所以在插入的元素个数/bucket个数达到某个阈值(加载因子)时,map会进行扩容,代码中详见 hashGrow函数。
- 加载因子越小,说明空间空置率高,空间使用率小,但是加载因子越大,说明空间利用率上去了,但是“产生冲突机会”高了
- Golang的map的加载因子的公式是:map长度 / 2^B阈值是6.5。其中B可以理解为已扩容的次数
- 首先创建bucket数组,长度为原长度的两倍
- 扩容完成后,每个key的hash对应到两个bucket(一个新的一个旧的)。oldbucket不会立即被转移到新的bucket下,只有当访问到具体的某个bucket的时候,会把bucket中的数据转移到新的bucket中。
- 在下一次扩容前,会不断进行迁移,直到完成
- 扩容时机理解:在没有溢出时hashmap总共可以存储8(2^B)
个KV对,当hashmap已经存储到6.5(2^B)个KV对时表示hashmap已经趋于溢出,即很有可能在存值时用到overflow链表,这样会增加hitprobe和missprobe。为了使hashmap保持读取和查找的高性能,在hashmap快满时需要在新分配的bucket中重新hash元素并拷贝,源码中称之为evacuate。 - hitprobe是指找到一个存在的key平均需要找多少次。missprobe是指找到一个不存在的key平均需要找多少次。
值为nil的map
- 添加键 - 元素对,会引发panic
- 其余任何操作,都不会引起错误
- 可以用v,ok:=aMap[key]判断key是否存在
package main
import "fmt"
func main() {
var aMap map[string]int
//添加键-元素对
//aMap["1"] = 1 //会引发panic: assignment to entry in nil map
//访问
fmt.Println(aMap["1"]) //0
//
//删除
delete(aMap,"1") //成功
//访问长度
fmt.Println(len(aMap)) //0
}
map无序
- map输出顺序与插入顺序不一致
map是根据key的hash值来定位buckets数组的索引位置,具体为bucket := hash & (uintptr(1)<<h.B - 1),即 hash % 2^B,是key的hash值与buckets数组长度,作与运算后得到,buckets中存储顺序本就和插入顺序不同
package main
import "fmt"
func main() {
aMap := map[string]int{
"one": 1,
"two": 2,
"three": 3,
"four": 4,
"five": 5,
"six": 6,
"seven": 7,
"eight": 8,
"nine": 9,
"ten": 10,
}
for k, v := range aMap {
fmt.Println(k, v)
}
}
输出顺序与插入顺序就是不一致
- for range map无序
go-in-action中有说明,翻译过来就是:当使用range循环迭代map时,迭代顺序不是特定的,也不保证与下一个迭代顺序相同;自从Go 1.0发布以来,运行时已经将map迭代顺序随机化;设计者意识到程序员开始依赖Go早期版本的稳定迭代顺序,但这会由于实现之间的差异,导致可移植性bug;可用如下方式实现顺序输出:
import "sort"
var m map[int]string
var keys []int
for k := range m {
keys = append(keys, k)
}
sort.Ints(keys)
for _, k := range keys {
fmt.Println("Key:", k, "Value:", m[k])
}
其它
-
map中的元素不是变量,不能寻址
-
删除掉map中的元素是否会释放内存?
不会,删除操作仅仅将对应的tophash[i]设置为empty,并非释放内存。若要释放内存只能等待指针无引用后被系统gc。 -
为什么bucket中不存储key的全部hash值,而只存储高8位?
在查找/删除/插入一个 key 的时候,先判断两个 key hash 的高8位是否相等,如果不等,则不必比较 key 的内容。故保存hash 值的高8位可以作为第一步的粗略过滤,很多时候可以省掉比较两个 key 的内容,因为比较两个 key 是否相等的代价远比两个 uint8 的代价高。而不存储整个 hash 值,是考虑内存的占用会多很多。 -
创建map时,可以适当预估map的容量。
若不指定大小,bucket 数组默认为1,随着插入的元素增多,会增长成2,4,8,16等。这中间要经历很多次的map扩容、元素拷贝。
package tests
import (
"testing"
)
func test(m map[int]int) {
for i := 0; i < 10000; i++ {
m[i] = i
}
}
func BenchmarkMap(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
m := make(map[int]int) //没有指定容量
test(m)
}
}
func BenchmarkMapCap(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
m := make(map[int]int, 10000) //带容量的初始化
test(m)
}
}
运行1:
//-bench表示是基准测试
//-benchtime设置运行时间,默认是1秒
//-run=none用于不运行单元测试代码
//BenchmarkMap-4其中的4表示运行时对应的GOMAXPROCS值
F:\Development\Go\src\otherPractice\go36\tests>go test -v -bench=. -benchtime=3s -run=none
goos: windows
goarch: amd64
pkg: otherPractice/go36/tests
BenchmarkMap-4 3000 1350897 ns/op
BenchmarkMapCap-4 10000 613401 ns/op
PASS
ok otherPractice/go36/tests 10.869s
运行2:找出不指定map容量时间慢的原因
//-benchmem可以提供每次操作分配内存的次数,以及每次操作分配的字节数。从结果我们可以看到,
//指定容量的函数,每次操作进行11次内存分配,而不指定容量的函数的那个要分配275次;性能高的
//每次操作分配322236个字节内存,而慢的每次需要分配1347727 字节的内存。
F:\Development\Go\src\otherPractice\go36\tests>go test -v -bench=. -benchtime=3s -benchmem -run=none
goos: windows
goarch: amd64
pkg: otherPractice/go36/tests
BenchmarkMap-4 3000 1347727 ns/op 687122 B/op 275 allocs/op
BenchmarkMapCap-4 10000 641282 ns/op 322236 B/op 11 allocs/op
PASS
ok otherPractice/go36/tests 11.283s