Go 基础
数据类型占用空间
类型 | 空间 |
---|---|
int8 | 1 |
int16 | 2 |
int32 | 4 |
int64 | 8 |
int | 4(32位)/8(64位) |
float32 | 4 |
float64 | 8 |
string | 1(英文)/2~4(中文取决于字符集) |
bool | 1 |
byte | 1 |
func
Go
语言中,函数被认为是一等公民(First-class citizens
),这意味着函数在语言中具有与其他类型(如整数、字符串等)相同的权利和地位。以下是函数在Go
语言中被视为一等公民的原因:
- 函数可以作为值进行传递:在
Go
语言中,函数可以像其他类型的值一样被传递给其他函数或赋值给变量。这意味着可以将函数作为参数传递给其他函数,也可以将函数作为返回值返回。 - 函数可以赋值给变量:在
Go
语言中,可以将函数赋值给变量,然后通过变量来调用函数。这种能力使得函数可以像其他数据类型一样被操作和处理。 - 函数可以匿名定义:
Go
语言支持匿名函数的定义,也称为闭包。这意味着可以在不给函数命名的情况下直接定义和使用函数,更加灵活和便捷。 - 函数可以作为数据结构的成员:在
Go
语言中,函数可以作为结构体的成员,从而使得函数与其他数据一起存储在结构体中。这种特性使得函数能够更好地与数据相关联,实现更复杂的功能。
Init()函数
golang程序初始化先于main函数执行,由runtime进行初始化,初始化顺序如下:
- 初始化导入的包(包的初始化顺序并不是按导入顺序(“从上到下”)执行的,runtime需要解析包依赖关系,==没有依赖的包最先初始化==,与变量初始化依赖关系类似)
- 初始化包作用域的变量(该作用域的变量的初始化也并非按照“从上到下、从左到右”的顺序,runtime解析变量依赖关系,没有依赖的变量最先初始化)
- 执行包的init函数;
init函数先于main函数自动执行,不能被其他函数调用;
init函数没有输入参数、返回值;
每个包可以有多个init函数;
包的每个源文件也可以有多个init函数,这点比较特殊;
同一个包的init执行顺序,golang没有明确定义,编程时要注意程序不要依赖这个执行顺序。
不同包的init函数按照包导入的依赖关系决定执行顺序。
init
函数没有输入参数、返回值,也未声明,所以无法被显示的调用,不能被引用(赋值给函数变量),否则会出现编译错误一个
go
文件可以拥有多个init
函数,执行顺序按定义顺序执行初始化常量/变量优于
init
函数执行,init
函数先于main
函数自动执行。执行顺序先后为:const
常量 >var
变量 >init
函数 >main
函数
只想调用包的init函数,不需要其他方法
1 | import _ "net/http/pprof" |
golang对没有使用的导入包会编译报错,但是有时我们只想调用该包的init函数,不使用包导出的变量或者方法,这时就采用上面的导入方案。
结构体
Go 语言中没有类的概念,因此在 Go 中结构体有着更为重要的地位。结构体是复合类型(composite types),当需要定义一个类型,它由一系列属性组成,每个属性都有自己的类型和值的时候,就应该使用结构体,它把数据聚集在一起。然后可以访问这些数据,就好像它是一个独立实体的一部分。结构体也是值类型,因此可以通过 new 函数来创建。
初始化
因为Go语言结构体是一个值类型,也就是说当你声明了一个结构体类型的变量时,实际上是在内存中分配了一块连续的内存空间的,这个空间里面包含这个结构体中定义的所有字段。字段均为默认零值
go的结构体能不能比较
golang中 Slice
,Map
,Func
这三种数据类型是不可以直接比较的。
同一个struct的两个实例可比较也不可比较,当结构不包含不可直接比较成员变量时可直接比较,否则不可直接比较
对struct{}{}的理解
结构体常用于抽象表示一类事物,可以拥有行为或者状态。
struct{ } :表示struct类型
struct{}{}是一种普通数据类型,一个无元素的结构体类型,通常在没有信息存储时使用。
==优点是大小为0,不需要内存来存储struct {}类型的值==。
空结构体是一种特殊的结构体,没有任何字段,不会进行内存对齐,也不占用内存,但是有固定的地址 zerobase。
struct {} {}:表示struct类型的值,该值也是空。
struct {} {}是一个复合字面量,它构造了一个struct {}类型的值,该值也是空。
应用场景
- struct{}类型的chan,用于传递信号,用于流转各类状态或是控制并发情况。
- map[type]struct{},实现set
- 实现方法接收者,不占空间,也便于未来针对该类型进行公共字段等的增加
实现方法接收者:
在业务场景下,我们需要将方法组合起来,代表其是一个 ”分组“ 的,便于后续拓展和维护。
但是如果我们使用:
1 | type T string |
又似乎有点不大友好,因为作为一个字符串类型,其本身会占据定的空间。
这种时候我们会采用空结构体的方式,这样==也便于未来针对该类型进行公共字段等的增加==。如下:
1 | type T struct{} |
在该场景下,使用空结构体从多维度来考量是最合适的,易拓展,省空间,最结构化。
函数传值
go都是值传递只是传的参数是值类型还是引用类型
golang中所有函数参数传递都是传值,slice、map和chan看上去像引用只是因为他们内部有指针或本身就是指针而已。由于它们本身就是引用类型,因此使用引用传递可以避免复制数据和额外的内存开销。
string在底层实现上是引用类型,但是因为string不允许修改,只能生成新的对象,在逻辑上和值类型无差别。
值传递或者指针传递都有可能发生逃逸,关键是有没有外部引用!!!,不是传指针就一定会逃逸!!!
当结构体很小且拷贝成本很低时,例如结构体中只包含几个基本类型的字段,可以直接传递结构体的值,这样可以避免创建指针的额外开销,而且较小的结构体会在栈上分配内存更加高效,减少GC压力。
当结构体很大时或者需要修改结构体中的字段时,传递指向结构体的指针会更加高效。因为在 Go 中,函数传递结构体时会进行一次值拷贝,如果结构体很大,则拷贝的成本也很高。而如果传递结构体的指针,函数就可以直接操作原始数据,避免了值拷贝的开销。
所以得出结论,当我们需要修改结构体的变量内容的时候,方法传入的结构体变量参数需要使用指针,也就是结构体的地址。
需要修改map中的架构体的变量的时候也需要使用结构体地址作为map的value。
如果仅仅是读取结构体变量,可以不使用指针,直接传递引用即可。
*type 这里的type这个变量存放的东西是地址,这点需要明确,需要使用&type获取到地址。
引用类型
引用类型
变量存储的是一个地址,这个地址存储最终的值。内存通常在堆上分配。通过 GC 回收。
包括 指针、slice 切片、管道 channel、接口 interface、map、函数等。
==struct是值类型==
值类型 直接存放值,内存通常在栈中分配
应用类型变量存储的地址(也就是通过指针访问类型里面的数据),通常真正的值在堆上分配。当没有变量引用这个地址的时候,该值会被gc回收。
make和new和var的区别?
引用类型
变量存储的是一个地址,这个地址存储最终的值。内存通常在堆上分配。通过 GC 回收。包括 指针、slice 切片、管道 channel、接口 interface、map、函数等。值类型
是基本数据类型,int,float,bool,string, 以及数组和 struct 特点:变量直接存储值,内存通常在栈中分配,栈在函数调用后会被释放
对于引用类型
的变量,我们不光要声明它,还要为它分配内存空间
对于值类型
的则不需要显示分配内存空间,是因为go会默认帮我们分配好
简单的说,new只分配内存,make用于slice,map,和channel的初始化。
make和new都是golang用来分配内存的內建函数,且在堆上分配内存,==make 即分配内存,也初始化内存。new只是将内存清零(赋零值),并没有初始化内存。==
make返回的还是==引用类型本身==;而new返回的是==指向类型的指针==。
make只能用来分配及初始化类型为==slice,map,channel==的数据;==new可以分配任意类型的数据==。
使用make(),来初始化slice,map 和channel 。
大多数场合,类型明确的场合下,使用短变量声明方式:=。
当使用文字方式初始化一个变量,并且需要指明类型时,使用var变量声明方式。
避免使用new(),除非你需要一个指针变量。
对于值类型的变量,var 声明(包括结构体),系统会默认为他分配内存空间,并赋该类型的零值。
- new 和 make都是Go语言的两个内建函数,用于分配内存
- new 一般用来返回指针类型(一般不用),make返回引用类型(map, slice,chan 这三个引用)
- var 声明的 基本类型和struct这种已经分配了内存,并且赋零值了。
make的参数
1 | make(Type, len, cap) |
Type
:数据类型,必要参数,Type 的值只能是 slice、 map、 channel 这三种数据类型。len
:数据类型实际占用的内存空间长度,map、 channel 是可选参数,slice 是必要参数。cap
:为数据类型提前预留的内存空间长度,可选参数。所谓的提前预留是当前为数据类型申请内存空间的时候,提前申请好额外的内存空间,这样可以避免二次分配内存带来的开销,大大提高程序的性能。
深拷贝和浅拷贝
- 深拷贝: 拷贝的是数据本身,创造一个新对象,新创建的对象与原对象不共享内存,新创建的对象在内存中开辟一个新的内存地址,新对象值修改时不会影响原对象值。 实现深拷贝的方式:
copy(slice2, slice1)
;- 遍历slice进行append赋值
- 浅拷贝∶拷贝的是数据地址,只复制指向的对象的指针,此时新对象和老对象指向的内存地址是一样的,新对象值修改时老对象也会变化。 实现浅拷贝的方式:引用类型的变量,默认赋值操作就是浅拷贝
- 如
slice2 := slice1
- 如
go中的uint无符号整型是否可以相减(uint类型溢出 )
不可以,如果相减会进行类型的自动推导c为uint32位,所以系统会把负数的1的正负位当做最高进制来算,造成数值很大
涉及到原码补码反码,计算机存储,减去一个数,相当于加上这个数的相反数的补码,负数的补码是符号位不变,其他位取反,相加变成一个很大的数,因为是无符号位,首位也会再变。
string 的底层
go底层系列-string底层实现原理与使用 - 掘金 (juejin.cn)
string我们看起来是一个整体,但是本质上是一片连续的内存空间,我们也可以将它理解成一个由字符组成的数组,相比于切片仅仅少了一个Cap属性。
- 相比于切片少了一个容量的cap字段,就意味着string是不能发生地址空间扩容;
- 可以把string当成一个只读的byte切片类型;
- string本身的切片是只读的,所以不会直接向字符串直接追加元素改变其本身的内存空间,所有在字符串上的写入操作都是通过拷贝实现的。
1 | type stringStruct struct { |
string的元素不能取地址,s[i]
代表第i个元素,但是&s[i]
是违法的。
如果一个string很大1G,值传递的时候也是复制一遍吗?可以修改吗?
传递指针的复制,不可以修改
string在底层实现上是引用类型,但是因为string不允许修改,只能生成新的对象,在逻辑上和值类型无差别。
string vs []byte
既然string
就是一系列字节,而[]byte
也可以表达一系列字节,那么实际运用中应当如何取舍?
string
可以直接比较,而[]byte
不可以,所以[]byte
不可以当map的key值。- 因为无法修改
string
中的某个字符,需要粒度小到操作一个字符时,用[]byte
。 string
值不可为nil
,所以如果你想要通过返回nil表达额外的含义,就用[]byte
。[]byte
切片这么灵活,想要用切片的特性就用[]byte
。- 需要大量字符串处理的时候用
[]byte
,性能好很多。
string转成byte数组会发生内存拷贝吗?
会
字符串转成切片,会产生拷贝。严格来说,只要是发生类型强转都会发生内存拷贝。那么问题来了。
频繁的内存拷贝操作听起来对性能不大友好。有没有什么办法可以在字符串转成切片的时候不用发生拷贝呢?
1 | a :="aaa" |
那么如果想要在底层转换二者,只需要把 StringHeader
的地址强转成 SliceHeader
就行。那么go有个很强的包叫 unsafe
。
unsafe.Pointer(&a)
方法可以得到变量a
的地址。(*reflect.StringHeader)(unsafe.Pointer(&a))
可以把字符串a转成底层结构的形式。(*[]byte)(unsafe.Pointer(&ssh))
可以把ssh底层结构体转成byte的切片的指针。- 再通过
*
转为指针指向的实际内容。
两个string合并,用“+”,fmt,strings,哪个效率高
Go 字符串拼接6种,最快的方式 – strings.builder - 技术颜良 - 博客园 (cnblogs.com)
通过两次benchmark
对比,我们可以看到
- 当进行少量字符串拼接时,直接使用
+
操作符进行拼接字符串,效率还是挺高的,但是当要拼接的字符串数量上来时,+
操作符的性能就比较低了; - 函数
fmt.Sprintf
还是不适合进行字符串拼接,无论拼接字符串数量多少,性能损耗都很大,还是老老实实做他的字符串格式化就好了; strings.Builder
无论是少量字符串的拼接还是大量的字符串拼接,性能一直都能稳定,这也是为什么Go
语言官方推荐使用strings.builder
进行字符串拼接的原因,在使用strings.builder
时最好使用Grow
方法进行初步的容量分配,观察strings.join
方法的benchmark就可以发现,因为使用了grow
方法,提前分配好内存,在字符串拼接的过程中,不需要进行字符串的拷贝,也不需要分配新的内存,这样使用strings.builder
性能最好,且内存消耗最小。bytes.Buffer
方法性能是低于strings.builder
的,bytes.Buffer
转化为字符串时重新申请了一块空间,存放生成的字符串变量,不像strings.buidler
这样直接将底层的[]byte
转换成了字符串类型返回,这就占用了更多的空间。
同步最后分析的结论:
无论什么情况下使用strings.builder
进行字符串拼接都是最高效的,不过要主要使用方法,记得调用grow
进行容量分配,才会高效。strings.join
的性能约等于strings.builder
,在已经字符串slice的时候可以使用,未知时不建议使用,构造切片也是有性能损耗的;如果进行少量的字符串拼接时,直接使用+
操作符是最方便也是性能最高的,可以放弃strings.builder
的使用。
综合对比性能排序:
1 | strings.join` ≈ `strings.builder` > `bytes.buffer` > `[]byte`转换`string` > "+" > `fmt.sprintf |
strings.builder
避免内存拷贝的问题,使用了强制转换来避免内存拷贝
1 | func (b *Builder) String() string { |
strings.join
也是基于strings.builder
来实现的,唯一不同在于在join
方法内调用了b.Grow(n)
方法,这个是进行初步的容量分配,而前面计算的n的长度就是我们要拼接的slice的长度,因为我们传入切片长度固定,所以提前进行容量分配可以减少内存分配,很高效。
rune 和 byte
rune
rune是int32的别名
,代表字符的Unicode编码,采用4个字节存储,将string转成rune就意味着任何一个字符都用4个字节来存储其unicode值,这样每次遍历的时候返回的就是unicode值,而不再是字节了,这样就可以解决乱码问题了。
==中文、特殊字符==
byte
bytes操作的对象也是字节切片,与string的不可变不同,byte是可变的,因此string按增量方式构建字符串会导致多次内存分配和复制,使用bytes就不会,因而更高效一点
区别:byte
表示一个字节,rune
表示四个字节
1 | first := "社区" |
Slice切片
切片(Slice)是一个动态数组,它不需要指定长度,可以动态增长。切片是对底层数组的一层封装,支持对底层数组进行动态增删改操作。切片的定义方式为 var s []int
,其中 s 为切片名,int 为元素类型。切片可以使用 append() 函数对其进行动态增长,例如 s = append(s, 1)。切片在内存中不是连续的存储空间,而是由一个指向底层数组的指针、长度和容量组成。
1.19源码:切片一定会分配在堆上 go/complit.go at master · golang/go · GitHub
截取规则左闭右开
1 | type slice struct { |
使用var声名的切片其实是一个nil切片,它与nil比较返回true
而使用语法糖或者make声名的切片是一个空切片,他们与ni比较返回false
arr[low:high:max]
len = high-low
cap = max-low
max不指定时max=high
max不允许超过cap
1 | panic: runtime error: slice bounds out of range [::6] with capacity 5 |
切片cap的值在被切时会改变
slice和数组的区别
数组(Array)是一种固定长度的数据结构,元素的类型都是相同的。数组的长度在创建时就已经确定,并且不可更改。数组的定义方式为 var a [5]int
,其中 a 为数组名,5 为数组的长度,int 为元素类型。数组可以使用下标进行访问和修改,例如 a[0] = 1。数组在内存中是连续的存储空间。
在Golang中数组是一个长度固定的数据类型,数组的长度是类型的一部分,也就是说[5]int和[10]int是两个不同的类型。
- 长度不同:数组的长度是固定的,而切片的长度可以动态增长。
- 内存分配方式不同:数组在定义时就已经分配好了内存空间,而切片需要使用 make() 函数进行初始化分配内存。
- 数据类型不同:数组中的元素类型必须相同,而切片可以是不同类型的元素的序列。
- 传递方式不同:数组是值类型,传递时会复制一份,而切片是引用类型,传递时会传递指向底层数组的指针,多个切片可能会共享底层数组。
- 访问方式不同:数组使用下标访问元素,而切片支持切片操作和下标访问元素。
为什么append()需要在传入一个切片后还需要再赋值
因为append之后可能会生成新的slice对象,赋值操作是用来接受可能会产生的新对象,确保期望使用的silce对象始终符合预期
append这种写法看起来有点奇怪而且重复,为什么需要将append函数返回的值再赋值给传入的值,而不是直接append(x,value)呢?这涉及到go语言的设计哲学,即参数传递是值拷贝。传入到函数中的参数x会建立一个新的副本。因此需要将添加元素后返回的新副本赋值给原始的变量。
nil slice
切片的零值是nil,所以只声明变量时,其缺省值为零值nil,这时也就是我们所说的nil slice
。
nil切片不能直接访问元素值,但可通过append()
追加元素。
append
内部append 会初始化 nil slice,与此类似的函数还有 copy
。这两个函数内部都进行 make 初始化。每次对 slice 的操作内部是会产生一个新的数组,然后返回
拷贝大切片一定比小切片代价大吗?
并不是,所有切片的大小相同;三个字段(一个 uintptr,两个int)。切片中的第一个字是指向切片底层数组的指针,这是切片的存储空间,第二个字段是切片的长度,第三个字段是容量。将一个 slice 变量分配给另一个变量只会复制三个机器字。所以 拷贝大切片跟小切片的代价应该是一样的。
函数传参修改会影响原值吗?
如果没有扩容则会影响,如果扩容此时函数的哪个就会变成独立的一个切片
注意:
1 | func main() { |
x的大小为3,当y扩容后其大小超过3此时xy就变成了两个独立的切片
Slice扩容规则
1.18 前
当原 slice 容量小于 1024
的时候,新 slice 容量变成原来的 2
倍;原 slice 容量超过 1024
,新 slice 容量变成原来的1.25
倍。
1.18 后
如果新切片的容量大于原切片的两倍,则直接将切片扩容到新切片的容量
当原slice容量(oldcap)小于256
的时候,新slice(newcap)容量为原来的2
倍;原slice容量超过256,新slice容量$newcap = oldcap+(oldcap+3*256)/4$
原来 newcap
只是一个我们的预期容量,实际的容量需要根据切片中的元素大小对齐内存
最后,会根据切片元素的大小和新容量计算内存,将超出切片长度的内存清空,并拷贝旧切片的内存数据到新申请的内存中,最后返回
defer
在 Go 中,defer
语句用于注册一个函数,这个函数会在当前函数返回前执行,即使函数发生错误或者 panic。
defer
语句的实现原理是,Go 编译器会把 defer
语句转换为一个栈。在函数调用时,每遇到一个 defer
语句,就将其函数推入栈中。在函数返回时,栈中的函数会按照后进先出
(LIFO)的顺序执行。这意味着最后注册的函数会最先执行。
return之后的语句先执行,defer后的语句后执行,return 不是原子级操作的,执行过程是: 保存返回值—>执行 defer —>执行 ret
底层
defer 语句后面是要跟一个函数的,所以 defer 的数据结构跟一般的函数类似,不同之处是 defer 结构含有一个指针,用于指向另一个 defer ,每个 goroutine 数据结构中实际上也有一个 defer 指针指向一个 defer 的单链表,每次声明一个 defer 时就将 defer 插入单链表的表头,每次执行 defer 时就从单链表的表头取出一个 defer 执行。保证 defer 是按 LIFO 方式执行的。
1 | type _defer struct { |
在Go语言的运行时环境中,_defer 结构体的实现可以通过一个链表来维护多个 defer 语句的执行顺序。具体来说,每个_defer 结构体都有指向下一个_defer 结构体的指针,从而可以形成一个链表。
当一个函数执行结束时,runtime 会自动遍历这个链表,按照 defer 语句的执行顺序依次执行这些被延迟的函数。同时,runtime 也会对这些 _defer 结构体进行释放,回收内存。
defer遇到panic时
遇到panic时,遍历本协程的defer链表,并执行defer。在执行defer过程中:遇到recover则停止panic,返回recover处继续往下执行。如果没有遇到recover,遍历完本协程的defer链表后,向stderr抛出panic信息。
panic仅有最后一个可以被revover捕获。
panic 和 recover
Panic :在 Go 语言中,出现 Panic 是代表一个严重问题,意味着程序结束并退出。在 Go 中 Panic 关键字用于抛出异常的。类似 Java 中的 throw。
recover:在 Go 语言中,用于将程序状态出现严重错误恢复到正常状态。当 发生 Panic 后,你需要使用recover 捕获,不捕获程序会退出。类似 Java 的 try catch 捕获异常。
panic
能够改变程序的控制流,调用panic
后会立刻停止执行当前函数的剩余代码,并在当前 Goroutine 中递归执行调用方的defer
;recover
可以中止panic
造成的程序崩溃。它是一个只能在defer
中发挥作用的函数,在其他作用域中调用不会发挥作用;
如果有若干个goroutine,其中有一个panic,会发生什么
有一个panic,那么剩余goroutine也会退出,程序退出。如果不想程序退出,那么必须通过调用 recover() 方法来捕获 panic 并恢复将要崩掉的程序。
defer可以捕获到其goroutine中的子goroutine的panic吗?
不能,它们处于不同的调度器P中。对于子goroutine,必须通过 recover() 机制来进行恢复,然后结合日志进行打印(或者通过channel传递error),下面是一个例子
1 | func main() { |
接口inteface
在Golang中接口(interface)是一种类型,一种抽象的类型。接口(interface)是一组函数method的集合,Golang中的接口不能包含任何变量。
在 Golang 中,interface 是一组 method 的集合,是 duck-type programming 的一种体现。不关心属性(数据),只关心行为(方法)。具体使用中你可以自定义自己的 struct,并提供特定的 interface 里面的 method 就可以把它当成 interface 来使用。
1 | type 接口名 interface { |
- interface 是方法声明的集合
- 任何类型的对象实现了在interface 接口中声明的全部方法,则表明该类型实现了该接口。
- interface 可以作为一种数据类型,实现了该接口的任何对象都可以给对应的接口类型变量赋值
注意:
- interface 可以被任意对象实现,一个类型/对象也可以实现多个 interface
- 方法不能重载,如 eat() eat(s string) 不能同时存在
空接口
空接口可以作为函数的参数,使用空接口可以接收任意类型的函数参数
1 | // 空接口表示没有任何约束,任意的类型都可以实现空接口 |
两个接口可以比较吗?
DeepEqual
函数的参数是两个 interface
,实际上也就是可以输入任意类型,输出 true 或者 flase 表示输入的两个变量是否是“深度”相等。
1 | // 判断类型是否一样 |
类型断言
前面说过,因为空接口 interface{}
没有定义任何函数,因此 Go 中所有类型都实现了空接口。当一个函数的形参是 interface{}
,那么在函数中,需要对形参进行断言,从而得到它的真实类型。
类型断言的本质,跟类型转换类似,都是类型之间进行转换,不同之处在于,类型断言实在接口之间进行
1 | <目标类型的值>,<布尔参数> := <表达式>.( 目标类型 ) // 安全类型断言 |
1 | func test6() { |
空接口类型断言实现流程:空接口类型断言实质是将eface
中_type
与要匹配的类型进行对比,匹配成功在内存中组装返回值,匹配失败直接清空寄存器,返回默认值。
小结:非空接口类型断言的实质是 iface 中 *itab
的对比。*itab
匹配成功会在内存中组装返回值。匹配失败直接清空寄存器,返回默认值。
作用
- 空接口
- 通用类型:实现可以接收任意类型的函数参数,保存任意值的字典。
- 类型断言:当我们需要在运行时确定一个值的类型时,可以使用类型断言将空接口转换为其他类型。
- 泛型编程:可以使用空接口将不同的类型转换为通用的类型,在函数或方法中进行处理,然后再将其转换为原来的类型。
- 接口可以定义通用的行为,提高代码复用性。例如,如果你编写了一个可以排序的数据结构,你可以定义一个名为Sort的接口,它定义了一个排序方法,然后任何实现了Sort接口的类型都可以使用这个排序方法。
- 接口可以实现多态性,让一个变量可以持有多种类型的值。这使得你可以写出更灵活的代码,可以在运行时根据具体情况选择使用哪个具体类型的方法。这对于实现插件系统、扩展性很高的应用程序或者抽象底层实现等场景非常有用。
- 抽象底层实现,接口可以降低模块之间的耦合度,增强代码的灵活性和可扩展性。通过面向接口编程,不同的模块之间可以更容易地协作,可以实现组件化的架构,让系统更加易于维护和扩展。使得上层代码只关注接口定义的行为特征,而不需要关心底层的实现细节。这使得代码更加易于维护和扩展
多态 怎么去复用一个接口的方法?
在Go语言中,实现接口只需要实现接口中所有的方法即可。也就是说,当一个类型定义了接口所包含的全部方法时,该类型就自动地实现了该接口。由于Go语言中不存在显示实现的语法,一个类型实现的接口的集合是由该类型自动地决定的。
在Go语言中,接口的嵌套是一种用于组合接口类型的机制。嵌套接口就是将多个接口的方法组合在一起,以便某个类型可以同时满足这些接口的方法要求。
1 | package main |
上述中体现了interface
接口的语法,在main
函数中,也体现了多态
的特性。
同样一个phone
的抽象接口,分别指向不同的实体对象,调用的call()方法,打印的效果不同,那么就是体现出了多态的特性。
底层
【golang】interface原理 - 个人文章 - SegmentFault 思否
iface
和 eface
都是 Go 中描述interface{}的底层结构体,区别在于 iface
描述的接口包含方法,而 eface
则是不包含任何方法的空接口:interface{}
。
eface
eface
表示不含 method 的 interface 结构,或者叫 empty interface。对于 Golang 中的大部分数据类型都可以抽象出来 _type
结构,同时针对不同的类型还会有一些其他信息。
eface结构体是golang中实现interface的一种方式。在语言中,interface是一种类型,代表了一组方法的签名,也就是说实现一个interface的类型必须拥有该interface定义的一组方法。而在golang中,使用interface时,由于interface的底层实现是复合类型,需要保存类型信息和值信息等,在实现中就要使用到eface结构体。
1 | type eface struct { |
其中_type指向类型信息(type information),data指向该类型的值(value of type)。因此,eface结构体可以保存任何类型的值,而不需要提前知道其类型。
当程序使用interface来定义变量时,这个变量实际上是一个eface结构体。程序在使用该变量时,可以通过类型信息对其进行断言,并调用具体的方法。
iface
iface
表示 non-empty interface 的底层实现。相比于 empty interface,non-empty 要包含一些 method。method 的具体实现存放在 itab.fun 变量里。如果 interface 包含多个 method,这里只有一个 fun 变量怎么存呢?这个下面再细说。
1 | type iface struct { |
iface 结构体有两个字段:
- tab,指向该接口类型变量的方法表指针,其中 itab 是一个包含了该接口类型的方法集的结构体。
- data,指向该接口类型变量具体的值的指针,其中 data 具体指向实现该接口的具体类型的值。
通过 iface 结构体的方法表指针,可以在运行时动态分派实际调用的具体实现方法。
使用 iface 结构体,可以在运行时实现接口类型的多态,这些多态的接口类型变量指向的具体类型也会在运行时动态确定。因此,iface 结构体在 Go 中的作用非常重要。
注意 nil != nil
- 接口是一种引用类型的数据结构,它的值可以为nil。
- 实现接口的类型必须实现接口中所有的方法,否则会编译错误。
- 接口的值可以赋给实现接口的类型的变量,反之亦然。
- 在实现接口的类型的方法中,可以通过类型断言来判断接口值的实际类型和值。
1 | var i interface{} = nil |
nil特点
nil 不是关键字或保留字:
var nil = errors.New("my god")
不会报错nil没有默认类型:
`fmt.Printf(“%T”, nil) // ./hello.go:9:7: use of untyped nil不同类型 nil 的指针是一样的
1
2
3var arr []int
var num *int
fmt.Printf("%p %p", arr, num) // 0x0 0x0
1 | - nil 是 map、slice、pointer、channel、func、interface 的零值 |
map
Go 语言map采用的是哈希查找表
,并且使用链表
解决哈希冲突。
哈希查找表用一个哈希函数将 key 分配到不同的桶(bucket,也就是数组的不同 index)。这样,开销主要在哈希函数的计算以及数组的常数访问时间。在很多场景下,哈希查找表的性能很高。
不能做为map的key
slice、map、func
以及包含这些类型的struct
why?因为这些类型不可以用==
比较
nil map 和空map的区别
根据官方定义,nil是预定义标识,代表了指针pointer
、通道channel
、函数func
、接口interface
、map
、切片slice
类型变量的零值。
- 只声明一个map类型变量时,为
nil map
- 此时为只读map,无法进行写操作,否则会触发panic
nil map
和empty map
区别:nil map
:只声明未初始化,此时为只读map,不能写入操作,示例:var m map[t]v
empty map
:空map,已初始化,可写入,示例:m := map[t]v{}
或m := make(map[string]string, 0)
删除
外层的循环就是在遍历整个 map,删除的核心就在那个empty
。它修改了当前 key 的标记,而不是直接删除了内存里面的数据。
内存没有释放。清空只是修改了一个标记,底层内存还是被占用了
只有将整个map置为nil时才会被GC map = nil
08794
我觉得这样不算是内存泄漏。如果继续给这个map
写入值,如果这个值命中了之前被删除的bucket,那么会覆盖之前的empty数据。
哈希冲突
当两个不同的 key 落在同一个桶中,就是发生了哈希冲突。冲突的解决手段是采用链表法:在 桶 中,从前往后找到第一个空位进行插入。如果8个kv满了,那么当前桶就会连接到下一个溢出桶(bmap)
如何有序遍历map
当我们在遍历 map 时,并不是固定地从 0 号 bucket 开始遍历,每次都是从一个随机值序号的 bucket 开始遍历,并且是从这个 bucket 的一个随机序号的 cell 开始遍历。
- 将
Map
中的 key 拿出来,放入slice
中做排序 - 利用官方库里的
list(链表)
封装一个结构体,实现一个有序的 K-V 存储结构,在里面维护一个 keys 的 list。
1 | type OrderedMap struct { |
可以对key/value取地址吗?
不可以,因为没必要,扩容会改变
如果通过其他 hack 的方式,例如 unsafe.Pointer 等获取到了 key 或 value 的地址,也不能长期持有,因为一旦发生扩容,key 和 value 的位置就会改变,之前保存的地址也就失效了。
注意
- map的key是无序的,不会通过key保持data的顺序。
- map中的data不是按插入顺序存储的。
- 每次迭代循环map时,key的输出都是无序的
- 在迭代期间对map进行添加的新元素有可能被输出,也有可能被跳过。
- 在使用make函数初始化map时,指定元素个数,在操作中可以降低内存分配次数,提高性能。
- map是非并发安全的,不能同时对同一个map进行读和写。想满足并发安全的场景,需要通过sync.RWMutex进行加锁同步。
- 可作为map的key的类型必须是能够是用 == 操作符进行可比较的类型。
- 当从map访问一个不存在的键时,他会返回该类型的零值
- 两个map不能判断相等,只能判断深度相等
底层
Golang | 由浅入深理解哈希表Map - 掘金 (juejin.cn)
哈希查找表用一个哈希函数将 key 分配到不同的桶(bucket,也就是数组的不同 index)。这样,开销主要在哈希函数的计算以及数组的常数访问时间。在很多场景下,哈希查找表的性能很高。
在go的map实现中,它的底层结构体是hmap,hmap里维护着若干个bucket数组 (即桶数组)。
Bucket数组中每个元素都是bmap结构,也即每个bucket(桶)都是bmap结构,【ps:后文为了语义一致,和方便理解,就不再提bmap了,统一叫作桶】 每个桶中保存了8个kv对,如果8个满了,又来了一个key落在了这个桶里,会使用overflow连接下一个桶(溢出桶)。
1 | type hmap struct { |
核心流程
写:
- 根据 key 取 hash 值;
- 根据 hash 值对桶数组取模,确定所在的桶;
- 倘若 map 处于扩容,则迁移命中的桶,帮助推进渐进式扩容;
- 沿着桶链表依次遍历各个桶内的 key-value 对;
- 倘若命中相同的 key,则对 value 中进行更新;
- 倘若 key 不存在,则插入 key-value 对;
- 倘若发现 map 达成扩容条件,则会开启扩容模式,并重新返回第2步.
读:
- 根据 key 取 hash 值;
- 根据 hash 值对桶数组取模,确定所在的桶;
- 沿着桶链表依次遍历各个桶内的 key-value 对;
- 命中相同的 key,则返回 value;倘若 key 不存在,则返回零值.
删:
- 根据 key 取 hash 值;
- 根据 hash 值对桶数组取模,确定所在的桶;
- 倘若 map 处于扩容,则迁移命中的桶,帮助推进渐进式扩容;
- 沿着桶链表依次遍历各个桶内的 key-value 对;
- 倘若命中相同的 key,删除对应的 key-value 对;并将当前位置的 tophash 置为 emptyOne,表示为空;
- 倘若当前位置为末位,或者下一个位置的 tophash 为 emptyRest,则沿当前位置向前遍历,将毗邻的 emptyOne 统一更新为 emptyRest.
桶数组
map 中,会通过长度为 2 的整数次幂的桶数组进行 key-value 对的存储:
- 每个桶固定可以存放 8 个 key-value 对;
- 倘若超过 8 个 key-value 对打到桶数组的同一个索引当中,此时会通过创建桶链表的方式来化解这一问题.
解决哈希冲突
在 map 解决 hash /分桶 冲突问题时,实际上结合了拉链法和开放寻址法两种思路. 以 map 的插入写流程为例,进行思路阐述:
- 桶数组中的每个桶,严格意义上是一个单向桶链表,以桶为节点进行串联;
- 每个桶固定可以存放 8 个 key-value 对;
- 当 key 命中一个桶时,首先根据开放寻址法,在桶的 8 个位置中寻找空位进行插入;
- 倘若桶的 8 个位置都已被占满,则基于桶的溢出桶指针,找到下一个桶,重复第3步;
- 倘若遍历到链表尾部,仍未找到空位,则基于拉链法,在桶链表尾部续接新桶,并插入 key-value 对.
哈希算法
对于哈希算法的选择,程序会根据当前架构判断是否支持AES,如果支持就使用AES hash,其实现代码位于src/runtime/asm_{386,amd64,arm64}.s中;若不支持,其hash算法则根据xxhash算法(https://code.google.com/p/xxhash/)和cityhash算法(https://code.google.com/p/cityhash/)启发而来,代码分别对应于32位(src/runtime/hash32.go)和64位机器(src/runtime/hash64.go)中,对这部分内容感兴趣的读者可以深入研究。
map扩容机制
[[go源码#map#扩容]]
在赋值过程中,会判断是否需要扩容,主要有两个函数:overLoadFactory和tooManyOverflowBuckets
(1)扩容分为增量扩容和等量扩容;
(2)当桶内 key-value 总数/桶数组长度 > 6.5 时发生增量扩容,桶数组长度增长为原值的两倍;
(3)当桶内溢出桶数量大于等于 2^B 时( B 为桶数组长度的指数,B 最大取 15),发生等量扩容,桶的长度保持为原值;
(4)采用渐进扩容的方式,当桶被实际操作到时,由使用者负责完成数据迁移,避免因为一次性的全量数据迁移引发性能抖动.
小结一下:map扩容有两种情况,一==种是map的负载因子超过了6.5,一种是溢出桶(数组)太多了==
扩容方式:
- 相同容量扩容:因为有添加和删除操作,所以桶中会出现一些空位,这种扩容实际上是进行了元素重排,不会换桶。
- 2倍容量扩容:由于当前桶数组确实不够用了,发生这种扩容时,元素会重排,可能会发生桶迁移。
扩容迁移规则:只有写操作会触发扩容
(1)在等量扩容中,新桶数组长度与原桶数组相同;
(2)key-value 对在新桶数组和老桶数组的中的索引号保持一致;
(3)在增量扩容中,新桶数组长度为原桶数组的两倍;
(4)把新桶数组中桶号对应于老桶数组的区域称为 x 区域,新扩展的区域称为 y 区域.
(5)实际上,一个 key 属于哪个桶,取决于其 hash 值对桶数组长度取模得到的结果,因此依赖于其低位的 hash 值结果.;
(6)在增量扩容流程中,新桶数组的长度会扩展一位,假定 key 原本从属的桶号为 i,则在新桶数组中从属的桶号只可能是 i (x 区域)或者 i + 老桶数组长度(y 区域);
(7)当 key 低位 hash 值向左扩展一位的 bit 位为 0,则应该迁往 x 区域的 i 位置;倘若该 bit 位为 1,应该迁往 y 区域对应的 i + 老桶数组长度的位置.
扩容过程:
==类似redis的渐进式rehash==
由于 map 扩容需要将原有的 key/value 重新搬迁到新的内存地址,如果有大量的 key/value 需要搬迁,会非常影响性能。因此 Go map 的扩容采取了一种称为“渐进式”地方式,原有的 key 并不会一次性搬迁完毕,每次最多只会搬迁 2 个 bucket。
当每次触发写、删操作时,会为处于扩容流程中的 map 完成两组桶的数据迁移:
- 一组桶是当前写、删操作所命中的桶;
- 另一组桶是,当前未迁移的桶中,索引最小的那个桶.
上面说的 hashGrow()
函数实际上并没有真正地“搬迁”,它只是分配好了新的 buckets,并将老的 buckets 挂到了 oldbuckets 字段上。真正搬迁 buckets 的动作在 growWork()
函数中,而调用 growWork()
函数的动作是在 mapassign 和 mapdelete 函数中。==也就是插入或修改、删除 key 的时候,都会尝试进行搬迁 buckets 的工作==。先检查 oldbuckets 是否搬迁完毕,具体来说就是检查 oldbuckets 是否为 nil。
如何保证并发安全?
(1)并发读没有问题;
(2)并发读写中的“写”是广义上的,包含写入、更新、删除等操作;
(3)读的时候发现其他 goroutine 在并发写,抛出 fatal error;
(4)写的时候发现其他 goroutine 在并发写,抛出 fatal error.
需要关注,此处并发读写会引发 fatal error,是一种比 panic 更严重的错误,无法使用 recover 操作捕获.
如果对map进行并发读写会报fatal error,是不能被recover捕获的
使用时加锁或使用sync.Map
倘若发现存在其他 goroutine 在写 map,直接抛出并发读写的 fatal error;其中,并发写标记,位于 hmap.flags 的第 3 个 bit 位;
==在读写前,和读写后都会判断==
1 | // 检测 hmap 中的 flags uint8 // 标识 map 是否被 goroutine 并发读写 |
sync.Map
底层是两个map , 一个read map 一个dirty map , 一开始读read map ,没有数据则加锁穿透去读dirty map
并且记录一个计数 ,计数满的时候read map被用dirty map进行覆盖
1 | type Map struct { |
- 通过
read
和dirty
两个字段将读写分离,读的数据存在只读字段 read 上,将最新写入的数据则存在 dirty 字段上 - 读取时会先查询 read,不存在再查询 dirty,写入时则只写入 dirty
- 读取 read 并不需要加锁,而读或写 dirty 都需要加锁
- 另外有 misses 字段来统计 read 被穿透的次数(被穿透只需要读 dirty 的情况),超过一定次数则将 dirty 数据同步到 read 上
- 对于删除数据则直接通过标记来延迟删除
sync.Map 由两个 map 构成:
- • read map:访问时全程无锁;
- • dirty map:是兜底的读写 map,访问时需要加锁.
之所以这样处理,是希望能根据对读、删、更新、写操作频次的探测,来实时动态地调整操作方式,希望在读、更新、删频次较高时,更多地采用 CAS 的方式无锁化地完成操作;在写操作频次较高时,则直接了当地采用加锁操作完成.
- • sync.Map 适用于读多、更新多、删多、写少的场景;
- • 倘若写操作过多,sync.Map 基本等价于互斥锁 + map;
- • sync.Map 可能存在性能抖动问题,主要发生于在读/删流程 miss 只读 map 次数过多时(触发 missLocked 流程),下一次插入操作的过程当中(dirtyLocked 流程).
读流程
• 查看 read map 中是否存在 key-entry 对,若存在,则直接读取 entry 返回;
• 倘若第一轮 read map 查询 miss,且 read map 不全,则需要加锁 double check;
• 第二轮 read map 查询仍 miss(加锁后),且 read map 不全,则查询 dirty map 兜底;
• 查询操作涉及到与 dirty map 的交互,misses 加一;
• 解锁,返回查得的结果.
• 在读流程中,倘若未命中 read map,且由于 read map 内容存在缺失需要和 dirty map 交互时,会走进 missLocked 流程;
• 在 missLocked 流程中,首先 misses 计数器累加 1;
• 倘若 miss 次数小于 dirty map 中存在的 key-entry 对数量,直接返回即可;
• 倘若 miss 次数大于等于 dirty map 中存在的 key-entry 对数量,则使用 dirty map 覆盖 read map,并将 read map 的 amended flag 置为 false;
• 新的 dirty map 置为 nil,misses 计数器清零.
写流程
(1)倘若 read map 存在拟写入的 key,且 entry 不为 expunged 状态,说明这次操作属于更新而非插入,直接基于 CAS 操作进行 entry 值的更新,并直接返回(存活态或者软删除,直接覆盖更新);
(2)倘若未命中(1)的分支,则需要加锁 double check;
(3)倘若第二轮检查中发现 read map 或者 dirty map 中存在 key-entry 对,则直接将 entry 更新为新值即可(存活态或者软删除,直接覆盖更新);
(4)在第(3)步中,如果发现 read map 中该 key-entry 为 expunged 态,需要在 dirty map 先补齐 key-entry 对,再更新 entry 值(从硬删除中恢复,然后覆盖更新);
(5)倘若 read map 和 dirty map 均不存在,则在 dirty map 中插入新 key-entry 对,并且保证 read map 的 amended flag 为 true.(插入)
(6)第(5)步的分支中,倘若发现 dirty map 未初始化,需要前置执行 dirtyLocked 流程;
(7)解锁返回.
删流程
(1)倘若 read map 中存在 key,则直接基于 cas 操作将其删除;
(2)倘若read map 不存在 key,且 read map 有缺失(amended flag 为 true),则加锁 dou check;
(3)倘若加锁 double check 时,read map 仍不存在 key 且 read map 有缺失,则从 dirty map 中取元素,并且将 key-entry 对从 dirty map 中物理删除;
(4)走入步骤(3),删操作需要和 dirty map 交互,需要走进 3.3 小节介绍的 missLocked 流程;
(5)解锁;
(6)倘若从 read map 或 dirty map 中获取到了 key 对应的 entry,则走入 entry.delete() 方法逻辑删除 entry;
(7)倘若 read map 和 dirty map 中均不存在 key,返回 false 标识删除失败.
Context
context包定义了Context类型,该类型包含了截止日期、取消信号以及跨API的进程间的其他用户级别范围的变量。
作用
一句话:context 用来解决 goroutine 之间退出通知
、元数据传递
的功能。
传递共享数据、取消goroutine、
Go语言中的context
包提供了一种在程序中传递请求范围内的上下文信息的方式。这个上下文信息可以包括请求相关的元数据、取消信号以及其他请求范围内的数据。
context 主要用来在 goroutine 之间传递上下文信息,包括:取消信号、超时时间、截止时间、k-v 等。
取消 goroutine(ctx怎么通知子节点取消的)
Done()
返回一个 channel,==标识ctx是否结束==,可以表示 context 被取消的信号:当这个 channel 被关闭时,说明 context 被取消了。注意,这是一个只读的channel。 我们又知道,读一个关闭的 channel 会读出相应类型的零值。并且源码里没有地方会向这个 channel 里面塞入值。换句话说,这是一个 receive-only
的 channel。因此在子协程里读这个 channel,除非被关闭,否则读不出来任何东西。也正是利用了这一点,子协程从 channel 里读出了值(零值)后,就可以做一些收尾工作,尽快退出。
1 | func Perform(ctx context.Context) { |
底层
1 | type Context interface { |
Context
是一个接口,定义了 4 个方法,它们都是幂等
的。也就是说连续多次调用同一个方法,得到的结果都是相同的。
Done()
返回一个 channel,==标识ctx是否结束==,可以表示 context 被取消的信号:当这个 channel 被关闭时,说明 context 被取消了。注意,这是一个只读的channel。 我们又知道,读一个关闭的 channel 会读出相应类型的零值。并且源码里没有地方会向这个 channel 里面塞入值。换句话说,这是一个 receive-only
的 channel。因此在子协程里读这个 channel,除非被关闭,否则读不出来任何东西。也正是利用了这一点,子协程从 channel 里读出了值(零值)后,就可以做一些收尾工作,尽快退出。
Err()
返回一个==错误==,表示 context 被关闭的原因。例如是被取消,还是超时。
Deadline()
返回 context 的截止时间,==过期时间==,通过此时间,函数就可以决定是否进行接下来的操作,如果时间太短,就可以不往下做了,否则浪费系统资源。当然,也可以用这个 deadline 来设置一个 I/O 操作的超时时间。
Value()
获取之前设置的 key 对应的 value。==返回ctx存放的对应key的value==
context有几种类型
WithCancel
context.WithCancel(parent Context) (ctx Context, cancel CancelFunc)
返回派生 context 和取消函数。只有创建它的函数才能调用取消函数来取消此 context。如果您愿意,可以传递取消函数,但是,强烈建议不要这样做。这可能导致取消函数的调用者没有意识到取消 context 的下游影响。
1 | ctx, cancel := context.WithCancel(context.Background()) |
WithDeadline
context.WithDeadline(parent Context, d time.Time) (ctx Context, cancel CancelFunc)
此函数返回其父项的派生 context,当截止日期超过或取消函数被调用时,该 context 将被取消。例如,您可以创建一个将在以后的某个时间自动取消的 context,并在子函数中传递它。当因为截止日期耗尽而取消该 context 时,获此 context 的所有函数都会收到通知去停止运行并返回。
1 | ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(2 * time.Second)) |
WithTimeout
context.WithTimeout(parent Context, timeout time.Duration) (ctx Context, cancel CancelFunc)
此函数类似于 context.WithDeadline。不同之处在于它将持续时间作为参数输入而不是时间对象。此函数返回派生 context,如果调用取消函数或超出超时持续时间,则会取消该派生 context。
1 | ctx, cancel := context.WithTimeout(context.Background(), 2 * time.Second) |
WithValue
context.WithValue(parent Context, key, val interface{}) (ctx Context, cancel CancelFunc)
此函数接收 context 并返回派生 context,其中值 val 与 key 关联,并通过 context 树与 context 一起传递。这意味着一旦获得带有值的 context,从中派生的任何 context 都会获得此值。
闭包
在函数内部引用了函数内部变量的函数
==一个函数内引用了外部的局部变量,这种现象,就称之为闭包。==
一般来说,一个函数返回另外一个函数,这个被返回的函数可以引用外层函数的局部变量,这形成了一个闭包。通常,闭包通过一个结构体来实现,它存储一个函数和一个关联的上下文环境。但 Go 语言中,匿名函数就是一个闭包,它可以直接引用外部函数的局部变量
- 在函数外部访问函数内部变量成为可能
- 函数内部变量离开其作用域后始终保持在内存中而不被销毁
闭包环境中引用的变量是不能够在栈上分配的,而是在堆上分配。因为如果引用的变量在栈上分配,那么该变量会跟随函数f返回之后回收,那么闭包函数就不可能访问未分配的一个变量,即未声明的变量,之所以能够再堆上分配,而不是在栈上分配,是Go的一个语言特性—-escape analyze(能够自动分析出变量的作用范围,是否将变量分配堆上)。
应用场景
- 数据隔离
- defer延迟调用与闭包
- 中间件
在闭包中,除了动态创建函数,还可以通过参数传递的方式,将函数穿进去,实现闭包。典型应用计算函数执行时间 - 访问到原本访问不到的数据
- 二分查找,排序时实现排序函数
注意
- 闭包对自由变量的修改是引用的方式。
- 闭包中,自由变量的生命周期等同于闭包函数的生命周期,和局部环境的周期无关。