行业知识
GO语言学习笔记——详细版
1.1.1. Go编译
- 词法与语法分析
- 意义:解析源代码文件,将文件中字符串序列转换成Token序列
- 把执行词法分析的程序称为词法解析器(lexer)
- 语法解析的结果就是抽象语法树(AST)
- 每个AST都对应一个单独的Go语言文件,这个抽象语法树中包括当前文件属于的包名,定义的常量,结构体和函数等
- 如果发生错误,被语法解析器发现并将消息打印在标准输出上,编译过程直接中止
- Go语言早期用lex做词法分析,后续还是使用Go语言实现词法分析器,自己写的词法分析器分析自己
- 类型检查和AST转换
- 编译器对语法树中定义和使用的类型进行检查
- 遍历抽象节点树,保证当前节点上不会出现类型错误
- 不仅对类型进行检查,还会对内置函数进行展开和改写,比如make关键字在这个阶段会根据子树的结构被替换成makeslice 或者 makechan 等函数
- 通用SSA(静态单赋值)生成
- 使用SSA特性,分析代码中无用变量和片段进行优化
- 类型检查之后,就对Go语言项目全部函数进行编译,生成中间代码
- 关键字和内置函数的功能其实是由语言的编译器和运行时共同完成的
- 机器代码生成
- 根据机器不同,生成不同的机器码
1.1.2. 函数
调用惯例
- C语言的函数的参数是通过寄存器和栈传递的
- x86_64的机器,6个(含6个)的参数会按照顺序分别使用 edi、esi、edx、ecx、r8d 和 r9d 六个寄存器传递,超过 6 个的剩余参数会通过栈进行传递
- 函数的返回是通过eax 寄存器进行传递的,所以不支持多个返回值
- Go语言传递和接收参数使用的都是栈
- 但是需要注意,函数入参和出参的内存空间都需要调用方再栈上进行分配
- 好处:
- 能够降低实现的复杂度(不需要考虑超过寄存器个数的参数应该如何传递)
- 更方便的兼容不同的硬件(不同cpu的寄存器差别比较大)
- 函数可以具有多个返回值(栈上的内存地址相比寄存器的个人是无限的,使用寄存器支持多个返回值也会非常困难,超出寄存器多个返回值也需要使用栈来传递)
- 通过堆栈传递参数,入栈的顺序都是从右到左
- 函数返回通过堆栈传递并由调用者预先分配内存空间
参数的传递
- 无论是传递基本类型,结构体还是指针,都会对传递的参数进行拷贝
- 指针作为参数传入某一个函数的时候,其实在函数内部会对指针进行复制,也就是会同时出现两个指针指向原有的内存空间,所以Golang传指针也是传值
- 调用函数都是传值,接收方会对入参进行复制再计算
1.1.3. 接口
其本质就是引入一个中间层对不同的模块进行解耦,上层的模块就不需要依赖某一个具体的实现! Go语言中的接口interface 不仅是一组方法,还是一种内置的类型
Go语言中所有的接口的实现都是隐式的
接口类型
interface{}类型并不表示任意类型, interface{}类型的变量再运行期间的类型只是interface{}
go/src/runtime/runtime2.go#144
带有一组方法的接口
- 表示成 iface结构体
type iface struct { tab *itab data unsafe.Pointer}Copyitab结构体
// layout of Itab known to compilers// allocated in non-garbage-collected memory// Needs to be in sync with// ../cmd/compile/data-sync/gc/reflect.go:/^func.dumptypestructs.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.}Copy- interfacetype 是对 _type类型的简单封装
- hash 对 _type.hash的拷贝, 它会从 interface 到具体类型的切换时用于 快速判断目标类型和接口中类型是否一致
- fun 数组其实是一个动态大小的数组,如果数组中内容为空表示 _type没有实现inter接口,虽然这是一个大小固定的数组,但是在使用时会直接通过指针获取其中的数据并不会检查数组的边界,所以该数组中保存的元素数量是不确定的
不带有任何方法的interface{}类型
- 表示成eface结构体
- 很常见,实现成一种特殊的类型
type eface struct { _type *_type data unsafe.Pointer}Copy- _type 类型有点复杂… 不看了…
动态派发
运行期间选择具体的多态操作执行的过程,在Go语言中,对于一个接口类型的方法调用,会在运行期间决定具体调用该方法的哪个实现
动态派发会出现一些消耗,但一般项目中不可能只存在动态派发的调用,荔港南湾,如果开启默认的编译器优化,动态派发开销还会降低,所以对整体性能影响很小
结构体指针换成结构体,消耗区别有点大,原因是 Go 函数调用是值传递,会出现参数拷贝,所以对于大结构体,参数拷贝会消耗非常多资源,所以应该用指针来传递大结构体
1.1.4. 数组与切片
数组
- 数组是由相同类型元素的集合组成的数据结构
- 数组大小在初始化之后就无法改变
- 编译期间,数组类型Array包含两个结构,一个是元素类型Elem, 另一个是数组的大小上限Bound,这两字段组成了数组类型
- 数组创建可以显式指定数组的大小,也可以根据源代码自定推断数组的大小,不过后者在编译期间会被转换前一种 -
数组上限推导
- 显式创建,变量类型会在编译进行到 类型检查 阶段就会被推断出来
- 非显式创建,会在 编译进行 类型检查 时候也会创建一个 Array 类型,不过Bound = -1 后面会推到该数组大小
- 所以[...]T类型的声明不是在运行是被推导的,会在类型检查期间就被推断出正确的数组大小
语句转换
- [...]T{1, 2, 3}与[3]T{1, 2, 3} 运行的时候 是等价的,理由如上
- 根据数组元素不同,会有不同的优化
- 当元素数量小于或者等于4个时,会直接将数组中的元素放置在栈上
- 当元素数量大于4个时,会将数组中的元素放置到静态区并在运行时取出
- 总结: 数组元素个数小于等于4个,所有变量会直接在栈上初始化,如果数组数量大于4个,变量就会在静态存储区初始化然后拷贝到栈上,转换后的代码才会进入 中间代码生成 和机器码生成阶段,最后生成可执行的二进制文件
访问和赋值
- 数组在内存中其实就是一连串的内存空间,表示数组的方法就是一个指向数组开头的指针
- 数组访问越界的判断也是在编译期间由静态类型检查完成的
- 数组的赋值和更新 也会在生成SSA期间就计算出数组当前元素的内存地址,然后修改当前内存地址的内容
- 数组的寻址或者赋值都是编译阶段完成,没有运行时的参与
切片
- 切片其实就是动态数组,长度不固定
- 切片在编译期间应该只会包含切片中的元素类型 Slice{Elem: elem}
- 切片的操作基本都是在编译期间完成的,除了访问切片的长度,容量或者其中的元素之外,使用range遍历切片时也是在编译期间被转换成了形式更简单的代码
切片结构
type SliceHeader struct { Data uintptr // 指向数组的指针 Len int // 当前切片长度 Cap int // 切片的容量}Copy切片初始化
- 字面量
- slice := []int{1, 2, 3} -
- 关键字
- slice := make([]int, 10)
切片追加
-
追加append 会根据inplace 在中间代码生成阶段转换不同流程,一种是追加之后,不需要赋值回原有的变量append(slice, 1, 2, 3),一种是需要赋值给原有的变量slice = apennd(slice, 1, 2, 3)
-
先对切片结构体进行解构获取数组指针,大小和容量,如果新切片大小大于容量,那么会使用
growslice
对切片进行扩容并将新的元素依次加入切片并创建新的切片
-
如果期望容量大于当前容量的两倍就会使用期望容量
-
如果当前切片容连小于1024就会将容连翻倍
-
如果当前切片容量大于1024就会每次增加25%的容量,知道新容量大于期望容量
func growslice(et *_type, old slice, cap int) slice {newcap := old.capdoublecap := newcap + newcapif cap > doublecap { newcap = cap} else { if old.len v := *mapaccess1(maptype, hash, &key)v, ok := hash[key] // => v, ok := mapaccess2(maptype, hash, &key)Copy -
当接收参数就一个时,使用mapaccess1函数,如果多加一个是否存在的布尔值就会使用mapaccess2
-
在这个函数中我们首先会通过哈希表设置的哈希函数、种子获取当前键对应的哈希,再通过 bucketMask 和 add 函数拿到该键值对所在的桶和哈希最上面的 8 位数字,这 8 位数字最终就会与桶中存储的 tophash 作对比,每一个桶其实都存储了 8 个 tophash,就是编译期间的 topbits 字段,每一次都会与桶中全部的 8 个 uint8 进行比较,这 8 位的 tophash 其实就像是一级缓存,它存储的是哈希最高的 8 位,而选择桶时使用了桶掩码使用的是最低的几位,这种方式能够帮助我们快速判断当前的键值对是否存在并且减少碰撞,每一个桶都是一整片的内存空间,当我们发现某一个 topbits 与传入键的 tophash 匹配时,通过指针和偏移量获取哈希中存储的键并对两者进行比较,如果相同就会通过相同的方法获取目标值的指针并返回。另一个同样用于访问哈希表中数据的 mapaccess2 函数其实只是在 mapaccess1 的基础上同时返回了一个标识当前数据是否存在的布尔值
写入
- 当哈希表没有处于扩容状态并且装载因子超过了6.5或者存在了太多溢出的桶,调用hashGrow对当前哈希表进行扩容
- 装载因子是同时由 loadFactorNum 和 loadFactDen 两个参数决定的,前者在 Go 源代码中的定义是 13 后者是 2,所以装载因子就是 6.5
- 如果桶满了,会调用 newoverflow 创建一个新的桶或者使用hmap预先在noverflow中创建好的桶来保存数据,新创建的桶的指针会被追加到已有桶中,与此同时,溢出桶的创建会增加哈希表的noverflow计数器
- 如果哈希表存储的键值是指针类型,其实就会被当前的键值对分别申请一块新的内存空间,并在插入的位置通过eypedmemmove将键移动到申请的内存空间,最后返回键对应的地址
扩容
- 如果扩容是溢出的桶太多,那么就是 sameSizeGrow
- 如果是一次不改变大小的扩容,evacDst结构体只会初始化一个,当哈希表容量翻倍时,一个桶中的元素会被分流到新创建的两个桶中,这两个桶会被evacDst数组引用
删除
- delete关键字,将某一个键对应的元素从哈希表中删除,无论该键对应的值是否存在,这个内建的函数都不会返回任何的结果
- 如果在删除期间遇到哈希表的扩容,就会对即将操作的桶进行分流,随后找到桶中的目标元素并根据数据的类型调用memclrHasPointers 或者 memclrNoHeapPointers 函数完成键值对的删除
- delete在类型检查阶段被转换成ODELETE操作,然后在 SSA中间代码生成时被转换成mapdelete函数簇
哈希表总结
- Go语言用拉链法来解决哈希碰撞
- 哈希在每一个桶中存储键对应哈希的前8位,当对哈希进行操作时,这些 tophash就成了一级缓存帮助哈希快速遍历桶中元素
- 每个桶只能存储8个键值对,一旦某个桶超过8个,新的键值对会被存储到哈希的溢出桶中
- 当键值对数量的增加,装载因子升高,到一定范围后,会出发扩容操作,扩容时将桶的数量分配,元素再分配的过程也是在调用写操作时增量进行的,不会造成性能的瞬时巨大波动
1.1.6. 字符串
- Go语言中的字符串是一个只读的字节数组切片
- 如果代码中存在的字符串,会被编译期间标记成只读数据SRODATA,但这只是表示这个字符串会被分配到只读的内存空间并且这段内存不会被修改,但是运行时,依然可以将这段内存拷贝到其他的堆或者栈上,同时将变量的类型修改成 []byte,在修改之后通过类型转换变成string,如果想直接修改string类型变量的内存空间,是不支持的!
结构
- 在运行时,用StringHeader结构体进行表示, 在运行时包的内部其实有一个私有的结构stringHeader, 它有着相同的结构,只是用于存储数据的Data字段使用了unsafe.Pointer类型
type StringHeader struct { Data uintptr Len int}Copy解析
-
解析器在 词法分析 时就完成的
-
两种字面量的方式可以声明字符串,一种是 双引号,一种是 使用反引号
-
双引号用于简单的 单行的字符串,内部出现双引号需要用
\
符号避免编译器的解析错误
-
标准字符串使用双引号表示开头和结尾
-
标准字符串中需要使用反斜线\来escape双引号
-
标准字符串中不能出现隐式的换行符号
\n
str := "startend"Copy
-
反引号 可以摆脱单行的限制,内部可以直接使用
"
,写
JSON
或者其他数据时候非常方便
- 只需要把非反引号的所有字符都划分到当前字符串的范围中
拼接
- 使用+符号,编译器在检查阶段将OADD节点转换成OADDSTR类型的节点,然后在SSA中间代码生成的阶段调用addstr函数
- addstr函数在编译期间合选择合适的函数对字符串进行拼接,如果拼接字符串小于或者等于5个,那么活直接调用concatstring{2,3,4,5} 等一系列函数,如果超过5个就会直接选择concatstrings传入一个数组切片
- 不管选择哪个函数,最终都调用concatstrings,这个函数会先对传入的切片参数进行遍历,首先会过滤空字符串比国内获取拼接后的字符串长度
类型转换
-
string(bytes)会在编译期间转换成slicebytetostring的函数调用
-
字符串到字节数组转换使用
stringtoslicebyte
- 使用传入的缓冲区或者根据字符串的长度调用rawbyteslice创建一个新的字节切片,copy关键字会将字符串中的内容拷贝到新的字节数组中
字符串总结
- 字符串内容是只读,切片是可读写的,所以相互转换都是对内容进行拷贝,拷贝性能会因为字符串数组和字节长度的增长而增长,所以需要转换需要注意性能问题
1.1.7. for 和 range
-
for 和 for...range 经过优化后,变成一样
-
结果是???
func main() { arr := []int{1, 2, 3} for _, v := range arr { arr = append(arr, v) } fmt.Println(arr)}Copy- 只遍历了切片里的元素,追加并不会导致循环次数增加,然后最后停了下来
func main() { arr := []int{1, 2, 3} newArr := []*int{} for _, v := range arr { newArr = append(newArr, &v) } for _, v := range newArr { fmt.Println(*v) }}Copy-
结果是 , 如何避免
-
for 经典循环
-
for…range 范围循环 编译器会在编译期间将带有range的循环变成普通的经典循环,这个过程发生在 SSA中间代码 阶段,所有的range都会被walkrange 函数转换成只包含基本表达式的语句,不包含任何复杂的结构
遍历数组和切片
- 对于所以的range循环,Go语言都会在编译期间将原切片或者数组赋值给一个新的变量ha,在赋值的过程中其实就发生了拷贝,所以我们遍历的切片其实已经不是原有的切片变量了!
- 当同时遍历索引和元素的range循环时,Go语言会额外创建一个新的v2变量存储切片中的元素,循环中使用的这个变量v2会在每一次迭代中都被重新赋值,在赋值时也发生了拷贝, 所以我们想要访问数组中元素所在的地址,不应该直接获取range返回的v2变量的地址&v2,想要解决这个问题应该使用&a[index]这种方式获取数组中元素对应的地址
遍历哈希
- 哈希表遍历会随机(fastrand函数)选择开始的位置,然后依次遍历桶中的元素,桶中元素如果被遍历完,就会遍历当前桶对应的溢出桶,溢出桶都遍历结束之后才会遍历哈希中下一个桶,直到所有的桶都被遍历完
遍历字符串
- 遍历过程中会获取字符串中索引对应的字节,然后将字节转换成rune,我们在遍历字符串时拿到的值都是rune类型的变量
遍历通道
- 循环会使用 { defer fmt.Println("defer runs") fmt.Println("block ends") } fmt.Println("main ends")}Copy value int}func (t Test) print() { println(t.value)}func main() { test := Test{} defer test.print() test.value += 1}Copy value int}func (t *Test) print() { println(t.value)}func main() { test := Test{} defer test.print() test.value += 1}Copy siz int32 started bool sp uintptr pc uintptr fn *funcval _panic *_panic link *_defer}Copy argp unsafe.Pointer // 指向`defer`调用时参数的指针 arg interface{} // 调用`panic`时传入的参数 link *_panic // 指向更早调用的`_panic`结构 recovered bool // 当前的`_panic`是否被`recover` 恢复 aborted bool // 表示当前的`panic`是否被强行终止}Copy Deadline() (deadline time.Time, ok bool) Done() } Err() error Value(key interface{}) interface{}}Copy tb *timersBucket i int when int64 period int64 f func(interface{}, uintptr) arg interface{} seq uintptr}Copy C state int32 sema uint32}Copy w Mutex writerSem uint32 readerSem uint32 readerCount int32 readerWait int32}Copy noCopy noCopy state1 [3]uint32}Copy done uint32 m Mutex}Copy noCopy noCopy L Locker notify notifyList checker copyChecker}Copy
- 遍历过程中会获取字符串中索引对应的字节,然后将字节转换成rune,我们在遍历字符串时拿到的值都是rune类型的变量
- 哈希表遍历会随机(fastrand函数)选择开始的位置,然后依次遍历桶中的元素,桶中元素如果被遍历完,就会遍历当前桶对应的溢出桶,溢出桶都遍历结束之后才会遍历哈希中下一个桶,直到所有的桶都被遍历完
-
- 只遍历了切片里的元素,追加并不会导致循环次数增加,然后最后停了下来
-
- 字符串内容是只读,切片是可读写的,所以相互转换都是对内容进行拷贝,拷贝性能会因为字符串数组和字节长度的增长而增长,所以需要转换需要注意性能问题
- 使用传入的缓冲区或者根据字符串的长度调用rawbyteslice创建一个新的字节切片,copy关键字会将字符串中的内容拷贝到新的字节数组中
-
- 只需要把非反引号的所有字符都划分到当前字符串的范围中
-
-
-
- 在运行时,用StringHeader结构体进行表示, 在运行时包的内部其实有一个私有的结构stringHeader, 它有着相同的结构,只是用于存储数据的Data字段使用了unsafe.Pointer类型
-
-
- slice := make([]int, 10)
- 字面量
- _type 类型有点复杂… 不看了…
- 表示成 iface结构体
- C语言的函数的参数是通过寄存器和栈传递的
- 根据机器不同,生成不同的机器码