0%

Golang

数据类型

整型

类型 范围
uint8 0 到 255
uint16 0 到 65535
uint32 0 到 4294967295
unit64 (0 到 18446744073709551615)
int8 -128 到 127
int16 -32768 到 32767
int32 -2147483648 到 2147483647
int64 (-9223372036854775808 到 9223372036854775807)

浮点型—float

float32、float64;分别表示单精度浮点数和双精度浮点数。

复数型—complex

complex64、complex128;表示由实部和虚部组成的复数。

字符和字符串型—byte

byte表示 0 到 255、string

错误类型—error

error,表示错误信息的类型。

布尔型—bool

true或false

数组类型

[n]T,表示固定长度的数组,其中 n 是数组的长度,T 是数组元素的类型。

切片类型

[]T,表示可变长度的序列,其中 T 是切片元素的类型。

映射类型—map

map[K]V,表示键值对的集合,其中 K 是键的类型,V 是值的类型。

map使用

1. 某些场景下,将value定义为struct,会有更优的性能
当 map 的 value 是 struct 类型时,数据会直接存储在 map 中,而不是通过指针引用。这可以减少内存分配的开销和 GC(垃圾回收)的负担。

1
2
3
4
5
6
7
8
9
10
11
type User struct {
ID int
Name string
}

m := make(map[string]User)
m["user1"] = User{ID: 1, Name: "John"}

// Example with pointer to struct
m2 := make(map[string]*User)
m2["user1"] = &User{ID: 1, Name: "John"}

在示例中,map 中存储的是指向 User 结构体的指针,这意味着除了存储指针本身外,还需要额外的内存来存储 User 结构体。

1
2
3
4
5
6
7
set1 := make(map[int]bool)
set1[1] = true
set1[2] = true

set2 := make(map[int]struct{})
set2[1] = struct{}{}
set2[2] = struct{}{}

在示例中,map[int]bool{} 会比 map[int]struct{}{} 使用更多的内存,因为 bool 类型需要存储一个字节(在实际应用中可能会有额外的内存对齐和管理开销),而 struct{} 是空的,不会增加任何内存开销。

优点:

  • 节省内存, 并且会减少 GC 的负担。
  • 避免内存碎片化,存储指针时,由于指针可能指向堆中的不同位置,这会导致内存碎片化,增加了内存使用的不确定性。而存储 struct 使得数据更紧凑,减少了碎片化。
  • 更高的缓存命中率,由于 struct 的数据是紧凑存储的,相对于存储指针,struct 的数据更可能在相邻的内存位置。这增加了 CPU 缓存的命中率,从而提高了性能。

2. Go语言的map的键类型为什么不能是函数类型、字典类型、切片类型?

  • 对于所有键值对元素的数据结构来说,存储过程先计算key的哈希值,随后使用哈希值定位到对应的bucket,将key和value作为元素存储到对应的bucket中。
  • 在这个过程中可能会发生哈希冲突,由于哈希冲突的存在对于哈希表的键的要求,不仅仅能够计算哈希值,同时能够对键进行判等操作(解决哈希冲突时key重复的问题)。
  • 如果go的map的键为上述三种类型,在运行时就会发生panic。

3. map使用时的并发安全问题:

对于map的读写是非原子性操作,存在资源竞争,不是现成安全的。为了将非并发安全的读取操作更改为并发安全的,可以引入sync.Mutex,在读、写操作前进行加锁操作;操作后进行解锁,保证并发安全。

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
35
36
37
38
39
40
41
func main() {
m := map[int]string{
1: "haha",
}
var mutex sync.Mutex // 创建一个互斥锁

// 启动读协程
go read(m, &mutex)

// 等待一秒钟,确保读协程已经开始运行
time.Sleep(time.Second)

// 启动写协程
go write(m, &mutex)

// 等待足够长的时间,以便读写协程可以运行
time.Sleep(30 * time.Second)

// 打印最终的map
fmt.Println(m)
}

func read(m map[int]string, mutex *sync.Mutex) {
for {
mutex.Lock() // 在读取之前加锁
value := m[1]
mutex.Unlock() // 读取完毕后解锁
fmt.Println("Read:", value)
time.Sleep(1 * time.Second)
}
}

func write(m map[int]string, mutex *sync.Mutex) {
for {
mutex.Lock() // 在写入之前加锁
m[1] = "write"
mutex.Unlock() // 写入完毕后解锁
fmt.Println("Write:", m[1])
time.Sleep(1 * time.Second)
}
}

map原理

map实现的两个关键数据结构

  • hmap 定义了map的结构
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    type hmap struct {
    count int // 当前 map 中的元素个数
    flags uint8 // 状态标志位
    B uint8 // buckets 数组的长度为 2^B
    noverflow uint16 // 溢出桶的数量

    buckets unsafe.Pointer // 指向 buckets 数组的指针
    oldbuckets unsafe.Pointer // 扩容时保存旧的 buckets 数组
    extra *mapextra // 用于保存溢出桶的地址
    }
  • bmap 定义了hmap.buckets中每个bucket的结构,每个 bmap 可以存储 8 个键值对(默认)。以下是 bmap 的关键字段:
    1
    2
    3
    4
    5
    6
    type bmap struct {
    tophash [bucketCnt]uint8 // 存储每个键的哈希值的高 8 位
    keys [bucketCnt]keytype // 存储 bucketCnt 个键
    values [bucketCnt]valuetype // 存储 bucketCnt 个值
    overflow uintptr // 指向下一个溢出桶
    }

key hash值的后B位作为桶index查找桶
key hash值的前8位作为桶内结构体的三个数组(tophash,key,value)的index
桶结构体的tophash复用,既作为tophash使用,也作为标志位使用

特点

  1. 哈希计算
    当我们向map中插入一个键值对,首先对键进行哈希计算。Go内置了哈希函数来计算键的哈希值。哈希值是一个64位的整数。

  2. 分桶依据
    Go 中的 map 是分成多个桶 (bucket) 来存储的。桶的数量通常是 2 的幂次,这样可以方便地通过位运算来定位到具体的桶。哈希值的高八位和低八位分别用于分桶和桶内定位:
    高八位 (top 8 bits):用于决定哈希表中的桶位置。
    低八位 (low 8 bits):用于桶内查找。

  3. 溢出桶使用
    每个桶中可以存储 8 个键值对。当某个桶中的元素超过 8 个时,Go 会使用溢出桶来存储额外的键值对,并通过 overflow 字段将其链接到当前 bmap 后面。这样可以动态扩展 map 的存储空间,而不需要立即进行扩容。

插入过程

当插入一个键值对时,过程如下:

  1. 计算哈希值
    对键进行哈希计算得到哈希值 hash。
  2. 定位桶
    通过 hash >> (64 - B)(B 是桶的数量的对数)得到桶的索引 index。
  3. 桶内查找
    通过 hash & (bucketCnt - 1) 得到桶内索引。然后通过对比 tophash 数组中的值来定位到具体的键值对存储位置。
  4. 存储键值对
    将键值对存储到相应的位置,如果当前桶已满,则分配新的溢出桶来存储额外的键值对。

查找过程

查找的过程与插入类似:

  1. 计算哈希值:
    对键进行哈希计算得到哈希值 hash。
  2. 定位桶:
    通过 hash >> (64 - B) 得到桶的索引 index。
  3. 桶内查找:
    通过 hash & (bucketCnt - 1) 得到桶内索引,然后在相应的 bmap 中查找 tophash 和 keys 数组中匹配的键。如果在当前桶中没有找到,则继续查找溢出桶。

扩容机制

扩容触发条件

  • 装载因子过高:装载因子(load factor)是 map 中元素数量与桶数量的比值。装载因子阈值通常为 6.5,当装载因子超过这个值时会触发双倍扩容
  • 溢出桶过多:当溢出桶的数量过多时,会触发等量扩容
双倍扩容

如下图,原来 B = 0,只有一个桶,装满后触发翻倍扩容,B = 1,buckets 指向两个新桶,oldbuckets 指向旧桶,nevacuate 表示接下来要迁移编号为 0 的旧桶。旧桶的键值对会渐进式分流到两个新桶中。直到旧桶中的键值对全部搬迁完毕后,删除oldbuckets。

这种扩容会导致 桶迁移,需要将原有的 key/value 重新搬迁到新的内存地址,如果map存储了数以亿计的key-value,一次性搬迁将会造成比较大的延时,因此 Go map 的扩容采取了渐进式扩容的方式,原有的 key 并不会一次性搬迁完毕,每次最多只会搬迁 2 个 bucket。只有在插入或修改、删除 key 的时候,都会尝试进行搬迁 buckets 的工作。具体来说就是检查 oldbuckets 是否为 nil。

迁移过程中使用与运算法hash & (bucketCnt-1),用旧桶的hash值跟扩容后的桶的个数 bucketCnt-1 的值相与(&),得几就在哪个位置上。

示例:

  • 如果旧桶数量为4,那么新桶的数量就为 8。
  • 如果该参数选择 0 号旧桶说明哈希值为 xxxxxx00,旧桶 m-1 = 3 = 00000011 —> xxxxxx00 & 00000011 = 0
  • 因此选择新桶取决于哈希值的第三位是 0 还是 1,新桶 m-1 = 7 = 00000111,与原哈希值 xxxxxx00 进行与运算,若第三位是 0 则为 00000000 = 0,第三位为 1 则为 00000100 = 4。
等量扩容
  • 当 B < 15 时,如果溢出桶的数量超过 2^B,则触发等量扩容。
  • 当 B >= 15 时,如果溢出桶的数量超过 2^15,则触发等量扩容。

虽然没有超过负载因子限制,但是使用溢出桶过多,就会触发等量扩容,创建和旧桶数目一样多的新桶,然后把原来的键值对迁移到新桶中。

一般发生在很多键值对被删除的情况下,这样会造成overflow的bucket数量增多,但负载因子又不高。同样数目的键值对,迁移到新桶中会把松散的键值对重新排列一次,使其排列的更加紧凑,进而保证更快的存取,这就是等量扩容的意义所在。

结构体类—struct

struct,表示由多个字段组成的复合类型。

接口类型—interface

interface,表示一组方法的集合。

函数类型—func

func,表示函数类型。

Go实现继承

1. 通过结构体嵌套进行组合

在Go语言中,结构体嵌套是一种常见的实现继承的方式。通过将一个结构体嵌套在另一个结构体中,可以使得外层结构体拥有内层结构体的所有字段和方法。

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
package main

import "fmt"

type Animal struct {
Name string
Age int
}

func (a Animal) Speak() {
fmt.Println("I am an animal.")

}

type Dog struct {
Animal
Breed string
}

func (d Dog) Bark() {
fmt.Println("Woof! Woof!")
}

func main() {
dog := Dog{
Animal: Animal{Name: "Buddy", Age: 3},
Breed: "Golden Retriever",
}
fmt.Printf("Name: %s, Age: %d, Breed: %s\n", dog.Name, dog.Age, dog.Breed)
dog.Speak() // 通过组合实现了继承
dog.Bark() // Dog结构体自己的方法
}

2. 通过接口实现多态性

接口是Go语言实现多态性的重要机制。通过定义接口,可以抽象出不同类型对象的共同行为,从而实现类似于继承和多态的效果。

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
35
package main

import "fmt"

type Speaker interface {
Speak()
}

type Animal struct {
Name string
}

func (a Animal) Speak() {
fmt.Println("I am an animal.")
}

type Dog struct {
Animal
}

func (d Dog) Speak() {
fmt.Println("Woof! Woof!")
}

func main() {
var speaker Speaker
animal := Animal{Name: "Buddy"}
dog := Dog{Animal: Animal{Name: "Max"}}
speaker = animal
speaker.Speak() // 输出:I am an animal.

speaker = dog
speaker.Speak() // 输出:Woof! Woof!
}

3. 通过方法实现功能共享

在Go语言中,方法可以绑定到特定类型的结构体上,从而实现功能的共享和复用。这种方式在某些情况下也可以看作是继承的一种变体。

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
package main

import "fmt"

type Animal struct {
Name string
}

func (a Animal) Speak() {
fmt.Println("I am an animal.")
}

type Dog struct {
Animal
}

func (d Dog) Speak() {
d.Animal.Speak() // 调用Animal的Speak方法
fmt.Println("Woof! Woof!")
}

func main() {
dog := Dog{Animal: Animal{Name: "Buddy"}}
dog.Speak() // 输出:I am an animal. Woof! Woof!
}

函数参数传递

值传递的数据类型有:基本数据类型(int、float、bool、string等)、数组和结构体
引用传递的数据类型有:指针、slice、map、管道(channel)、interface等

Go Runtime

Go runtime 可以形象的理解为 Go 程序运行时的环境,类似于 JVM。不同于 JVM 的是,Go 的 runtime 与业务程序直接打包在一块,是一个可执行文件,直接运行在操作系统上,效率很高。

runtime 包含了一些 Go 的一些非常核心的功能:协程调度、垃圾回收、内存分配等。

协程调度

协程调度是指 Go 如何管理和执行协程,Go 的协程调度基于 GMP 模型。即:

  1. G (Goroutine):即 Go 的协程,包含了栈信息,代码指针,状态等;
  2. M (Machine):代表一个工作线程,由操作系统直接分配;
  3. P (Processor):处理器(Go 定义的一个概念,不是指 CPU),包含了协程运行的所需资源,如本地队列、全局队列、计数器等。

GMP 三者的关系:

P 的个数取决于设置的 runtime.GOMAXPROCS,默认是物理 CPU 的逻辑核心数量,比如四核八线程的 CPU,P 的数量就是 8;
M 的数量一般是多于 P 的,M 要想被 CPU 执行,必须先获取 P。没有获取 P 的 M,则处于休眠状态;
G 可以理解为代码本体,G 必须要被 P 调度进入 M,才可以被 CPU 执行;
P 包含了一个 LRQ (Local Run Queue)本地运行队列,保存着等待执行的协程(G)队列。没有被分配到 P 的 G,会被保存到 GRQ (Global Run Queue) 全局队列中,处于休眠状态。
假如主机是单逻辑 CPU 的,那么 GMP 是这样的:

红色部分表示休眠或者挂起状态,黄色代表等待执行,绿色表示正在运行。系统初始化了两个线程,但我们只有一个处理器(P), M1 没有获取到 P,所以只能休眠。M0 当前获取到 P ,正在处理 G0, LRQ 里面目前有三个 G 在排队等待被 M 运行,GRQ 里面保存着 G4、G5、G6,表示它们还没有分配到队列中。

P 这个时候会分别对 LRQ 进行周期队列轮转 和 GRO 周期性检查:

队列轮转:LRQ 中的 G 被 P 调度到 M 中执行,每个 G 执行一段时间后,就会保存其上下文并放入队列尾部,然后取出队列头部的 G 进入 M 执行。
周期性检查:P 会检查 GRQ 中是否有 G 正在等待运行,并将其放入 M 中执行,防止协程被饿死。
在队列轮转中,如果当前正在运行的 G 遇到了系统调用,那么系统就会挂起当前 M0,释放 P,M1 就会绑定释放的 P,来继续执行其他协程。
假设 GO 遇到了系统调用:

等到 M1 中所有的协程执行完或者 M1 处理某个协程也遇到了了系统调用,就会重新释放 P 给其他空闲的 M。而另外一边 G0 的系统调用结束后,就会将 M0 线程从挂起状态变成休眠状态,并将 G0 放入 GRQ,等待被 P 重新调入 LRQ 中轮转执行。

如果我们的主机具备多个逻辑 CPU,创建了多个 P,那么就会变成多个线程并行执行:

多线程同时处理时,很有可能多个 LRQ 是不均衡的。假如上图的 M0 已经执行完了,其他线程还处于繁忙状态,M0 所绑定的 P 就会去检查 GQR,GQR 中也没有 G,那么它就会去偷取其他 LRQ 一部分的 G 来执行,一般每次会偷取一半。

垃圾回收(GC)

垃圾回收(Garbage Collection,GC) 是Go语言的核心特性之一,是实现内存自动管理的一种形式。golang的自动垃圾回收屏蔽了复杂且容易出错的内存操作,让开发变得更加简单、高效。在Go语言中,从实现机制上来说,垃圾回收可能是最复杂的模块了。了解垃圾回收的机制,有助于更好地理解Go语言的内存管理机制,从而更好的使用Go语言进行开发。

垃圾回收涉及到一下三个组件:

  • Allocator-分配器:在堆上申请内存
  • Mutator-赋值器:将Allocator申请到的内存对象赋值给栈上的变量(或全局变量)。
  • Collector-回收器:回收不再活跃的内存对象。

标记-清除 算法

Go语言使用标记-清除(Mark-Sweep)算法来进行内存垃圾回收。Mark-Sweep算法执行过程可以分成标记(Mark)和清除(Sweep)两个阶段:

  • Mark阶段: 从根对象开始,对内存对象进行图遍历,对所有可达的对象进行标记。
  • Sweep阶段: 对Mark阶段未被标记的内存对象进行回收,回收完毕后重置所有内存对象的标记以便下一轮‘标记-清除’。

什么是根对象?
根对象主要是指应用程序中通过变量可以直接访问的对象。假设应用程序在堆上申请了A-J共10个内存对象。其中可以被程序中的变量直接访问的只有对象A和B(分别被变量a、b直接访问),因此根对象只有A和B。其他内存对象都是间接访问,不是根对象。

缺点

原始的Mark-Sweep算法流程上相对简单,但是在实际应用中有一定的限制条件。在垃圾回收的整个过程中,如果应用程序并发进行内存相关操作,可能导致活跃对象被错误回收

如上图所示,在标记阶段根对象A做DFS遍历完成后,Mutator并发修改了对内存对象D的引用关系(增加了A对象对D对象的引用,删除了B对象对D对象的引用),然后Collector在对根对象B做DFS遍历。最终导致的结果是,从根对象A可以到达对象D(即D是活跃的),但是标记结果显示对象D是可以被回收的内存对象,从而错误释放了对象D的内存。

在原始<标记-清除>算法中,要保证活跃对象不被被错误回收,需要满足以下必要条件:活跃白色对象必须从白色的根对象可达。这个条件在并发和增量执行的过程中其实是很难保证的,因而:原始的Mark-Sweep算法会在垃圾回收阶段Stop-The-World(STW),不支持并发和增量执行,就是需要将程序暂停,程序出现卡顿 (重要问题)。还有一些小问题就是这个:标记需要扫描整个heap,清除数据会产生heap碎片。

三色标记法

Go语言使用三色标记法和屏障技术来支持并发和增量执行。

三色标记算法将所有的内存对象抽象为黑、白、灰三类:

  • 白色: 初始色,如果标记阶段结束还是白色,则该内存对象将被回收。
  • 灰色: 活跃的对象,存在于标记阶段中间状态,自身及其下游需要被进一步扫描、标记。所有的根对象在标记开始时被全部置为灰色。
  • 黑色: 活跃的对象,已被标记完成,不会再通过该对象对其下游对象扫描标记。

三色标记法的基本过程:

  1. 垃圾回收开始前,所有的内存对象都是白色;
  2. 垃圾回收开始时,所有根对象被标记会灰色;
  3. 从灰色对象集合,选取一个灰色对象,将其下游白色对象置灰,将自己置为黑色;
  4. 重复第3步骤,直至灰色对象集合为空。

三色标记法只能由白->灰->黑单调变色

三色标记法本身仍然不能保证并发和增量执行的正确性。在并发和增量执行的场景下,活跃对象(白色)被错误回收的触发条件:

  1. 不存在从灰色对象到达该白色对象的路径。(该白色对象在标记阶段不会再被扫描到)
  2. 存在从黑色对象到达该白色对象的路径。(否则结合1的话,该白色对象本来就是一个需要被回收的垃圾对象)

等于说,单纯的三色标记法,还是无法避免要进行STW,如果不进行STW,则活跃对象可能被错误回收,如下图:

此时在进行GC但是程序也是在运行的,此时黑色对象4创建了一个指针指向了白色对象3

并且这个黑色对象2删除了和对象3的引用关系,那么活跃对象3会被回收

两种三色不变性

由此,就衍生了两种三色不变性:

  1. 强三色不变性:不存在黑色对象对白色对象的直接引用。(隐含了一层意思:如果该对象是活跃对象,那么必然存在从灰色对象到该对象的路径)。强三色不变性破坏了两个条件。
  2. 弱三色不变性:被黑色对象直接引用的白色对象,必须要能够通过一些灰色对象可达。破坏了”不存在从灰色对象到达该白色对象的路径”这个条件。


屏障技术

内存屏障技术是一种CPU屏障指令,用于让CPU和编译器对屏障指令前后的内存操作的执行顺序进行强制约束。现在处理器为了优化性能,经常会改变程序指令的执行顺序,而内存屏障技术则是用来对一些内存操作的执行顺序进行强制约束。内存屏障指令前的内存操作一定会在内存屏障指令后的操作之前执行。
内存屏障技术本身并不能用来保证三色不变性。但是我们可以利用内存屏障技术在内存操作之前执行特定的(用于保护三色不变性)的指令(执行特定代码)(就像Hook一样)。可以把内存操作分为以下几种:

  1. 堆对象的读,写
  2. 栈对象的读,写

通过两种三色不变性,谷歌团队引入了这个插入屏障和删除屏障

插入写屏障

当Mutator在增加一个内存对象A到另一个内存对象B的指向时(在有向图中增加A->B这条边),引入内存屏障,将内存对象B置灰。可以用来保证内存对象的强三色不变性。
还有一种情况就是A之前引用了这个对象C,现在A想引用B也就是不引用C了此时B也会被置为灰色。

下图的示例:没有暂停程序,黑色对象4引用了这个白色对象8

那么此时开启这个插入屏障,白色对象会被置为灰色。如下图所示:
栈是不开启这个插入写屏障的,所以对象9并不会被标记为灰色,这是因为栈要保证效率,因此运行时对栈上对象一般都不会开启写屏障,而只对堆上的对象做此处理

然后继续三次标记法的流程,遍历完成后的样子大致是这样:

但是此时对象9不会被当作是垃圾而被清除掉吗?此时在处理回收这个白色对象时,会开启STW,注意:只是对栈开启这个SWT,将栈里面的对象全部置为白色重新上述过程(此处STW占用大概10ms-100ms)。这样就能保证这个对象9不会被误删

最终会变成如下图:

删除写屏障

当Mutator在删除一个内存对象A到另一个内存对象B的指向时(在有向图中删除A->B这条边),引入内存屏障,将内存对象B置灰。这一点其实就是为了满足弱三色不变式。 (保护灰色对象到白色对象的路径不会断)
还有一种情况就是A之前引用了B,此时A更换了引用关系引用C,也就是A和B之间的引用关系被删除掉了。此时B对象会被置为灰色。

下图的示例:此时对象1想要删除和对象5之间的引用关系,此时就会触发删除写屏障。

删除完成后如下图:

最后的情况:

此时又有疑问了:对象5、2、3不是垃圾吗?为什么保留下来了?在这一次GC过程当中他们确实是被保留下来了,但是下一轮GC的时候就会被干掉了。
由此可知:这种方式的回收精度低,一个对象即使被删除了最后一个指向它的指针也依旧可以活过这一轮,在下一轮GC中被清理掉。

混合写屏障

那么我们可以得知这两个写屏障的缺点:

  • 插入写屏障:需要两次STW。
  • 删除写屏障:一次STW, 回收精度较低。

那么有没有一种方式,即不需要STW,也能有较高的回收精度呢?Go V1.8版本引入了混合写屏障机制(hybrid write barrier),避免了对栈re-scan的过程,极大的减少了STW的时间。结合了两者的优点:

  1. 混合写屏障一开始将栈上的对象全部标记为黑色
  2. 在GC期间任何在栈上创建的对象均为黑色。
  3. 被删除引用的对象均标记为灰色
  4. 被添加引用的对象均标记为灰色
  5. 栈不开启屏障机制
场景一

对象被一个堆对象删除引用,成为栈对象的下游

下图示例:请注意GC刚开始所有对象都是白色的,然后第一步扫描栈区将栈上的对象全部标记为黑色(后续不再赘述)

对象1添加引用关系到对象7,此时因为栈是不开启任何屏障机制所以了直接添加即可,如下图:

对象4删除和对象7的引用关系,那么按照规则需要将对象7置为灰色。此时我们发现不会有任何错误,如下图:

场景二

对象被一个栈对象删除引用,成为另一个栈对象的下游

首先在栈上新键一个对象9,按照规则在栈上创建的对象均为黑色对象。

添加对象9到对象3的引用关系,由于这个栈不启动任何屏障直接添加即可。

接着对象2删除和对象3之间的引用关系,由于是在栈上所以了之间删除即可。此时也是没有任何问题,不存在误删的情况

场景三

对象被一个堆对象删除引用,成为另一个堆对象的下游

对象10想要添加这个引用关系和对象7

由于是在堆上所以了会触发这个屏障机制会将对象7变为灰色。对象7变为灰色之后,就不会被当中垃圾被回收了。

场景四

对象从一个栈对象删除引用,成为另一个堆对象的下游

对象1想要删除和对象2之间的引用关系,由于栈不开启这个屏障机制所以直接删除即可。

对象4尝试引用对象2,对象2本来就是黑色了所以不用管。

对象4删除和对象7之间的引用关系,此时由于是在堆上所以了触发了这个混合写屏障机制,将对象7置为灰色。

对象7和对象6现在属于垃圾对象,应该被回收。但是对象7是灰色,这会让对象7和对象6不被回收掉。在这一轮GC的情况下确实不会被回收,但是下一轮GC就会将对象7和对象6回收掉。这一点设计者是考虑到了的

总结

以上就是Go语言垃圾回收机制,Go中的混合写屏障满足弱三色不变式

Go 1.3版本:普通标记清除法,整体过程需要启动STW,效率极低。
Go 1.5版本:三色标记法, 堆空间启动写屏障,栈空间不启动,全部扫描之后,需要重新扫描一次栈(需要STW),效率普通
Go 1.8版本:三色标记法,混合写屏障机制, 栈空间不启动,堆空间启动。整个过程几乎不需要STW,效率较高。

内存分配

Go是内置运行时的编程语言(runtime),像这种内置运行时的编程语言通常会抛弃传统的内存分配方式,改为自己管理。这样可以完成类似预分配、内存池等操作,以避开系统调用带来的性能问题,防止每次分配内存都需要系统调用。

Go的内存分配的核心思想可以分为以下几点:

  1. 每次从操作系统申请一大块儿的内存,由Go来对这块儿内存做分配,减少系统调用
  2. 内存分配算法采用Google的TCMalloc算法。算法比较复杂,究其原理可自行查阅。其核心思想就是把内存切分的非常的细小,分为多级管理,以降低锁的粒度。
  3. 回收对象内存时,并没有将其真正释放掉,只是放回预先分配的大块内存中,以便复用。只有内存闲置过多的时候,才会尝试归还部分内存给操作系统,降低整体开销

Go的内存结构

Go在程序启动的时候,会分配一块连续的内存(虚拟内存)。整体如下:图中span和bitmap的大小会随着heap的改变而改变

arena

arena区域就是我们通常所说的heap。

spans

spans区域,可以认为是用于上面所说的管理分配arena(即heap)的区域。
此区域存放了mspan的指针,spans区域用于表示arena区中的某一页(page)属于哪个mspan。

思考下面的问题,假如再分配“p4”的时候,是不是内存不足没法分配了?是不是有很多碎片?

go的解决方式:按需分配
go将内存块分为大小不同的67种,然后再把这67种大内存块,逐个分为小块(可以理解为大小不同的page),称之为span(连续的page),在go语言中就是上文提及的mspan

对象分配的时候,根据对象的大小选择大小相近的span,这样,碎片问题就解决了。

在源码 src/runtime/sizeclasses.go,可以查到所有的 span 类型,67中不同大小的span代码注释如下(go版本1.11):

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
// class  bytes/obj  bytes/span  objects  tail waste  max waste
// 1 8 8192 1024 0 87.50%
// 2 16 8192 512 0 43.75%
// 3 32 8192 256 0 46.88%
// 4 48 8192 170 32 31.52%
// 5 64 8192 128 0 23.44%
// 6 80 8192 102 32 19.07%
// 7 96 8192 85 32 15.95%
// 8 112 8192 73 16 13.56%
// 9 128 8192 64 0 11.72%
// 10 144 8192 56 128 11.82%
// 11 160 8192 51 32 9.73%
// 12 176 8192 46 96 9.59%
// 13 192 8192 42 128 9.25%
// 14 208 8192 39 80 8.12%
// 15 224 8192 36 128 8.15%
// 16 240 8192 34 32 6.62%
// 17 256 8192 32 0 5.86%
// 18 288 8192 28 128 12.16%
// 19 320 8192 25 192 11.80%
// 20 352 8192 23 96 9.88%
// 21 384 8192 21 128 9.51%
// 22 416 8192 19 288 10.71%
// 23 448 8192 18 128 8.37%
// 24 480 8192 17 32 6.82%
// 25 512 8192 16 0 6.05%
// 26 576 8192 14 128 12.33%
// 27 640 8192 12 512 15.48%
// 28 704 8192 11 448 13.93%
// 29 768 8192 10 512 13.94%
// 30 896 8192 9 128 15.52%
// 31 1024 8192 8 0 12.40%
// 32 1152 8192 7 128 12.41%
// 33 1280 8192 6 512 15.55%
// 34 1408 16384 11 896 14.00%
// 35 1536 8192 5 512 14.00%
// 36 1792 16384 9 256 15.57%
// 37 2048 8192 4 0 12.45%
// 38 2304 16384 7 256 12.46%
// 39 2688 8192 3 128 15.59%
// 40 3072 24576 8 0 12.47%
// 41 3200 16384 5 384 6.22%
// 42 3456 24576 7 384 8.83%
// 43 4096 8192 2 0 15.60%
// 44 4864 24576 5 256 16.65%
// 45 5376 16384 3 256 10.92%
// 46 6144 24576 4 0 12.48%
// 47 6528 32768 5 128 6.23%
// 48 6784 40960 6 256 4.36%
// 49 6912 49152 7 768 3.37%
// 50 8192 8192 1 0 15.61%
// 51 9472 57344 6 512 14.28%
// 52 9728 49152 5 512 3.64%
// 53 10240 40960 4 0 4.99%
// 54 10880 32768 3 128 6.24%
// 55 12288 24576 2 0 11.45%
// 56 13568 40960 3 256 9.99%
// 57 14336 57344 4 0 5.35%
// 58 16384 16384 1 0 12.49%
// 59 18432 73728 4 0 11.11%
// 60 19072 57344 3 128 3.57%
// 61 20480 40960 2 0 6.87%
// 62 21760 65536 3 256 6.25%
// 63 24576 24576 1 0 11.45%
// 64 27264 81920 3 128 10.00%
// 65 28672 57344 2 0 4.91%
// 66 32768 32768 1 0 12.50%

  • class: class ID,每个span结构中都有一个class ID, 表示该span可处理的对象类型
  • bytes/obj:该class代表对象的字节数
  • bytes/span:每个span占用堆的字节数,也即页数*页大小
  • objects: 每个span可分配的对象个数,也即(bytes/spans)/(bytes/obj)
  • waste bytes: 每个span产生的内存碎片,也即(bytes/spans)%(bytes/obj)

以类型(class)为1的span为例,span中的元素大小是8 byte, span本身占1页也就是8K, 一共可以保存1024个对象。

细心的同学可能会发现代码中一共有66种,还有一种特殊的span:即对于大于32k的对象出现时,会直接从heap分配一个特殊的span,这个特殊的span的类型(class)是0, 只包含了一个大对象, span的大小由对象的大小决定。

bitmap

bitmap 有好几种:Stack, data, and bss bitmaps和heap bitmaps。

在此bitmap的作用是标记arena(即heap)中的对象。一是的标记对应地址中是否存在对象,另外是标记此对象是否被gc标记过。一个功能一个bit位,所以,heap bitmaps用两个bit位。

bitmap区域中的一个byte对应arena区域的四个指针大小的内存的结构如下:

bitmap的地址是由高地址向低地址增长的。

arena中包含基本的管理单元和程序运行时候生成的对象或实体,这两部分分别被spans和bitmap这两块非heap区域的内存所对应着。

Go的内存管理组件

  1. mspan:为内存管理的基础单元,直接存储数据的地方。
  2. mcache:每个运行期的goroutine都会绑定的一个mcache(具体来讲是绑定的GMP并发模型中的P,所以可以无锁分配mspan),mcache会分配goroutine运行中所需要的内存空间(即mspan)。
  3. mcentral:为所有mcache切分好后备的mspan
  4. mheap:代表Go程序持有的所有堆空间。还会管理闲置的span,需要时向操作系统申请新内存。

mspan

mspan可以说是go内存管理的最基本单元,page是内存存储的基本单元

mspan(源码:src/runtime/mheap.go)是包含了 n 个页的集合,内部有两个字段startAddr和npages,分别代表 page 的开始地址,和一共几个 page. 还有spanclass字段表示该 mspan 是哪种对象的集合(上述定义的 67 种的一种)

spans 里面存放了 mspan 的集合,每个 mspan 里面存放 n 个page ,span 内部的 page 一定是连续的

mspan结构本身的内存是从系统分配的,且是双向链表

mcache

为了避免多线程申请内存时不断的加锁,goroutine为每个线程分配了span内存块的缓存,这个缓存即是mcache。

mcache (源码:src/runtime/mcache.go)是绑定在GMP模型中 P 上的缓存对象,每个goroutine都会绑定的一个mcache,各个goroutine申请内存时不存在锁竞争的情况。
点击此处跳转到:Go协程调度

如何做到的?因为运行期间一个goroutine只能和一个P关联,而mcache就在P上,所以,不可能有锁的竞争。

mcache中的span链表分为两组,一组是包含指针类型的对象,另一组是不包含指针类型的对象。主要是方便GC,在进行垃圾回收的时候,对于不包含指针的对象列表无需进一步扫描是否引用其他活跃的对象。点击此处跳转到:Go垃圾回收

mcentral

mecntral(源码:src/runtime/mcentral.go)为所有mcache提供切分好的mspan,但是保存了从 mheap 申请的内存块,包括带指针和不带指针的, 67 种类型.每一种类型对应一个 mcentral,每个mcentral保存一种特定类型的全局mspan列表。

有多少种类型的mspan就有多少个mcentral。每个mcentral都会包含两个mspan的列表:

  1. 没有空闲对象或mspan已经被mcache缓存的mspan列表(empty mspanList)
  2. 有空闲对象的mspan列表(empty mspanList)

由于mspan是全局的,会被所有的mcache访问,所以会出现并发性问题,因而mcentral会存在一个锁。

单个的mcentral结构如下:

假如需要分配内存时,mcentral没有空闲的mspan列表了,此时需要向mheap去获取。

mheap

mheap(源码:src/runtime/mheap.go)保存了全局的对象,从内存分配的对象先由 mheap 托管,同时,大文件(>32KB)文件也是直接从 mheap 申请,拥有全局锁

mheap可以认为是Go程序持有的整个堆空间,mheap全局唯一,可以认为是个全局变量。

这个lock是作用是什么呢?

大对象直接通过mheap 分配,这些大对象的申请是由mcache发出的,而mcache在P上,程序运行的时候往往会存在多个P,因此,这个内存申请是并发的;所以为了保证线程安全,必须有一个全局锁。

假如需要分配的内存时,mheap中也没有了,则向操作系统申请一系列新的页(最小 1MB)。

Go的内存分配

  1. 微对象 (<16B)
    有一个微型分配器tiny allocator来分配,分配的对象都是不包含指针的,例如一些小的字符串和不包含指针的独立的逃逸变量等
  2. 小对象(16B~32KB)
    就是mcache根据对象的大小来找自身存在的大小相匹配mspan来分配。又分为 67 种
  3. 大对象(>32KB)
    大对象直接从 mheap 上申请,同时由于可能并发,所以一定是加锁获取的

微对象和小对象都是直接通过mcache来分配的,小对象分配顺序:

  1. 首先去 mcache 中申请,由于 mcache 是绑定在 P (GMP) 上的,所以不会发生竞争,这块不用加锁,分配器根据申请的对象大小,向上取整,找到对应的 67 种类型的一种。
  2. 当 mcache 中的该类型内存不够分配时,分配器去先去 mcentral 对应的类型下的 mspan 列表申请,然后放到P 的mcache 中,这个过程由于可能多个 goroutine 竞争,所以会有锁
  3. 当该类型的 mcentral 中的空间也不够时,分配器会去 mheap中申请,mheap 最后给 goroutine 分配
  4. 最后如果 mheap 中的内存也不够了,那么这时候,就需要重新去操作系统申请了(mmap)。

整体内存分配示意图:

runtime包常用

runtime.GOMAXPROCS

runtime.GOMAXPROCS() 可以用来设置 P 的数量,一般设置为和逻辑 CPU 数量相等的值:

1
2
3
4
5
fmt.Println(runtime.NumCPU())
runtime.GOMAXPROCS(runtime.NumCPU()) // 使用所有的逻辑 CPU

// 结果
我的主机 CPU 是1624线程,所以会使用24个 P

runtime.Gosched

runtime.Gosched() 用于让出当前协程的运行时间片,也就是当 P 遇到它时,会先安排其他协程先执行:

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
func main() {
runtime.GOMAXPROCS(1)
go func() {
for i := 0; i < 5; i++ {
fmt.Println("go")
}
}()

runtime.Gosched()
fmt.Println("hello")
}


// 结果
输入结果不是固定的,有可能是
go
go
go
go
go
hello
也有可能是
go
go
go
go
hello
go
也有可能是
hello

输出第一种情况容易理解,主协程让出了时间片,理所应当先打印 Go,但是如果子协程还没有来得及被调度或者打印,就会出现其他情况。

runtime.Goexit

runtime.Goexit() 会结束当前的协程,但是 defer 语句会正常执行。此语法不能在主函数中使用,会引发 panic:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func main() {
runtime.GOMAXPROCS(1)
go func() {
defer fmt.Println("defer不受影响")
fmt.Println("我被执行了")
runtime.Goexit()
fmt.Println("我被跳过了")
}()

time.Sleep(1 * time.Second)
}

// 结果
我被执行了
defer不受影响

Go的变量逃逸

以一个问题进行举例:

1
2
3
4
5
6
7
8
9
10
11
//test.c程序

int * get_res(int num){
int tmp = num + 10;
return &tmp;
}

int main(){
int * res = get_res(80);
printf("%d -- %p\n" , *res, res);
}

上面写了一个简单的 C 代码,获取传入数据并 + 10 得到的结果

这里可以看出编译程序,报了 warning 了,不过不影响程序的编译 , 这个 warning 报错信息是 因为我们返回了临时变量的地址,C 编译器检测到了,给我们抛出了一个 warning。
执行编译的程序后,崩溃了 ,出现“段错误”的原因很明显,上面有说到,是因为外部访问了局部变量的地址,外部访问的时候,此时这个局部变量已经被销毁了,此时外部访问的这个指针,属于野指针,因此出现程序崩溃

熟悉 C 的小伙伴一点都不惊慌,他们不会写出这种代码,那么 go 程序的逻辑和上面 C 程序的逻辑一模一样,那么我们看看是否会出现程序崩溃呢

1
2
3
4
5
6
7
8
9
10
11
//test.go程序

func getRes(num int) *int {
tmp := num + 10
return &tmp
}

func main() {
res := getRes(80)
fmt.Printf("%d -- %p\n", *res, res)
}

执行上述代码,查看效果

1
2
# go run main.go
90 -- 0xc420018078

熟悉 go 语言的小伙伴看到这里心中也毫无波澜,程序正常执行,没有崩溃,因为他们知道原因,这个现象属于变量逃逸

go自己决定了内存分配,一个变量是分配在栈上面还是堆上面,不是简单的看一个变量是局部变量就分配到栈上,这个是根据具体的使用的,有时候它也会被分配到堆上面,当我们发现本应该分配在栈上面的变量,却分配在堆上面了,说明发生了逃逸

尝试写一个简单的 demo ,还是将局部变量的地址返回到外部去,外部来访问这个局部变量的地址

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func getRes(tmp int) *int {

var t1 int = 1
var t2 int = 2
var t3 int = 3

println(&tmp, &t1, &t2, &t3)

return &t2
}

func main() {
res := getRes(80)
println(*res, res)
}

执行上述代码查看效果,通过将变量地址打印出来,貌似没有看出端倪,地址是也是连续的。

1
2
3
# go run main.go
0xc420045f50 0xc420045f68 0xc420045f60 0xc420045f58
2 0xc420045f60

那么我们使用 go 提供的工具来看看这个程序是不是存在逃逸,执行 go tool compile -m main.go查看效果如下

go tool compile 工具很明显的调试出来说明 t2 这个变量已经逃逸到堆上面去了,参数 -m 是直接查看是否逃逸,可以加 -S 会打印出具体的会变代码,查看该变量是否是 new 出来的

1
2
3
# go tool compile -S main.go | grep new
0x0035 00053 (main.go:6) CALL runtime.newobject(SB)
rel 54+4 t=8 runtime.newobject+0

Go的channel

channel(通道)用于goroutine(协程)之间的通信。它提供了一种在不同协程之间传递数据的机制。channel是一种类型安全的、阻塞的、先进先出(FIFO)的数据结构,确保发送的数据按照发送的顺序接收。Go语言提供通过通信来共享内存,而不是通过共享内存来通信

默认情况下,读写未就绪的channel(读没有数据的channel,或写缓冲区已经满了的channel)时,协程会阻塞。但是当读写channel操作和select搭配操作时,即使channel没有准备就绪,也可以执行其他分支,当前协程并不会阻塞

1
2
3
4
5
ch := make(chan int)
select {
case <-ch:
default:
}

channel的底层源码和相关实现在src/runtime/chan.go中,channel设计的核心数据结构包含以下三个:

hchan

hchan是channel底层的数据结构,其核心是由数组实现的一个环形缓冲区

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//channel
type hchan struct {
qcount uint // total data in the queue 通道中数据个数
dataqsiz uint // size of the circular queue 数据缓冲buf长度
buf unsafe.Pointer // points to an array of dataqsiz elements 数组指针,存储对应的channel数据
elemsize uint16 //元素大小 channel 元素大小
closed uint32 //通道关闭标志
elemtype *_type // element type channel 元素类型
sendx uint // send index 写入数据的index
recvx uint // receive index 读取数据的index
recvq waitq // list of recv waiters 由双向链表实现的recv waiters 队列 阻塞读操作的gorotinue队列
sendq waitq // list of send waiters 由双向链表实现的send waiters队列 阻塞写操作的gorotinue队列

// lock protects all fields in hchan, as well as several
// fields in sudogs blocked on this channel.
//
// Do not change another G's status while holding this lock
// (in particular, do not ready a G), as this can deadlock
// with stack shrinking.
lock mutex //锁
}

waitq

waitq 是因读写channel而陷入阻塞的协程等待队列,hchan的recvq和sendq使用这种数据结构,本质是双向链表

1
2
3
4
5
//等待队列(双向链表)
type waitq struct{
first *sudog //队列头部
last *sudog //队列尾部
}

sudog

sudog 是协程等待队列的节点

1
2
3
4
5
6
7
8
type sudog struct{
g *g //等待读取或写入的协程g,即因读写channel而陷入阻塞的协程
next *sudog//等待队列下一个节点
prev *sudog//等待队列上一个节点
elem unsafe,Pointer //对于写channel 是需要发送的数据指针,对于读channel是目标需要复制的数据指针
success bool //标记协程g被唤醒是因为数据传递(true)还是channle被关闭(false)
c *hchan//对应的channel指针
}

分配内存

  1. 如果是无缓冲channel,直接给hchan结构体分配内存并返回指针。
  2. 如果是有缓冲channel,但元素不包含指针类型,则一次性为hchan结构体和底层循环数组分配连续内存并返回指针。(需要连续内存空间)
  3. 如果是有缓冲channel,且元素包含指针类型,则分别分配hchan结构体内存和底层循环数组的内存并返回指针。(可以利用内存碎片)

创建channel的主要实现是在makechan()函数中:

1
func makechan(t *chantype, size int) *hchan

发送数据

如果channel的读等待队列中存在接收者goroutine,则为同步发送

  • 无缓冲channel,不用经过channel直接将数据发送给第一个等待接收的goroutine,并将其唤醒等待调度。
  • 有缓冲channel,但是元素个数为0,不用经过channel(假装经过channel)直接将数据发送给第一个等待接收的goroutine,并将其唤醒等待调度。

如果channel的读等待队列中不存在接收者goroutine:

  • 如果底层循环数组未满,那么把发送者携带的数据入队队尾,此为异步发送
  • 如果底层循环数组已满或者是无缓冲channel,那么将当前goroutine加入写等待队列,并将其挂起,等待被唤醒,此为阻塞发送

接收数据

如果 channel 的写等待队列存在发送者 goroutine,此为同步接收

  • 如果是无缓冲 channel,直接从第一个发送者 goroutine 那里把数据拷贝给接收变量,唤醒发送的 goroutine;
  • 如果是有缓冲 channel(已满),将循环数组 buf 的队首元素拷贝给接收变量,将第一个发送者 goroutine 的数据拷贝到 buf 循环数组队尾,唤醒发送的 goroutine;

如果 channel 的写等待队列不存在发送者 goroutine:

  • 如果循环数组 buf 非空,将循环数组 buf 的队首元素拷贝给接收变量,此为异步接收
  • 如果循环数组 buf 为空,将当前 goroutine 加入读等待队列,并挂起等待唤醒,此为阻塞接收

关闭channel

关闭操作在编译时转换为closechan()函数,总体流程如下:

  1. 边界检查
  2. 从recvq释放所有的readers
  3. 从sendq释放所有的writers(会产生panic)
  4. 唤醒所有的readers和writers

—————请稍等———————

------赞助耶耶,加快更新!------

给耶耶买杯咖啡喝