Go map 的设计原理 #私藏项目实操分享#

简介: Go map 的设计原理 #私藏项目实操分享#

前言

一直很好奇 Go 语言的 map 底层是如何实现的。 Go map 的形式就是键值对,给定一个键,能尽快的找到对应的值。

  • 任何可比较的类型都可以是键——所有简单的标量类型(布尔、整数、浮点、复数、字符串)、指针、通道、数组、接口。
  • 不可比较的类型——切片、映射、函数。

因此,映射键和值应存储在为映射分配的内存中。这个过程我们使用的方法叫做哈希算法,哈希算法一般包括两步,伪代码如下:

  • hash = hashfunc(key)
  • index = hash % array_size

第一步,通过哈希算法计算键的哈希值,这个结果与桶的数量无关。而且这个计算出的哈希值一般是唯一的,避免出现两个不同的键得到相同的哈希值。 第二步,通过执行取模运算得到 0 到 array_size-1 之间的 index 序号。

哈希函数

为了处理内存地址,我们应该使用哈希函数。我们对哈希函数有以下要求:

  • 确定性 - 应该为相同的键返回相同的值;
  • 统一 — 值应该以某种方式与所有桶同等相关;
  • 快速 - 应快速运行以多次使用;

map 的常见设计

map 的任务是设计一种数据结构用来维护一个集合的数据,同时需要对该集合进行增删查改的操作。最主要的数据结构有两种:哈希查找表(Hash table)搜索树(Search tree)。哈希查找表用一个哈希函数将 key 分配到不同的桶(bucket,也就是数组的不同 index)。这样,开销主要在哈希函数的计算以及数组的常数访问时间。在很多场景下,哈希查找表的性能很高。 哈希查找表一般会存在“碰撞”的问题,就是说不同的 key 被哈希到了同一个 bucket。 一般有两种应对方法:链表法开放地址法

链表法将一个 bucket 实现成一个链表,落在同一个 bucket 中的 key 都会插入这个链表。开放地址法则是碰撞发生后,通过一定的规律,在数组的后面挑选“空位”,用来放置新的 key。

搜索树法一般采用自平衡搜索树,包括:AVL  树,红黑树。自平衡搜索树法的最差搜索效率是 O(logN),而哈希查找表最差是 O(N)。当然,哈希查找表的平均查找效率是  O(1),如果哈希函数设计的很好,最坏的情况基本不会出现。还有一点,遍历自平衡搜索树,返回的 key  序列,一般会按照从小到大的顺序;而哈希查找表则是乱序的。

Go map 的底层实现

Go 语言采用的是哈希查找表,并且使用链表法解决哈希冲突。

现在到 Go 源代码 map.go中看一看,源码中 Go map 的结构体如下:

image.png

hmap 是 hashmap 的缩写:

// A header for a Go map.
type hmap struct {
    // Make sure this stays in sync with the compiler's definition.
    count     int       // 元素数量,调用 len(map) 时,直接返回这个值
    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 // 哈希 随机数种子
    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
}

  • count 代表 map 中元素的数量
  • flags 代表当前 map 的状态(是否处于正在写入的状态等)
  • 2 的 B 次幂表示当前 map 中桶的数量,2^B = Buckets size
  • noverflow 为 map 中溢出桶的数量。当溢出的桶太多时,map 会进行 same-size map growth,其实质是避免桶过大导致内存泄露。
  • hash0 代表生成 hash 的随机数种子
  • buckets 是指向当前 map 对应的桶的指针。
  • oldbuckets 是在 map 扩容时存储旧桶的,当所有旧桶中的数据都已经转移到了新桶中时,则清空。
  • nevacuate 在扩容时使用,用于标记当前旧桶中小于 nevacuate 的数据都已经转移到了新桶中。
  • extra 存储 map 中的溢出桶。

Go 中桶 buckets bmap 结构。在运行时只列出了首个字段,即一个固定长度为 8 的数组。此字段顺序存储 key 的哈希值的前 8 位。

// A bucket for a Go map.
type bmap struct {
    tophash [bucketCnt]uint8
}

tophash 通常包含此 buckets 中每个键的哈希值的最高字节。 如果 tophash[0] < minTopHash,则 tophash[0] 是一个桶疏散状态。

map 在编译时即确定了 map 中 key、value 及桶的大小,因此在运行时仅仅通过指针操作就可以找到特定位置的元素。编译期动态地创建一个新的结构:

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个位置)。桶在存储的 tophash 字段后,会存储 key  数组和 value 数组。

如何从映射中获取数据(查)

我们需要找到键和值的内存地址。

首先找到桶。它是通过将密钥散列的前 8 位与桶结构中的相应数据存储进行比较来选择的。

接下来通过其地址查找键值。

k := add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize))
if t.indirectkey() {
    k = *((*unsafe.Pointer)(k))
}

接下来以同样的方式找到值。

if t.key.equal(key, k) {
    e := add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.elemsize))
    if t.indirectelem() {
        e = *((*unsafe.Pointer)(e))
    }
    return e
}

映射扩增(增)

我们使用 oldbuckets 映射结构属性进行数据迁移。当存储桶中的数据过多时,迁移开始。我们将桶的当前值保存到旧桶中,然后在桶属性中创建双倍数量的桶。映射数据从旧桶复制到桶。 桶的数量总是 2 的幂。 这就是为什么有

B 属性——它是 2 的幂,显示当前的桶数。在迁移映射上是可访问的。这就是为什么在源代码的相同功能中使用存储桶和旧存储桶进行大量工作的原因。迁移后

oldbuckets 设置为 nil。数据的迁移地址可以更改,因此GO不允许获取映射值的指针:

mymap := map[string]string{"1": "1"}
fmt.Println(&mymap["1"])
// cannot take the address of mymap["1"]

哈希碰撞的解决方式

 

哈希碰撞(HashCollision),即不同的键通过哈希函数可能产生相同的哈希值。解决哈希碰撞的两种主要的方法:

  • 拉链法
  • 开放定址法

Go语言中的哈希表采用的是开放寻址法中的线性探测(LinearProbing)策略,线性探测策略是顺序(每次探测间隔为1)的。由于良好的CPU高速缓存利用率和高性能,该算法是现代计算机体系中使用最广泛的结构。

线性探测,字面意思就是按照顺序来,从冲突的下标处开始往后探测,到达序列末尾时,从序列开始处探测,直到找到一个空位置储存这个  key,当序列都找不到的情况下回扩容(事实上当阵列容量快满的时候就会扩容了);查询某一个 key 的时候,找到 key 对应的下标,比较  key 是否相等,如果相等直接取出来,否则按照顺寻探测直到碰到一个空位置,说明key不存在。

拉链法,简单理解为将元素连结成链表,当 key 的 hash 冲突时,我们在冲突位置的元素上形成一个链表,通过指针相互链接,当查询时,发现 key 冲突,顺着链表一直往下找,直到链表的尾节点,找不到则返回空。


相关文章
|
4月前
|
人工智能 安全 Java
Go与Java泛型原理简介
本文介绍了Go与Java泛型的实现原理。Go通过单态化为不同类型生成函数副本,提升运行效率;而Java则采用类型擦除,将泛型转为Object类型处理,保持兼容性但牺牲部分类型安全。两种机制各有优劣,适用于不同场景。
159 24
|
4月前
|
存储 人工智能 安全
深入理解 go sync.Map - 基本原理
本文介绍了 Go 语言中 `map` 在并发使用时的常见问题及其解决方案,重点对比了 `sync.Mutex`、`sync.RWMutex` 和 `sync.Map` 的性能差异及适用场景。文章指出,普通 `map` 不支持并发读写,容易引发错误;而 `sync.Map` 通过原子操作和优化设计,在某些场景下能显著提升性能。同时详细讲解了 `sync.Map` 的基本用法及其适合的应用环境,如读多写少或不同 goroutine 操作不同键的场景。
206 1
|
5月前
|
算法 Java Go
Go内存原理-GC原理
本文介绍了Go语言中垃圾回收(GC)机制的发展与实现原理,涵盖从标记-清除算法到三色标记法,再到三色标记加混合写屏障的演进过程,重点解析各版本GC的核心思想、优缺点及性能优化方向。
144 4
|
6月前
|
存储 安全 Go
Map的遍历与判断键是否存在-《Go语言实战指南》
本文介绍了 Go 语言中对 `map` 的常见操作,包括遍历所有项和判断键是否存在。通过 `for range` 可以遍历 `map` 的键值对、仅键或仅值(需忽略键)。注意,`map` 遍历顺序是随机的。判断键是否存在时,使用双赋值语法 `value, ok := map[key]`,其中 `ok` 表示键是否存在。直接访问不存在的键会返回类型的零值,可能导致逻辑错误。掌握这些机制可更安全高效地处理键值对数据。
|
6月前
|
安全 Go 开发者
Go语言之切片的原理与用法 - 《Go语言实战指南》
切片(slice)是Go语言中用于处理变长数据集合的核心结构,基于数组的轻量级抽象,具有灵活高效的特点。切片本质是一个三元组:指向底层数组的指针、长度(len)和容量(cap)。本文详细介绍了切片的声明与初始化方式、基本操作(如访问、修改、遍历)、长度与容量的区别、自动扩容机制、共享与副本处理、引用类型特性以及常见陷阱。通过理解切片的底层原理,开发者可以更高效地使用这一数据结构,优化代码性能。
198 13
|
6月前
|
人工智能 Go
[go]Slice 切片原理
本文详细介绍了Go语言中的切片(slice)数据结构,包括其定义、创建方式、扩容机制及常见操作。切片是一种动态数组,依托底层数组实现,具有灵活的扩容和传递特性。文章解析了切片的内部结构(包含指向底层数组的指针、长度和容量),并探讨了通过`make`创建切片、基于数组生成切片以及切片扩容的规则。此外,还分析了`append`函数的工作原理及其可能引发的扩容问题,以及切片拷贝时需要注意的细节。最后,通过典型面试题深入讲解了切片在函数间传递时的行为特点,帮助读者更好地理解和使用Go语言中的切片。
188 0
|
9月前
|
存储 缓存 安全
Go 语言中的 Sync.Map 详解:并发安全的 Map 实现
`sync.Map` 是 Go 语言中用于并发安全操作的 Map 实现,适用于读多写少的场景。它通过两个底层 Map(`read` 和 `dirty`)实现读写分离,提供高效的读性能。主要方法包括 `Store`、`Load`、`Delete` 等。在大量写入时性能可能下降,需谨慎选择使用场景。
|
10月前
|
存储 安全 Go
Go语言中的map数据结构是如何实现的?
Go 语言中的 `map` 是基于哈希表实现的键值对数据结构,支持快速查找、插入和删除操作。其原理涉及哈希函数、桶(Bucket)、动态扩容和哈希冲突处理等关键机制,平均时间复杂度为 O(1)。为了确保线程安全,Go 提供了 `sync.Map` 类型,通过分段锁实现并发访问的安全性。示例代码展示了如何使用自定义结构体和切片模拟 `map` 功能,以及如何使用 `sync.Map` 进行线程安全的操作。
288 9
|
11月前
|
Go
go语言for遍历映射(map)
go语言for遍历映射(map)
408 12
|
12月前
|
存储 Go
go语言 遍历映射(map)
go语言 遍历映射(map)
343 2