一、基础部分
1.1、go的协程操作数据不需要加锁吗
go协程是并发或者并行的运行的,那么多个协程操作同一份数据时,需要考虑数据的同步,因此需要加锁,但go中有些类型如channel底层是自带锁的,所以操作这类数据不需要再额外加锁。
1.2、组合和继承模型的思想、差异?什么场景倾向继承,什么场景倾向组合?
组合和继承都是为了实现代码的复用。继承将会使得子类继承父类的全部公有功能,即使不需要的功能也会被迫继承。组合是功能的组合使用,可以自主选择需要的功能,所以组合要优于继承。
1.3、golang 中 make 和 new?(基本必问)
它们都是给变量分配内存的。但
1)作用变量类型不同,make只用于给slice,map,channel分配内存,new用于给其他变量分配内存;
2)返回类型不一样,new返回指向变量的指针,make返回变量本身;
1.4、内存中栈和堆的区别
-
栈区:为线程运行预留的空间。通常用来存储局部变量,采用先进后出的方式分配和释放空间,所以追踪栈的变化非常简单。
-
堆区:用来为动态内存分配预留的空间,由GC负责垃圾回收,和栈不一样,堆上的空间可以任何时候分配和释放。
1.5、Go语言的内存逃逸问题
- 如果一个函数返回对一个变量的引用,那么就会发生逃逸,该变量可能会被分配到堆上,但如果编译器进行逃逸分析后,发现该变量并未后引用,那么还是会将其分配到栈上。
- 如果变量在函数外部未引用,但其开辟的内存空间过大,超过了栈的存储能力,也会分配到堆区。
那如何判断变量被分配在堆上还是栈上?
- 分配在堆上的变量包括:全局变量、大对象和从栈上逃逸到堆上的对象;
- 分配在栈上的变量包括:函数调用的参数、返回值等被分配在栈上;
1.6、for range 的时候它的地址会发生变化么?
如下所示,v的地址不会发生变化,每一轮遍历到的数据都是以值覆盖的方式存储到v中的;但需要注意的是,如果遍历的nums是引用类型,那么v中存储的数据也会是nums中对应数据的地址,也就是v中存储的地址与nums对应数据的地址指向的是同一片内存空间。
for _, v := range nums{
......
}
1.7、多个 defer 的顺序,defer 在什么时机会修改返回值,go defer应用场景?
-
多个defer的顺序
- 结论:先进后出
- 原理:多个defer是按照链表的方式前后链接的,每次实例化一个defer,都会按头插法的方式插入在链表中,如下所示,defer函数执行时,从头结点开始依次遍历执行,所以遵循先进后出的原则
-
defer 在什么时机会修改返回值
-
结论:返回值类型为引用类型或者具名返回值时,有机会修改;
-
原理:defer执行的时机是在return之前,它执行的过程并不是原子操作,分为三步:
- 接受返回值变量;
- 执行defer函数;
- return
例如下面的程序
func goDefer(num int) (res int) { defer func() { num++ }() return num }return num的执行过程可以分为三步
- res = num
- num++
- return
显然此时num++修改不了返回的值res,但如果返回的是一个引用类型,即代码变为:
func goDefer(num int) (res *int) { defer func() { num++ }() return &num }那么retuen &num的执行过程为:
- res = &num
- num++
- return 此时res指向num,num++,会导致该内存中对应的值发生变化,影响返回res的值;同理,如果代码变为:
func goDefer(num int) (res int) { defer func() { res++ }() return num }return num的执行过程变为:
- res = num
- res++
- return
defer也将会改变返回值。
-
-
go defer应用场景
- 用于执行函数的一些收尾工作,例如关闭文件,清理内存,关闭连接等;
- 用于接收错误,拒绝panic中断程序执行;如果在defer中使用recover接收panic错误,那么panic将不会报错;
1.8、反射是什么?反射原理是什么?
-
反射是什么?
- 反射提供了一种让接口变量检查自身存储类型的结构和值的能力;这个功能在某些场景下特别重要,例如sql语言构造器,该构造器不管输入的结构体是怎样的,都能构造出对应的sql语句,这时就必须要求程序能够检查输入接口对应的结构体字段与字段值了。
-
反射的原理是什么?
- 反射原理:反射是对接口的反射,因为接口底层包含了两个字段,一个是指向对象类型信息的指针,一个指向对象值的指针,反射正是通过这两个指针去获取类型的字段以及字段值的。
-
反射三大法则:
- 可以将接口对象还原为对应的反射对象
- 可以将反射对象还原为接口对象
- 反射对象的值能够被修改
1.9、调用函数传入结构体时,应该传值还是指针?
传入的是值,是原数据的复制品
1.10、讲讲 Go 的 select 底层数据结构和一些特性?
select主要是用于提供IO多路复用机制,通过一个线程去监听多个IO事件。它的结构由case组成,每个case对应的是对通道的读写操作。如果没设置default语句,就会一直阻塞直至IO事件触发,如果设置了default就不会阻塞:
- select操作至少要有一个case语句,如果没有就是阻塞状态。
- 每个case只能出现一个管道,要么读要么写。
- 多个case语句的执行顺序是随机的。
- 存在default语句,select将不会阻塞,但是存在default会影响性能。
1.11、Go中init 函数的特征?
init的函数是初始化函数,其无返回值,也不能被调用。一个包下可以定义多个init的函数,按定义的顺序执行,不管该包被调用多少次,其init函数只会执行一次,它的执行顺序是从被导入包的最深处开始,层层递出到main包。
1.12、go有几种引用类型
channel、map、slice
二、Go Slice底层原理
2.1 数组和切片的区别
- 数组的容量是固定的,访问不能超过数组定义的长度,切片长度和容量可自动扩容。
- 数组是值类型,切片是引用类型,本身不存储数组,存储数据的是底层的数组,当切片一旦扩容,其底层数组的内存地址也会发生变化。
2.2、讲讲 Go 的 slice 底层数据结构和一些特性
slice底层数据结构由指向数组的指针、当前存储数据的长度、切片的容量这三个数据项组成。使用append添加元素时,若容量足够,则会直接在切片末尾添加数据,若容量已满,则会开辟一块新的更大容量底层数组,存储切片数据,再添加插入数据。扩容规则是,当容量小于1024采用较快的2倍扩容方法,容量大于1024采用1.25倍速度进行扩容。
1.8以后的版本是:
- 判断新的长度是不是大于老容量的两倍,是则直接将新的长度作为容量;
- 否则,判断老容量是不是小于阈值256,小于则直接双倍扩容;
- 大于则将每次递增的扩容(老容量+3*阈值)/4,直至容量大于新切片的长度。
- 最后,进行内存对齐的操作,对容量进行稍微进行调整。
2.3、golang中数组和slice作为参数的区别?slice作为参数传递有什么问题?
数组和切片都是值传递的方式,但切片是引用类型,传参时传递了一个指向底层数组的指针,所以在调用函数内部的修改会影响到源数据,因为操作的是同一块内存空间,而数组不会。
二、GO Channel
2.1、go Channel底层
channel底层主要的结构包括:用于存储数据的循环队列、用于存储等待接收数据的协程队列,存储等待发送数据的协程队列,用于并发控制的互斥锁。
2.2、channel 是否线程安全?锁用在什么地方?
channel是线程安全的,它的底层结构体自带有锁,对通道的读写操作会对该通道加锁,保证了同一时刻只有一个线程能拥有该同通道的锁。
2.3、nil、关闭的 channel、有数据的 channel,再进行读、写、关闭会怎么样?(各类变种题型,重要)
- nil channel(未初始化的channel): 读写均会造成永久性阻塞,因此会报错deadlock;
- 已经关闭的channel:写操作会panic,读操作会得到对应类型的零值;
- 有数据的 channel:写操作如果channel已经存不下数据,且发送区等待队列为空,则会将该写协程挂起在发送的等待队列中;读操作如果channel缓存区无数据,且发送队列中为空,则会将该协程挂起放入读的等待队列中;
- 无数据的channel:无数据缓存区,写协程写数据时,如果读等待队列中为空,则将该写协程挂起在写等待队列中;读数据时,如果写等待队列为空,则将该读协程挂起在读等待队列中。
2.4、向 channel 发送数据和从 channel 读数据的流程是什么样的?
-
发送流程:
- 如果等待接收队列不为空则会直接从等待接收队列中将协程取出,写入数据,再唤醒协程,结束发送。
- 如果等待接受队列为空,缓存队列还能存的下数据,则将数据存入缓存队列,等待协程读取数据。如果缓存队列已经满了,存不下数据了,则将该发送数据协程送入到发送队列,等待唤醒。
-
读取流程:
- 如果缓存区不为空,则直接从缓存区中读取数据。
- 缓存区为空,则将该读取协程放入等待接收数据的队列中,等待被唤醒。
- 如果无缓存区,则看发送队列中是否有协程,有则直接唤醒,接收该协程中的数据,无则将该接收协程放入等待队列中。
-
关闭channel:关闭channel时会把接收队列中的协程全部唤醒,让其接收到的数据全部为空,发送队列中的协程全部唤醒,并panic报错。
2.5、讲讲 Go 的 chan 底层数据结构和主要使用场景
见2.1。 channel主要用于协程间的通信。
2.6、有缓存channel和无缓存channel
无缓冲channel,底层没有用于数据缓存的循环队列,这要求发送者和接收者同时存在,才能实现消息的传递,否则将会阻塞,适用于数据要求同步的场景,有缓存channel适用于无数据同步的场景。
三、go map
3.1、map 使用注意的点,是否并发安全?
使用map前需要初始化,map不是并发安全的,因为官方认为map访问的使用场景一般不会出现并发的访问map。如果想使用并发安全的map可以使用互斥锁去实现,或者使用官方提供的sync.Map这个自带锁的map。
3.2、map 循环是有序的还是无序的?
无序的。map底层是由多个桶结构存储的,当map扩容,将旧桶数据时,存储的数据会被分散打乱,所以其本身就很难实现有序的访问,此外,GO map在设计时,就设置了让其每次访问时,都随机的去选择一个初始桶。
3.3、 map 中删除一个 key,它的内存会释放么?
如果删除的key对于的键类型是值类型,那么它的内存空间不会被释放。如果是引用类型,那么会释放引用类型所指向的那片空间。
3.4、nil map 和空 map 有何不同?
nil map只还没初始化分配内存,所以不能够使用。空map指已经初始化好了的,但是还未存储数据。
3.4、map 的数据结构是什么?
底层是一个存储“键值”数据的散列表,这个散列表是由一个个桶组成的,一个桶能存储8对数据。map在初始化的时候,会实例化一个hmap的结构体,该结构体是桶的管理者,会根据初始所给的map容量去计算所需要创建桶的数量。
3.5、map是怎么实现扩容?
有两个条件会使得map扩容,第一当存放元素总数量/标准桶的数量>6.5时会触发翻倍扩容。当溢出桶太多时,会引发等量扩容:
- B <= 15,使用溢出桶>=2的B次方时。
- B>15,使用溢出桶数量大于2的15次方时。
需要注意的是,等量扩容并未开辟新的空间,只是开辟了新的空间,然后将旧桶数据重新哈希到了新的桶中,让数据更加紧凑。因为旧桶中桶的数量很多,但又未引发翻倍扩容,说明数据很分散的存储在溢出桶中,影响了操作效率,所以需要等量扩容,重新散列,使得数据更紧凑。
3.6、slices能作为map类型的key吗?
不能。能比较的类型是可以作为map的key的,例如,字符串,数字,通道,接口,结构体和数组。而切片是引用类型,不能比较。
四、接口
接口是一个特殊的类型,任何对象只要实现了接口中全部的方法都可以作为该接口类型,接口有空接口和非空接口,空接口表示没有方法的接口,可以作为泛型使用。空接口和非空接口对应的底层类型不同,分别为eface和iface, eface的结构体如下:
type eface struct{ // 两个指针,16byte
_type *_type // 指向类型信息
data unsafe.Pointer // 指向存储的数据信息
}
- _type:包含了接口指向对象的类型信息,_type结构体如下;
type _type struct { size uintptr // 类型的大小 ptrdata uintptr // 包含所有指针的内存前缀的大小 hash uint32 // 类型的 hash 值,此处提前计算好,可以避免在哈希表中计算 tflag tflag // 额外的类型信息标志,此处为类型的 flag 标志,主要用于反射 align uint8 // 对应变量与该类型的内存对齐大小 fieldAlign uint8 // 对应类型的结构体的内存对齐大小 kind uint8 // 类型的枚举值, 包含 Go 语言中的所有类型,例如:`kindBool`、`kindInt`、`kindInt8`、`kindInt16` 等 equal func(unsafe.Pointer, unsafe.Pointer) bool // 用于比较此对象的回调函数 gcdata *byte // 存储垃圾收集器的 GC 类型数据 str nameOff ptrToThis typeOff } - data:包含了对象的值信息;
iface的结构体如下:
type iface struct{ // 两个指针,16byte
tab *itab // 指向类型和方法信息
data unsafe.Pointer // 指向存储的数据信息
}
- tab:是对
_type的封装,不仅包含了指向对象的类型信息还包含了对象的方法;结构体如下:
type itab struct {
inter *interfacetype // 接口的类型信息
_type *_type // 具体类型信息
hash uint32 // _type.hash 的副本,用于目标类型和接口变量的类型对比判断
_ [4]byte
fun [1]uintptr // 存储接口的方法集的具体实现的地址,其包含一组函数指针,实现了接口方法的动态分派,且每次在接口发生变更时都会更
}
- data:包含了对象的值信息;
4.1、go语言和鸭子类型的关系
鸭子类型是一种动态语言的风格,表示如果一个东西长的像鸭子,可以像鸭子一样游泳,像鸭子一样叫,那么它就可以被认为是鸭子,也就是说变量类型不需要显示声明实现了某个接口,它只要实现了该接口下的所有方法,那么它就可以被认为是该接口类型的变量。这正是go语言接口类型的特点。
4.2、值接收者和指针接收者的区别
值接收者调用方法时,操作的是值的拷贝,和原数据不是同一块内存,所以在方法内的修改不会影响原数据。指针接收者操作的是值的引用,和原数据是同一片内存,所以方法内的修改会影响原数据。
4.3、iface 和 eface 的区别是什么
iface 和 eface 都是 Go 中描述接口的底层结构体,区别在于 iface 描述的接口包含方法,而 eface 则是不包含任何方法的空接口:interface{}。
4.4、接口的动态类型和动态值
iface中包括数据项tab和data,tab是接口表指针,指向类型信息,是接口的动态类型。data是数据指针,指向具体的数据,是接口的动态值。
4.5、编译器自动检测类型是否实现接口
可以通过如下方法去检查myWriter是否实现io.writer接口
var _ io.Writer = (*myWriter)(nil)
var _ io.Writer = myWriter{}
4.6、类型转换和断言的区别
它们很相似,都是把一个类型转换成另外一个类型。不同之处在于,类型断言是对接口变量进行的操作。
4.7、如何用 interface 实现多态
go语言中只要实现了该接口中方法的都可以认为是该接口类型,从而实现一种类型具有多种类型的能力,而实现多态。
五、context相关
5.1、context 结构是什么样的?context 使用场景和用途?
上下文控制,多个 goroutine 之间的数据交互等,超时控制:到某个时间点超时,过多久超时。
六、GMP
6.1、go进程、线程、协程有什么区别?
进程是操作系统用于资源调度的基本单位,运行一个程序时会创建一个或者多个进行。线程是操作系统进行调度的基本单位,线程本身不拥有系统资源,多个线程共享隶属进程的资源。协程是更轻量级的线程,是线程内部调度的基本单位,是处于用户态的,协程本身不拥有cpu的调度资源,一般是通过一个调度器去使用线程的资源从而实现调度的。
6.2、什么是 GMP?(必问)
GMP是go协程调度的模型。其中G表示协程,P是调度器,M为线程。因为协程是处于用户态的,不能直接使用CPU资源进行调度,需要通过调度器P,让协程使用M的资源,从而得到调度。GMP的主要类除了G、M、P之外、还有全局队列,全局队列存放着需要被调度的G。它们之间的交互逻辑是:
- M会绑定一个P,每次执行从P的本地队列中拿取G。一个P同一时刻也只会被一个M绑定,所以避免了并发冲突,这也是GMP速度快的原因之一。但由于工作量窃取机制,这个并没完全解决冲突。
- 当M绑定的P中无可用G之后,会首先从全局队列中获取G放入本地队列P中进行调度(加锁)。如果本地队列和全局队列均无可获取的G,那么会尝试从其他P中窃取一半的G放入本地队列P中,这个是工作量窃取机制。
- 如果当前M运行G时阻塞了,会解绑P,让其它空闲的M去执行P。这个叫移交机制。
工作量窃取、移交机制保证了线程的复用,避免的频繁的创建与销毁线程。
-
利用并行:使用CPU的多个核去创建线程。
-
抢占:协程最多占用10ms的CPU资源,防止其它协程被饿死。
关键数据结构的关键字段如下:
// 协程g
type g struct {
m *m // 当前执行该g的m
...
}
// 线程m
type m struct {
g0 *g // 当前执行的g
tls [tlsSlots]uintptr // 操作系统的线程
...
}
// 调度器p
type p struct {
// p存储g的本地队列
runqhead uint32 // 队列头端
runqtail uint32 // 队列尾端
runq [256]guintptr // 队列数组
runnext guintptr // 下一个执行的位置
...
}
// 全局队列
type schedt struct {
lock mutex // 锁
runq gQueue // 用来存储g的队列
runqsize int32 // 大小
...
}
// 队列结构
type gQueue struct {
head guintptr
tail guintptr
}
6.2、调度类型
指协程让出cpu的调度类型。
- 主动调度:主动让出线程资源,进入阻塞状态;
- 被动调度:由于互斥等操作,没法立法执行,让出线程资源;
- 正常调度:执行完成,让出调度;
- 抢占调度:发起系统调用时间过长,让出调度。
七、锁相关
7.1、除了 mutex 以外还有那些方式安全读写共享变量?
- 将共享变量放到channel中,进行读写;
- 使用信号量机制控制。
7.2、Go 如何实现原子操作?
原子操作是不可中断的操作。可以使用互斥锁实现,或者sync/atomic实现。
7.3、Mutex 是悲观锁还是乐观锁?悲观锁、乐观锁是什么?
是悲观锁。
- 悲观锁:悲观锁是认为如果不严格同步线程调用,将会出现多个线程同时访问资源而造成异常,所以需要每次访问前对资源加锁,只允许一个时刻只有一个线程访问该资源。
- 乐观锁:乐观锁则比较乐观,只有在数据提交的时间检查是否出现冲突,发送冲突则将冲突信息返回,让用户觉得如何做。乐观锁适用于读多写少的场景。
7.4、Mutex 有几种模式?
有两种模式,分别为正常状态和饥饿状态。
- 正常模式:协程会争抢锁,如果锁已经被获取了,则将协程放入到先进先出的等待队列中。当锁被释放时,则会将队首协程拿去与新进的协程一起抢锁,抢锁失败则继续插入到队首中。如果等待时间超过了1毫秒,则进入饥饿模式。
- 饥饿模式:此时所有的协程包括新进来的协程均会放入到队列中,当锁被释放,则会直接将锁交给队首协程。当出现以下两种情况,饥饿模式将会切换为正常模式。
- 协程总的等待时间小于1ms,就获得了锁。
- 等待队列无协程。
八、并发相关
8.1、怎么控制并发数?
根据通道中没有数据时读取操作陷入阻塞和通道已满时继续写入操作陷入阻塞的特性,正好实现控制并发数量。或者使用第三方的线程池去控制并发数量。
九、GO GC
9.1、go gc 是怎么实现的?(必问)
go gc有三个迭代的过程,在1.3版本,通过普通标记清除法,它的做法是启动STW暂停程序,然后进行标记,在执行数据回收,最后停止STW。由于需要使用STW暂停程序,导致其效率较低。 在1.5版本,使用了三色标记法,它使用黑灰白三个颜色的表去标记对象,并使用插入屏障和删除屏障去防止对象被误删。
- 开始将所有对象标记为白色。
- 从对象图的根结点开始遍历一层,将遍历到的对象标记为灰色。
- 遍历所有灰色对象的下一层对象,将遍历到的对象标记为白色,原灰色对象标记为黑色。
- 整个过程中,堆中新创建的对象将标记为灰色(插入屏障)。被取消引用的对象将标记为灰色(删除屏障)。
- 重复3,4直至灰色表中无对象。再启用SWT重新遍历栈对象,防止对象被误删。最后删除白色标记的对象。
9.2、GC 的触发时机?
分为系统触发和主动触发。 若堆中的容量超过一堆阈值或者距离上次GC允许时间过去了足够多的时间,系统将会自动触发GC。也可使用runtime.GC去主动触发GC。
十、内存相关
10.1、谈谈内存泄露,什么情况下内存会泄露?怎么定位排查内存泄漏问题?
- 协程泄漏,指协程在执行过程中阻塞,而一直得不到释放。
- 互斥锁未释放或者造成死锁而导致内存泄漏
- 字符串截取引发临时性的内存泄漏
- 切片截取数组和错误返回引用
一般通过 pprof 是 Go 的性能分析工具,当某个函数的未回收内存随着时间的增加而持续增加,那么它可能发生了内存泄漏。
10.2、golang 的内存逃逸吗?什么情况下会发生内存逃逸?
- 函数内返回局部变量的引用。
- 向channel中发送指针数据。
- 在闭包中使用包外的数据。
- 在slice或者map中存储指针。
- 切片长度太大。
- 在interface类型上调用方法。
10.3、请简述 Go 是如何分配内存的?
go程序在启动时会申请一大片内存空间,然后自主管理这块内存空间,这样不用每次分配都需要进行系统调用,提高了程序运行效率。
- 每个线程会维护一个独立的内存池,进行内存分配的时候会优先从该内存池中分配,不够用时,才会去向全局内存池申请内存,减少了不同线程对全局内存池中锁的竞争。
- 把内存切片分的很小,分为多级管理,降低了锁的粒度。
- 回收内存对象时,并未将内存真正释放掉,只是放回了全局内存池,以便复用,只有内存闲置过多,才会尝试归还部分内存给操作系统,以降低系统开销。
10.4、Channel 分配在栈上还是堆上?哪些对象分配在堆上,哪些对象分配在栈上?
channel一般用于协程间的通信,所以它生命周期不会局限于某个函数内,它是分配在堆上的。go语言对象分配在哪,是由它的具体使用定的,即使在函数内声明的变量,如果被引用的返回了,那么它会被分配到堆上。如果没被引用的局部变量,一般是分配在栈上,当然该变量内存过大,也会被分配至堆上。
10.5、介绍一下大对象小对象,为什么小对象多了会造成 gc 压力?
小于等于32k的对象是小对象。其它的都是大对象。 gc垃圾回收是以对象为单位进行遍历检查并回收的,如果小对象过多,将会增加遍历检查和回收的时间复杂度从而增加GC的压力。
感谢阅读
如有错误或者不足之处欢迎批评指正。