目录

go的底层相关知识

go的类型系统

  • 首先接口类型是无效的方法接收者,要理解这句话就要知道什么是方法?方法是与特定类型关联的函数,那什么是方法接收者,方法接收者:方法有一个接收者(receiver),它指定了这个方法属于哪个类型。
1
2
3
4
// Person 就是方法接受者, 这就是定义了Person结构体的一种方法
func (p Person) SayHello() {
fmt.Println("Hello, my name is", p.Name)
}
  • 理解方法接收者后,因为接口他只是定义了方法,本身不能实现方法,也就不能作为方法接收者

类型元数据

  • 不管是自定义类型还是内置类型,都有相应的类型元数据,类型元数据用来记录一些信息,例如:类型名称,大小,类型边界 等信息
    所以底层会有一个_type结构体来存储类型元数据,其结构体如下:
1
2
3
4
5
6
7
8
9
10
type _type struct {
size uintptr // 类型大小
ptrdata uintptr // 指针大小
hash uint32 // 哈希值
tflag tflag // 类型标志
align uint8 // 对齐
fieldalign uint8 // 字段对齐
kind uint8 // 类型种类
......
}

以上的信息作为元数据的头部,其下面还会有一些其他的描述数据,例如切片元数据的类型元数据结构如下:

1
2
3
4
type slicetype struct {
typ _type
elem *_type // 切片元素类型,指向的是切片存储类型的类型元数据(例如stringtype,inttype等等)
}

elem就是其中一种描述数据

  • 对于自定义类型来说,其类型还会有一个uncommontype结构体字段,其结构体如下:
1
2
3
4
5
6
7
8
type uncommontype struct {
pkgpath nameoff // 包路径
mcount uint16 // 方法数量
xcount uint16 // 字段数量
moff uint32 // 方法列表偏移值
_ uint32 // 未使用
_ uint32 // 未使用
}

详细讲讲方法列表偏移值的作用,这个偏移值就是uncommontype结构体的moff字段,加上uncommontype的地址就是方法列表所在位置,如图
alt text

  • type 的两种不同用法的区别:

alt text

go接口底层

空接口底层

  • 因为空接口可以指向任意类型,所以在底层只需要知道指向的地址在哪,以及指向的是什么类型就行,所以其底层大概如下:
    alt text
  • 赋值前后的变化:
    1. 赋值前:两个字段都是nil
      alt text
    2. 赋值后:data字段指向实际地址,_type执行类型元数据
      alt text

非空接口底层

  • 非空接口(有方法的接口)底层的结构如下:
    alt text

  • 非空接口赋值前后的变化:

    1. 赋值前:两个字段都是nil
    2. 赋值后:
      alt text
      对上图的解释:
      首先data字段就是指向实际的地址tab字段就是接口的相关信息,从上到下分别是inter(指向的是对应接口类型的元数据)
      _type(指向*os.File类型元数据),hash为快速判断是否相等的哈希值,然后是两个空接口的字段,分别是两个空接口的类型元数据及hash值,hash是用来快速判断是否相等。
      fun则是一个数字,里面存的是拷贝过来的方法,这样就不需要反复去元数据中查找了,并且能快速定位到方法
      其次对于itab只要知道了inter和_type就知道了itab的类型,也就是说inter和_type可以唯一确定一个itab,因此在底层有缓存机制
      在go中会把用到的itab结构体缓存起来,并且创建以接口类型和动态类型为key,指向itab的指针为值的哈希表,需要itab时会首先在这个哈希表(接口类型哈希值与动态类型哈希值进行异或得到哈希表的哈希值)中查找,没有的话就创建一个itab结构体并加入到哈希表中

类型断言是如何工作的

go的堆栈和逃逸分析

go的堆栈和逃逸分析详解

相比于把内存分配到堆中,分配到栈中优势更明显。Go语言也是这么做的:Go编译器会尽可能将变量分配到到栈上。但是,当编译器无法证明函数返回的变量有没有被引用时,编译器就必须在堆上分配该变量,以此避免悬挂指针(dangling pointer)的问题。另外,如果局部变量占用内存非常大,也会将其分配在堆上。

问题:go是如何确定内存是分配到栈上还是堆上?

答案:逃逸分析

逃逸分析

  • 什么是逃逸分析:编译器通过逃逸分析技术去选择堆或者栈,逃逸分析的基本思想如下:检查变量的生命周期是否是完全可知的,如果通过检查,则在栈上分配。否则,就是所谓的逃逸,必须在堆上进行分配。
逃逸分析的基本原则
  • 不同于JAVA JVM的运行时逃逸分析,Go的逃逸分析是在编译期完成的:编译期无法确定的参数类型必定放到堆中;
  • 如果变量在函数外部存在引用,则必定放在堆中;
  • 如果变量占用内存较大时,则优先放到堆中;
  • 如果变量在函数外部没有引用,则优先放到中;
逃逸分析举例

我们使用这个命令来查看逃逸分析的结果: go build -gcflags ‘-m -m -l’

  1. 参数类型是interface类型
1
2
3
4
5
6
7
pacakge main
import "fmt"

func main() {
a := 666
fmt.Println(a)
}

运行结果

这段代码就输出了 a escapes to heap

原因分析
因为Println的参数类型是interface,编译器无法确定它的具体类型,因此必须在堆上分配。

  1. 变量在外部存在引用
1
2
3
4
5
6
7
8
9
10
package main

func test() *int {
a := 6
return &a
}

func main() {
_ := test()
}

运行结果
这段代码就输出了moved to head: a

原因分析
变量a在函数外部存在引用。

我们来分析一下执行过程:当函数执行完毕,对应的栈帧就被销毁,但是引用已经被返回到函数之外。如果这时外部通过引用地址取值,虽然地址还在,但是这块内存已经被释放回收了,这就是非法内存。

为了避免上述非法内存的情况,在这种情况下变量的内存分配必须分配到堆上

  1. 变量占用内存较大
1
2
3
4
5
6
7
8
9
10
11
12
package main

func test() {
a := make([]int, 10000, 10000)
for i := 0; i < 10000; i ++ {
a[i] = i;
}
}

func main() {
test()
}

运行结果
这段代码就输出了make([]int, 10000, 10000) escapes to heap

原因分析
我们定义了一个容量为10000的int类型切片,发生了逃逸,内存分配到了堆上(heap)。

  1. 变量大小不确定的时候也会分配在堆上
1
2
3
4
5
6
7
8
9
10
11
12
13
package main

func test() {
l := 1
a := make([]int, l, l)
for i := 0; i < l; i++ {
a[i] = i
}
}

func main() {
test()
}

运行结果
这段代码就输出了make([]int, l, l) escapes to heap

原因分析
我们虽然在代码段中给变量 l 赋值了1,但是编译期间只能识别到初始化int类型切片时,传入的长度和容量是变量l,编译期并不能确定变量l的值,所以发生了逃逸,会把内存分配到堆中。

go的字符串

  • 在go中字符串是以字符串所占字节数结尾的,例如:

    1. Hello 字符串 那么它结尾就会存5
    2. 如果是Hello世界,那么他结尾就是5+3+3=11
  • 一个字符串所占用的内存空间为16字节,Why?
    首先字符串是一个类型,一个字符串由一个指向字符串值的指针和一个int型数据组成,这个int数据表示字符串值所占的实际字节数,所以一个字符串所占用的内存为8(指针的大小)+8(int的大小)=16字节。

  • 在go中字符串是存在只读内存段中,不能对字符串内部进行修改,如果想要修改可以转换成byteSlice切片,但这样会导致原来的内存失效,数据被拷贝到新内存中,但是也可以通过使用unsafe包和slice结构时就可以做到使用原来的内存,也就导致依然无法修改内部内容

go的切片底层实现

  • slice 切片底层一共有三个数据,一个表示data数据指针,一个表示长度,一个表示容量

  • 给slice切片开辟的时候有两种方式,第一种方式用**make(in[],2,5)**这样会开辟一个有5容量的底层数组,并且默认赋值0,再执行append操作就会在第三个位置插入,然后长度改为3, 如图:
    alt text

  • 另一种方式使用new,使用new的话不会开辟数组空间,且返回的是一个切片的起始地址,只有通过append之后触发了扩容机制,扩容底层数组,导致了地址的分配

alt text

  • 对于以下情况
1
2
3
arr := [10]int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9} // 开辟一个数组
s1 := arr[1:4]
s2 := arr[7:]

这样的话,s1和s2的底层数组指向的都是数组的对应地址,如图所示:
alt text
所以如果修改s1或者s2的元素,会影响到arr的元素,因为他们指向的是同一个数组的不同部分。同时s1的容量是9长度是3而s2的长度和容量都是3

但是如果向s2append新元素,就会开辟新空间,把数组的应位置的元素拷贝到新空间,由于容量不够就会触发扩容机制,会2倍扩容,之后会把新元素追加到后面,如图所示:
alt text

go中int占8字节

1.18之前的扩容规则

  1. 在分配内存空间之前需要先确定新的切片容量,运行时根据切片的当前容量选择不同的策略进行扩容:

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

  3. 如果当前切片的长度小于 1024 就会将容量翻倍;

  4. 如果当前切片的长度大于等于 1024 就会每次增加 25% 的容量,直到新容量大于期望容量;

alt text

记录内存的变化如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
[0 ->   -1] cap = 0     |  after append 0     cap = 1   
[0 -> 0] cap = 1 | after append 1 cap = 2
[0 -> 1] cap = 2 | after append 2 cap = 4
[0 -> 3] cap = 4 | after append 4 cap = 8
[0 -> 7] cap = 8 | after append 8 cap = 16
[0 -> 15] cap = 16 | after append 16 cap = 32
[0 -> 31] cap = 32 | after append 32 cap = 64
[0 -> 63] cap = 64 | after append 64 cap = 128
[0 -> 127] cap = 128 | after append 128 cap = 256
[0 -> 255] cap = 256 | after append 256 cap = 512
[0 -> 511] cap = 512 | after append 512 cap = 1024
[0 -> 1023] cap = 1024 | after append 1024 cap = 1280
[0 -> 1279] cap = 1280 | after append 1280 cap = 1696
[0 -> 1695] cap = 1696 | after append 1696 cap = 2304

1.18之后的扩容规则

  1. 在分配内存空间之前需要先确定新的切片容量,运行时根据切片的当前容量选择不同的策略进行扩容:

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

  3. 如果当前切片的长度小于阈值(默认 256)就会将容量翻倍;

  4. 如果当前切片的长度大于等于阈值(默认 256),就会每次增加 25% 的容量,基准是 newcap + 3*threshold,直到新容量大于期望容量;

alt text

算出期望扩容后还会内存对齐,会调用roundupsize函数进行内存对齐

1
var class_to_size = [_NumSizeClasses]uint16{0, 8, 16, 24, 32, 48, 64, 80, 96, 112, 128, 144, 160, 176, 192, 208, 224, 240, 256, 288, 320, 352, 384, 416, 448, 480, 512, 576, 640, 704, 768, 896, 1024, 1152, 1280, 1408, 1536, 1792, 2048, 2304, 2688, 3072, 3200, 3456, 4096, 4864, 5376, 6144, 6528, 6784, 6912, 8192, 9472, 9728, 10240, 10880, 12288, 13568, 14336, 16384, 18432, 19072, 20480, 21760, 24576, 27264, 28672, 32768}

假设我们运行此处代码:

1
2
s1 := []int{1, 2}
s1 = append(s1, 3, 4, 5)

期望容量是5,但实际会输出6

继续分析可以得出,divRoundUp的结果为 (40 +8 - 1) / 8 = 5, size_to_class8[5] = 5,class_to_size[5] = 48,最终结果capmem为48

具体可以看如下链接扩容后内存对齐是怎么对齐的

执行下述代码

1
2
3
4
5
6
7
8
//go 1.18
func TestSliceGrowing(t *testing.T) {
s := []int{}
for i := 0; i < 4098; i++ {
s = append(s, i)
t.Log(len(s), cap(s))
}
}

输出结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
=== RUN   TestSliceGrowing
slice_test.go:32: 1 1
slice_test.go:32: 2 2
slice_test.go:32: 3 4
slice_test.go:32: 4 4
slice_test.go:32: 5 8
slice_test.go:32: 6 8
slice_test.go:32: 7 8
slice_test.go:32: 8 8
slice_test.go:32: 9 16
......
slice_test.go:32: 128 128
slice_test.go:32: 129 256
......
slice_test.go:32: 256 256
slice_test.go:32: 257 512
......
slice_test.go:32: 512 512
slice_test.go:32: 513 848
......
slice_test.go:32: 848 848
slice_test.go:32: 849 1280
......
slice_test.go:32: 1280 1280
slice_test.go:32: 1281 1792
......
slice_test.go:32: 1792 1792
slice_test.go:32: 1793 2560
......
slice_test.go:32: 2560 2560
slice_test.go:32: 2561 3408
......
slice_test.go:32: 3408 3408
slice_test.go:32: 3409 5120

go的map底层原理

go中map使用的是渐进性扩容,这样可以避免一次性迁移太多的数据而导致消费过多的时间

go map的结构定义

alt text

  • 一个map的结构定义如下:
    1. count:元素个数
    2. flags:标记位,包括是否扩容,是否有哈希冲突
    3. B: 表示2的B次方
    4. buckets:存储键值对的数组,每个bucket是一个bucket结构,存储键值对
    5. oldbuckets:扩容时使用,指向旧的buckets数组
    6. hash0:哈希种子
    7. nevacuate:表示下一次进行迁徙的旧桶编号
    8. extra:额外的存储空间,指向的是mapextra结构体,存储的是溢出桶的信息

一个bmap大概如下:
alt text

其中h1到h8表示tophash,tophash一共由两个作用: 一是状态位,二是存储key值的高八位

tophash作为状态位
1
2
3
4
5
6
emptyRest      = 0  // 有两层意思:一是表示该tophash对应的K/V位置是可用的;二是表示该位置后面的K/V位置都是可用的。
emptyOne = 1 // 仅表示该tophash对应的K/V位置是可用的,其后面的是否可用不知道。
evacuatedX = 2 // 与迁移有关
evacuatedY = 3 // 与迁移有关
evacuatedEmpty = 4 // 当bucket被迁移完时,tophash值置为evacuatedEmpty。
minTopHash = 5

emptyRest的作用:

  • 判断bucket是否为空
    当tophash[0]==emptyRest表示整个bucket都是空的,这就是源码里面判断bucket是否为空的方法。

  • 查找时快速判断后面位置是否还需遍历
    如在查找时,在一个bucket中,找到tophash[2]位置,发现值为emptyRest,就可以判断该bucket没有该元素,继续查找下一个bucket

详细介绍可以看Golang之map tophash详解

hmap

alt text

当一个位置的bmap存满了,可能会在其后面添加溢出桶,溢出桶的overflow指向下一个溢出桶的位置,单个桶的容量为8个元素

go map扩容规则

go map扩容规则如下:

  1. Go语言map的默认负载因子是6.5,也就是说当count/2^B > 6.5时,就会触发翻倍扩容(是旧桶容量的两倍)
  2. 如果负载因子没有超标,但是使用了太多的溢出桶,就会触发等量扩容,即新桶容量等于旧桶容量
翻倍扩容

翻倍扩容时,旧桶里的元素会被分流到新的桶中,比如0号桶(旧桶容量是四个)会被分流到新桶的0号或者4号桶中如图所示:

alt text

等量扩容

有两个规则:

  1. 当B<=15, 且常规桶的数量不超过2^15时,溢出桶的数目超过常规桶(2^B),就会触发等量扩容;
  2. 如果B>15, 且溢出桶的数目超过2^15,就会触发等量扩容;

为什么需要等量扩容?

因为在溢出桶中可能有很多元素被删除了,导致一个溢出桶内只有少量的元素,但是数量没变,这是就需要等量扩容了

函数调用栈

  • 在go语言函数栈帧布局中,返回值在函数参数之上,如果一个函数内有defer函数,那么这个函数再放回前是先给放回值赋值,在执行defer函数,如图所示:
    alt text

所以上图中main函数中的b为1而不是2

如果使用的是匿名返回值,结果又会不一样

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func incr(a int)(b int){
defer func(){
a ++
b ++
}()

a++
return a;
}

func main(){
var a, b int
b = incr(a)
fmt.Println(a,b) // 输出 0, 2
}
  • 这个函数中在返回a的时候,a的值为1会赋值给b,此时b也等于1,然后执行defer函数,a再加1,此时a等于2,b也等于2,由于a是局部变量,所以输出为0,2
    alt text

闭包底层

  • 当函数作为返回值或者作为参数以及变量的时候,go语言称这些为function valuefunction value本质上是一个指针,但是不直接指向函数帧的入口,而是指向一个叫做funcval的结构体,结构体内部为指向函数指令入口地址的指针,也就是说function value本质采用了二级指针的方式,如图所示:
    alt text

具体例子:

alt text

其中addr1为A函数的入口地址,addr2为function value的地址, f1与f2指向的是addr2也就是function value的地址
那么为什么要通过funcval结构体包装函数入口地址,来实现二级指针调用呢?

答案是为闭包服务

闭包函数

补充下闭包的定义:闭包是指一个函数有权访问到其外部函数作用域中的变量

  • 闭包函数定义: 当一个函数的返回值是另外一个函数,而返回的那个函数如果调用了其父函数内部的其它变量,如果返回的这个函数在外部被执行,就产生了闭包
    例如:
1
2
3
4
5
6
7
8
9
10
11
12
func create() func() int {
c := 2
return func() int {
return c
}
}

func main() {
f1 := create()
f2 := create()
fmt.Println(f1(), f2()) // 输出 2 2
}
  1. 闭包函数使用外部变量的时候,会有一个捕获列表,这个捕获列表里面的值就是我捕捉的外部变量
  2. 捕获列表里面的变量会copy一份外部变量,并且与funcval结构体一块被分配到堆上,堆上的funcval结构体的入口地址就会赋值给函数对象,如图所示:
    alt text

总结来说go语言中闭包就是有捕获列表的Function Value,其是通过寄存器的值来存储Function Value的地址,这样在闭包函数中就能通过寄存器取出funcval结构体的地址,然后加上相应偏离找到捕获变量

alt text

在以上图片的例子中,捕获变量除了初始化还会被修改,这是变量就会逃逸到推上,然后fs[0]和fs[1]都使用这个堆上变量,如图所示:
alt text

所以函数最终输出都为2