Go 基础数据结构的底层原理(slice,channel,map)

简介: Go 基础数据结构的底层原理(slice,channel,map)

一:slice

Slice又称动态数组, 依托数组实现, 可以方便的进行扩容、 传递等, 实际使用中比数组更灵活。

底层数据结构:

type slice struct {
    array unsafe.Pointer
    len int
    cap int
}

slice的创建

创建切片的过程就是初始化该结构的过程。创建切片的方式有3种:

sliceOne := make(\[\]int, 0, 10)

通过Make创建,可指定创建的切片的长度和容量。如果不指定容量,那么容量就等于长度。

sliceTwo := sliceOne\[2:4\]

这种方式是基于其他切片或数据创建容量,长度为创建切片是指定的结束-起始位置, = 4-2=2;容量就等于切片的容量-起始位置=10-2=8.且两个切片共享同一个数据区;

sliceThree := \[\]int{1,2}

这种方式直接字面量创建,长度和容量都等于其元素个数。

slice的追加

使用append向Slice追加元素时, 如果Slice空间不足, 将会触发Slice扩容, 扩容实际上重新一配一块更大的内存, 将原Slice数据拷贝进新Slice, 然后返回新Slice, 扩容后再将数据追加进去。

扩容容量的选择遵循以下规则:

如果原Slice容量小于1024, 则新Slice容量将扩大为原来的2倍;

如果原Slice容量大于等于1024, 则新Slice容量将扩大为原来的1.25倍;

slice的拷贝

使用copy()内置函数拷贝两个切片,但是需要注意的是,copy 会将源切片的数据逐个拷贝到目的切片指向的数组中, 拷贝数量取两个切片长度的最小值。copy不会扩容,只有append才会扩容。

基于以上切片特性。编程过程需要注意:

1.创建切片时可跟据实际需要预分配容量, 尽量避免追加过程中扩容操作, 有利于提升性能;

2.切片拷贝时需要判断实际拷贝的元素个数

3.谨慎使用多个切片操作同一个数组, 以防读写冲突

二:channel

channel是go语言协程间通信的管道。channel可用于协程同步,也可以协程间可以传递各种消息数据。

底层数据结构

type hchan struct {
    qcount uint
    dataqsiz uint
    buf unsafe.Pointer
    elemsize uint16
    closed uint32
    elemtype *_type
    sendx uint
    recvx uint
    recvq waitq
    sendq waitq
    lock mutex
}

channel底层数据结构有一个环形队列指针   buf

以及环形队列存储的数据类型和数据结构大小。  elemsize,elemtype

,还有标识环形队列指针读写位置的标记。sendx,recvx

等待读写的两个goroutine队列。 recvq,sendq

还有互斥锁。 lock

其中环形队列是用来存储传递的消息

两个gotoutine队列是用来存储和唤醒被该channel阻塞的队列。

数据类型和数据大小是用来读写消息时用于计算便宜量。

channel创建

channel初始化的过程时用make初始化,初始化的过程也就是初始化channel底层数据结构的过程;

ch := make(chan int, 1)

向channel写数据的过程

1. 如果等待接收队列recvq不为空, 说明缓冲区中没有数据或者没有缓冲区, 此时直接从recvq取出G,并把数据写入, 最后把该G唤醒, 结束发送过程;

2. 如果接受队列recvq为空,且缓冲区中有空余位置, 将数据写入缓冲区, 结束发送过程;

3. 如果接受队列recvq为空,缓冲区中没有空余位置, 将待发送数据写入G, 将当前G加入sendq, 进入睡眠, 等待被读goroutine唤醒;

从一个channel读数据简单过程

1. 如果等待发送队列sendq不为空, 且没有缓冲区, 直接从sendq中取出G, 把G中数据读出, 最后把G唤醒, 结束读取过程;

2. 如果等待发送队列sendq不为空, 此时说明缓冲区已满, 从缓冲区中首部读出数据, 把G中数据写入缓冲区尾部, 把G唤醒, 结束读取过程;

3. 如果缓冲区中有数据, 则从缓冲区取出数据, 结束读取过程;

4. 将当前goroutine加入recvq, 进入睡眠, 等待被写goroutine唤醒;

关闭channel

关闭channel时会把recvq中的G全部唤醒, 返回Nil。 把sendq中的G全部唤醒, 让这些G panic。

channel导致panic的场景

1. 关闭值为nil的channel

2. 关闭已经被关闭的channel

3. 向已经关闭的channel写数据

常见用法

1.函数传参限制为单向channel

2.select

3.range

三:map

map底层使用哈希表来实现的,哈希过程产生冲突使用的冲突解决办法是链地址法。

除此之外常见的冲突解决办法还有:开放寻址法,链地址法,再次哈希法,创建一个公共溢出区等几种方法。

python中的字典底层依靠哈希表(hash table)实现, 使用开放寻址法解决冲突,

java和go都采用链地址法来解决哈希冲突。

底层结构

type hmap struct {
    count int // 当前保存的元素个数
    ...
    B uint8 // 指示bucket数组的大小
    ...
    buckets unsafe.Pointer // bucket数组指针, 数组的大小为2^B
    ...
}

go的哈希表实现底层数据结构中有一个count标识当前元素个数,一个B成员标识桶的个数,还有一个buckes是一个指针指向桶数组。

而桶的底层数据结构:

type bmap struct {
    tophash [8]uint8 //存储哈希值的高8位
    data byte[1] //key value数据:key/key/key/.../value/value/value...
    overflow *bmap //溢出bucket的地址
}

tophash是个数组用来存放键的哈希值的高8位,,一个data成员用来存放value,还有一个Overflow成员用来指向溢出的桶的地址。

map的创建

1.make方式创建

hash := make(map[string]int, 10)

10指定按10个count创建hash,此时的B成员标识桶的个数,会根据10个count来划分桶的个数。

如果不预先指定count,那么按照0个来划分桶的个数,后续往map写数据会自动进行扩容。

2.字面量方式创建

hash := map[string]int{ "1": 2, "3": 4, "5": 6,}

count个数就是字面量的个数。

map解决冲突的方法

使用链地址法:当多个键被哈希到了同一个bucket时,也就是产生了哈希冲突。由于每个bucket可以存放8个键值对, 所以同一个bucket存放超过8个键值对时就会创建一个桶, 用链表的方式将bucket关联起来。

map的扩容

因为不能放任它无休止的冲突下去,无休止冲突的话会影响读写性能,于是引入了负载因子的概念,计算方式为:

负载因子=键数量/桶数量,当负载因子达到指定的值就会进行扩容操作。

go语言中的哈希表触发扩容的条件有两个:

1. 负载因子 > 6.5时, 也即平均每个bucket存储的键值对达到6.5个

2. overflow数量 > 2^15时, 也即overflow数量超过32768时

第一种情况负载因子过大,使用增量扩容。

当负载因子过大时, 就新建一个bucket, 新的bucket长度是原来的2倍, 然后旧bucket数据搬迁到新的bucket。

考虑到如果map存储了数以亿计的key-value, 一次性搬迁将会造成比较大的延时, Go采用逐步搬迁策略, 即每次访问map时都会触发一次搬迁, 每次搬迁2个键值对。

第二种overflow数量过多,使用等量扩容。

所谓等量扩容, 实际上并不是扩大容量, buckets数量不变, 重新做一遍类似增量扩容的搬迁动作, 把松散的键值对重新排列一次, 以使bucket的使用率更高, 进而保证更快的存取。 在极端场景下, 比如不断的增删, 而键值对正好集中在一小部分的bucket, 这样会造成overflow的bucket数量增多, 但负载因子又不高, 从而无法执行增量搬迁的情况。

map查找过程

1. 跟据key值算出哈希值

2. 取哈希值低位与hmpa.B取模确定bucket位置

3. 取哈希值高位在tophash数组中查询

4. 如果tophash[i]中存储值也哈希值相等, 则去找到该bucket中的key值进行比较

5. 当前bucket没有找到, 则继续从下个overflow的bucket中查找。

6. 如果当前处于搬迁过程, 则优先从oldbuckets查找

map插入过程

1. 跟据key值算出哈希值

2. 取哈希值低位与hmap.B取模确定bucket位置

3. 查找该key是否已经存在, 如果存在则直接更新值

4. 如果没找到将key, 将key插入

map拓展知识(重要)

go语言的map数据结构并不是并发安全的。想要并发安全的使用map结构。

通常由几种方式:

1.为map加读写锁,

2.使用concurrent-map(开源库)

3.使用sync.map

三者的区别在于第一种是为整个map加锁,加锁粒度较大。影响性能。

第二个使用开源库concurrent-map,原理是对map分段加锁。加锁粒度相对减少,性能相对第一个有所提高。

第三种是go1.9引入的官方库,

使用了空间换时间策略,通过冗余的两个数据结构(read、dirty),实现加锁对性能的影响。 通过引入两个map将读写分离到不同的map,其中read map提供并发读和已存元素原子写,而dirty map则负责读写。 这样read map就可以在不加锁的情况下进行并发读取,当read map中没有读取到值时,再加锁进行后续读取,并累加未命中数,当未命中数大于等于dirty map长度,将dirty map上升为read map。

所以具体使用哪种并发安全的map,根据实际情况而定。

如并发不是很高,为了避免并发冲突可简单使用map+rwmature.

如读操作原因大于写操作,且写操作大都是插入Map,那用sync.map更好。

如果是需要维护一段内存映射表,那么使用分段map性能更高。

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

热门文章

最新文章