Go面试题(一)
1 选择go的原因?
- 简单性
Go语言的语法简洁清晰,没有过多的封装和继承机制,学习和使用门槛较低。 - 高并发
Go语言从语言层面原生支持并发,通过goroutine和channel实现并发编程,比如Web服务器可以轻松处理大量并发连接。 - 高性能
Go程序接近机器码运行,甚至有些情况下性能可以与C程序相媻,而开发效率又高于C/C++。GC(垃圾回收)简单高效。 - 静态链接
Go编译后生成的是单个静态链接的二进制文件,方便部署,跨平台也更简单。 - 标准库
Go语言自带了很多强大的标准库,网络编程、并发编程、编码解码等功能一应俱全。
与Java相比,Go语言的性能更出色,对并发支持更好。同时Go没有像Java那样庞大的类库和复杂的语法,更注重语法的简洁和可读性。
但Java在企业级应用、成熟的框架和庞大的资源库上仍占有优势,生态更为完善。
总的来说,Go语言更适合编写底层系统编程、分布式系统、网络编程等对性能和并发性有较高要求的领域。
2 带GC的语言都有哪些,说一下Go语言的GC
有GC(垃圾回收)的编程语言有很多,比如Java、C#、Python、Ruby、Go等。其中比较流行和常用的语言有:
- Java
- C#
- Go
- Python
- Node.js(JavaScript)
- Ruby
- Scala
- Rust
Go的GC
变革
- Go V1.3的标记-清除(mark and sweep)法。
- Go V1.5的三色并发标记法。
- Go V1.5的强三色不变式、弱三色不变式、插入屏障、删除屏障。
- Go V1.8的混合写屏障机制。
Go语言使用了三色标记清除(Tri-Color Mark Sweep)算法进行垃圾回收。这是一种增量式的并发GC算法。
Go V1.5的三色并发标记法, 主要流程是:
- 启动STW
- 新创建的对象默认颜色是白色
- GC回收从根节点一次遍历所有对象,把遍历到的对象从白色集合放入灰色集合。
- 循环遍历灰色集合,将灰色对象引用的对象从白色集合放入灰色集合,之后将此灰色对象放入黑色集合,直到灰色中无任何对象
- 停止STW
- 回收所有的白色标记表的对象.也就是回收垃圾。
强三色不变式和弱三色不变式。
在三色标记法的过程中对象丢失,需要同时满足下面两个条件:
条件一:白色对象被黑色对象引用
强三色不变式
规则:不允许黑色对象引用白色对象
条件二:灰色对象与白色对象之间的可达关系遭到破坏
弱三色不变式
规则:黑色对象可以引用白色对象,但是白色对象的上游必须存在灰色对象
实现机制:插入写屏障和删除写屏障。
插入写屏障:
规则:当一个对象引用另外一个对象时,将另外一个对象标记为灰色。
满足:强三色不变式。不会存在黑色对象引用白色对象
插入屏障仅会在堆内存中生效,不对栈内存空间生效,这是因为go在并发运行时,大部分的操作都发生在栈上,函数调用会非常频繁。
删除写屏障
规则:在删除引用时,如果被删除引用的对象自身为灰色或者白色,那么被标记为灰色。
满足弱三色不变式。灰色对象到白色对象的路径不会断
对比插入写屏障和删除写屏障:
- 插入写屏障:
- 插入写屏障哪里都好,就是栈上的操作管不到,所以最后需要对栈空间进行stw保护,然后rescan保证引用的白色对象存活。
- 删除写屏障:
- 在GC开始时,会扫描记录整个栈做快照,从而在删除操作时,可以拦截操作,将白色对象置为灰色对象。
- 回收精度低。
Go V1.8的混合写屏障机制。
混合写屏障的具体核心规则如下:
- GC开始将栈上的对象全部扫描并标记为黑色(之后不再进行第二次重复扫描,无需STW),
- GC期间,任何在栈上创建的新对象,均为黑色。
- 被删除的对象标记为灰色。
- 被添加的对象标记为灰色。
GoV1.8三色标记法加混合写屏障机制,栈空间不启动屏障机制,堆空间启动屏障机制。整个过程几乎不需要STW,效率较高。
Go的GC有如下一些特点:
- 并发的增量式GC,与应用程序并发运行
- 对CPU开销控制良好,在20%以内
- 自动调整GOGC值来控制内存占用
- 避免在系统压力大时启动GC以防停顿时间过长
- 避免内存碎片
总结
- Golang v1.3之前采用传统采取标记-清除法,需要STW,暂停整个程序的运行。
- 在v1.5版本中,引入了三色标记法和插入写屏障机制,其中插入写屏障机制只在堆内存中生效。但在标记过程中,最后需要对栈进行STW。
- 在v1.8版本中结合删除写屏障机制,推出了混合屏障机制,屏障限制只在堆内存中生效。避免了最后节点对栈进行STW的问题,提升了GC效率
总的来说,Go语言的GC算法在简单性、开销和用户体验等方面做了很好的权衡和优化。这也是Go适合编写高并发、对延时敏感的系统的一个重要原因。
3 闭包和defer
对于闭包的理解
在函数A内部定义了一个引用了函数A内部创建的自由变量m的匿名函数B。因为闭包的作用,所以函数B可以访问函数B外部、函数A内部的变量m;函数A内部的变量m在函数A执行完毕后会发生内存逃逸到堆上从而始终保存在内存上不被销毁。
defer
defer 语句
defer 语句用于延迟函数的执行,直到包含 defer 的函数返回时才执行。它的执行顺序是后进先出(LIFO),这意味着最后一个 defer 语句会最先执行。
使用场景
资源清理:例如,在打开文件、网络连接或数据库连接后,可以使用 defer 语句来确保在函数结束时正确地关闭这些资源。
异常处理:在处理可能发生 panic 的代码时,可以使用 defer 来捕获异常并进行恢复。
日志和调试:在进入和退出函数时记录日志,方便调试。
两大特性:延迟调用: 后进先出
1,多个defer出现的时候,它是一个“栈”的关系,也就是先进后出
2,return之后的语句先执行,defer后的语句后执行
return语句本身并不是一条原子指令,它会先给返回值赋值,然后再是返回,如下;而在含defer表达式时,函数返回的过程是这样的:先给返回值赋值,然后调用defer表达式,最后再是返回结果
3,只要声明函数的返回值变量名称,就会在函数初始化时候为之赋值为0,而且在函数体作用域可见。
4,执行完return之后,还要再执行defer里的语句,依然可以修改本应该返回的结果。
5,遇到panic时,遍历本协程的defer链表,并执行defer。在执行defer过程中:遇到recover则停止panic,返回recover处继续往下执行。如果没有遇到recover,遍历完本协程的defer链表后,向stderr抛出panic信息。
6,defer中包含panic,panic仅有最后一个可以被revover捕获。
7,defer下的函数参数包含子函数
package main
import "fmt"
func function(index int, value int) int {
fmt.Println(index)
return index
}
func main() {
defer function(1, function(3, 0))
defer function(2, function(4, 0))
}
- defer压栈function1,压栈函数地址、形参1、形参2(调用function3) --> 打印3
- defer压栈function2,压栈函数地址、形参1、形参2(调用function4) --> 打印4
- defer出栈function2, 调用function2 --> 打印2
- defer出栈function1, 调用function1--> 打印1
3
4
2
1
练习
package main
import "fmt"
func DeferFunc1(i int) (t int) {
t = i
defer func() {
t += 3
}()
return t
}
func DeferFunc2(i int) int {
t := i
defer func() {
t += 3
}()
return t
}
func DeferFunc3(i int) (t int) {
defer func() {
t += i
}()
return 2
}
func DeferFunc4() (t int) {
defer func(i int) {
fmt.Println(i)
fmt.Println(t)
}(t)
t = 1
return 2
}
func main() {
fmt.Println(DeferFunc1(1))
fmt.Println(DeferFunc2(1))
fmt.Println(DeferFunc3(1))
DeferF
使用场景
场景一:资源释放
场景二:异常捕获
场景三:代码追踪
场景四:打印函数的参数和返回值
4 map的实现原理
map是一种key-value键值对的存储结构,其中key是不能重复的,其底层实现采用的是hash表。
map 是基于哈希表实现的键值对数据结构。
可以通过 make 函数或字面量语法来创建 map。
提供了插入、更新、删除和查找等基本操作。
可以使用 range 关键字来遍历 map。
map的数据结构
在go的map实现中,它的底层结构体是hmap,其中关键的字段包括:
- count:表示map中键值对的数量。
- B:表示哈希表的大小,即桶的数量。
- buckets:一个指向桶数组的指针,每个桶中存储具有相同哈希值的键值对。
Bucket数组中每个元素都是bmap结构,也即每个bucket(桶)都是bmap结构, 每个桶中保存了8个kv对,如果8个满了,又来了一个key落在了这个桶里,会使用overflow连接下一个桶(溢出桶)。
map中数据操作
- Get获取数据
假设当前 B=4 即桶数量为2^B=16个,要从map中获取k4对应的value
①计算k4的hash值。②通过最后的“B”位来确定在哪号桶。③根据k4对应的hash值前8位快速确定是在这个桶的哪个位置。④对比key完整的hash是否匹配,如果匹配则获取对应value。⑤如果都没有找到,就去连接的下一个溢出桶中找。 - PUT存放数据
①通过key的hash值后“B”位确定是哪一个桶。② 遍历当前桶,通过key的tophash和hash值,防止key重复,然后找到第一个可以插入的位置,即空位置处存储数据。③如果当前桶元素已满,会通过overflow链接创建一个新的桶,来存储数据
关于hash冲突:当两个不同的 key 落在同一个桶中,就是发生了哈希冲突。冲突的解决手段是采用链表法:在 桶 中,从前往后找到第一个空位进行插入。如果8个kv满了,那么当前桶就会连接到下一个溢出桶(bmap)。
扩容
- 相同容量扩容
由于map中不断的put和delete key,桶中可能会出现很多断断续续的空位,这些空位会导致连接的bmap溢出桶很长,导致扫描时间边长。这种扩容实际上是一种整理,把后置位的数据整理到前面。这种情况下,元素会发生重排,但不会换桶。 - 2倍容量扩容
当前桶数组确实不够用了,发生这种扩容时,元素会重排,可能会发生桶迁移。在扩容前,由hash值的后B位来决定几号桶。
扩容的条件
装载因子(loadFactor) :loadFactor:=count / (2^B) 即 装载因子 = map中元素的个数 / map中当前桶的个数 ;即装载因子是指当前map中,每个桶中的平均元素个数。 - 扩容条件1:装载因子 > 6.5(源码中定义的)
- 扩容条件2: 溢出桶的数量过多;
当 B < 15 时,如果overflow的bucket数量超过 2^B。
当 B >= 15 时,overflow的bucket数量超过 2^15。
及时的扩容,可以对这些元素进行重排,使元素在桶的位置更平均一些。
扩容时的细节
在我们的hmap结构中有一个oldbuckets(发生扩容时,记录扩容前的buckets数组指针),扩容刚发生时,会先将老数据存到这个里面。
每次对map进行删改操作时,会触发从oldbucket中迁移到bucket的操作【非一次性,分多次】
在扩容没有完全迁移完成之前,每次get或者put遍历数据时,都会先遍历oldbuckets,然后再遍历buckets。
Attention
- 对map数据进行操作时不可取地址
因为map 会随着元素数量的增长而重新分配更大的内存空间,会导致之前的地址无效。 - map是线程不安全的
示例:在同一时间点,两个 goroutine 对同一个map进行读写操作。可能在添加key时触发扩容导致读取key时失败。当我们涉及到对一个map进行并发读写时,一般采用的做法是采用golang中自带的mutex锁
5 sync.Mutex(互斥锁)
type Mutex struct {
state int32
sema uint32
}
// Sync.Mutex由两个字段构成,state用来表示当前互斥锁处于的状态,
// sema用于控制锁状态的信号量:当持有锁的gorouine释放锁后,会释放sema信号量,这个信号量会唤醒之前抢锁阻塞的gorouine来获取锁。
互斥锁在设计上主要有两种模式:正常模式和饥饿模式。
饥饿模式,是为了保证goroutine获取互斥锁的公平性。所谓公平性,其实就是多个goroutine在获取锁时,goroutine获取锁的顺序,和请求锁的顺序一致,则为公平。
正常模式下,所有阻塞在等待队列中的goroutine会按顺序进行锁获取,当唤醒一个等待队列中的goroutine时,此goroutine并不会直接获取到锁,而是会和新请求锁的goroutine竞争。 通常新请求锁的goroutine更容易获取锁,这是因为新请求锁的goroutine正在占用cpu片执行,大概率可以直接执行到获取到锁的逻辑。
饥饿模式下, 新请求锁的goroutine不会进行锁获取,而是加入到队列尾部阻塞等待获取锁。
6 sync.RWMutex(读写锁)
读写互斥锁sync.RWMutex不限制对资源的并发读,但是读写,写写操作无法并行执行
RLock(): 申请读锁
RUnlock(): 解除读锁
Lock(): 申请写锁
Unlock(): 解除写锁
type RWMutex struct {
w Mutex // 复用互斥锁
writerSem uint32 // 写锁监听读锁释放的信号量
readerSem uint32 // 读锁监听写锁释放的信号量
readerCount int32 // 当前正在执行读操作的数量
readerWait int32 // 当写操作被阻塞时,需要等待读操作完成的个数
}
- 读操作如何防止并发读写问题的?
RLock(): 申请读锁,每次执行此函数后,会对readerCount++,此时当有写操作执行Lock()时会判断readerCount>0,就会阻塞。
RUnLock(): 解除读锁,执行readerCount--,释放信号量唤醒等待写操作的goroutine。
- 写操作如何防止并发读写、并发写写问题?
Lock(): 申请写锁,获取互斥锁,此时会阻塞其他的写操作。并将readerCount 置为 -1,当有读操作进来,发现readerCount = -1, 即知道有写操作在进行,阻塞。
Unlock(): 解除写锁,会先通知所有阻塞的读操作goroutine,然后才会释放持有的互斥锁。
- 连续读操作下 ,如果写操作等待所有读操作结束,很可能会一直阻塞
当写操作到来时,会把RWMutex.readerCount值拷贝到RWMutex.readerWait中,用于标记排在写操作前面的读者个数。
前面的读操作结束后,除了会递减RWMutex.readerCount,还会递减RWMutex.readerWait值,当RWMutex.readerWait值变为0时唤醒写操作。
7 Golang Channel 的底层原理
//G1
func sendTask(taskList []Task) {
...
ch:=make(chan Task, 4) // 初始化长度为4的channel
for _,task:=range taskList {
ch <- task //发送任务到channel
}
...
}
//G2
func handleTask(ch chan Task) {
for {
task:= <-ch //接收任务
process(task) //处理任务
}
}
ch是长度为4的带缓冲的channel,G1是发送者,G2是接收者
初始hchan结构体重的buf为空,sendx和recvx均为0。
当G1向ch里发送数据时,首先会对buf加锁,然后将数据copy到buf中,然后sendx++,然后释放对buf的锁。
当G2消费ch的时候,会首先对buf加锁,然后将buf中的数据copy到task变量对应的内存里,然后recvx++,并释放锁。
可以发现整个过程,G1和G2没有共享的内存,底层是通过hchan结构体的buf,并使用copy内存的方式进行通信,最后达到了共享内存的目的,这里也体现了Go中的CSP并发模型。
Go中的CSP并发模型即是通过goroutine和channel实现的。
CSP并发模型:不要以共享内存的方式来通信,相反,要通过通信的方式来共享内存。
Channel为什么是线程安全的?
在对buf中的数据进行入队和出队操作时,为当前chnnel使用了互斥锁,防止多个线程并发修改数据
多个goroutine向有缓存的channel接收/发送数据时,可以保证顺序吗?
不能。代码中先写的gorouine并不能保证先从channel中获取数据,或者发送数据。但是先执行的gorouine与后执行的goroutine在channel中获取的数据肯定是有序的。
- channel中的数据遵循队列先进先出原则。
- 每一个goroutine抢到处理器的时间点不一致,gorouine的执行本身不能保证顺序。
一个 channel不能多次关闭,会导致painc;
读已关闭的channel会得到零值;
向一个已经关闭了的 channel发送数据会导致panic
posted on 2024-05-29 18:32 little_mushroom 阅读(106) 评论(0) 编辑 收藏 举报