一文学会使用Go语言中map

144 阅读21分钟

Go语言中map类型

map类型是一个无序的键值对集合;由键(key)和值(value)组成,在一个map中键是唯一的。

map基础

声明语法

var name map[key_type]value_type
  • name 变量名
  • key_type 键的类型
  • value_type 值的类型

注意:

  • key的类型必须是可比较的类型,即支持 ==!= 两种操作。
  • map类型的变量不能进行比较,但是可以与nil比较。
  • map类型的变量需要显式初始化后才能使用,否则会导致进程异常退出。

显式赋值map类型的两种方式

符合字面值

var n map[int]string //只声明,未赋值,不能直接操作,他是一个nil map
m := map[int]string{} // 等价于 var m map[int]string = map[int]string{}
m[1] = "Go 语言 so easy"
fmt.Println(m)

使用内置函数make

var m = make(map[int]string)
var n = make(map[string]int,10) //初始容量为10

map容量不会受制于初始容量,当容量不够会自动进行扩容。

map变量的传递开销

map是引用变量,它作为参数传递给函数或者方法的时候,实际上是传递了一个“描述符”,不是值拷贝,所以开销是固定的,函数内部对map参数的更改,在外部也是可见的。


func update(m map[string]int) {
    m["a"] = 50
    m["b"] = 60
}
func main() {
    m := map[string]int{
       "a": 5,
       "b": 6,
    }
    fmt.Println(m)
    update(m)
    fmt.Println(m)
}
// map[a:5 b:6]
// map[a:50 b:60]

map的基本操作

插入新的键值对

如果插入的key不存在则进行插入操作,如果key存在则进行更新value值的操作。

func main() {
    m := map[int]string{}
    m[1] = "go study" // 插入
    
    m[1]="we go study" // 更新value的值
}

获取键值对数量

通过内置函数len()获取当前存储键值对的数量。不能对map进行cap获取当前容量。

func main() {
    m := map[int]string{
       1: "a",
       2: "b",
       3: "c",
       4: "d",
    }
    fmt.Println(len(m)) // 4
}

查找和读取数据

判断某个key是否存在,使用“value ok”的形式来判断。

func main() {
    m := map[int]string{}
    m[1] = "a" // 数据插入

    if v, ok := m[1]; ok {   // v = "a"
       fmt.Println("键1存在,值为:", v)
    }
    if v, ok := m[2]; ok {   // v = ""
       fmt.Println("键2存在,值为:", v)
    } else {
       fmt.Println("键2不存在")
    }
}

通过这种方式可以获取,键是否存在,如果存在的话,返回对应的值。如果不存在则返回对应类型的零值。

删除数据

使用内置函数delete来删除map中的数据;delete函数是从map中删除键的唯一方法;即使传给detele的键在map中不存在,delete函数的执行也不会失败 ,也不会抛出运行时的异常。

func main() {
    m := map[int]string{}
    m[1] = "a"

    delete(m, 1)
    delete(m, 2)
    fmt.Println(m)
}
遍历

对同一个map进行遍历,每次遍历元素的次序都是不一样的。他的无序性与插入数据的顺序也没有关系。

func main() {
    m := map[int]int{}
    for i := 0; i < 10; i++ {
       m[i] = i * i
    }
    for k, v := range m {
       fmt.Println(k, v)
    }
}

map的底层实现

虽然已经掌握了 map 的基本使用,但是我们也需知道他的底层实现,以备不时之需。 map是由键值对key-value组成的,key只会出现一次,key是无序的。

底层结构:

type _map *hashtableImpl //官方标准编译器是使用哈希表来实现映射的

map的实现方式

map的实现方式有以下两种:

  1. 哈希查找表

    哈希查找表有一定的概率会出现“碰撞”问题,也就是不同的key可能会被hash到同一个bucket。通常有两种方式来解决碰撞问题:链表法和开放地址法。

    链表法是将所有哈希地址相同的记录都链接在同一条链表中,称为同义词子表,在哈希表中只存储所有同义词子表的头指针。

    链表法的优点是简单且无堆积现象,由于非同义词不会发生冲突,因此平均查询长度较短;适合造表前无法确定表长的情况;节省空间,删除操作易于实现,缺点是需要额外的指针空间;当装填因子较大时,查找效率会降低。

    开放地址法是发生碰撞时,通过某种探测技术在散列表中形成一个探测序列,沿此序列逐个单元地查找,直到找到给定的关键字或者碰到一个开放的地址为止。

    开放地址的优点是不需要额外的指针空间;当装填因子较小时,查找效率较高。开放地址法的缺点是探测过程复杂且可能失败;存在堆积现象,即非同义词也会发生冲突,删除操作困难。

    Go语言使用的就是哈希查找表并通过链表法来解决哈希碰撞的问题。

  2. 搜索树

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

map的底层实现

先看map在源码中的定义,源码地址:$GOROOT/src/runtime/map.go

const (
    bucketCntBits = 3 // 表示桶的基数,这里的值为3,意味着桶的大小为(2^3=8)
    bucketCnt     = 8 // 桶数组的实际大小

    loadFactorDen = 2                                   //负载因子的分母,负载因子决定了何时进行扩容操作
    loadFactorNum = loadFactorDen * bucketCnt * 13 / 16 //负载因子的分子
)
type hmap struct {
    count     int    // 表示当前map中活跃的键值对数量,len()的值
    flags     uint8  // 用于存储标志位,例如是否已初始化等。
    B         uint8  // 桶的个数:2^B;可以保存的元素可数为:2^B*(填充因子,默认为6.5)
    noverflow uint16 // 溢出桶的个数
    hash0     uint32 // 哈希因子

    buckets    unsafe.Pointer // 指向当前桶数组的指针,桶数组的大小为:2^B
    oldbuckets unsafe.Pointer // 指向旧数组桶的指针,发生扩容前的buckets,在增长时非nil
    nevacuate  uintptr        // 记录渐进式rehashing的进度,表示已经迁移桶的数量。

    extra *mapextra // 用于存储额外的信息。即存储一些不是所有哈希表都需要的字段。
}

type mapextra struct {
    // 指向一个包含指向溢出桶的切片的指针 用于存储当前桶数组的溢出桶。
    overflow *[]*bmap
    // 指向一个包含指向旧溢出桶的切片的指针 用于存储旧数组桶的溢出桶。
    oldoverflow *[]*bmap

    // 指向下一个可用的溢出桶,用于快速分配新的溢出桶。
    nextOverflow *bmap
}

//定义了hmap.buckets中每个bucket的结构
type bmap struct {
    //tophash不仅仅用来存放key的哈希高8位,在不同场景下它还可以标记迁移状态,bucket是否为空等.当tophash对应的K/V被使用时,存的是key的哈希值的高8位;当tophash对应的K/V未被使用时,存的是K/V对应位置的状态
    //bucketCnt 是常量=8,一个bucket最多存储8个key/value对
    tophash [bucketCnt]uint8
}

这几个结构体在map的实现中,非常重要需要牢牢记住。

hmap

  • count 表示当前map中活跃的键值对数量,len()的值。
  • flags 用于存储标志位,例如是否已初始化等。
  • B 桶的个数:2^B;可以保存的元素,个数为:2^B*(填充因子,默认为6.5)。
  • noverflow 溢出桶个数。
  • hash0 哈希因子 。
  • buckets 指向当前桶数组的指针,桶的大小为:2^B。
  • oldbuckets 指向丢数组桶的指针,发生扩容前的buckets,在增长时非nil。
  • nevacuate 记录渐进式rehashing的进度,表示已经迁移桶的数量。
  • extra 用于存储额外的信息。即存储一些不是所有哈希表都需要的字段。

mapextrx

  • overflow 指向一个包含指向溢出桶的切片的指针,用于存储当前桶数组的溢出桶。溢出桶是当哈希表某个位置冲突时,用于存放多余数据的额外桶,overflow字段只有在哈希表的键和值都不包含指针并且可以内联时才使用,这样可以避免扫描这些哈希表。
  • oldoverflow 指向一个包含旧溢出桶的切片的指针,用于存储旧数组桶的溢出桶。
  • nextOverflow 指向下一个可用的溢出桶,用于快速分配新的溢出桶。

bmap

bmap 类型是哈希表中存储数据的基本单位。 在源码中它只有一个tophash字段,tophash不仅仅用来存放key的哈希高八位,在不同场景下它还可以标记迁移状态,bucket是否为空等;当tophash对应的K/V被使用时,存的是key的哈希值高8位;当tophash对应的K/V未使用时,存的是K/V对应位置的状态;bucketCnt是常量,表示一个bucket最多存储8个键值对;

为了方便理解,这里可以理解为bmap的结构如下:

// 伪代码
type bmap struct {
	tophash [bucketCnt]uint8
	// 下面是补全的字段
	keys    [bucketCnt]interface{} // 假设键的类型是 interface{}
	elems   [bucketCnt]interface{} // 假设值的类型是 interface{}
	overflow *bmap                  // 指向溢出桶的指针
}

bmap中实际存储分别是长度为8的tophash数组key数组,elems数组以及一个溢出桶

key在map中的定位

根据上面的三个结构体的详细介绍明,知道键值对是存储在hmap.buckets的桶中,也就是bmap中。 map的结构示意图如下:

map的底层实现.png

可以看到hmap结构体一个成员是buckets数组,数组长度是2的次方。 数组每个元素bucket里面存储的长度为8的tophash数组keys数组values数组,还有当这8个槽装不下时用于存储键值的overflow bucket链表

那么就很好理解key在map中的定位了,主要步骤如下:

  1. 计算key的哈希值。
  2. 根据哈希值的后B位进行位运算,得到key所在的桶。
  3. 再根据高8位的哈希与tophash遍历进行比较,如果相等则去keys里面遍历完全比较,如果相等则返回values对应位置的值,否则会进入溢出桶overflow bucket,再次进行tophash比较,keys比较等步骤,一直往下找,直到找到或者遍历完溢出桶链表为止.

可以直接这么理解,高8位在tophash里找不到就进入溢出桶链表找。如果tophash遍历有相等的则去keys里面完全比较,如果找到则返回values对应位置的值,如果keys里面完全比较,找不到也进入溢出桶链表,一直往下找,直到遍历完,或者找到为止。

map中数据写入的过程

读取数据的步骤:

  1. 计算哈希,确定key的位置
  2. 如果需要扩容,则进行扩容后,继续第一步
  3. 如果buckets已满会存入overflow buckets,overflow buckets也满了,就会新建overflow bucket,获取key和elem的地址并存入数据。

map的读取步骤

  1. 对key进行哈希运算,用低B位确定桶的位置;用高8位获得key在tophash中的Index位置;
  2. 如果找不到,但overflow不为空,则继续到overflow里面寻找Index位置;当找到tophash的Index位置后,也就确定了key在keys数组中的位置,这时候就需要完全比较了,如果相等则说明找到key了,就可以读取values数组上相同位置的value了。如果不一样,则说明还得继续遍历寻找,直到没有元素,也没有overflow可继续遍历。

map的扩容

哪些情况会触发map的扩容?

  1. 当map元素达到某个阈值 map元素过多可能会增加hash冲突的概率,导致map读取效率下降,超过一定的阈值后,就应该对map的数据元素进行重整,平衡数据的读取速度。这个阈值由扩容因子决定:

    bucketCntBits = 3
    bucketCnt     = 1 << bucketCntBits
    loadFactorNum = 13
    loadFactorDen = 2
    
    func overLoadFactor(count int, B uint8) bool {
        return count > bucketCnt && uintptr(count) > loadFactorNum*(bucketShift(B)/loadFactorDen)
    }
    

    也就是count > LoadFactor * 2^B (LoadFactor =loadFactorNum/loadFactorDen=6.5)

    其中count 指的是当前 map 里的元素个数,2^B 是指当前 map 里 buckets 数组长度,从这可以看出元素越来越多,即 count 越来越大,则越可能触发扩容。

  2. overflow 数量过多,但元素很少

    这种情况的产生,是因为出现了大量数据的插入和删除,导致 overflow 不能回收,所以也要进行数据重整。

首先先来看第一种情况下的扩容,hmap会先增加 B 的值,也就是 buckets 数组的数量。然后,重新计算 key 所需要迁移的桶的位置,以便让数据能均衡的分布在新老 buckets 桶里。

当然,Go 并不会一下子就把所有的数据都迁移完毕,而是一种懒迁移的机制。它会等到用到某个 key 时,检查此时 key 的迁移状态,再作出迁移动作。从上面的扩容过程我们也可以看出为什么 map 是无序的了,因为原本在一个 bucket 上的数据有可能被迁移到其他的 bucket 上了,会被打乱顺序。

具体的扩容过程如下:

  1. 在扩容时,调用hasGrow函数,如果负载因子超载,则会进行双倍重建。当溢出桶的数量过多时,会进行等量重建。
  2. 新桶会存储到buckets字段,旧桶会存储在oldbuckets字段。map中extra字段的溢出桶也进行同样的转移。
  3. 数据转移遵循写时复制(copy on write)的规则,只有真正赋值时,才会选择是否需要进行数据迁移。也就说这个过程是渐渐地,并不是一次性将所有键值对都搬移到新的哈希表中,而是逐步迁移的。这样避免一次性的大量内存分配和复制操作,减少了扩容时的性能开销;
  4. 在迁移过程中,新的哈希表会被标记为正在扩容状态,这样查找键值对时,会同时在新旧两个哈希表中进行查找,直到所有的键值对都已迁移到新的哈希表中。
  5. 迁移完成后,旧的哈希表会被丢弃,释放其占用的内存空间。
  6. 需要注意的是,由于哈希表的大小是2的幂次方,因此扩容后的大小总是2的幂次方,这样可以保证哈希函数计算得到的哈希值能够在新的哈希表中找到对应的桶,避免了重新计算哈希值的开销

总的来说,map的扩容过程是一种动态的调整大小的机制,它允许map在存储越来越多的键值对时保持高效的查找性能。

第二种情况的数据重整就简单了,只要当前bucket里收缩各个overflow到空位上即可。

map中的key为什么是无序的

当向map中插入键值对时,map会先计算键的哈希值,并根据哈希值找到对应的桶。每个桶存储一个或多个键值对,最多8个。

  1. 由于哈希函数的随机性和哈希冲突存在,不同的键可能会被映射到哈希表中的不同桶,因此插入键值的顺序并不能代表键在哈希表中的位置,导致map中键是无序的;或者说hash算法本身就无法保证key的有序性。
  2. 当我们对map进行遍历时,每次遍历的顺序可能不同。知道map会进行懒迁移,当mapkey被访问时,会检查它的迁移状态从而可能会将这个key迁移到新的bucket。这也导致了map的无序性以及每次迭代时元素顺序的不确定性;
  3. 从Go1.12版本开始,Go语言在runtime中引入了一种伪随机的哈希算法,以减少哈希冲突带来的风险。这种伪随机哈希算法使得同样的key在不同运行时刻或不同机器上可能会被映射到不同的桶,从而进一步增加map中的无序性。

这里有一种方法可以让map遍历有序,就是先将map的键拷贝到一个切片中,并对切片进行排序。这样就可以得到有序的键值对了。

为什么不能对map中的元素取地址

已经知道map的底层是根据哈希查找表的链表法实现的。那么我们向map中添加键值对时,Go语言会自动进行扩容和重新哈希等操作,以保证map的性能和空间效率。

而这些操作可能导致map中的键值对在内存中的重新分布。如果我们允许对map的元素取地址,并通过指针访问map中的键值对,那么当map进行扩容或者重新哈希时,指向旧的键值对指针将会变的无效,这时map元素不能取地址的本质原因。

具体而言,为了保证元素索引的效率,map底层的哈希表只会为它所有的键值维护一段连续的内存段。当map中键值对增加到一定程度时,就需要开辟新的内存段来扩容。

扩容的过程是将原来内存段上的值全部拷贝到新开铺的内存段上。即使map的内存段没有扩容触发,某些哈希表的实现也可能在当前内存段移动其中的元素。所以,map中元素地址会因各种原因改变。如果Go语言的map去维护这些指针值,会增加Go编译器和运行时的复杂度,最主要是会影响程序的运行效率,所以Go不支持map元素取地址。

nil map 和 空map的不同

nil map

当一个map变量被声明但未初始化,他的值就是nil。这就是所谓的“nil map”,也就是未分配内存的map。 一个“nil map”不能直接用于存储键值对,否则会导致运行时错误(panic)。如果试图向“nil map”存储数据,会引发 runtime panic,这一点跟切片是有区别的。

var m map[string]int  // 这是一个nil map
fmt.Println(m)        // 输出: map[]
fmt.Println(m["key"]) //0
delete(m, "key")
m["key"] = 1 // 运行时错误: panic: assignment to entry in nil map

空map

“空 map”是指已经初始化的map,但其中没有任何键值对。 空map是合法的,可以直接用于存储和读取键值对,而不会引发运行时错误。 例如:

m := make(map[string]int) // 这是一个空 map
fmt.Println(m)            // 输出: map[]
m["key"] = 1                // 添加键值对
fmt.Println(m)            // 输出: map[key:1]

所以nil map 和空map的本质区别体现在是否分配内存,也就是是否对map类型变量进行了初始化。

有一点需要注意,切片和map在赋值上的表现是有差异的:

func init() {
    log.SetFlags(log.Lshortfile)
}
func main() {
    m := make(map[string]string)
    m1 := m
    m1["a"] = "aa"
    log.Printf("m:%v;ptr:%p\n", m, m)
    log.Printf("m1:%v;ptr:%p\n", m1, m1)

    s := make([]string, 0, 10)
    s1 := s
    log.Printf("s:%v;ptr:%p", s, s)
    log.Printf("s1:%v;ptr:%p", s1, s1)

    s1 = append(s1, "a")
    log.Printf("s:%v;ptr:%p", s, s)
    log.Printf("s1:%v;ptr:%p", s1, s1)
}

上面的输出结果为:

map与切片的赋值区别.go:12: m:map[a:aa];ptr:0x140001100f0
map与切片的赋值区别.go:13: m1:map[a:aa];ptr:0x140001100f0
map与切片的赋值区别.go:17: s:[];ptr:0x14000140000
map与切片的赋值区别.go:18: s1:[];ptr:0x14000140000
map与切片的赋值区别.go:21: s:[];ptr:0x14000140000
map与切片的赋值区别.go:22: s1:[a];ptr:0x14000140000

可以看到map重新赋值后m1和m是共享同一块底层空间,所以当我们向赋值后的map(m1)中增加元素时,赋值前的map(m)中也会有这个元素

而切片的赋值虽然也是共享同一块内存空间,并且在容量足够的情况下,只要切片没发生扩容,向复制后的切片中追加元素时不会导致复制后的切片的地址发生变化,在这种情况下,这两个切片虽然共享了底层的存储空间,但是赋值后的切片s1追加的元素不会体现到赋值前的切片s上。

这是切片的一种特性,即使这两个切片共享底层存储,但是他们的元素个数,容量信息以及指向地址这些元信息都是单独记录的,这跟切片截取是一个道理,截取后的切片和原切片是共享同一块内存空间,只是各自维护元素个数,容量信息以及指向的起始地址这些元信息。

map中删除key,会释放内存吗?

先看下面例子:

const cnt = 10000

var m = make(map[int]int, cnt)

func init() {
    log.SetFlags(log.Lshortfile)
}
func main() {
    for i := 0; i < cnt; i++ {
       m[i] = i
    }
    runtime.GC()
    log.Println(getMemStats())

    for k, _ := range m {
       delete(m, k)
    }
    runtime.GC()
    log.Println(getMemStats())

    m = nil
    runtime.GC()
    log.Println(getMemStats())

}
func getMemStats() string {
    var m runtime.MemStats
    runtime.ReadMemStats(&m)
    return fmt.Sprintf("分配的内存 = %vKB, GC的次数 = %v\n", m.Alloc/1024, m.NumGC)
}

上面的执行结果:

map中删除key.go:21: 分配的内存 = 436KB, GC的次数 = 1

map中删除key.go:27: 分配的内存 = 440KB, GC的次数 = 2

map中删除key.go:31: 分配的内存 = 126KB, GC的次数 = 3

根据上面的代码发现,删除map的key之后,手动执行GC后map的内存占用几乎没有多大变化,直到将整个map设置为nil之后,其内存空间才被回收。说明删除map的key是无法达到回收map内存空间的目的。即map不会因为删除了一个键值对而自动缩小哈希表的容量。

这是什么原因? 在前面的扩容机制已经介绍过了,因为Go语言中map使用的是一种渐进式扩容法的动态调整放方法,它只会在插入新元素的时候,根据装填因子来判断是否需要扩容,并且扩容过程是分批进行的,也就是前面讲到的懒迁移策略。所以map中即使删除了key也不会立马回收空间。

map为什么会内存泄露?

删除map的key,其元素是没法被GC回收的,也就说map的容量只能增加不能减少,当频繁删除map中的元素时,他们所占用的桶并不会主动释放,从而可能出现内存泄露

如何手动触发map的空间回收? 如果想要减少map所占用的内存大小,需要重新创建一个新的map。并将旧map赋值为nil或者让他超出作用域范围,以便垃圾回收器回收旧map。

然后将新的map赋值给旧的map。这样相当于强制对map进行了一次重整,就可以释放掉旧map中key删除而又保留的内存空间。 代码实现如下:

const Cnt = 100000

var m = make(map[int]int, Cnt)

func init() {
    log.SetFlags(log.Lshortfile)
}
func main() {

    for i := 0; i < Cnt; i++ {
       m[i] = i
    }
    runtime.GC()
    log.Println(getMemStats())

    //模拟大量,map删除的场景
    for k, _ := range m {
       if k != 1 {
          delete(m, k)
       }

    }
    
    runtime.GC()
    log.Println(getMemStats())

    //将原map的值拷贝到新map
    tmp := make(map[int]int, len(m))
    for k, v := range m {
       tmp[k] = v
    }
    //将新map置空
    m = nil
    //将临时map赋值给新map
    m = tmp
    //将临时map置空
    tmp = nil
    runtime.GC()
    log.Println(m)
    log.Println(getMemStats())
}

func getMemStats() string {
    var m runtime.MemStats
    runtime.ReadMemStats(&m)
    return fmt.Sprintf("分配的内存 = %vKB, GC的次数 = %v\n", m.Alloc/1024, m.NumGC)
}

运行结果如下:

map的内存泄露.go:22: 分配的内存 = 2830KB, GC的次数 = 1

map的内存泄露.go:33: 分配的内存 = 2833KB, GC的次数 = 2

map的内存泄露.go:47: map[1:1]
map的内存泄露.go:48: 分配的内存 = 133KB, GC的次数 = 3

可以看到通过中间变量的操作,实现了手动对map的内存回收。

总结

本文主要讲了map的基础使用以及稍微有点底层的东西,map是使用哈希查找表链表法实现,主要是通过链表法解决了哈希冲突。底层map的结构有hmap,mapextra,bmap,需要知道重要的字段的作用。其次是理解key的定位过程,以及数据查找删除。

纯属网上寻找的内容,加上自己的理解写的一篇,如有错误,请指出,新手写作勿喷,哈哈哈。