零值是Go语言中变量在声明之后但是未初始化被赋予的该类型的一个默认值。
在Go语言中,布尔类型的零值(初始值)为 false,数值类型的零值为 0,字符串类型的零值为空字符串""
,而指针、切片、映射、通道、函数和接口的零值则是 nil。
注意:Go中的两个nil可能是不相等的,因为接口(interface) 是对非接口值(例如指针,struct等)的封装,内部实现包含 2 个字段,类型 T 和 值 V。一个接口等于 nil,当且仅当 T 和 V 处于 unset 状态(T=nil,V is unset)。两个接口值比较时,会先比较 T,再比较 V。 接口值与非接口值比较时,会先将非接口值尝试转换为接口值,再比较。
使用空结构体可以节省内存,一般作为占位符使用,表明这里不需要一个值:(1)通常使用map表示集合时,value可以使用struct{}作为占位符,(2)还有使用channel控制并发时,只是需要一个信号,并不需要传递值,也适用struct{}代替。
(1)golang中Slice,Map,Function等数据类型是不可以比较的,因此struct可以比较也不可以比较,主要看结构体内的成员变量是否都可以比较;结论就是同一个struct的两个实例可比较也不可比较,当结构不包含不可直接比较成员变量时可直接比较,否则不可直接比较。
(2)struct必须是可比较的,才能作为map的key,否则编译时报错。
(3)GO中map、slice、func是不可比较类型,可比较:int、ifloat、string、bool、complex、pointe、channel、interface、array
tag 可以理解为 struct 字段的注解,可以用来定义字段的一个或多个属性。框架/工具可以通过反射获取到某个字段定义的属性,采取相应的处理方式。
package main
import "fmt"
import "encoding/json"
type Stu struct {
Name string `json:"stu_name"`
ID string `json:"stu_id"`
Age int `json:"-"`
}
func main() {
buf, _ := json.Marshal(Stu{"Tom", "t001", 18})
fmt.Printf("%s\n", buf)
}
该例子使用 tag 定义了结构体字段与 json 字段的转换关系,Name -> stu_name
, ID -> stu_id
,忽略 Age 字段。很方便地实现了 Go 结构体与不同规范的 json 文本之间的转换。
defer意为延迟,在go lang中用于延迟执行一个函数,主要用于帮助我们处理资源释放、连接关闭等操作。
每个defer语句都对应一个_defer实例,多个实例使用指针连接起来形成一个单连表,保存在gotoutine数据结构中,每次插入_defer实例,均插入到链表的头部,函数结束再一次从头部取出,从而形成后进先出的效果。
defer与panic和recover结合,形成了Go语言风格的异常与捕获机制。
defer func() {
if err := recover(); err != nil {
// 打印异常,关闭资源,退出此函数
fmt.Println(err)
}
}()
总结:context的主要功能就是用于控制协程退出和附加链路信息。核心实现的结构体有4个,最复杂的是cancelCtx,最常用的是cancelCtx和valueCtx。整体呈树状结构,父子节点间同步取消信号。
接下来介绍Context的基础用法,最为重要的就是3个基础能力,取消、超时、附加值
ctx := context.TODO()
ctx := context.Background()
这两个方法返回的内容是一样的,都是返回一个空的Context,这个Context一般用来做父Context
// 函数声明
func WithCancel(parent Context) (ctx Context, cancel CancelFunc)
// 用法:返回一个子Context和主动取消函数
ctx, cancel := context.WithCancel(parentCtx)
根据传入的Context生成一个子Context和一个取消函数。当父Context有相关取消操作,或者直接调用cancel函数的话,子Context就会被取消。
// 函数声明
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)
// 用法:返回一个子Context(会在一段时间后自动取消),主动取消函数
ctx := context.WithTimeout(parentCtx, 5*time.Second)
例如检查:
select {
// 轮询检测是否已经超时
case <-ctx.Done():
这个函数在日常工作中使用得非常多,简单来说就是给Context附加一个超时控制,当超时ctx.Done()返回的channel就能读取到值,协程可以通过这个方式来判断执行时间是否满足要求
// 函数声明
func WithDeadline(parent Context, d time.Time) (Context, CancelFunc)
// 用法:返回一个子Context(会在指定的时间自动取消),主动取消函数
ctx, cancel := context.WithDeadline(parentCtx, time.Now().Add(5*time.Second))
这个函数感觉用得比较少,和WithTimeout相比的话就是使用的是截止时间
// 函数声明
func WithValue(parent Context, key, val interface{}) Context
// 用法: 传入父Context和(key, value),相当于存一个kv
ctx := context.WithValue(parentCtx, "name", 123)
// 用法:将key对应的值取出
v := ctx.Value("name")
这个函数常用来保存一些链路追踪信息,比如API服务里会有来保存一些来源ip、请求参数等
答:make和new都是golang用来分配内存的內建函数,且在堆上分配内存,make 即分配内存,也初始化内存。new只是将内存清零,并没有初始化内存。make是用于引用类型(map,chan,slice)的创建,返回引用类型的本身,new创建的是指针类型,new可以分配任意类型的数据,返回的是指针。
变量初始化,一般包括2步,变量声明 + 变量内存分配,var关键字就是用来声明变量的,new和make函数主要是用来分配内存的并且在堆上分配内存;
var声明值类型的变量时,系统会默认为他分配内存空间,并赋该类型的零值:比如布尔、数字、字符串、结构体;
如果指针类型或者引用类型的变量,系统不会为它分配内存,默认就是nil
。此时如果你想直接使用,那么系统会抛异常,必须进行内存分配后,才能使用。
new 和 make 两个内置函数,主要用来分配内存空间,有了内存,变量就能使用了,主要有以下2点区别:
使用场景区别:
make 只能用来分配及初始化类型为slice、map、chan 的数据,并且返回引用类型本身。
new 可以分配任意类型的数据,返回的是指针类型;只是将内存清零,并不初始化内存。
返回值区别:
这3种类型是引用类型,就没有必要返回他们的指针
func make(t Type, size ...IntegerType) Type
new函数原型如下,返回一个指向该类型内存地址的指针
func new(Type) *Type
Go中的函数参数传递都是值传递,所谓值传递即在调用函数时将实际参数复制一份到函数中,函数内对参数修改并不会影响到实际参数;所谓引用传递指在调用函数时将实际参数的地址传递到函数中,那么在函数中对参数做的修改将影响到实际参数。
参数如果是非引用类型(int、string、struct等这些),这样就在函数中就无法修改原内容数据;如果是引用类型(指针、map、slice、chan等这些),这样就可以修改原内容数据。
什么是引用类型:即别名,声明引用并不开辟内存单元
切片是基于数组实现的,它的底层是数组,可以理解为对底层数组的抽象。
type slice struct {
array unsafe.Pointer //指向底层数组的指针,占用8个字节
len int //切片的长度,占用8个字节
cap int //切片的容量,cap 总是大于等于 len 的,占用8个字节
}
对于append向slice添加元素时,假如slice容量够用,则追加新元素进去,slice.len++,返回原来的slice。当原容量不够,则slice先扩容,扩容之后slice得到新的slice,将元素追加进新的slice,slice.len++,返回新的slice。对于切片的扩容规则:当切片比较小时(容量小于1024),则采用较大的扩容倍速进行扩容(2倍),避免频繁扩容,从而减少内存分配的次数和数据拷贝的代价。当切片较大的时(原来的slice的容量大于或者等于1024),采用较小的扩容倍速(1.25倍),主要避免空间浪费,网上其实很多总结的是1.25倍,那是在不考虑内存对齐的情况下,实际上还要考虑内存对齐,扩容是大于或者等于1.25倍。
golang中map的底层实现主要是哈希表,结构体有两个:hmap和bmap.
type hmap struct {
count int //元素个数,调用len(map)时直接返回
flags uint8 //标志map当前状态,正在删除元素、添加元素.....
B uint8 //单元(buckets)的对数 B=5表示能容纳32个元素
noverflow uint16 //单元(buckets)溢出数量,如果一个单元能存8个key,此时存储了9个,溢出了,就需要再增加一个单元
hash0 uint32 //哈希种子
buckets unsafe.Pointer //指向单元(buckets)数组,大小为2^B,可以为nil
oldbuckets unsafe.Pointer //扩容的时候,buckets长度会是oldbuckets的两倍
nevacute uintptr //指示扩容进度,小于此buckets迁移完成
extra *mapextra //与gc相关 可选字段
}
buckets:一个指针,指向一个bmap数组、存储多个桶。
oldbuckets: 是一个指针,指向一个bmap数组,存储多个旧桶,用于扩容。
overflow:overflow是一个指针,指向一个元素个数为2的数组,数组的类型是一个指针,指向一个slice,slice的元素是桶(bmap)的地址,这些桶都是溢出桶。为什么有两个?因为Go map在哈希冲突过多时,会发生扩容操作。[0]表示当前使用的溢出桶集合,[1]是在发生扩容时,保存了旧的溢出桶集合。overflow存在的意义在于防止溢出桶被gc。
type bmap struct {
topbits [8]uint8
keys [8]keytype
values [8]valuetype
pad uintptr
overflow uintptr
}
一个桶(bmap)可以存储8个键值对。如果有第9个键值对被分配到该桶,那就需要再创建一个桶,通过overflow指针将两个桶连接起来。在hmap中,多个bmap桶通过overflow指针相连,组成一个链表。
键和值是分开存放的,原因是当key和value类型不一样的时候,key和value占用字节大小不一样,使用key/value这种形式可能会因为内存对齐导致内存空间浪费,所以Go采用key和value分开存储的设计,更节省内存空间
哈希表的特点是根据哈希函数求得哈希值,来存储对应的key,golang中根据哈希值将其分为高位和低位;
低8位用于寻找当前key属于哪个hmap中的哪个bucket;而高8位用于确定将其存放在桶中(bucket)的哪个位置;
注意:一个桶内最多有8个位置。这也是为什么map
无法使用cap()
来求容量的关键原因:map
的容量是编译器进行计算后得出的一个结果,由于桶的存在,map
在内存中实际存放的大小不一定同make
出来后的map
的大小一致。
map默认是并发不安全的,同时对map进行并发读写时,程序会panic,原因如下:
Go 官方在经过了长时间的讨论后,认为 Go map 更应适配典型使用场景(不需要从多个 goroutine 中进行安全访问),而不是为了小部分情况(并发访问),导致大部分程序付出加锁代价(性能),决定了map不支持并发安全。
方式一:使用读写锁 map + sync.RWMutex
var lock sync.RWMutex
方式二:使用Go提供的 sync.Map
var map sync.Map
条件一:超过负载
map元素个数 > 6.5 * 桶个数
条件二:溢出桶太多
当桶总数 < 2 ^ 15 时,如果溢出桶总数 >= 桶总数,则认为溢出桶过多。
当桶总数 >= 2 ^ 15 时,直接与 2 ^ 15 比较,当溢出桶总数 >= 2 ^ 15 时,即认为溢出桶太多了。
问:map 中删除一个 key,它的内存会释放么?
通过delete删除map的key,执行gc不会,内存没有被释放,如果通过map=nil,内存才会释放
问:nil map 和空 map 有何不同?
nil map是未初始化的map,空map是长度为空
**首先说明:**Channel被设计用来实现协程间的通信,其作用域和生命周期不可能仅限于某个函数内部,所以golang直接将其分配在堆上。
使用场景:停止信号监听、定时任务、生产消费解藕、控制并发数
通过var声明或者make函数创建的channel变量是一个存储在函数栈帧上的指针,占用8个字节,指向堆上的hchan结构体
type hchan struct {
qcount uint // chan 里元素数量
dataqsiz uint // chan 底层循环数组的长度
buf unsafe.Pointer // 指向底层循环数组的指针,// 只针对有缓冲的 channel
elemsize uint16 // chan 中元素大小
closed uint32 // chan 是否被关闭的标志
elemtype *_type // chan 中元素类型
sendx uint // 已发送元素在循环数组中的索引
recvx uint // 已接收元素在循环数组中的索引
recvq waitq // 等待接收的 goroutine 队列
sendq waitq // 等待发送的 goroutine 队列
lock mutex // 保护 hchan 中所有字段
}
buf指向一个底层的循环数组,只有设置为有缓存的channel才会有buf
sendx和recvx分别指向底层循环数组的发送和接收元素位置的索引
sendq和recvq分别表示发送数据的被阻塞的goroutine和读取数据的goroutine,这两个都是一个双向链表结构
sendq和recvq 的结构为 waitq 类型,sudog是对goroutine的一种封装
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-6sCfFshN-1667188886837)(/Users/bytedance/Desktop/下载.png)]
操作 | nil channel | closed channel | not nil,not channel |
---|---|---|---|
close | panic | panic | 正常关闭 |
写ch<- | 阻塞 | panic | 阻塞或正常写入数据,非缓冲型channel没有等待接收者或缓冲型channel buf满时会被阻塞 |
读<-ch | 阻塞 | 读到对应类型的零值 | 阻塞或正常读取数据,缓冲型channel为空或非缓冲型channel等待发送者时会阻塞 |
向channel写数据的流程:
向channel读数据的流程:
总结hchan结构体的主要组成部分有四个:
chan := make(chan int) // 无缓冲
chan := make(chan int, 1) // 有缓冲
最大的区别是一个是同步的,一个是非同步的
go的select为golang提供了IO多路复用机制,和其他IO复用一样,用于检测是否有读写事件发生,是否ready。
如果通道没有数据发送,但 select 中有存在接收通道数据的语句,将发生死锁。
package main
func main() {
ch := make(chan string)
select {
case <-ch:
// 操作
}
}
select 语句只能对其中的每一个case表达式各求值一次。所以,如果想连续或定时地操作其中的通道的话,就需要通过在for语句中嵌入select语句的方式实现。
主要使用的 time.After
实现超时控制。
func main() {
ch := make(chan int)
quit := make(chan bool)
go func() {
for {
select {
case num := <-ch: //如果有数据,下面打印。但是有可能ch一直没数据
fmt.Println("num = ", num)
case <-time.After(3 * time.Second): //上面的ch如果一直没数据会阻塞,那么select也会检测其他case条件,检测到后3秒超时
fmt.Println("超时")
quit <- true //写入
}
}
}()
for i := 0; i < 5; i++ {
ch <- i
time.Sleep(time.Second)
}
<-quit //这里暂时阻塞,直到可读
fmt.Println("程序结束")
}
在一段程序中,每一个函数都会有自己的内存区域存放自己的局部变量、返回地址等,这些内存会由编译器在栈中进行分配,每一个函数都会分配一个栈桢,在函数运行结束后进行销毁,但是有些变量我们想在函数运行结束后仍然使用它,那么就需要把这个变量在堆上分配,这种从"栈"上逃逸到"堆"上的现象就成为内存逃逸。
在栈上分配的地址,一般由系统申请和释放,不会有额外性能的开销,比如函数的入参、局部变量、返回值等。在堆上分配的内存,如果要回收掉,需要进行 GC,那么GC 一定会带来额外的性能开销。
在for k, v := range nums
遍历中, k 和 v 在内存中只会存在一份,即之后每次循环时遍历到的数据都是以值覆盖的方式赋给 k 和 v,k,v 的内存地址始终不变。由于有这个特性,for循环里面如果开协程,不要直接把 k 或者 v 的地址传给协程。
相同点:
区别:
G(Goroutine)
:协程 Goroutine 的缩写,相当于操作系统中的进程控制块。其中存着 goroutine 的运行时栈信息,CPU 的一些寄存器的值以及执行的函数指令等。M(Machine)
:代表一个操作系统的内核线程。一个 M 直接关联一个 os 内核线程,用于执行 G。M 会优先从关联的 P 的本地队列中直接获取待执行的 G。M 保存了 M 自身使用的栈信息、当前正在 M上执行的 G 信息、与之绑定的 P 信息。P(Processor)
:代表了 M 所需的上下文环境,即运行 G 所需要的资源。是处理用户级代码逻辑的处理器,可以将其看作一个局部调度器使 go 代码在一个线程上跑。当 P 有任务时,就需要创建或者唤醒一个系统线程来执行它队列里的任务,所以 P 和 M 是相互绑定的。G
type g struct {
stack stack // 描述真实的栈内存,包括上下界
goid int64 // goroutine 的 ID
m *m // 当前的 m
sched gobuf // goroutine 切换时,用于保存 g 的上下文
param unsafe.Pointer // 用于传递参数,睡眠时其他 goroutine 可以设置 param,唤醒时该goroutine可以获取
waitsince int64 // g 被阻塞的大体时间
lockedm *m // G 被锁定只在这个 m 上运行
}
其中 sched 比较重要,该字段保存了 goroutine 的上下文。goroutine 切换的时候不同于线程有 OS 来负责这部分数据,而是由一个 gobuf 结构体来保存。
M
type m struct {
g0 *g // 带有调度栈的goroutine
gsignal *g // 处理信号的goroutine
tls [6]uintptr // thread-local storage
curg *g // 当前运行的goroutine
p puintptr // 关联p和执行的go代码
nextp puintptr
id int32
mallocing int32 // 状态
spinning bool // m是否out of work
blocked bool // m是否被阻塞
inwb bool // m是否在执行写屏蔽
ncgocall uint64 // cgo调用的总数
ncgo int32 // 当前cgo调用的数目
alllink *m // 用于链接allm
mcache *mcache // 当前m的内存缓存
lockedg *g // 锁定g在当前m上执行,而不会切换到其他m
createstack [32]uintptr // thread创建的栈
}
结构体 M 中,有两个重要的字段:
P
type p struct {
lock mutex
id int32
status uint32 // 状态,可以为pidle/prunning/...
schedtick uint32 // 每调度一次加1
syscalltick uint32 // 每一次系统调用加1
m muintptr // 会链到关联的m
goidcache uint64 // goroutine的ID的缓存
// 可运行的goroutine的队列
runqhead uint32 // 本地队列对头
runqtail uint32 // 本地队列队尾
runq [256]guintptr // // 本地队列,大小256的数组,数组往往会被都读入到缓存中,对缓存友好,效率较高
runnext guintptr // 下一个运行的g
}
Sched:调度器结构,它维护有存储M和G的全局队列,以及调度器的一些状态信息
type schedt struct {
...
runq gQueue // 全局队列,链表(长度无限制)
runqsize int32 // 全局队列长度
...
}
G | M | P | |
---|---|---|---|
数量限制 | 无限制,受机器内存影响 | 有限制,默认最多10000 | 有限制,最多GOMAXPROCS个(默认CPU核心数) |
创建时机 | go func | 当没有足够的M来关联P并运行其中的可运行的G时会请求创建新的M | 在确定了P的最大数量n后,运行时系统会根据这个数量创建个P |
Go的GC回收有三次演进过程:
Go V1.3之前普通标记清除(mark and sweep)方法,整体过程需要启动STW,效率极低。
GoV1.5三色标记法,堆空间启动写屏障,栈空间不启动,全部扫描之后,需要重新扫描一次栈(需要STW),效率普通。
GoV1.8三色标记法,混合写屏障机制:栈空间不启动(全部标记成黑色),堆空间启用写屏障,整个过程不要STW,效率高。
系统触发:运行时自行根据内置的条件,检查、发现到,则进行 GC 处理,维护整个应用程序的可用性。
a. 使用系统监控,当超过两分钟没有产生任何GC时,强制触发 GC;
b.使用步调(Pacing)算法,其核心思想是控制内存增长的比例,当前内存分配达到一定比例则触发
手动触发:开发者在业务代码中自行调用 **runtime.GC()**
方法来触发 GC 行为。
进程: 进程是具有一定独立功能的程序,进程是系统资源分配和调度的最小单位。 每个进程都有自己的独立内存空间,不同进程通过进程间通信来通信。由于进程比较重量,占据独立的内存,所以上下文进程间的切换开销(栈、寄存器、虚拟内存、文件句柄等)比较大,但相对比较稳定安全。
线程: 线程是进程的一个实体,线程是内核态,而且是CPU调度和分派的基本单位,它是比进程更小的能独立运行的基本单位。线程间通信主要通过共享内存,上下文切换很快,资源开销较少,但相比进程不够稳定容易丢失数据。
协程: 协程是一种用户态的轻量级线程,协程的调度完全是由用户来控制的。协程拥有自己的寄存器上下文和栈。 协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈,直接操作栈则基本没有内核切换的开销,可以不加锁的访问全局变量,所以上下文的切换非常快。