Golang 面试题从浅入深高频必刷「2023 版」
大家好,我是阳哥。专注 Go 语言的学习经验分享和就业辅导。
Go 语言特点
Go 语言相比 C++/Java 等语言是优雅且简洁的,是我最喜爱的编程语言之一,它既保留了 C++的高性能,又可以像 Java,Python 优雅的调用三方库和管理项目,同时还有接口,自动垃圾回收和 goroutine 等让人拍案叫绝的设计。
有许多基于 Go 的优秀项目。Docker,Kubernetes,etcd,deis,flynn,lime,revel 等等。Go 无疑是云时代的最好语言!
题外话到此为止,在面试中,我们需要深入了解 Go 语言特性,并适当辅以源码阅读(Go 源码非常人性化,注释非常详细,基本上只要你学过 Go 就能看懂)来提升能力。常考的点包括:切片,通道,异常处理,Goroutine,GMP 模型,字符串高效拼接,指针,反射,接口,sync,go test 和相关工具链。)
下面开始从浅入深的为大家分享高频面试题
01 切片与数组的区别
在 Go 语言中,数组(Array)和切片(Slice)是两种不同的数据类型,它们有以下区别:
长度固定 vs 动态长度:
数组是长度固定的,在声明时需要指定长度,并且无法改变长度。
切片是动态长度的,可以根据需要自动调整长度,无需在声明时指定长度。
值类型 vs 引用类型:
数组是值类型,赋值或传递数组时会进行值的复制。
切片是引用类型,赋值或传递切片时会共享底层数据,修改其中一个切片会影响其他引用该底层数组的切片。
内存分配:
数组在声明时会直接分配连续的内存空间,长度固定。
切片是基于数组的动态长度的视图,底层使用数组来存储数据,但可以根据需要动态调整切片的长度。
灵活性:
数组的长度固定,无法动态增加或缩小,需要重新创建一个新的数组。
切片可以通过追加元素或切割操作来动态增加或缩小长度。
使用场景:
数组适用于固定长度的数据集合,如存储一组固定大小的元素。
切片适用于动态长度的数据集合,如存储可变数量的元素,并且经常需要进行动态调整。
总的来说,数组适用于长度固定的数据集合,而切片适用于动态长度的数据集合。切片提供了更大的灵活性和方便的操作,是在 Go 语言中更常用的数据结构。
02 切片的使用方法
初始化
make 函数初始化
从数组中截取
获取长度和容量
添加元素
删除元素
遍历
03 切片的扩容机制
1. Go1.18 版本前
新申请的容量如果大于当前容量的两倍,会将新申请的容量直接作为新的容量,如果新申请的容量小于当前容量的两倍,会有一个阈值,即当前切片容量小于 1024 时,切片会将当前容量的 2 倍作为新申请的容量,如果大于等于 1024,会将当前的容量的 1.25 倍作为新申请的容量。
源码片段
2. Go 1.18 版本后
新申请的容量如果大于当前容量的两倍,会将新申请的容量直接作为新的容量,如果新申请的容量小于当前容量的两倍,会有一个阈值,即当前切片容量小于 256 时,切片会将当前容量的 2 倍作为新申请的容量,如果大于等于 256,会将当前的容量的 1.25 倍+192 作为新申请的容量,扩容的时候更加平滑,不会出现从 2 到 1.25 的突变。
源码片段
04 锁
互斥锁(Mutex)
Mutex 是 Golang 的互斥锁,作用是在并发程序中对共享资源的保护,避免出现数据竞争问题。
使用方法:Mutex 实现了 Locker 接口
也就是互斥锁 Mutex 提供两个方法 Lock 和 Unlock
使用示例:
Mutex 的两种模式
Mutex 可能处于两种操作模式下:正常模式和饥饿模式
正常模式
在正常模式下,所有的 goroutine 会按照先进先出的顺序进行等待,被唤醒的 goroutine 不会直接持有锁,会和新进来的锁进行竞争,新请求进来的锁会更容易抢占到锁,因为正在 CPU 上运行,因此刚唤醒的 goroutine 可能会竞争失败,回到队列头部;如果队列的 goroutine 超过 1 毫秒的等待时间,则会转换到饥饿模式。
饥饿模式
在饥饿模式下,锁会直接交给队列的第一个 goroutine,新进来的 goroutine 不会抢占锁也不会进入自旋状态,直接进入队列尾部;如果当前 goroutine 已经是队列的最后一个或者当前 goroutine 等待时间小于 1 毫秒,则会转换到正常模式
正常模式下,性能更好,但饥饿模式解决取锁公平问题,性能较差。
底层结构
state 是一个复合型的字段,一个字段包含多个意义:
mutexWaiters 阻塞等待的 waiter 数量
mutexStarving 饥饿标记
mutexWoken 唤醒标记
mutexLocked 持有锁的标记
易错场景
Lock/Unlock 没有成对出现(加锁后必须有解锁操作),如果 Lock 之后,没有 Unlock 会出现死锁的情况,或者是因为 Unlock 一个未 Lock 的 Mutex 而导致 panic
复制已经使用过的 Mutex,因为复制了已经使用了的 Mutex,导致锁无法使用,程序处于死锁的状态
重入锁,Mutex 是不可重入锁,如果一个线程成功获取到这个锁。之后,如果其它线程再请求这个锁,就会处于阻塞等待的状态
死锁,两个或两个以上的 goroutine 争夺共享资源,互相等待对方的锁释放
读写锁(RWMutex)
RWMutex 是一个 reader/writer 互斥锁。RWMutex 在某一时刻只能由任意数量的 reader goroutine 持有,或者是只被单个的 writer goroutine 持有,适用于读多写少的场景。
使用方法
Lock/Unlock:写操作时调用的方法
RLock/RUnlock:读操作时调用的方法
RLocker:这个方法的作用是为读操作返回一个 Locker 接口的对象。它的 Lock 方法会调用 RWMutex 的 RLock 方法,它的 Unlock 方法会调用 RWMutex 的 RUnlock 方法。
使用示例:
底层结构
实现原理
一个 writer goroutine 获得了内部的互斥锁,就会反转 readerCount 字段,把它从原来的正整数 readerCount(>=0) 修改为负数(readerCount - rwmutexMaxReaders),让这个字段保持两个含义(既保存了 reader 的数量,又表示当前有 writer)。也就是说当 readerCount 为负数的时候表示当前 writer goroutine 持有写锁中,reader goroutine 会进行阻塞。
当一个 writer 释放锁的时候,它会再次反转 readerCount 字段。可以肯定的是,因为当前锁由 writer 持有,所以,readerCount 字段是反转过的,并且减去了 rwmutexMaxReaders 这个常数,变成了负数。所以,这里的反转方法就是给它增加 rwmutexMaxReaders 这个常数值。
易错场景
复制已经使用的读写锁,会把它的状态也给复制过来,原来的锁在释放的时候,并不会修改你复制出来的这个读写锁,这就会导致复制出来的读写锁的状态不对,可能永远无法释放锁
重入导致死锁,因为读写锁内部基于互斥锁实现对 writer 的并发访问,而互斥锁本身是有重入问题的,所以,writer 重入调用 Lock 的时候,就会出现死锁的现象
在 reader 的读操作时调用 writer 的写操作(调用 Lock 方法),那么,这个 reader 和 writer 就会形成互相依赖的死锁状态
当一个 writer 请求锁的时候,如果已经有一些活跃的 reader,它会等待这些活跃的 reader 完成,才有可能获取到锁,但是,如果之后活跃的 reader 再依赖新的 reader 的话,这些新的 reader 就会等待 writer 释放锁之后才能继续执行,这就形成了一个环形依赖: writer 依赖活跃的 reader -> 活跃的 reader 依赖新来的 reader -> 新来的 reader 依赖 writer
释放未加锁的 RWMutex,和互斥锁一样,Lock 和 Unlock 的调用总是成对出现的,RLock 和 RUnlock 的调用也必须成对出现。Lock 和 RLock 多余的调用会导致锁没有被释放,可能会出现死锁,而 Unlock 和 RUnlock 多余的调用会导致 panic
05 死锁
概念
两个或两个以上的进程(或线程,goroutine)在执行过程中,因争夺共享资源而处于一种互相等待的状态,如果没有外部干涉,它们都将无法推进下去,此时,我们称系统处于死锁状态或系统产生了死锁。
产生死锁的四个必要条件
互斥:资源只能被一个 goroutine 持有,其他 gouroutine 必须等待,直到资源被释放
持有和等待:goroutine 持有一个资源,并且还在请求其它 goroutine 持有的资源
不可剥夺:资源只能由持有它的 goroutine 来释放
环路等待:多个等待 goroutine(g1,g2,g3),g1 等待 g2 的资源,g2 等待 g3 的资源,g3 等待 g1 的资源,形成环路等待的死结
如何解决死锁?(只需要打破必要条件其中一个即可避免死锁)
设置超时时间
避免使用多个锁
按照规定顺序申请锁
死锁检测
06 sync.Cond
Cond 通常应用于等待某个条件的一组 goroutine,等条件变为 true 的时候,其中一个 goroutine 或者所有的 goroutine 都会被唤醒执行。
基本方法
Singal(): 唤醒一个等待此 Cond 的 goroutine
Broadcast(): 唤醒所有等待此 Cond 的 goroutine
Wait(): 放入 Cond 的等待队列中并阻塞,直到被 Signal 或者 Broadcast 的方法从等待队列中移除并唤醒,使用该方法是需要搭配满足条件
使用示例:
实现原理
在上述的实现源码中,Signal 和 Broadcast 调用了底层的通知方法;重点在 Wait 方法中,把调用者加入到等待队列时会释放锁,在被唤醒之后还会请求锁。在阻塞休眠期间,调用者是不持有锁的,这样能让其他 goroutine 有机会检查或者更新等待变量,因此在使用 Wait 方法的时候必须持有锁。
易错场景
调用 Wait 方法没有加锁
没有检查等待条件是否满足
07 Channel
channel 用于 goroutine 之间的通信,go 语言中,CSP 并发模型,不要通过共享内存实现通信,而是通过通信实现共享内存,就是由 goroutine 和 channel 实现的。
应用场景
数据交流
信号通知
任务编排
锁
基本用法
初始化
发送数据
接收数据
返回容量
返回 channel 中缓存的还未被取走的元素数量
关闭 channel
遍历 channel
底层结构
qcount 已经接收但还未被取走的元素个数 内置函数 len 获取到
datasiz 循环队列的大小 暂时认为是 cap 容量的值
elemtype 和 elemsize 声明 chan 时到元素类型和大小 固定
buf 指向缓冲区的指针 无缓冲通道中 buf 的值为 nil
sendx 处理发送进来数据的指针在 buf 中的位置 接收到数据 指针会加上 elemsize,移向下一个位置
recvx 处理接收请求(发送出去)的指针在 buf 中的位置
recvq 如果没有数据可读而阻塞, 会加入到 recvq 队列中
sendq 向一个满了的 buf 发送数据而阻塞,会加入到 sendq 队列中
实现原理
向 channel 写数据的流程:
有缓冲区:优先查看 recvq 是否为空,如果不为空,优先唤醒 recvq 的中 goroutine,并写入数据;如果队列为空,则写入缓冲区,如果缓冲区已满则写入 sendq 队列;
无缓冲区:直接写入 sendq 队列
向 channel 读数据的流程:
有缓冲区:优先查看缓冲区,如果缓冲区有数据并且未满,直接从缓冲区取出数据;如果缓冲区已满并且 sendq 队列不为空,优先读取缓冲区头部的数据,并将队列的 G 的数据写入缓冲区尾部;
无缓冲区:将当前 goroutine 加入 recvq 队列,等到写 goroutine 的唤醒
易错点
channel 未初始化,写入或者读取都会阻塞
往 close 的 channel 写入数据会发生 panic
close 未初始化 channel 会发生 panic
close 已经 close 过的 channel 会发生 panic
08 SingleFlight
基本概念
SingleFlight 是 Go 开发组提供的一个扩展并发原语。它的作用是,在处理多个 goroutine 同时调用同一个函数的时候,只让一个 goroutine 去调用这个函数,等到这个 goroutine 返回结果的时候,再把结果返回给这几个同时调用的 goroutine,这样可以减少并发调用的数量。
与 sync.Once 的区别
sync.Once 不是只在并发的时候保证只有一个 goroutine 执行函数 f,而是会保证永远只执行一次,而 SingleFlight 是每次调用都重新执行,并且在多个请求同时调用的时候只有一个执行。
sync.Once 主要是用在单次初始化场景中,而 SingleFlight 主要用在合并并发请求的场景中
应用场景
使用 SingleFlight 时,可以通过合并请求的方式降低对下游服务的并发压力,从而提高系统的性能,常常用于缓存系统中
基本方法
func (g *Group) Do(key string, fn func() (interface{}, error)) (v interface{}, err error, shared bool)
提供一个 key,对于同一个 key,在同一时间只有一个在执行,同一个 key 并发的请求会等待。第一个执行的请求返回的结果,就是它的返回结果。函数 fn 是一个无参的函数,返回一个结果或者 error,而 Do 方法会返回函数执行的结果或者是 error,shared 会指示 v 是否返回给多个请求
func (g *Group) DoChan(key string, fn func() (interface{}, error)) <-chan Result
类似 Do 方法,只不过是返回一个 chan,等 fn 函数执行完,产生了结果以后,就能从这个 chan 中接收这个结果
func (g *Group) Forget(key string)
告诉 Group 忘记这个 key。这样一来,之后这个 key 请求会执行 f,而不是等待前一个未完成的 fn 函数的结果
实现方法
SingleFlight 定义一个辅助对象 call,这个 call 就代表正在执行 fn 函数的请求或者是已经执行完的请求
在 Do 方法中,传入 key 与执行函数,加锁,查询是否存在 key,如果存在,等待第一个请求完成并返回。如果不存在,创建一个 call,将这个 call 加入到 key map 中,释放锁,执行 doCall 函数,执行完实际函数后,删除 key。
一起学习
欢迎大家关注我的账号,你的支持,是我更文的最大动力!
也欢迎关注我的公众号: 程序员升职加薪之旅,领取更多面试题资料。
微信号:wangzhongyang1993
版权声明: 本文为 InfoQ 作者【王中阳Go】的原创文章。
原文链接:【http://xie.infoq.cn/article/0f2a5bf7b82f7a8e3142f3966】。文章转载请联系作者。
评论 (1 条评论)