深度解密Go语言之map

什么是map

是由一组<key,value>对组成的数据结构,并且同一个key只能出现一次

有map相关的操作主要是
  1. 增加一个k-v对------Add or insert
  2. 删除一个k-v对------Remover or delete
  3. 修改某个k对应的v------Reassign
  4. 查询某个k对应的v------Lookup
  • map的设计也称为The dictionary problem,它的任务是设计一种数据结构用来维护一个集合的数据,并且可以同时对集合进行增删改查的操作。最主要的数据结构有两种:哈希查找表(Hashtable)、 搜索树(Searchtree)

哈希查找表用一个哈希函数将 key 分配到不同的桶(bucket,也就是数组的不同 index)。这样,开销主要在哈希函数的计算以及数组的常数访问时间。在很多场景下,哈希查找表的性能很高。

哈希查找表一般会存在"碰撞"的问题,就是说不同的key被哈希到同一个bucket(bucket :数组的不同index)。一般有两种应对的方法:链表法和开放地址法链表法将同一个bucket实现成一个链表,落在同一个bucket中的key都会插入这个链表。开放地址法则是碰撞发生后,通过一定的规律,在数组的后面挑选"空位"用来放置新的key

搜索树法一般采用自平衡搜索树,包括:AVL 树,红黑树。

自平衡搜索树法的最差搜索效率是 O(logN),而哈希查找表最差是 O(N)。

遍历自平衡搜索树,返回的 key 序列,一般会按照从小到大的顺序;而哈希查找表则是乱序的。

为什么要用 map

Go语言内置的map实现了hash table,特点是实现了快速查找,添加,删除的功能。

map的底层实现

在源码中,表示map的结构体是hmap,他是hashmap的"缩写":

// A header for a Go map.

type hmap struct {
    
    
// 元素个数,调用 len(map) 时,直接返回此值
count     int
flags     uint8 
// buckets 的对数 log_2
 B         uint8
// overflow 的 bucket 近似数
 noverflow uint16
// 计算 key 的哈希的时候会传入哈希函数
hash0     uint32
// 指向 buckets 数组,大小为 2^B
// 如果元素个数为0,就为 nil
buckets    unsafe.Pointer  
// 扩容的时候,buckets 长度会是 oldbuckets 的两倍
oldbuckets unsafe.Pointer
// 指示扩容进度,小于此地址的 buckets 迁移完成
nevacuate  uintptr
extra *mapextra // optional fields
}

说明一下, B 是 buckets 数组的长度的对数,也就是说 buckets 数组的长度就是 2^B。

bucket 里面存储了 key 和 value,后面会再讲。

buckets 是一个指针,最终它指向的是一个结构体:

type bmap struct {
    
    
tophash [bucketCnt]uint8
}

但这只是表面(src/runtime/hashmap.go)的结构,编译期间会给它加料,动态地创建一个新的结构:


type bmap struct {
    
    
topbits  [8]uint8
keys     [8]
keytype values   [8]valuetype
pad      uintptr
overflow uintptr
}

bmap 就是我们常说的“桶”,桶里面会最多装 8 个 key,这些 key 之所以会落入同一个桶,是因为它们经过哈希计算后,哈希结果是“一类”的。在桶内,又会根据 key 计算出来的 hash 值的高 8 位来决定 key 到底落入桶内的哪个位置(一个桶内最多有8个位置)。

来一个整体的图:

在这里插入图片描述
当 map 的 key 和 value 都不是指针,并且 size 都小于 128 字节的情况下,会把 bmap 标记为不含指针,这样可以避免 gc 时扫描整个 hmap。但是,我们看 bmap 其实有一个 overflow 的字段,是指针类型的,破坏了 bmap 不含指针的设想,这时会把 overflow 移动到 extra 字段来。

type mapextra struct {
    
    
// overflow[0] contains overflow buckets for hmap.buckets.
// overflow[1] contains overflow buckets for hmap.oldbuckets.
overflow [2]*[]*bmap
// nextOverflow 包含空闲的 overflow bucket,这是预分配的 bucket
nextOverflow *bmap
}

bmap 是存放 k-v 的地方,我们把视角拉近,仔细看 bmap 的内部组成。

在这里插入图片描述
上图就是 bucket 的内存模型, HOBHash 指的就是 top hash。注意到 key 和 value 是各自放在一起的,并不是 key/value/key/value/… 这样的形式。源码里说明这样的好处是在某些情况下可以省略掉 padding 字段,节省内存空间。

例如,有这样一个类型的 map:

map[int64]int8

如果按照 key/value/key/value/… 这样的模式存储,那在每一个 key/value 对之后都要额外 padding 7 个字节;而将所有的 key,value 分别绑定到一起,这种形式 key/key/…/value/value/…,则只需要在最后添加 padding。

每个 bucket 设计成最多只能放 8 个 key-value 对,如果有第 9 个 key-value 落入当前的 bucket,那就需要再构建一个 bucket ,通过 overflow 指针连接起来。

创建 map

ageMp:=make(map[string]int)
//指定map长度
ageMp:=make(map[string]int,8)
//ageMp 为nil,不能向其添加元素,否则会直接panic

通过汇编语言可以看到,实际上底层调用的是 makemap 函数,主要做的工作就是初始化 hmap 结构体的各种字段,例如计算 B 的大小,设置哈希种子 hash0 等等。

func makemap(t *maptype, hint int64, h *hmap, bucket unsafe.Pointer) *hmap

注意,这个函数返回的结果:*hmap,它是一个指针,而我们之前讲过的 makeslice 函数返回的是 Slice 结构体:

func makeslice(et *_type,len ,cap int)slice

回顾一下 slice 的结构体定义:

type slice struct{
    
    
array unsafe.Pointer//元素指针
len   int// 长度
cap   int// 容量
}

结构体内部包含底层的数据指针。

makemap 和 makeslice 的区别,带来一个不同点:当 map 和 slice 作为函数参数时,在函数参数内部对 map 的操作会影响 map 自身;而对 slice 却不会(之前讲 slice 的文章里有讲过)。

主要原因:一个是指针( *hmap),一个是结构体( slice)。Go 语言中的函数传参都是值传递,在函数内部,参数会被 copy 到本地。*hmap指针 copy 完之后,仍然指向同一个 map,因此函数内部对 map 的操作会影响实参。而 slice 被 copy 后,会成为一个新的 slice,对它进行的操作不会影响到实参。

哈希函数

key 定位过程

以上省略了好大一部分的讲解~~
因为看不懂,后续再来补充~
原文地址

map 的两种 get 操作

Go 语言中读取 map 有两种语法:带 comma 和 不带 comma。当要查询的 key 不在 map 里,带 comma 的用法会返回一个 bool 型变量提示 key 是否在 map 中;而不带 comma 的语句则会返回一个 value 类型的零值。如果 value 是 int 型就会返回 0,如果 value 是 string 类型,就会返回空字符串。


package main

import (
	"fmt"
)

func main() {
    
    
ageMap :=make(map[string]int)
ageMap["fu"] =26
//不带comma用法
agel :=ageMap["shao"]
fmt.Println(agel)
//带comma用法
age2,ok :=ageMap["shao"]
fmt.Println(age2,ok)

}

运行结果:

0
0 false

以前一直觉得好神奇,怎么实现的?这其实是编译器在背后做的工作:分析代码后,将两种语法对应到底层两个不同的函数。

func mapaccess1(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer
func mapaccess2(t *maptype, h *hmap, key unsafe.Pointer) (unsafe.Pointer, bool)

源码里,函数命名不拘小节,直接带上后缀 1,2,完全不理会《代码大全》里的那一套命名的做法。从上面两个函数的声明也可以看出差别了, mapaccess2 函数返回值多了一个 bool 型变量,两者的代码也是完全一样的,只是在返回值后面多加了一个 false 或者 true。

如何进行扩容

以上省略了好大一部分的讲解~~
因为看不懂,后续再来补充~
原文地址

map 的遍历

package main

import (
	"fmt"
)
func main() {
    
    
ageMap :=make(map[string]int)
ageMap["fu"] =26
	for name,age :=range ageMap{
    
    
		fmt.Println(name,age)
	}
}

执行汇编命令:

go tool compile -S main.go

关键的几行汇编代码如下:
在这里插入图片描述
这样,关于 map 迭代,底层的函数调用关系一目了然。先是调用 mapiterinit 函数初始化迭代器,然后循环调用 mapiternext 函数进行 map 迭代。在这里插入图片描述
以上省略了好大一部分的讲解~~
因为看不懂,后续再来补充~
原文地址

map 的赋值


package main

import (
	"fmt"
)

func main() {
    
    
	scene := make(map[string]int)

	// 准备map数据
	scene["route"] = 66
	scene["brazil"] = 666
	scene["china"] = 960


	for k, v := range scene {
    
    
		fmt.Println(k, v)
	}
}

map 的删除

package main

import (
	"fmt"
)

func main() {
    
    
	scene := make(map[string]int)

	// 准备map数据
	scene["route"] = 66
	scene["brazil"] = 4
	scene["china"] = 960

	delete(scene, "brazil")

	for k, v := range scene {
    
    
		fmt.Println(k, v)
	}
}

可以边遍历边删除吗

go语言map遍历时删除是安全的, 且可以完全删除

package main
import (
	"fmt"
)
func main() {
    
    
	x := map[int]int{
    
    }
	for i := 0; i < 10000; i++ {
    
    
		x[i] = i
	}
	fmt.Println("初始化后,长度:", len(x))

	// 遍历时删除所有的偶数
	for k := range x {
    
    
		if k%2 == 0 {
    
    
			delete(x, k)
		}
	}
	fmt.Println("删除所有的偶数后,长度:", len(x))

	// 遍历时删除所有的元素
	for k := range x {
    
    
		delete(x, k)
	}
	fmt.Println("删除所有的元素后,长度:", len(x))
}

map并发读写

map 并不是一个线程安全的数据结构。同时读写一个 map 是未定义的行为,如果被检测到,会直接 panic。

package main

import (
	"fmt"
	"time"
)

func main(){
    
    
	c := make(map[string]int)
	go func() {
    
    //开一个goroutine写map
		for j := 0; j < 1000000; j++ {
    
    
			c[fmt.Sprintf("%d", j)] = j
		}
	}()
	go func() {
    
        //开一个goroutine读map
		for j := 0; j < 1000000; j++ {
    
    
			fmt.Println(c[fmt.Sprintf("%d",j)])
		}
	}()
	time.Sleep(time.Second*20)
}

一般而言,这可以通过读写锁来解决:sync.RWMutex。

读之前调用 RLock() 函数,读完之后调用 RUnlock() 函数解锁;写之前调用 Lock() 函数,写完之后,调用 Unlock() 解锁。

package main

import (
	"fmt"
	"time"
	"sync"
)

func main(){
    
    
	c := make(map[string]int)
	var rwLock *sync.RWMutex = new(sync.RWMutex)
	go func() {
    
    //开一个goroutine写map
		for j := 0; j < 10; j++ {
    
    
			rwLock.RLock()
			c[fmt.Sprintf("%d", j)] = j
			rwLock.RUnlock()
		}
	}()
	go func() {
    
        //开一个goroutine读map
		for j := 0; j < 10; j++ {
    
    
			rwLock.Lock()
			fmt.Println(c[fmt.Sprintf("%d",j)])
			rwLock.Unlock()
		}
	}()
	time.Sleep(time.Second*1)
}

另外, sync.Map 是线程安全的 map,也可以使用。它的实现原理,这次先不说了。
sync.map的使用

key 可以是 float 型吗?

从语法上看,是可以的。Go 语言中只要是可比较的类型都可以作为 key。除开 slice,map,functions 这几种类型,其他类型都是 OK 的。具体包括:布尔值、数字、字符串、指针、通道、接口类型、结构体、只包含上述类型的数组。这些类型的共同特征是支持 == 和 != 操作符, k1==k2 时,可认为 k1 和 k2 是同一个 key。如果是结构体,则需要它们的字段值都相等,才被认为是相同的 key。

顺便说一句,任何类型都可以作为 value,包括 map 类型

猜你喜欢

转载自blog.csdn.net/weixin_44014995/article/details/109410330