Go数据结构的底层原理(图文详解)

空结构体的底层原理

基本类型的字节数

fmt.Println(unsafe.Sizeof(0))  // 8
fmt.Println(unsafe.Sizeof(uint(0)))  // 8
a := 0
b := &a  
fmt.Println(unsafe.Sizeof(b))  // 8
  • int大小跟随系统字长
  • 指针的大小也是系统字长

空结构体

a := struct {  
}{}  
b := struct {  
}{}  
fmt.Println(unsafe.Sizeof(a))  // 0
fmt.Printf("a=%p \n", &a)  //0x10e1438
fmt.Printf("b=%p \n", &b)  //0x10e1438

空结构体指向的地址是zerobase,位置:runtime/malloc.go

// base address for all 0-byte allocations  
var zerobase uintptr

空结构体的地址均相同(不被包含在其它结构体中时)

空结构体主要是为了节约内存,不占用空间

  • 结合map,可以实现hashset(只要key,不要value)
  • 结合channel,可以当纯信号

字符串,数组,切片的底层原理

字符串底层原理

fmt.Println(unsafe.Sizeof("hello RdrB1te"))  //16
fmt.Println(unsafe.Sizeof("RdrB1te"))  //16

上面两行字符串的长度都为16个字节,这是为什么?大胆猜测这一个字符串是不是包含了两个指针

![[string的底层结构路径.png]]

string的底层结构:

type stringStruct struct {  
   str unsafe.Pointer  
   len int  
}
  • 字符串的本质是个结构体
  • Data指针指向底层Byte数组

Len表示Byte数组的长度?字符个数?

由于上面的stringStruct不允许外面的包使用,我们通过反射包类似的StringHeader来查看Len变量的大小

![[StringHeader的路径.png]]

StringHeader的底层结构

type StringHeader struct {  
   Data uintptr  
   Len  int  
}

打印出字符串中Len的值:

s := "武汉"  
sh := (*reflect.StringHeader)(unsafe.Pointer(&s))  
fmt.Println(sh.Len) // 6

字符编码问题

  • 所有的字符均使用Unicode字符集
  • 使用UTF-8编码

Unicode

  • 一种统一的字符集
  • 囊括了159种文字的144679个字符
  • 14万个字符至少需要3个字节表示
  • 英文字母均排在前128个

UTF-8

  • Unicode的一种变长格式
  • 128个US-ASCII字符只需要一个字节编码
  • 西方常用字符需要两个字节
  • 其他字符需要3个字节,极少需要4个字节

按照UTF-8编码,下面的字符串的Len值应为10

s := "武汉haha"  
sh := (*reflect.StringHeader)(unsafe.Pointer(&s))  
fmt.Println(sh.Len) // 10

结论:Len表示Byte数组的长度(字节数)

![[字符串的底层结构图示.png]]

字符串的访问

  • 对字符串使用len方法得到的是字节数不是字符数
  • 对字符串直接使用下标访问,得到的是字节
  • 字符串被range遍历时,被解码成rune类型的字符
  • UTF-8编码解码算法位于runtime/utf8.go

字符串的切分

先转为rune数组,切片,再转为string

s := "武汉武汉"  
s = string([]rune(s)[:2])  
fmt.Println(s) // 武汉

切片底层原理

slice的底层结构是不是也是一个结构体?猜对了

slice的底层结构(位于runtime/slice.go)

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

切片的本质是对数组的引用
![[切片的底层结构图示.png]]

切片的创建

根据数组或切片创建

arr := [4]int{1, 2, 3, 4}  
sli1 := arr[0:4]  
sli2 := sli1[0:1]  
fmt.Println(sli1) // [1 2 3 4]  
fmt.Println(sli2) // [1]

字面量:编译时插入创建数组的代码

sli1 := []int{1, 2, 3}  
fmt.Println(sli1) // [1 2 3]

make:运行时创建

sli1 := make([]int, 2)  
fmt.Println(sli1) // [0 0]

那切片的创建是在编译时完成的,还是运行时完成的呢?这该如何查看

使用go build -gcflags -S main.go查看

找到字面量创建切片的行数进行查看,发现是在编译时先创建了一个数组,再基于数组创建了切片:
![[字面量方式创建切片的底层过程.png]]

同理,使用make创建切片的底层逻辑也可以通过上面的命令进行查看:

![[make方式创建切片的底层过程.png]]

不同于字面量的创建过程,使用make创建时直接调用了makeslice方法,这个方法是个运行时的方法,直接传入参数,返回新建切片的指针:

func makeslice(et *_type, len, cap int) unsafe.Pointer {
......
}

再通过下面的示例回顾下切片创建的原理:
![[切片创建的原理图示回顾.png]]

arr := [10]int{0,1,2,3,4,5,6,7,8,9}
slice := arr[1:4]

切片的访问

  • 下标直接访问元素
  • range遍历元素
  • len(slice)查看切片长度
  • cap(slice)查看数组容量

切片的追加

  • 不扩容时,只调整len(编译器负责)

  • 扩容时,编译时转为调用runtime.growslice()

  • 如果期望容量大于当前容量的两倍就会使用期望容量

  • 如果当前切片的长度小于1024,将容量翻倍

  • 如果当前切片的长度大于1024,每次增加25%

  • 切片扩容时,并发不安全,注意切片并发要加锁

上面扩容的逻辑可通过runtime.growslice()方法进行查看:

newcap := old.cap  
doublecap := newcap + newcap  
if cap > doublecap {  
   newcap = cap  
} else {  
   const threshold = 256  
   if old.cap < threshold {  
      newcap = doublecap  
   } else {  
      // Check 0 < newcap to detect overflow  
      // and prevent an infinite loop.      
      for 0 < newcap && newcap < cap {  
         // Transition from growing 2x for small slices  
         // to growing 1.25x for large slices. This formula         // gives a smooth-ish transition between the two.         
         newcap += (newcap + 3*threshold) / 4  
      }  
      // Set newcap to the requested cap when  
      // the newcap calculation overflowed.      
      if newcap <= 0 {  
         newcap = cap  
      }  
   }  
}

map的底层原理

golang语言的map底层本质是用hashmap进行实现的

hashmap的基本方案

开放寻址法

![[开放寻址法原理图示.png]]

底层实际是一个数组,数组元素是一个个的键值对。现在假设要插入一个新的键值对,key是字母b,value是字母B,先把key先hash,Hash后与下面的数组长度6求模为1,按理应放到下标为1的位置,但是被占了,就往后找,直到有空为止。

如果要读取key是字母b的值也是一样的,先hash再取模,如果下标为1的位置不是字母b,说明字母b往后放了,就继续往后找,找到为止。

拉链法

![[拉链法原理图示.png]]

前面的步骤一样,Hash后与下面的数组长度6求模为1,但是这里槽为1(下标为1)存放的并不是实际的键值对,而是一个指针,后面挂了一个相当于链表的东西,哈希碰撞或哈希值一样的情况下,会挂在1号槽的链表后面,假如1号槽的链表中已经有了一个键值对,则b:B会追加到后面。这样就不像开放寻址法横向地往后找,而是向拉链一样,纵向地向下拉一个链表,挂在后面。

如果要读取key是字母b的值也是一样的,hash求模后遍历链表,就能找到想要的数据。

Go map底层结构

runtime/map.go文件中,有个hmap的结构体,这就是map的底层结构:

type hmap struct {  
   // Note: the format of the hmap is also encoded in cmd/compile/internal/reflectdata/reflect.go.   // Make sure this stays in sync with the compiler's definition.   
   count     int // 存的键值对的数量
   flags     uint8  
   B         uint8  // lg2桶的大小
   noverflow uint16 // approximate number of overflow buckets; see incrnoverflow for details   
   hash0     uint32 // hash的种子
   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  
}

里面有个参数是buckets,意思是桶,这与上面讲到的拉链法中桶的概念名称一致,基本可以断定Go的map底层是用类似拉链法实现的。

那桶的数据结构是什么,继续往下看,buckets的指针指向的是一个由很多个bmap组成的数组,bmap由tophash、keys、elems、overflow四个参数组成,后三个参数是编译的时候才会生成(好支持不同的数据类型),tophash存了8个key的前一个字节的hash值,overflow是溢出指针,指向下一个bmap。

// A bucket for a Go map.
type bmap struct {  
   // tophash generally contains the top byte of the hash value  
   // for each key in this bucket. If tophash[0] < minTopHash,   // tophash[0] is a bucket evacuation state instead.   
   tophash [bucketCnt]uint8  // 存了8个key的前一个字节的hash值
   // Followed by bucketCnt keys and then bucketCnt elems.  
   // NOTE: packing all the keys together and then all the elems together makes the   // code a bit more complicated than alternating key/elem/key/elem/... but it allows   // us to eliminate padding which would be needed for, e.g., map[int64]int8.   // Followed by an overflow pointer.}

map的底层结构图示如下:
![[go的map的底层结构图示.png]]

map的初始化

make的方式初始化
m := make(map[string]int, 9)  
fmt.Println(m)

make初始化的过程一样可以用go build -gcflags -S main.go查看:

![[make方式初始化map的编译过程.png]]

进入makemap方法:

func makemap(t *maptype, hint int, h *hmap) *hmap {  
  ......
}

make map初始化解析,首先根据map元素的数量计算出B,根据B的值创建桶,还有溢出桶;还会创建mapextra的结构体,这个结构体有个参数nextOverflow,它会指向下一个可用的溢出桶。
![[makemap方法解析图示.png]]

字面量方式的初始化
m := map[string]string{"a": "A", "b": "B"}  
fmt.Println(m)
  • 元素少于25个时,转化为简单赋值
  • 元素多余25个时,转化为循环赋值
map的访问

如何读取a这个key的value:

1.先计算桶号:
![[map的访问-计算桶号图示.png]]

2.确认了它在2号桶里面:
![[map的访问-确认桶的位置.png]]

3.计算tophash,看在2号桶的哪个位置:
![[map的访问-计算桶中所在的位置图示.png]]

4.发现2号桶第一个位置的tophash就是0x5c,进一步匹配key值,获取最终的value;如果key的值没有匹配上,就会进一步往后匹配,如果都没找到,说明map中不存在这个key。
![[map的访问-确认最终的值.png]]

写入的过程,与访问的过程类似,这里不再赘述。

总结

  • Go语言使用拉链实现了hashmap
  • 每一个桶中存储键哈希的前8位
  • 桶超出8个数据,就会存储到溢出桶中

Go map扩容原理

map为什么需要扩容

哈希碰撞的频率越高,会导致溢出桶越多,链表也越来越长,哈希操作的效率就会越来越低,性能严重下降:
![[map扩容的原因图示.png]]

Go map插入数据调用的mapassign方法有相关的扩容逻辑:

// Like mapaccess, but allocates a slot for the key if it is not present in the map.  
func mapassign(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer {  
   ......
   if !h.growing() && (overLoadFactor(h.count+1, h.B) || tooManyOverflowBuckets(h.noverflow, h.B)) {  
      hashGrow(t, h)  
      goto again // Growing the table invalidates everything, so try again  
  ......
}

runtime.mapassign()可能会触发扩容的情况:

  • 装载因子超过6.5(平均每个槽6.5个key)
  • 使用了太多溢出桶(溢出桶超过了普通桶)

map扩容的类型

  • 等量扩容:数据不多但是溢出桶太多了(之前数据很多,都被删了,需要整理)
  • 翻倍扩容:数据太多了

map扩容步骤

map扩容:步骤1

可见hashGrow这个方法:
![[map扩容步骤1.png]]

  • 创建一组新桶
  • oldbuckets指向原有的桶数组
  • buckets指向新的桶数组
  • map标记为扩容状态

map扩容:步骤2
写入时,先找到原来的key的位置:
![[map扩容步骤2-1.png]]

扩容后,假如变成了8个桶,这时B变成了3,就要看哈希的后三位,如果前面一位是0,说明要去2号桶,如果前面一位是1,说明要去6号桶,就将原来的旧桶里的数据往新桶进行了一分为2的迁移。如果新桶的数量没有变,那相当于就是对旧桶做了一个整理。
![[map扩容步骤2-2.png]]

具体的扩容逻辑代码,可见runtime.map文件中的evacuate这个方法。

  • 将所有的数据从旧桶驱逐到新桶
  • 采用渐进式驱逐
  • 每次操作一个旧桶时,将旧桶数据驱逐到新桶
  • 读取时不进行驱逐,只判断读取新桶还是旧桶

map扩容:步骤3

![[map扩容步骤3.png]]

  • 所有的旧桶驱逐完成后
  • oldbuckets回收

总结

  • 装载系数或者溢出桶的增加,会触发map扩容
  • 扩容可能并不是增加桶数,而是整理
  • map扩容采用渐进式,桶被操作时才会重新分配

Go map并发问题

当map同时进行读写操作时,会弹出fatal error: concurrent map read and map write的报错,可用下面一段代码实验:

func main() {  
   testM := make(map[int]int)  
   go func() {  
      for {  
         _ = testM[0]  
      }  
  
   }()  
   go func() {  
      for {  
         testM[1] = 2  
      }  
   }()  
   select {}  
}

map为什么不支持并发读写

A正在旧桶读取数据时,B这时写入,要对这个旧桶进行驱逐:
![[map的并发问题缘由.png]]

  • map的读写有并发问题
  • A协程在桶中读数据时,B协程驱逐了这个桶
  • A协程会读到错误的数据或者找不到数据

map并发问题解决方案

  • 给map加锁(mutex)
  • 使用sync.Map

sync.map的原理

sync.map的查询、修改、新增

sync.map的底层结构
可见sync.map结构体:

type Map struct {  
   mu Mutex   
   read atomic.Value 
   dirty map[any]*entry     
   misses int  
}

![[sync.map的底层结构图示.png]]

相当于一套value有两套一模一样的key

sync.map正常读写的过程
读出a这个key的值"A":
![[sync.map正常读写过程.png]]

sync.map追加的过程
先去read map找,发现没有,然后上锁,去下面的dirty map中(同时只能有一个协程去操作dirty map):
![[sync.map追加的过程.png]]

假如我要追加一个d:D,追加后,这时read中的amended变为了true,意思是提醒使用者这时read map已经不完整了,有追加的新键值:
![[sync.map追加后变化.png]]

sync.map追加后读写的过程
假如我现在要读写刚才追加的d的值,首先先去上面找,没找到,由于amended变为了true,我开始往下面找,找到了,这时misses加1(上面没有命中,下面命中了的数量):
![[sync.map追加后读写.png]]

sync.map dirty提升
当misses的值等于下面dirty中key的数量时,几乎每次读都要走下面的,于是上面的就可以不要了:
![[sync.map dirty提升-1.png]]

上面的不要了,dirty往上移:
![[sync.map dirty提升-2.png]]

dirty取代了原来m的位置,上面的amended置为false,结构体中的misses置为0:
![[sync.map dirty提升-3.png]]

如果要追加,会重建dirty,指针指向一个新的dirty:
![[sync.map dirty提升-4.png]]

sync.map的删除
  • 相比于查询、修改、新增,删除更麻烦
  • 删除可以分为正常删除和追加后删除
  • 提升后,被删key还需特殊处理

正常删除
没有追加的情况下,假如要删除d这个key,走上面的read map,将Pointter指针置为空,go的GC就会自动进行删除。
![[sync.map正常删除key.png]]

追加后删除
d是刚追加的情况下,要删除d这个key,首先还是走下面的dirty map,将Pointter指针置为空
![[sync.map追加后删除-1.png]]

后面遇到要提升dirty,提升上来后,下面要重建dirty map,是否要将d包含在其中,这是一个问题
![[sync.map追加后删除-2.png]]

sync.map采用的办法是,之前的d会指向expunged(被删除的),下面的dirty map重建的时候将不会在包含d
![[sync.map追加后删除-3.png]]

总结

  • map在扩容时会有并发问题
  • sync.Map使用了两个map,分离了扩容问题
  • 不会引发扩容的操作(查、改)使用read map
  • 可能引发扩容的操作(新增)使用dirty map

接口的底层原理

go隐式接口特点

  • 只要实现了接口的全部方法,就是自动实现接口
  • 可以在不修改代码的情况下抽象出新的接口

底层是如何表示接口的值

接口的简单用法:

type Phone interface {  
   call()  
}  
type Xiaomi struct {  
   Model string // 型号  
}  
  
func (x Xiaomi) call() {  
  
}  
func main() {  
   var phone Phone = Xiaomi{}  
   fmt.Println(phone)  
}

上面的phone的类型是Xiaomi,但是为什么phone.Model无法打印出Model这个成员参数,所以phone不是一个简单的转成了Xiaomi的类型,而是一个Phone接口的值,那接口的值底层是一个什么样的表示呢,找到runtime/runtime2.go文件:

type iface struct {  
   tab  *itab  
   data unsafe.Pointer  // 指向装载的结构体
}

继续看下itab这个指针接口体:

type itab struct {  
   inter *interfacetype  
   _type *_type  // 装载的结构体具体是一个什么类型
   hash  uint32 // copy of _type.hash. Used for type switches.  
   _     [4]byte  
   fun   [1]uintptr // variable sized. fun[0]==0 means _type does not implement inter.  这个类型实现了哪些方法
}
  • 接口数据使用runtime.iface表示
  • iface记录了数据的地址
  • iface中也记录了接口类型信息和实现的方法(方便类型断言)

类型断言

  • 类型断言是一个使用在接口值上的操作
  • 可以将接口值转换为其它类型值(实现或者兼容接口)
  • 可以配合switch进行类型判断

还是以上面的phone为例:

type Phone interface {  
   call()  
}  
  
type CommunicationTools interface {  
   call()  
}  
  
type Xiaomi struct {  
   Model string // 型号  
}  
  
func (x Xiaomi) call() {  
   fmt.Println(x.Model)  
}  
func main() {  
   var phone Phone = Xiaomi{}  
   fmt.Println(phone)  
   c := phone.(CommunicationTools)  
   fmt.Println(c)  
   switch phone.(type) {  
   case CommunicationTools:  
      fmt.Println("ok")  
   }  
  
}

结构体和指针实现接口

![[结构体和指针实现接口差异.png]]

用下面的示例代码来解释上面这个表格的意思:

type Phone interface {  
   call()  
}  
  
type Xiaomi struct {  
   Model string // 型号  
}  
  
type Huawei struct {  
   Model string // 型号  
}  
// Xiaomi结构体实现了Phone接口,go在编译时会自动让Xiaomi结构体指针也实现Phone接口  
func (x Xiaomi) call() {   
   fmt.Println(x.Model)  
}  
// 只有Huawei结构体指针实现了Phone接口   
func (x *Huawei) call() {  
   fmt.Println(x.Model)  
}  
  
func main() {  
   var phone1 Phone = Xiaomi{}  
   var phone2 Phone = &Xiaomi{}  
     
   var phoneA Phone = Huawei{} // 报错了,因为Huawei结构体没有实现Phone接口  
   var phoneB Phone = &Huawei{}  
   fmt.Println(phone1, phone2)  
   fmt.Println(phoneA, phoneB)  
}

go build -gcflags -S main.go查看go在编译时自动给Xiaomi结构体指针实现的Phone接口:
![[查看go在编译时自动给结构体指针实现的接口.png]]

空接口的值及用途

空接口的值

  • runtime.eface结构体
  • 空接口底层不是普通接口
  • 空接口值可以承载任何数据

空接口的用途

  • 空接口的最大用途是作为任意类型的函数入参
  • 函数调用时,会新生成一个空接口,再传参

总结

  • Go的隐式接口更加方便系统的扩展和重构
  • 结构体和指针都可以实现接口
  • 空接口值可以承载任何类型的数据

nil,空结构体,空接口区分

nil

nil的变量定义位于builtin/builtin.go

// nil is a predeclared identifier representing the zero value for a  
// pointer, channel, func, interface, map, or slice type.  
var nil Type // Type must be a pointer, channel, func, interface, map, or slice type

a的零值和b的零值都为nil,但是它们两者之间并不能比较:

var a *int  
var b map[int]bool  
fmt.Println(a == nil) // true  
fmt.Println(b == nil) // true  
  
fmt.Println(a == b) // error mismatched types *int and map[int]bool
  • nil是空,并不一定是“空指针”
  • nil是6种类型(pointer, channel, func, interface, map, or slice type)的“零值”
  • 每种类型的nil是不同的,无法比较

空结构体

  • 空结构体是Go中非常特殊的类型
  • 空结构体的值不是nil
  • 空结构体的指针也不是nil,但是都相同(zerobase)

空接口

var a interface{}  
fmt.Println(a == nil) // true  
var b *int  
a = b  
fmt.Println(b == nil) // true  
fmt.Println(a == nil) // false a接口底层的eface里面有了类型信息
  • 空接口不一定是“nil接口”
  • 两个属性都nil才是nil接口

总结

  • nil是多个类型的零值,或者空值
  • 空结构体的指针和值都不是nil
  • 空接口零值是nil,一旦有了类型信息就不是nil
  • 15
    点赞
  • 28
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值