Golang--多种数据结构详解

在Golang中,没有类的概念。故,所有提供给我们使用的结构如切片slice、map等_本质上都是封装好的结构体_。将这些数据结构看作是我们自己在代码中实现的结构体就可以很好理解其各种操作的原理。

slice

结构

源码包src/runtime/slice.go定义了slice:

go 复制代码
type slice struct {
    array unsafe.Pointer  //指向底层数组的指针
    len   int             //切片长度
    cap   int             //底层数组容量
}

// src/unsafe/unsafe.go 
type Pointer *ArbitraryType
type ArbitraryType int

创建

make创建:slice:=make([]int,5,10) //底层数据类型、切片长度、底层数组容量

数组截取创建:slice:=array[5:7:5] // start、end(左闭右开)、切片容量

此时,slice和原数组共用一部分数据。

扩容

使用append对slice添加元素时,若slice空间不足,即slice.len==slice.cap时,会触发slice扩容。

扩容步骤:

  1. 根据扩容规则重新分配一块更大的内存
  2. 将原slice拷贝到新内存作为新slice
  3. 将要添加到元素添加到新slice
  4. 返回新slice

扩容规则

Go1.18对切片的扩容规则进行了修改,之前的规则为:

  1. 若oldcap(原切片容量)<1024,newcap(新切片容量)=oldcap*2。
  2. 若oldcap>=1024,newcap每次增加0.25倍,直到满足需求。

以下为Go1.20的切片扩容规则:

源码:

go 复制代码
newcap := oldCap
doublecap := newcap + newcap

//若原切片容量的两倍无法满足需求容量
//新切片容量即为需求容量
if newLen > doublecap {
    newcap = newLen
} else {
    //若可以满足
    const threshold = 256
    //若oldcap(原切片容量)<256,newcap(新切片容量)=oldcap*2。
    if oldCap < threshold {
        newcap = doublecap
    } else {
        //若oldcap(原切片容量)>=256
        //newcap(新切片容量)每次增加0.25倍并加上192固定容量,直到满足需求。
        for 0 < newcap && newcap < newLen {
            newcap += (newcap + 3*threshold) / 4
        }
        //若循环扩容超过了容量最大值(int最大值),新容量即为需求容量
        if newcap <= 0 {
            newcap = newLen
        }
    }
}

避坑

  1. 注意扩容后产生的新切片
go 复制代码
func main() {
	s1 := []int{}
	s2 := s1              //此时s2与s1指向同一块底层数组
	s2 = append(s2, 1, 2) //向s2添加元素触发扩容,s2为新底层数组,不再与s1使用同一块数组

	fmt.Println("s1.len:", len(s1), "  s1.cap:", cap(s1))
	fmt.Println("s2.len:", len(s2), "  s2.cap:", cap(s2))
}

输出:

chan

<font style="color:rgb(0, 0, 0);background-color:rgba(0, 0, 0, 0.06);">src/runtime/chan.go</font>中定义了chan的数据结构

go 复制代码
type hchan struct {
    qcount   uint           // 当前队列中剩余元素个数
	dataqsiz uint           // 环形队列长度,即可以存放的元素个数
	buf      unsafe.Pointer // 环形队列指针 ()
	elemsize uint16         // 每个元素的大小
	closed   uint32         // 标识关闭状态
	elemtype *_type         // 元素类型
	sendx    uint           // 队列下标,指示元素写入时存放到队列中的位置
	recvx    uint           // 队列下标,指示元素从队列的该位置读出
	recvq    waitq          // 等待读消息的goroutine队列
	sendq    waitq          // 等待写消息的goroutine队列
	lock mutex              // 互斥锁,chan不允许并发读写
                            //一个channel同时仅允许被一个goroutine读写
}

chan内部实现了一个环形队列作为其缓冲区,队列长度在创建chan时指定。

等待队列

从空chan或者无缓冲区的chan读数据当前goroutine会阻塞;向满chan或者无缓冲区的chan写数据当前goroutine会阻塞。

被阻塞的goroutine会被挂在channel的等待队列recvq或sendq中:

  • 读阻塞的goroutine会被向channel写数据的goroutine唤醒。
  • 写阻塞的goroutine会被从channel读数据的goroutine唤醒。

一般情况下,sendq和recvq至少一个为空,只有一个例外,同一个goroutine使用select向channel一边写,一边读。

channel读写实现

创建channel

伪代码:

go 复制代码
func makechan(t *_type,size int) *hchan{
    c:=new(hchan)
    c.buf=malloc(元素类型大小*size)
    c.elemsize=元素类型大小
    c.elemtype=元素类型
    c.dataqsiz=size
    return c
}

写数据

简单流程:

读数据

简单流程:

关闭channel

一般情况下,recvq和sendq至少一个为空。关闭channel时,会把recvq中的G全部唤醒,本该写入G的数据置为nil,即全部读到对应类型的零值。因为既然读阻塞了,说明chan里没有数据,被唤醒的goroutine相当于在读一个被关闭且没有数据的chan,那读到的就是零值。会把sendq中的G全部唤醒,但这些G会panic。同理,相当于向一个关闭的chan写数据。

panic常见场景:

  1. 关闭值为nil的channel
  2. 关闭已经关闭的channel
  3. 向已经关闭的channel写数据
  4. for range读取未关闭的channel时,向此channel写数据的goroutine没有关闭该chan就直接退出,系统监测到该情况会panic,否则range会永久阻塞。

map

哈希表作为底层实现。

<font style="color:rgb(0, 0, 0);background-color:rgba(0, 0, 0, 0.06);">runtime/map.go/hmap</font>

go 复制代码
type hmap struct {
    count     int  //当前保存的元素个数
    flags     uint8
    B         uint8   //桶个数为2^B
    noverflow uint16  //溢出桶数量
    hash0     uint32 

    buckets    unsafe.Pointer //桶数组指针
    oldbuckets unsafe.Pointer //旧桶数组指针
    nevacuate  uintptr         //下一个要搬迁的旧桶编号

    extra *mapextra //记录溢出桶信息
}

哈希算法

通过<font style="color:rgb(0, 0, 0);background-color:rgba(0, 0, 0, 0.06);">hash(key)&(m -1) 其中m为桶的数量</font>确定填入的桶,由于防止出现空桶,m必为2的整数次幂,故m=2^B。

或者说桶的数量为2^B时,<font style="color:rgb(0, 0, 0);background-color:rgba(0, 0, 0, 0.06);">hash(key)&(m -1)</font>也就相当于<font style="color:rgb(0, 0, 0);background-color:rgba(0, 0, 0, 0.06);">hash(key)%m</font>

源码:

go 复制代码
// bucketShift returns 1<<b, optimized for code generation.
func bucketShift(b uint8) uintptr { //返回2^b
	// Masking the shift amount allows overflow checks to be elided.
	return uintptr(1) << (b & (goarch.PtrSize*8 - 1))
}

// bucketMask returns 1<<b - 1, optimized for code generation.
func bucketMask(b uint8) uintptr { //返回2^b
	return bucketShift(b) - 1
}

func mapassign(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer{  
    .....
    hash := t.hasher(key, uintptr(h.hash0)) //通过键和hash0种子得到hash(key)
    bucket := hash & bucketMask(h.B)        //对2^b-1取余得到桶号
    ....
}

桶(bucket)结构

<font style="color:rgb(0, 0, 0);background-color:rgba(0, 0, 0, 0.06);">runtime/map.go/bmap</font>

go 复制代码
// A bucket for a Go map.
type bmap struct {
  tophash [8]uint8 //存储哈希值的高8位
  data byte[1]     //key value数据:key/key/key/.../value/value/value...
  overflow *bmap   //溢出bucket的地址
}

一个桶中存储八个键值对。

tophash[i]存储着其中一个桶中第i个键值对的hash值高8位方便后续匹配。

data为桶中键值对,overflow为溢出桶的指针。

data和overflow不在结构体中显示定义,而是直接通过指针运算访问。

哈希冲突

Go中map采用拉链法解决哈希冲突。

若B>4,即哈希表要分配的桶数目大于24,则会预分配2(B-1)个溢出桶,溢出桶在内存上与常规桶是连续的。

hmap中的extra溢出桶信息字段:

go 复制代码
type mapextra struct {
    overflow    *[]*bmap
    oldoverflow *[]*bmap
    nextOverflow *bmap
}

overflow数组:保存了目前使用的溢出桶的地址

oldoverflow数组:保存了还没迁移的老桶中使用溢出桶的地址

nextOverflow:下一个可用的溢出桶地址

扩容机制

map采用渐进式扩容,即不会一次性完成全部数据搬迁,而是每次访问map都会触发一次搬迁,每次搬迁2个键值对。

其中 oldbuckets 字段会指向原桶列表,而 buckets 指向新申请的桶列表,后续访问map都会从oldbuckets中向buckets迁移,直到全部搬迁完成,删除oldbuckets。

hmap中的nevacuate字段表示了下一个要搬迁的桶

翻倍扩容

负载因子:平均一个桶中的键值对数量。

当负载因子大于6.5,会触发翻倍扩容,新桶数量会为旧桶数量的2倍

等量扩容

在一系列增删操作之后,可能会出现键值对不多,负载因子不高,但键值对分散分布在多个溢出桶中或者集中在一小部分溢出桶中,降低查找效率。

当B<=15时,溢出桶数量>常规桶数量,或当B>15时,溢出桶数量 > 2^15时,会触发等量扩容。

等量扩容分配和原来一样的桶的数量,迁移数据让数据更加紧凑来提高查找效率。

查找过程

  1. key算出hash值
  2. hash与1<<B取余确定桶位置,如果当前处于搬迁状态,优先从旧桶查找
  3. 取hash高8位在tophash数组中查找
  4. 如果与tophash[i]相等,再与桶中对应的 key比较
  5. 当前桶没找到就继续去下一个溢出桶

没找到会返回对应类型的零值

插入过程

  1. key算hash值
  2. hash低位与1<<B取余确定桶位置
  3. 查找key是否存在,存在直接更新
  4. 没找到把key插入

string

概念

Go标准库<font style="color:rgb(0, 0, 0);background-color:rgba(0, 0, 0, 0.06);">builtin</font>给出了所有内置类型的定义。源码位于<font style="color:rgb(0, 0, 0);background-color:rgba(0, 0, 0, 0.06);">src/builtin/builtin.go</font>,关于string的描述如下:

go 复制代码
// string is the set of all strings of 8-bit bytes, conventionally but not
// necessarily representing UTF-8-encoded text. A string may be empty, but
// not nil. Values of string type are immutable.
type string string

string是8比特字节的集合,通常但不一定是UTF8的编码文本。string可以为空字符串(""),但不会是nil。string对象不可修改。

结构

<font style="color:rgb(0, 0, 0);background-color:rgba(0, 0, 0, 0.06);">src/runtime/string.go</font>中定义了string的数据结构:

go 复制代码
type stringstruct struct{
    str unsafe.Pointer   //字符串的首地址
    len int              //字符串大小
}

操作

声明

字符串构建是先根据字符串构建stringStruct,再转换成string。

源码如下:

go 复制代码
func gostringnocopy(str *byte) string {
    ss := stringStruct{str: unsafe.Pointer(str), len: findnull(str)}
    s := *(*string)(unsafe.Pointer(&ss))
    return s
}

string在runtime包就是stringStruct,对外呈现叫做string。

[]byte转string

go 复制代码
var s []byte
s = append(s, 'a', 'b', 'c')
ss := string(s)
fmt.Printf("ss: %T\n", ss)

转化需要一个内存拷贝。

转换过程:

  1. 根据[]byte的len申请内存空间,得到内存地址p
  2. 构建string(string.str=p,string.len=len(字节切片))
  3. \]byte中数据拷贝到新申请的内存空间

string转[]byte

go 复制代码
var s string = "asd"
ss := []byte(s)
fmt.Printf("ss: %T\n", ss)

转换也需要一次内存拷贝:

  1. 申请切片内存空间
  2. 将string拷贝到切片

字符串拼接

<font style="color:rgb(0, 0, 0);background-color:rgba(0, 0, 0, 0.06);">str:="abc"+"111"+"ccc"</font>

一个拼接语句中的多个字符串在编译时会被存放到一个string切片([]string)中,拼接过程遍历两次切片,第一次获取总的字符串长度以分配内存,第二次遍历把字符串逐个拷贝过去。

伪代码:

go 复制代码
func concatstrings(a []string) string {
    size:=0              //拼接后的字符串总长度
    for _,str:=range a{  //第一次遍历,计算总长度
        size+=len(str)    
    }

    s,b:=rawstring(size)  //生成size大小的字符串,返回一个string(s)和[]byte(b)
    //返回的字符串和字节切片共享一块内存空间
    for _,str:=range a{   
        copy(b,str)       //字符串无法修改,只能通过切片修改
        b=b[len(str):]
    }

    return s
}

func rawstring(size int) (s string, b []byte) {
    p := mallocgc(uintptr(size), nil, false)
    return unsafe.String((*byte)(p), size), unsafe.Slice((*byte)(p), size)
}

为何字符串不允许修改

C++中的string,其本身拥有内存空间,修改string是支持的。但Go对string的实现不包含内存空间,而是一个指针,好处在于string变得非常轻量,可以很方便得传递而不需要担心内存拷贝。

Go中的string通常指向字符串字面量,而字符串字面量都保存在只读段,而不是堆栈上,所有才有了string不可修改的约定。

[]byte转成string一定有内存拷贝吗

临时需要字符串的场景中,转换不会拷贝内存,而是直接返回一个string,该string的指针(string.str)指向切片的内存。

如:

go 复制代码
b:=[]byte{'G','o'}

//作为key
m:=map[string]int{
    "Go":1,
    "C++":2
}
n:=m[string(b)]

//字符串拼接
s:="java"+string(b)

//字符串比较
w:=string(b)=="Go"

由于只是临时把byte切片转为string,也就避免了与byte切片同容导致的string引用失败的情况。

iota

iota常用于const表达式中,其值从0开始,const每增加一行iota值自增1。

其实iota的规则就一条:<font style="color:rgb(0, 0, 0);background-color:rgba(0, 0, 0, 0.06);">iota</font>代表了const声明块的行索引(下标0开始)。

struct的Tag

Go的struct允许字段附带<font style="color:rgb(0, 0, 0);background-color:rgba(0, 0, 0, 0.06);">Tag</font>来对字段做一些标记。

<font style="color:rgb(0, 0, 0);background-color:rgba(0, 0, 0, 0.06);">Tag</font>主要用于反射场景,<font style="color:rgb(0, 0, 0);background-color:rgba(0, 0, 0, 0.06);">reflect</font>包中提供了操作Tag的方法。

<font style="color:rgb(0, 0, 0);background-color:rgba(0, 0, 0, 0.06);">reflect</font>包中struct字段的类型声明:

go 复制代码
type StructField struct{
    Name string  //字段的名字
    ...
    Type Type   //字段的类型
    Tag StructTag //字段的Tag
} 

type StructTag string

可以看出,Tag是结构体字段的一个组成部分。

<font style="color:rgb(0, 0, 0);background-color:rgba(0, 0, 0, 0.06);">StructTag</font>提供了<font style="color:rgb(0, 0, 0);background-color:rgba(0, 0, 0, 0.06);">Get(key string) string</font>方法来获取<font style="color:rgb(0, 0, 0);background-color:rgba(0, 0, 0, 0.06);">Tag</font>

Tag主要用于json数据解析,orm映射等,或者也可以自定义一种tag规则来处理自定义的数据。

相关推荐
wearegogog1232 小时前
C# Modbus 协议实现
开发语言·c#
深蓝轨迹2 小时前
SpringBoot YAML配置文件全解析:语法+读取+高级用法
java·spring boot·后端·学习
紫郢剑侠2 小时前
【C语言编程gcc@Kylin | 麒麟 】5:获取系统启动时间
c语言·开发语言·kylin·gcc·麒麟操作系统
颜酱2 小时前
最小生成树(MST)核心原理 + Kruskal & Prim 算法
javascript·后端·算法
深蓝轨迹2 小时前
乐观锁 vs 悲观锁 含面试模板
java·spring boot·笔记·后端·学习·mysql·面试
王老师青少年编程2 小时前
2026年3月GESP真题及题解(C++一级):数字替换
c++·题解·真题·gesp·一级·2026年3月·数字替换
高旭的旭2 小时前
Ubuntu 无显示器远程桌面完美方案
linux·ubuntu·计算机外设
晓晓hh2 小时前
JavaSe学习——基础
java·开发语言·学习
峥嵘life2 小时前
Android16 【CTS】CtsWindowManagerDeviceAnimations存在fail项
android·linux·学习