【Go语言学习】——go 数据结构底层原理

go 数据结构底层原理


array底层原理

go中的数组是由固定长度的特定类型元素组成的序列,数组的长度是数据类型的组成方式,所以不同长度和不同类型的元素组成的数组是不同的数组类型。数组属于值类型,因此在复制或者传递参数时,会对整个数组内容进行复制,所以在调用的函数中修改数组的值不会影响到原来的数组的值。此外由于需要复制整个数组的内容,如果数组太大会导致复制成本太大, 所以可以传递数组的指针。

Slice底层原理

简单的将,切片是一种简化版的动态数组。切片的在go中的定义为如下,在对切片赋值,就是修改指向数组的指针,len,cap的值。而在拷贝的时候,如果直接使用=,则会复制被拷贝的切片的数组指针,cap,len值,因此会指向同一个地址,而使用copy的话会把被拷贝的切片中的数组的值复制到拷贝的切片的数组中。即地址是不同的。

type SliceHeader struct{
    Data uintptr
    Len int
    Cap int
}

Data可以简单看做指向了底层数组(其实其中Data保存了底层数组的指针的值,可以进行指针计算从而得到其他值,然后转变为Pointer,再转变为对应类型的指针进行访问。(uintptr不等于指针,只存储了底层数组的值,参考uintprt和pointer的区别))。len代表可以访问的长度,而Cap则是底层数组的容量(由可访问的第一个元素到底层数组末尾的长度),显然cap>=len,所以如果使用划分的方式的得到新切片时,如果从前面阶段,会导致cap减少,而从后面cap不会变.

a:=[]int{1,2,3,4,5} //len=5,cap=5
b:=a[1:3]//{2,3},len=2,cap=4
c:=a[:4]//{1,2,3,4},len=4,cap=5

Slice属于引用类型,简单来讲就是在调用函数中修改了slice中的值会影响到原来的切片中的值。因为golang是一个值传递的语言,在函数调用时候传递的参数时拷贝的副本。由上述复制原理可知,这里使用的是浅拷贝,及会把切片的值(指针,cap,len)复制一份,以此可以通过这个指针直接改变原切片的值,

参考资料1

参考资料2

但是如果在函数内部使用append扩容,首先如果cap足够,只需修改len的值,则此时函数里和函数外切片的指针值相同,但是外面len没有变,所以原来的切片并没有被扩容。而如果cap不够,进行扩容后会生成一个新的data数组,用于存储新的数据,这个时候,数组指针值,len,cap都不一样。所以对内部的修改不会影响到外面了。

map底层原理

参考资料

go中的map提供键值对形式的存储,属于引用类型,参数传递时其内部的指针被复制,指向同一个地址。所以函数内部的修改会影响到原来的map。map底层是散列表,通过拉链法(数组+链表)解决碰撞,在map扩容后不会立即替换原来的内存,只会慢慢通过GC释放

  • Golang的map本质上是一个指针,占用8个字节,指向了一个hmap结构体。
  • hmap中有一个字段是buckets,buckets是一个数组指针,指向由若干个bmap(bucket)组成的数组,数组的大小为2^B(B是桶的对数)。
  • bmap由四个字段组成:tophash、keys、values和overflow。每一个bmap最多可以存储8个元素(key、value对)。
  • 数据的存储机制:key经过哈希运算得到哈希值,哈希值的低B位决定了这个key进入哪一个bmap,哈希值的高8位决定key落入bmap的哪一个位置。当一个bmap存满之后,会创建一个新的bmap并通过链表连接

go map的底层结构:

// Map contains Type fields specific to maps.
type Map struct {
    Key  *Type // Key type
    Elem *Type // Val (elem) type

    Bucket *Type // internal struct type representing a hash bucket
    Hmap   *Type // internal struct type representing the Hmap (map header object)
    Hiter  *Type // internal struct type representing hash iterator state
}

前两个字段分别为 key value, 由于 go map 支持多种数据类型, go 会在编译期推断其具体的数据类型, Bucket 是哈希桶, Hmap 表征了 map 底层使用的 HashTable 的元信息, 如当前 HashTable 中含有的元素数据、桶指针等, Hiter 是用于遍历 go map 的数据结构

Hmap低层结构如下:

type hmap struct {
    count     int    // 元素的个数,当使用len()返回的就是这个值
    flags     uint8  // 状态标志,遍历/写入
    B         uint8  // 可以最多容纳2 ^ B 个元素
    noverflow uint16 // 溢出的个数
    hash0     uint32 // 哈希种子,它能为哈希函数的结果引入随机性降低冲突率,这个值在创建哈希表时确定,并在调用哈希函数时作为参数传入
 
    buckets    unsafe.Pointer // 当前桶的地址
    oldbuckets unsafe.Pointer // 旧桶的地址,用于扩容,哈希在扩容时用于保存之前 buckets 的字段,它的大小是当前 buckets 的一半
    nevacuate  uintptr        // 搬迁进度,小于nevacuate的已经搬迁
    overflow *[2]*[]*bmap 
    
     extra *mapextra // optional fields 表示溢出桶的变量,当key,value不为指针时有效,通过指向溢出桶的指针防止溢出桶被被gc
}

bmap的底层结构如下:

type bmap struct {
    topbits  [8]uint8 //键哈希值的高八位
    keys     [8]keytype//哈希桶中所有的建,最多8个
    elems    [8]elemtype//哈希桶里所有的值,最多8个
    //pad      uintptr(新的 go 版本已经移除了该字段, 之前设置该字段是为了在 nacl/amd64p32 上的内存对齐)
    overflow uintptr//存放了所指向的溢出桶的地址,当元素超过8个就会将新的元素放到溢出桶中, 并使用 overflow 指针链向这个溢出桶, 
}

mapextra的底层结构如下:

type mapextra struct {
    // If both key and elem do not contain pointers and are inline, then we mark bucket
    // type as containing no pointers. This avoids scanning such maps.
    // However, bmap.overflow is a pointer. In order to keep overflow buckets
    // alive, we store pointers to all overflow buckets in hmap.extra.overflow and hmap.extra.oldoverflow.
    // overflow and oldoverflow are only used if key and elem do not contain pointers.
    // overflow contains overflow buckets for hmap.buckets.
    // oldoverflow contains overflow buckets for hmap.oldbuckets.
    // The indirection allows to store a pointer to the slice in hiter.
    overflow    *[]*bmap
    oldoverflow *[]*bmap

    // nextOverflow holds a pointer to a free overflow bucket.
    nextOverflow *bmap
}

当一个 map 的 key 和 elem 都不含指针并且他们的长度都没有超过 128 时(当 key 或 value 的长度超过 128 时, go 在 map 中会使用指针存储), 该 map 的 bucket 类型会被标注为不含有指针, 这样 gc 不会扫描该 map, 这会导致一个问题, bucket 的底层结构 bmap 中含有一个指向溢出桶的指针(uintptr类型, uintptr指针指向的内存不保证不会被 gc free 掉), 当 gc 不扫描该结构时, 该指针指向的内存会被 gc free 掉, 因此在 hmap 结构中增加了 mapextra 字段, 其中 overflow 是一个指向保存了所有 hmap.buckets 的溢出桶地址的 slice 的指针, 相对应的 oldoverflow 是指向保存了所有 hmap.oldbuckets 的溢出桶地址的 slice 的指针, 只有当 map 的 key 和 elem 都不含指针时这两个字段才有效, 因为这两个字段设置的目的就是避免当 map 被 gc 跳过扫描带来的引用内存被 free 的问题, 当 map 的 key 和 elem 含有指针时, gc 会扫描 map, 从而也会获知 bmap 中指针指向的内存是被引用的, 因此不会释放对应的内存。

hmap 结构相当于 go map 的头, 它存储了哈希桶的内存地址, 哈希桶之间在内存中紧密连续存储, 彼此之间没有额外的 gap, 每个哈希桶最多存放 8 个 k/v 对, 冲突次数超过 8 时会存放到溢出桶中, 哈希桶可以跟随多个溢出桶, 呈现一种链式结构, 当 HashTable 的装载因子超过阈值(6.5) 后会触发哈希的扩容, 避免效率下降

  • 增删查改

哈希函数是哈希表的特点之一,通过 key 值计算哈希,快速映射到数据的地址. golang 的 map 进行哈希计算后,将结果分为高位值和低位值,其中低位值用于定位 buckets 数组中的具体 bucket,而高位值用于定位这个 bucket 链表中具体的 key .

  • 扩容

当插入的元素越来越多,导致哈希桶慢慢填满,导致溢出桶越来越多,所以发生哈希碰撞的频率越来越高,就需要进行扩容,

若装载因子过大, 说明此时 map 中元素数目过多, 此时 go map 的扩容策略为将 hmap 中的 B 增一, 即将整个哈希桶数目扩充为原来的两倍大小, 而当因为溢出桶数目过多导致扩容时, 因此时装载因子并没有超过 6.5, 这意味着 map 中的元素数目并不是很多, 因此这时的扩容策略是等量扩容, 即新建完全等量的哈希桶, 然后将原哈希桶的所有元素搬迁到新的哈希桶中。

  • key的选择

    slice,map,包含了slice的function和struct不能够作为Map的Key,而数字,string,bool,数组,channel,指针都可以作为key。这是因为map的key必须是可以比较的(可以使用==运算符的称为可比较),而slice和map只能与nil比较。参考资料

channel底层原理

参考资料1

参考资料2

channel是golang中用来实现多个goroutine通信的管道,它的底层是一个叫做hchan的结构体。在go的runtime包下。

type hchan struct {
  //channel分为无缓冲和有缓冲两种。
  //对于有缓冲的channel存储数据,借助的是如下循环数组的结构
	qcount   uint           // 循环数组中的元素数量
	dataqsiz uint           // 循环数组的长度
	buf      unsafe.Pointer // 指向底层循环数组的指针
	elemsize uint16 //能够收发元素的大小
  

	closed   uint32   //channel是否关闭的标志
	elemtype *_type //channel中的元素类型
  
  //有缓冲channel内的缓冲数组会被作为一个“环型”来使用。
  //当下标超过数组容量后会回到第一个位置,所以需要有两个字段记录当前读和写的下标位置
	sendx    uint   // 下一次发送数据的下标位置
	recvx    uint   // 下一次读取数据的下标位置
  
  //当循环数组中没有数据时,收到了接收请求,那么接收数据的变量地址将会写入读等待队列
  //当循环数组中数据已满时,收到了发送请求,那么发送数据的变量地址将写入写等待队列
	recvq    waitq  // 读等待队列
	sendq    waitq  // 写等待队列


	lock mutex //互斥锁,保证读写channel时不存在并发竞争问题
}

Go 语言中,不要通过共享内存来通信,而要通过通信来实现内存共享。Go 的CSP(Communicating Sequential Process)并发模型,中文可以叫做通信顺序进程,是通过 goroutine 和 channel 来实现的。 加入ch时长度为4带缓冲的channel,G1是发送者,G2是接收者:

初始hchan结构体重的buf为空,sendx和recvx均为0。

当G1向ch里发送数据时,首先会对buf加锁,然后将数据copy到buf中,然后sendx++,然后释放对buf的锁。

当G2消费ch的时候,会首先对buf加锁,然后将buf中的数据copy到task变量对应的内存里,然后recvx++,并释放锁。

可以发现整个过程,G1和G2没有共享的内存,底层是通过hchan结构体的buf,并使用copy内存的方式进行通信,最后达到了共享内存的目的,这里也体现了Go中的CSP并发模型。

  • 无缓冲channel读写

先读后写:由于channel是无缓冲的,G1(读goroutine)会被挂在recvq队列,然后休眠。当G2(写goroutine)写入数据时,发现recvq队列中的G1,就会将数据传给G1,并设置G1 goready函数,等待下次调度运行,同时会将G1从等待队列中移出。

先写后读:由于channel是无缓冲的,因此G1(写goroutine)会被挂在sendq队列,然后休眠。当G2(读goroutine)来读数据时,发现sendq队列中的G1,将G1的数据取出来,并对G1设置goready函数,这样下次再发生调度时,G1就可以正常运行,并且会从等待队列中移除。

  • 有缓冲channel

先读再写:先判断缓冲区是否有数据,如果有数据则从缓冲区取数据,取完数据之后如果sendq队列中有数据则会按序将sendq队列中的数据放入缓冲区尾部。如果没有数据则将G1(读goroutine)保存在recevq队列,并且休眠。当G2(写goroutine)写数据时,为了提高效率,runtime并不会对hchan结构体题的buf进行加锁,而是直接将G2里的发送到ch的数据copy到了G1sudog里对应的elem指向的内存地址!【不通过buf】

先写再读:先判断缓冲区是否已满,如果未满则将G1(写goroutine)保存在缓冲区,如果已满则将G1挂在sendq队列,并且休眠。当G2(读goroutine)读数据时,优先去缓冲区取数据,如果缓冲区没有数据则挂在recevq队列,并且休眠。当G2取完数据之后如果sendq队列中有数据则会按序将sendq队列中的数据放入缓冲区尾部。

为什么在第一种情况下,即G1向缓存满的channel中发送数据时被阻塞。在G2后来接收时,不将阻塞的G1发送的数据直接拷贝到G2中呢?

这是因为channel中的数据是队列的,遵循先进先出的原则,当有消费者G2接收数据时,需要先接收缓存中的数据,即buf中的数据,而不是直接消费阻塞的G1中的数据。

  • Channel为什么是线程安全的?

    在对buf中的数据进行入队和出队操作时,为当前chnnel使用了互斥锁,防止多个线程并发修改数据

  • buffer实现

channel中使用了 ring buffer(环形缓冲区) 来缓存写入的数据。ring buffer 有很多好处,而且非常适合用来实现 FIFO 式的固定长度队列。

  • 3
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值