了解一些golang数据结构的底层实现?map、slice、chan

296 阅读7分钟

slice

首先让我们了解一下slice的底层数据结构
type slice struct {
    array unsafe.Pointer   指向一个指针数组
    len   int 长度
    cap   int 容量
}

array的类型是unsafe.Pointer指针,指向了一个数组的内存地址
len 代表了当前数组的长度
cap 代表了当前数组的容量

接下来我们介绍一下如何创建slice
s := make([]int,4,10)
定义一个数组0-4填充为0,长度容量为10的slice切片
根据结构中可以看出来,array是个指针,所以这时用一个变量继承这个数组 例如:
a := s[3,4]
那么这时 a和s中array指向的内存空间实际上是一块内存,也就是说我此时操作 a = append(a,9),那么s变量中也会发生改变
那么什么时候a改变了后s不会发生同步改变呢?
答案就是a从下表3开始,往后追加,追加到s的长度不足以满足需求后,那么这时候a就会自己开辟内存空间了

slice的是如何扩容的呢?
当空间不够后,slice会翻倍扩容,然后将原内存中的值copy到新的内存空间中

如果slice当作参数传递到另外一个函数中,那么此时如果函数对slice做出修改,那么外部的slice也会修改,如果做了追加外部的slice则不会继续追加
这是为什么呢?
因为slice虽然是指传递,但是他的array实际上是指针,也就是说两个slice同时指向了同一个内存地址,所以slice的长度发生变化时,函数内的len和cap会增长和扩容,而函数外的则不会

chan

channel是Golang在语言层面提供的goroutine间的通信方式,比Unix管道更易用也更轻便。channel主要用于进程内各goroutine间通信,如果需要跨进程通信,建议使用分布式系统的方法来解决。

接下来我们现看一下chan的基本数据结构,再来分析一下chan的底层原理
type hchan struct {
    qcount   uint           // 当前队列中剩余元素个数
    dataqsiz uint           // 环形队列长度,即可以存放的元素个数
    buf      unsafe.Pointer // 环形队列指针
    elemsize uint16         // 每个元素的大小
    closed   uint32            // 标识关闭状态
    elemtype *_type         // 元素类型
    sendx    uint           // 队列下标,指示元素写入时存放到队列中的位置
    recvx    uint           // 队列下标,指示元素从队列的该位置读出
    sendq    waitq
    recvq    waitq          // 等待读消息的goroutine队列    lock     mutex          // 互斥锁,chan不允许并发读写    lock     mutex          // 互斥锁,chan不允许并发读写
    lock     mutex          // 互斥锁,chan不允许并发读写
}

qcount 字段代表当前队列剩余元素的个数
dataqsiz 环形指针,当chan有缓冲区的时候,可以存放多少个元素
buf 环形指针,缓冲队列,如果有人往chan中写数据,没人读则会暂时写到缓冲队列中
sendx 环形队列中写队列的下标(比如一个程序往chan中要写数据,那么如果有缓冲区则根据这个下表来决定写入的下标地址)
recvx 环形队列中读队列的下标(比如一个程序从chan中读取数据,那么读取哪一个数据呢?可以根据这个下标来决定)
sendq 存放当前没有缓冲区还往队列中写入的goroutine,如果有人读则会唤醒该goroutine
recvq 存放当前chan数据为空(缓冲区没数据,sendq中也没数据)的goroutine,等待sendq中有数据后在释放recvq中的goroutine程序

map

老规矩,还是先来参考下map的数据结构

type hmap struct {
    count     int 
    // 代表哈希表中的元素个数,调用len(map)时,返回的就是该字段值。
    flags     uint8 
    // 状态标志(是否处于正在写入的状态等)
    B         uint8  
    // buckets(桶)的对数
    // 如果B=5,则buckets数组的长度 = 2^B=32,意味着有32个桶
    noverflow uint16 
    // 溢出桶的数量
    hash0     uint32 
    // 生成hash的随机数种子
    buckets    unsafe.Pointer 
    // 指向buckets数组的指针,数组大小为2^B,如果元素个数为0,它为nil。
    oldbuckets unsafe.Pointer 
    // 如果发生扩容,oldbuckets是指向老的buckets数组的指针,老的buckets数组大小是新的buckets的1/2;非扩容状态下,它为nil。
    nevacuate  uintptr        
    // 表示扩容进度,小于此地址的buckets代表已搬迁完成。
    extra *mapextra 
    // 存储溢出桶,这个字段是为了优化GC扫描而设计的,下面详细介绍
 }
type mapextra struct {
    overflow    *[]*bmap
    // overflow 包含的是 hmap.buckets 的 overflow 的 buckets
    oldoverflow *[]*bma
   // oldoverflow 包含扩容时 hmap.oldbuckets 的 overflow 的 bucket
    nextOverflow *bmap 
     // 指向空闲的 overflow bucket 的指针
}

每个map都会指向一个hmap指针大概8b,接下来我们着重介绍下buckets、oldbuckets、extra 这三个字段

count 代表当前map中的数量,一般用len()方法可以查询到
flags 代表当前状态,map不能被同时写入和删除,保证原子性
B 代表桶的对数 假如B=3 那么桶的对数为 8个
noverflow 溢出桶的数量
hash0 随机数
buckets 指向buckets数组的指针,数组的个数为2^B个
oldbuckets 库容时用于存放历史数据
nevacuate 扩容进度
extra 下面会着重介绍

我们先来介绍一下buckets这个字段
其底层存放多个buckets,数量就是2^B个
每个bucket的结构如下
type bmap struct {
    tophash [8]uint8 //存储哈希值的高8位
    data    byte[1]  //key value数据:key/key/key/.../value/value/value...
    overflow *bmap   //溢出bucket的地址
}
tophash 是个数组存放每个key的高8值,用于判断当前key是否在这个bucket中
data kkk vvv的方式存放map
overflow 这个字段我们先来介绍一下hash冲突,比如一个key 被hash放到了1个bucket中,如果bucket已经满了(超过了8个元素),那么则会通过链表的方式连接到下一个bmap结构
存储结构我们先说这么多,接下来我们聊一下map结构是如何扩容的?

说到扩容我们先了解一个公式
负载因子 = 键数量/bucket数量
比如说我们当前桶的对数为3 也就是支持有8个bucket,每个bucket能存放8kv(8*8=64)
负载因子= 64/8 = 8

扩容方式1:存量空间不够扩容
当负载因子> 6.5的时候就会对这个map进行扩容
但是又不能直接copy过来对吧,所以扩容也是有策略的
比如说当前的负载因子是6.4 再插入一个新的元素就需要进行扩容了,那么此时的数据结构为
buckets -> buckets(原bucket为空)
        -> buckets1(新申请的bucket也是新数据)
oldbuckets -> buckets(之前有数据的buckets)
这是新数据会插入到新申请的buckets1中,然后将oldbuckets中的数据回写到buckets中(每插入1条新数据,回写两条原数据)

扩容方式2:等量扩容
当如在因子没那么大,但是map由于增加 删除 修改等操作,造成了bucket中的overflow数据比较分散不集中
这时会重新排列一下overflow 增加效率

那么map是怎么查找和删除的呢?
查找过程如下:
根据key值算出哈希值
取哈希值低位与hmap.B取模确定bucket位置
取哈希值高位在tophash数组中查询
如果tophash[i]中存储值也哈希值相等,则去找到该bucket中的key值进行比较
当前bucket没有找到,则继续从下个overflow的bucket中查找。
如果当前处于搬迁过程,则优先从oldbuckets查找

注:如果查找不到,也不会返回空值,而是返回相应类型的0值。

插入过程
通过key计算出hash值
用hash值的低位值与hmap.B取模计算出becket位置
通过hash高8位在tophash中查找,找到更改,找不到则插入