0%

Golang面试题记录

前置

记录面试题以供复习

面试题

Golang

Go数据类型:

基础类型:

整型(int/uint/int8/uint8/int16/uint16/int32/uint32/int64/uint64/byte/rune等)、浮点数(float32/float64)、复数类型(complex64/complex128)、字符串(string

复合类型:

数组和结构体类型。

引用类型:

切片(slice)、map、channel、指针 保存底层数据的引用

接口类型:

interface类型

基础语法

for range

for range遍历对象,在编译器都会转换成 基础循环 for i 那种模式,

  • 遍历切片
    • 在不关心返回东西的情况下,编译出的代码和 for i是一样的
    • 在关心返回索引情况下, 编译出的代码 在for i的基础上 新增了一个 临时变量,保存索引,在for 循环内部更新
    • 在关心索引和返回数据情况下, 编译出的代码在第二种情况,新增一个变量,保存当前遍历出的对象,在for循环内部更新值,所以在引用返回值地址的时候,其实是同一个地址
  • 遍历map
    • map本身做了无序处理,在遍历时候,加了随机数

string

String 底层为结构体,内部包含 一个指针,指向字节数组,一个len,保存字节个数,字符串重新赋值时候会重新分配底层数组地址,不会在原来位置修改,go默认认为字符串内存是不可修改的,字符串转换成字节切片时候,会重新分配地址,拷贝字符串的值

字符串为什么保存字节个数呢,中文和英文的表达方式不同,中文可能占3个字节,底层存储,统一按照utf8格式编码和解码

map

创建map时候,返回的是hmap 指针,不能对map内元素取地址,map内的元素会扩容迁移地址会改变

Hmap 是map的底层实现,内部包含桶的个数,桶数组的地址,定位具体的桶是通过unsafe包 操作内存地址得到的,

bmap为桶内的具体实现, 包含8个key 的hash值, 8个key,8个value 依次排序,可以减少内存padding

1
2
3
4
5
6
7
bmap{
tophash [8]int8
key
key
value
value
}
  • Get:

得到key的hash值,对桶数取模,得到桶的位置,如果老的桶还在且 该桶没有迁移走,遍历老的桶内的cell,比较tophash,如果tophash一样,需要对比key的具体值equal,如果没有遍历该桶的溢出桶

  • put:

    • 在增加元素时候,如果发现其他协程在写,panic并发写问题, 如果没有,更新map状态,有人在操作,如果map的桶是空的,创建桶
    • 根据桶的个数和 keyhash 值 得到需要去的桶,判断map是否在扩容状态,如果是,支持扩容
    • 根据桶的地址,和落的桶的个数乘每个桶的大小 得到落的桶的 地址
    • 循环比较这个桶里面的tophash,如果不一样,cell是空的,且key没有找到位置, 将tophash的位置,key的位置,和value的位置保存
    • 如果tophash和需要的hash一样,比较key的值,如果值不一样,跳过,如果一样,更新key,返回value的地址,跳出
    • 如果这个桶没有找到,遍历溢出桶
    • 判断是否需要扩容,
    • 如果没有找到可以插入的节点,创建溢出桶,保存cell的 tophash key value地址,更新值
    • map个数+1
    • 修改map的状态,无人操作
  • delete

    • 如果有其他协程在写,panic,修改map状态,有人在操作
    • 算出需要删除的key在那个 桶,如果在扩容,协助扩容这个桶
    • 得到桶的具体地址,遍历桶内容,判断tophash和 hash值是否一样,如果一样,得到key的地址,解析值,对比,不一样退出,继续找
    • 如果一样,得到value的值,删除,更新tophash的值为 emptyOne
    • 如果此位置是第8个 tophash位置,判断是否有溢出桶,如果溢出桶的tophash不是emptyRest,直接退出
    • 如果是其他位置,会判断后面一个tophash是否是emptyRest,如果不是直接退出,
    • 判断,emptyRest,代表此位置后续没有数据了,如果后续没有数据了,将当前位置赋值 emptyRest
    • 如果这个是 第一个cell, 且不是溢出桶,直接退出,如果是溢出桶,需要找到之前的桶,查看桶内的cell状态,直到不是emptyOne才退出
    • 删除时候,会减少桶内元素个数,修改map状态,无人操作
  • 扩容逻辑

    • 在增加元素时候,两种情况会扩容,1)桶内元素个数 大于 6.5 * 桶的个数。 2)溢出桶的个数太多 大于桶的个数
    • 如果不是第一种情况,不用扩桶的大小,如果是第一种,桶的个数为之前的2倍,将桶移动给老的桶,桶指向新的桶,溢出桶为0,迁移桶的个数为0
    • 如果老的桶 不是nil,扩容就没有结束
    • 扩容当前使用的桶
    • 根据桶的索引,计算出桶的地址,
    • 如果桶的第一个tophash 是 h > emptyOne && h < minTopHash, 则认为该桶 迁移完成
    • 扩容分等量扩容和 2倍扩容,所以桶一分为二,默认是等量扩容,使用前面一半桶X, 后面桶叫Y
    • 在新桶定位到这个迁移的桶的位置,得到X的操作地址, key 和value的地址,如果是2倍扩容,Y的值,为桶的地址加上之前的桶的个数的地址
    • 遍历这个老桶,得到key 和value的起始地址,遍历所有cell
    • 如果tophash是 被删除的,tophash被重置为evacuatedEmpty
    • 判断是否是二倍扩容,及如果是二倍扩容,这个hashkey 落到X还是Y
    • 将位置的tophash 重置为 evacuatedX 或者 evacuatedY
    • 保存迁移数据的数组 对应X 或者Y 节点增加数据,如果这桶加满了,创建溢出桶
  • for range

    • 遍历数据
    • 生成随机数,和桶数与,得到开始扫描的桶,该桶随机扫描的cell位置,
    • 开始扫描初始桶,从指定位置cell 开始扫描,如果tophash不是emptyOne, evacuatedEmpty,如果tophash也不是已经迁移,则直接获取地址上的, key和value
    • 如果是已经迁移的,根据key重新扫描桶,和get一样,得到key和value
    • 遍历溢出桶, 遍历其他的桶
  • map为什么需要tophash

    • 可以优化查询速率,当hash冲突后,会放入同一个桶,桶内有8个实例,计算出键的hash值的高8位,然后对比,当对比成功后,在对比键的真实值,可以降低对比键的速度,特别是大键

slice切片数据结构,扩容逻辑

切片底层结构是结构体,属性包括 字节数组,长度,和容量

1
2
3
4
5
type SliceHeader struct {
Data uintptr
Len int
Cap int
}
  • Make 创建切片时候,会创建底层数组,

  • append 增加元素时候 也会创建底层数组

  • new 创建切片时候,只会创建指针,不会分配底层数组

  • 如果多个切片是 通过一个 数组生成的,len为创建时候大小,cap受底层数组变化 那么会互相影响,知道切片大小大于原来数组之后,会重新分配内存作为底层数组,将原有数据拷贝走,之前的数组不受影响了

扩容机制

第一步,获取预估扩容后的元素个数

  1. 如果老的元素个数 翻倍 不能满足 新增后的元素个数要求,直接定为 新增后的元素个数
  2. 如果翻倍满足需求, 如果老的元素小于1024,直接翻倍
  3. 如果老的元素大于1024,扩容1.25倍,1/4

得到个数后,根据个数*元素大小,获取到总的申请内存,然后和内存块 匹配适合的内存块, 然后根据内存块/ 单元素大小= 容量

go函数是值传递吗,在底层函数修改了切片,map上游会变吗

  • 切片通过函数传递后,不论是否传了指针,在下游修改都会改变原有数据,因为传递过来的是切片包含底层数组的指针,对数据修改原数据会受影响,但是使用append,不会改变原有数据,会生成新数据

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    func main() {

    list := []string{"zhangsan"}

    testSlice(list)
    fmt.Println("waibu:", list)
    testSlicePtr(&list)
    fmt.Println("waibu:", list)

    }

    func testSlice(list []string) {
    //list = append(list, "list")
    list[0] = "aa"
    fmt.Println("neibu:", list)
    }


    func testSlicePtr(list *[]string) {
    strings := *list
    //lists := append(strings, "list")
    strings[0] = "bb"
    fmt.Println("neibuPtr:", strings)
    }
  • map创建后本身就是指针类型hmap, 对map增删改,都会改变上游map的数据

    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
    func main() {

    m := map[string]string{
    "zhangsan": "test",
    }

    testMap(m)
    fmt.Println("waibu:", m)
    testMapPtr(&m)
    fmt.Println("waibu:", m)

    }

    func testMap(m map[string]string) {
    m["zhangsan"] = "testMap"

    m["lisi"] = "a"
    m["lisi1"] = "a"
    m["lisi2"] = "a"
    m["lisi3"] = "a"
    m["lisi4"] = "a"
    m["lisi5"] = "a"

    }


    func testMapPtr(m *map[string]string) {
    mt := *m
    mt["zhangsan"] = "testMapPtr"
    fmt.Println("neibuPtr:", mt)
    }

== 机制

前提, 比较的两个数据类型一定要相同,不相同编译器会报错

  • 基本数据类型

    直接比较 类型的值是否一样

  • 符合类型,数组和结构体

    • 数组,数组长度也是类型的一部分,如果长度不一样,不可以比较, 依次比较内部元素,全部相等才可以
    • 结构体,依次比较字段和类型,根据类型比较每个字段的值,全部相等才可以
  • 引用类型,切片,map,channel,指针

    切片和map不允许比较, 只能和nil比较

    引用类型保存底层数据的地址,比较时候只会比较引用的是不是同一个地址, 地址里面的内容不会比较

  • 接口类型

    接口类型内部 保存 动态数据类型,和动态数据的值,根据类型 执行对应类型的比较

  • 自定义类型

    比较 底层真实的数据类型,

atomic

原子操作,通过CPU指令LOCK,硬件层级实现对内存的原子操作

提供了Add,Store,Load,compareAndSwap函数。可以对共享变量进行存储,获取,增加,比较替换等原子操作。

问题:

  • AddInt32,AddInt64,没有AddInt16

    是因为这个操作主要依赖CPU架构,目前没有16位的系统,都是使用32/64位系统

Mutex

Mutex数据结构

1
2
3
4
5
6
7
8
9
10
11
type Mutex struct {
state int32
sema uint32
}

const (
mutexLocked = 1 << iota // mutex is locked
mutexWoken
mutexStarving
mutexWaiterShift = iota
)

state用来存储锁的状态

sema用来传递信号,供gorutine接收,是否唤醒

1
2
3
4
5
state信息 32位分配
0-29 (计数存储多少个goroutine在等待锁)
30位 锁是否处于饥饿状态,是否有goroutine等待超过1ms
31位 存储是否有唤醒goroutine, 1代表已经在唤醒goroutine,在加锁中
32位 存储锁的状态 1代表已经被别人占锁, 0代表没人占用

加锁过程

如果是单携程加锁,通过CompareAndSwapInt32函数,尝试修改state的值,Lock状态,如果修改成功,则加锁,如果失败被阻塞,等待加1。加锁失败时候,会先自旋,然后才会阻塞

解锁过程

如果当前mutex没有被加锁,使用解锁,panic,如果当前没有携程在等待,将lock状态置为0,不需要发送信号。如果等待的goroutine大于1,则需要发送信号,唤醒goroutine抢锁。

RWMutex

1
2
3
4
5
6
7
type RWMutex struct {
w Mutex // held if there are pending writers
writerSem uint32 // semaphore for writers to wait for completing readers
readerSem uint32 // semaphore for readers to wait for completing writers
readerCount int32 // number of pending readers
readerWait int32 // number of departing readers
}

加锁

使用互斥锁,获取锁,如果没有正在读的携程,则直接获取锁,如果有正在读取的携程,则阻塞,等待

解锁

循环唤醒之前阻塞的读携程

Channel

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
type hchan struct {
qcount uint // total data in the queue
dataqsiz uint // size of the circular queue
buf unsafe.Pointer // points to an array of dataqsiz elements
elemsize uint16
closed uint32
elemtype *_type // element type
sendx uint // send index
recvx uint // receive index
recvq waitq // list of recv waiters
sendq waitq // list of send waiters

// 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
}
  • qcount 队列内有多少个数据
  • dataqsiz 循环队列的大小
  • buf,循环队列指针
  • sendx 发送索引
  • recvx 接收索引
  • recvq 发送阻塞的goroutine链表
  • sendq 接收阻塞的goroutine链表
  • Lock 保证读写安全的锁

读取关闭后的channel,读取到的是零值,ok结果是false

channel只能关闭一次,不然会panic,不能向关闭的channel发送数据,会panic

断言

断言是通过 判断接口的 动态类型 和 断言的动态类型是否一致,

如果是非空接口,会对比方法是否被实现

反射

  • 可以通过interface 类型 转换成 反射类型
  • 可以通过反射类型, interface 方法转成 interface类型
  • 修改反射类型的值, 需要保证字段是可以修改的

在编译时不知道类型的情况下,通过反射机制可以获取对象的类型、值、方法甚至动态改变对象的成员,这就是反射机制

反射原理

TypeOf(a)

返回反射类型 rtype的接口Type,rtype实现Type接口,有方法,还有类型元数据

获取a的类型,方法接受空接口类型参数,需要一个地址,所以编译阶段会生成a的拷贝变量,用这个作为参数,为什么不用a的地址,因为传的是a,值拷贝,不应该改变原来的值,如果是地址,操作之后很有可能改变之前的数据

将传入的值 转换成 eface接口类型, 并返回 eface的 动态类型元数据,这个类型实现了类型接口,有很多方法,这个类型是个非空接口,因为实现了类型接口

根据返回值 调用方法,根据得到的类型元数据 调用方法

ValueOf(a)

Value结构体内部保存 反射变量的动态类型,动态值

获取接口的反射值,代码会直接将 a 逃逸到堆上,a如果不是指针,那么逃逸的是a的副本,返回Value类型,修改a的副本,没有意义,所以ValueOf参数必须是指针,可以修改原有的值

参数的指针,那么指向的值在堆上,栈里面保存的是指针

Unsafe包

Uintptr 数据类型 表示非常大的整数,可以保存指针,

Pointer 底层是 *int 类型

*int 指针类型

指针类型和pointer互转, pointer可以和 uintptr 互相转, pointer无法进行运算,可以转成 uintprt 运算地址

1
2
3
4
5
6
7
unsafe 提供三个函数
// 得到x 占用字节大小,指针占用8字节
func Sizeof(x ArbitraryType) uintptr
// 得到结构体成员 距离结构体开始地址差距
func Offsetof(x ArbitraryType) uintptr
// 得到内存对齐参数
func Alignof(x ArbitraryType) uintptr

例子

slice 内部包含,一个指针,int len , int cap

1
2
3
var Len = *(*int)(unsafe.Pointer(uintptr(unsafe.Pointer(&s)) + uintptr(8)))
// 通过内存操作
fmt.Println(Len, len(s)) // 9 9

使用unsafe将字符串和字节切片互转,零拷贝

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func strToByteSlice(str string) []byte {

fmt.Println("str:", str)
// 因为 str的底层结构是 字节数组指针 和len长度, 直接获取到字符串的地址,就是字节数组指针的地址,强转成字节切片指针,然后获取值
sli := *(*[]byte)(unsafe.Pointer(&str))

fmt.Println("slice:", sli)
return nil
}

func byteSliceToStr() []byte {

bs := []byte{'a', 'b', 'c'}
fmt.Println("slice:", bs)
s := *(*string)(unsafe.Pointer(&bs))

fmt.Println("s:", s)
return nil
}

内存对齐

CPU访问内存数据 是按照字长读取的,不是字节, 字长和CPU位数有关,32位 字长为4 64位字长为8,数据类型在存储的时候需要做内存对齐,每种类型存储地址需要是对应类型的对齐倍数,4字节,地址需要被4除尽

32位机器 最大对齐边界 4字节, 64位机器 最大对齐边界 8字节

int8 对比边界 1字节

Int16 对齐边界 2字节

int16在 64位机器上 对齐边界是 2字节,存储的地址要被2除尽

最大对齐边界,是类型对齐边界和 机器平台的最大对齐边界的最小值,为什么这样,主要是全部以平台最大边界使用的话,会有内存浪费

结构体的对齐边界,是字段类型的 最大对齐边界,内部字段顺序存放,结构体整体占有的内存 需要是内存对齐的倍数

闭包

函数在GO语言中是 funcVal 结构体, 在编译期间固定,在代码段保存 方法,在只读数据段分配一个funcVal结构体,内部保存函数的起始地址用于执行,

闭包为函数引用了外部变量,一个函数的返回值是一个函数,返回的函数内 引用了其他的外部变量叫做闭包。

闭包函数的指令 也是在编译阶段生成,闭包函数对象都有自己的捕获对象,所以在执行闭包函数时候,会在堆上创建funcVal结构体,内部包含 闭包内部的函数地址,和捕获变量,如果这个变量有被改变,那么捕获的是变量地址,如果变量没有改动,捕获的是快照

捕获的局部变量如果修改的话,局部变量会在堆上分配,捕获的地址,如果捕获的是参数,栈里面会保存参数,也会拷贝到堆上去,闭包函数操作的是堆上的,如果捕获的是返回值,调用者栈帧也会分配返回值空间, 堆上也会分配一个返回值空间,闭包操作堆上的,但是最终会把堆上的返回值,拷贝到栈上

错误回溯

可以获取error的堆栈

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
func trace() {
pc := make([]uintptr, 10)
n := runtime.Callers(2, pc)
frames := runtime.CallersFrames(pc[:n])

for {
frame, more := frames.Next()
fmt.Printf("%s\n\t%s:%d\n", frame.Function, frame.File, frame.Line)
if !more {
break
}
}
}

func main() {
err := doSomething()
if err != nil {
fmt.Println("Error:", err)
trace()
}
}

内存结构

高地址

BSS

数据段

代码段

低地址

代码段:存放可执行的操作指令

数据段:保存初始化的全局变量,静态变量和全局变量

BSS:存放未初始化的全局变量

栈:存放函数执行过程临时变量

堆:存动态分配的内存段

defer

defer结构体

1
2
3
4
5
6
7
8
9
 siz     int32 // 参数和返回值占用的大小,会在defer结构体后面分配,在注册阶段保存参数
started bool // defer是否开始执行
heap bool // 是否在堆上
openDefer bool // 开放编码优化
sp uintptr // 注册defer的栈指针
pc uintptr // 注册defer的 pc 程序计数器
fn *funcval // defer注册的函数
_panic *_panic // panic that is running defer
link *_defer // 下游defer

goroutine中会有一个 defer的链表,保存g注册的所有defer,

注册

go 1.12版本

1
deferproc(siz int32, fn *funcval)

参数为 参数和返回值占用的 大小, 和注册函数的地址,没有捕获列表的方法, 在编译阶段,方法会存放在 代码段,会在只读数据段分配funcval,指向代码段的地址,注册defer传的是 只读数据段段funval地址

在堆上分配 defer结构体,构建参数,保存注册时候的参数,参数是需要计算好,保存起来的

会存在defer 池,如果没有结构体,或者不符合需要的,才会在堆上创建

有捕获列表的defer,会形成闭包,在代码段为函数代码指令,堆上分配funcval,包含捕获变量的地址

go 1.13版本

deferprocStack 单独的defer函数 会在编译时候,在栈上分配 defer结构体,局部变量,然后注册到链表上,所以defer多了字段,heap区分地址,for循环的defer不能这样,还是在堆上分配

Go1.14版本

开放编码法, 将defer函数 编译成代码,直接插入现有代码, 限制条件函数defer少于8个, 用一个byte大小,存储每一个bit位置defer是否需要执行,然后在方法底部,判断每一个位置,进行执行,但是如果在执行过程panic 跳出方法了, 下面的defer就执行不了了,所以会进行栈扫描,执行函数,panic会变慢,但是panic毕竟是少数

执行阶段

在函数结束前 执行 deferreturn 函数 开始执行注册的defer,从堆上的参数拷贝到栈上的参数空间,进行执行, 什么时候决定方法执行完了,defer列表为空,或者defer的sp 不是自己的时候

panic recover

goroutine也保存 panic的链表,也是栈模式

在发生panic时候,下面的代码就不会执行了, 执行defer,会把defer的started字段变为true,且内部的panic指针,指向这个panic,然后执行defer函数,在执行defer过程中发生了panic,会新建panic结构插入 链表,然后执行defer列表,如果发现defer的panic指针不是自己,会找到那个panic,设置为已经终止, 不是自己触发的defer会被移除,如果defer执行完了,就打印panic链表信息

1
2
3
4
5
6
7
8
9
10
type _panic struct {
argp unsafe.Pointer // 存储需要执行的defer的指针
arg interface{} // panic的参数
link *_panic // panic参数列表
pc uintptr // where to return to in runtime if this panic is bypassed
sp unsafe.Pointer // where to return to in runtime if this panic is bypassed
recovered bool // 是否被恢复
aborted bool // 是否被终止
goexit bool
}

recover,将panic的 recovered设置为true,在每个defer函数执行完之后,会监测panic是否恢复,如果恢复,将panic从链表移除

代码编译成可执行文件,被加载后,代码会在虚拟内存中 保存为代码段,后续执行,如果在代码中调用方法,编译器会产生call指令,指向对应的代码段执行,执行结束后,会有ret指令,跳转回原本地址

栈,也是在虚拟内存地址中的一段空间, BP表示栈基,SP表示栈指针, 栈地址从高地址向下增长

GO函数栈帧布局, 函数调用者栈基地址,局部变量,调用函数的返回值,参数,返回地址

GO在分配栈空间时候,函数所需栈大小可以在编译期确定,一次性分配栈空间,如果需要较大空间,会在方法头部加入 栈增长代码,分配一个新的栈,将原来的数据拷贝过来

函数调用过程

CPU寄存器: BP寄存器(保存栈基)SP寄存器(保存栈指针)IP寄存器(保存CPU当前执行的指令)

call指令:1、将返回地址(call之前的代码地址)入栈,也会更新SP,2、跳转到指定call代码地址处

执行被调用函数:1、将SP向下移动指定位置(SP-24);2、入栈调用者栈基地址;3、更新栈基寄存器内的值,指向新的栈基

ret指令, 1、恢复调用者栈基;2、释放自己的栈空间,将SP向上移动

函数案例(匿名返回值)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func incr(a int) int{
var b int
defer func(){
a++
b++
}
a++
b = a
return b
}
func main(){
var a, b int
b =incr(a)
fmt.println(a,b)
}

打印 0, 1
代码 含义
BP,main函数栈基 函数栈基
a=0 局部变量
b=0 局部变量
0 被调用函数返回值 int默认值0,是从右至左入栈
0 a 被调用函数参数
return addr call指令存储的函数返回地址,代码块地址
BP,main函数栈基 Incr函数栈帧
b=0 局部变量
a++ 执行函数代码,会修改 被调用函数参数地方的值,加一
b=a 将a的值赋值给,b的局部变量位置,
return 执行ret指令, 需要恢复栈帧, 先给返回值赋值,后执行defer函数
先给返回值赋值,将b的值拷贝到 返回值位置
执行defer函数,参数位置的a++, 局部变量b+1

函数案例(命名返回值)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func incr(a int) (b int){
var b int
defer func(){
a++
b++
}
a++
return a
}
func main(){
var a, b int
b =incr(a)
fmt.println(a,b)
}

打印 0, 2
代码 含义
BP,main函数栈基 函数栈基
a=0 局部变量
b=0 局部变量
0 被调用函数返回值 int默认值0,是从右至左入栈
0 a 被调用函数参数
return addr call指令存储的函数返回地址,代码块地址
BP,main函数栈基 Incr函数栈帧
a++ 执行函数代码,会修改 被调用函数参数地方的值,加一
return 执行ret指令, 需要恢复栈帧, 先给返回值赋值,后执行defer函数
先给返回值赋值,将a的值拷贝到 返回值位置
执行defer函数,参数位置的a++, 返回值b+1. 返回2

垃圾回收

标记清除法

go 1.3 之前使用的方式,

  • 流程

    stop the world -> 根据root对象,向下遍历,标记可达对象 -> 对没有标记的对象清除 -> 停止stw

  • 缺点

    1. stw 影响用户体验,程序不可用
    2. 需要扫描整个堆
    3. 标记清除法 容易导致堆内存碎片化

三色标记法

v1.5

流程

  1. 将所有对象标记为白色

  2. 将root对象 添加为灰色,

  3. 将灰色对象取出,遍历下一层可达对象,加入灰色, 该灰色对象标记为黑色

  4. 直到灰色列表为空, 清除所有白色

  5. 在处理过程中,灰色引用的白色对象 突然释放了,且在同时有 黑色节点引用白色, 会导致对象丢失

问题

如果三色标记法没有stop the world 会出现问题, 在处理过程中,灰色引用的白色对象 突然释放了,且在同时有 黑色节点引用白色, 会导致对象丢失

两个组合条件

  1. 黑色引用了白色对象
  2. 灰色引用了该对象,且断链

优化

如果三色标记法 满足下列之一,即可避免问题

  1. 强三色表达式

    用于破坏对象丢失的第一个条件, 不允许黑色引用白色

    操作:使用插入屏障实现

    在堆上的对象,引用新的对象,被引用的对象,强制转换成灰色,在栈中的对象没有插入屏障, 但是在删除操作时候,会启用stop the world 将栈上的对象全部标记为白色,然后重新执行一次可达性分析,全部标记为黑色,然后清除剩余的白色,可以减少stop the world 时

  2. 弱三色表达式

    用于破坏对象丢失的第二个条件, 黑色可以引用白色对象,但是需要保证,白色对象上游有灰色对象

    操作:使用删除屏障实现

    在对象被删除时候,该对象如果是灰色或者白色, 会被标记为灰色,不足,会有多余的对象对保留, 等待下次GC删除

三色标记法加混合写屏障

v1.8之后

栈对象不会有屏障操作

堆上对象会有屏障操作

流程

  1. GC开始后优先扫描 栈空间, 将栈所有可达节点标记为黑色
  2. GC开始之后,栈上创建的对象,全部标记为黑色
  3. 被删除的对象标记为灰色
  4. 被添加的对象标记为灰色

GMP模型

概念

G: goroutine

M:内核线程,操作系统分配给该GO程序的 线程

P:processor 通过GOMAXPROCS 指定, P的本地队列 最多存放256个G

M0:进程启动之后,启动第一个线程,创建G0,创建Main函数的G,放入绑定的P,然后执行G

G0: 每个M都会创建自己的G0 用于负责调度,不处理业务函数,M0创建G0 是全局变量,用于初始化环境,创建P 创建全局队列

G的状态: 创建newproc状态是 Grunnable 开始执行 Grunning 系统调用 GsystemCall,执行完成Goexit,中断等待gopark状态是 Gwating,系统调用可以直接返回running状态,其他都要是Grunnable状态,才能到running

P的状态:在创建P时候,Pidle状态, 当M请求到P时候,状态为Prunning,当G在执行系统调用,P会被置为Psyscall,如果系统监控线程发现这个状态的P,会被释放,转换成Pidle 给其他P用,如果程序发生GC,P被设置为Pstop,等待系统执行startWorld开启Prunning状态

M的状态:自旋当 没事情的时候,会自旋找工作,如果 超过一定时间,就会休眠,等待其他工作线程唤醒他

流程:

M为内核线程 连接CPU,做真正的操作, M和P是一一对应, P有自己的本地队列,保存待处理的G

  1. 创建的G 会优先放进 P的本地队列,放不下 才会加入全局队列,从全局队列获取 需要加锁
  2. 当M 处理G 有阻塞行为时候,M会睡眠, 会将自己的P 分配给其他的M,如果没有M会创建, 当G恢复后,会加入其他的P的队列等待执行
  3. 如果M对应的P的本地队列没有G的时候, 会优先从全局G队列中获取, 如果没有取到,从其他P的队列中取,

注意点

  1. 如果在M上执行G 内部又创建了一个G1, 那么优先将G1 放入G所在的P的本地队列
  2. 如果M上的G执行完成,G被销毁,M会加载对应的G0 作为调度,获取对应P中的G 进行处理
  3. 如果G 一直创建 导致 所在P的队列满了, 会将队列的前一半的 G 随机选择 放入 全局队列
  4. 如果M 对应的P中没有G 且 M正在运行G0,那么称为自旋线程,会去全局队列获取G,获取算法 min(. Len(GQ)/ GOMAXPROCS + 1, len(GQ) /2 ), 当全局队列没有G时候,会去 其他P中获取 偷取其他队列的 上面一半
  5. 自旋线程 + 执行线程 <= GOMAXPROCS 如果一直增加线程, 没有对应的P, 没用, 线程会进入休眠状态

Debug

  • 使用 go tool trace
    1. 在代码中 启动trace, 将数据写入文件
    2. go tool trace trace 文件查看
  • 使用go debug
    1. 将代码build
    2. 使用GODEBUG=schedtrace=1000 ./文件

内存管理

基本概念

  • Page

    内存分配都是以页为单位的默认8KB,是go与虚拟内存交互的单位

  • mSpan

    一组连续的page,叫做mspan

  • size class

    表示一块内存的刻度,0-66

  • object size

    表示span存储的对象的大小, 67种

  • span class

    每一种内存刻度都会有两个span class,一个存储带指针的对象,需要GC扫描,一种不带指针

  • MCache

    每个线程M, 独享的一份空间,申请内存时候,不用加锁,内部有很多规格的span。每个规格有一个span存储不同大小的对象, 携程申请内存时候,是按照需要的大小对象申请,返回

  • MCentral

    当MCache内存中的span不足时候,会向MCentral申请 span, 内部保存的是多个规格的span链表(go 1.16后改成集合存储),每一个规格有两个span,一个用于维护已经分给MCache使用的(Empty Span list), 一类是MCache退还回来的(noEmpty Span list)

  • Mheap

    当MCentral内存不足时候,向MHeap申请Page, MHeap内部通过HeapArenas 管理内存, 一个HeapArenas保存64M,一共8096个Page

如果申请的内存是0的时候,默认申请的是固定地址,不会产生内存的,所以会使用struct{}

对象内存分配

Tiny对象

1-16Byte

tiny对象较小,从span class 4或5 得到 对象大小为16Byte的空间,专属于tiny对象,如果包含指针,走小对象分配流程

小对象

16Byte-32Byte

从MCache中申请对象 span class的 span存储,会返回一个object内存

(1)首先协程逻辑层P向Golang内存管理申请一个对象所需的内存空间。

(2)MCache在接收到请求后,会根据对象所需的内存空间计算出具体的大小Size。

(3)判断Size是否小于16B,如果小于16B则进入Tiny微对象申请流程,否则进入小对象申请流程。

(4)根据Size匹配对应的Size Class内存规格,再根据Size Class和该对象是否包含指针,来定位是从noscan Span Class 还是 scan Span Class获取空间,没有指针则锁定noscan。

(5)在定位的Span Class中的Span取出一个Object返回给协程逻辑层P,P得到内存空间,流程结束。

(6)如果定位的Span Class中的Span所有的内存块Object都被占用,则MCache会向MCentral申请一个Span。

(7)MCentral收到内存申请后,优先从相对应的Span Class中的NonEmpty Span List(或Partial Set,Golang V1.16+)里取出Span(多个Object组成),NonEmpty Span List没有则从Empty List(或 Full Set Golang V1.16+)中取,返回给MCache。

(8)MCache得到MCentral返回的Span,补充到对应的Span Class中,之后再次执行第(5)步流程。

(9)如果Empty Span List(或Full Set)中没有符合条件的Span,则MCentral会向MHeap申请内存。

(10)MHeap收到内存请求从其中一个HeapArena从取出一部分Pages返回给MCentral,当MHeap没有足够的内存时,MHeap会向操作系统申请内存,将申请的内存也保存到HeapArena中的mspan中。MCentral将从MHeap获取的由Pages组成的Span添加到对应的Span Class链表或集合中,作为新的补充,之后再次执行第(7)步。

(11)最后协程业务逻辑层得到该对象申请到的内存,流程结束。

大对象

(1)协程逻辑层申请大对象所需的内存空间,如果超过32KB,则直接绕过MCache和MCentral直接向MHeap申请。

(2)MHeap根据对象所需的空间计算得到需要多少个Page。

(3)MHeap向Arenas中的HeapArena申请相对应的Pages。

(4)如果Arenas中没有HeapA可提供合适的Pages内存,则向操作系统的虚拟内存申请,且填充至Arenas中。

(5)MHeap返回大对象的内存空间。

(6)协程逻辑层P得到内存,流程结束。

项目

  • 系统架构,服务体量
  • 排查问题的过程
  • 系统监控
  • http长连接
  • 怎么部署的
  • 内存泄漏的场景和解决
  • 如何控制并发
    • 通过channel控制并发数
    • 通过WaiteGroup控制并发,在方法内使用时候一定使用指针 或者 闭包模式,不然是指拷贝会死锁
    • 通过Context控制
  • 测试
  • ces
  • 测试