map-字典的底层、约束和操作

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

映射过程

  1. 先把键值作为参数传给这个哈希表。哈希表会先用哈希函数把键值转换为哈希值,通常是一个无符号整数。
  2. 一个哈希表会持有一定数量的桶(bucket),也可称之为哈希桶,这些哈希桶会均匀地储存其所属哈希表收纳的那些键 - 元素对。
  3. 利用哈希值的低B位寻找当前key属于hmap中的哪个bucket。
  4. 利用哈希值的高位,遍历bucket中所有key对应的高位哈希值,看是否相等,若找不到,表示没有要查找的值
  5. 若有相等的,再用键值本身去对比一次。为什么还要对比?原因是,不同值的哈希值是可能相同的(哈希碰撞)
  6. 最后,只有键的哈希值和键值都相等,才能说明查找到了匹配的键 - 元素对。

键类型受限

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

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值