//xia仔k:2023全新GO工程师面试总攻略,助力快速斩获offer
前言
前段时间找工作搜索 golang 面试题时,发现都是比拟零散或是根底的标题,掩盖面较小。而本人也在边面试时边总结了一些学问点,为了便当后续回忆,特此整理了一下。
1. 相比拟于其他言语, Go 有什么优势或者特性?
- Go 允许跨平台编译,编译出来的是二进制的可执行文件,直接部署在对应系统上即可运转。
- Go 在言语层次上天生支持高并发,经过 goroutine 和 channel 完成。channel 的理论根据是 CSP 并发模型, 即所谓的
经过通讯来共享内存;Go 在 runtime 运转时里完成了属于本人的调度机制:GMP,降低了内核态和用户态的切换本钱。 - Go 的代码作风是强迫性的统一,假如没有依照规则来,会编译不经过。
2. Golang 里的 GMP 模型?
GMP 模型是 golang 本人的一个调度模型,它笼统出了下面三个构造:
G:也就是协程 goroutine,由 Go runtime 管理。我们能够以为它是用户级别的线程。P:processor 处置器。每当有 goroutine 要创立时,会被添加到 P 上的 goroutine 本地队列上,假如 P 的本地队列已满,则会维护到全局队列里。M:系统线程。在 M 上有调度函数,它是真正的调度执行者,M 需求跟 P 绑定,并且会让 P 按下面的准绳挑出个 goroutine 来执行:
优先从 P 的本地队列获取 goroutine 来执行;假如本地队列没有,从全局队列获取,假如全局队列也没有,会从其他的 P 上偷取 goroutine。
3. goroutine 的协程有什么特性,和线程相比?
goroutine 十分的轻量,初始分配只要 2KB,当栈空间不够用时,会自动扩容。同时,本身存储了执行 stack 信息,用于在调度时能恢复上下文信息。
而线程比拟重,普通初始大小有几 MB(不同系统分配不同),线程是由操作系统调度,是操作系统的调度根本单位。而 golang 完成了本人的调度机制,goroutine 是它的调度根本单位。
4. Go 的渣滓回收机制?
Go 采用的是三色标志法,将内存里的对象分为了三种:
- 白色对象:未被运用的对象;
- 灰色对象:当前对象有援用对象,但是还没有对援用对象继续扫描过;
- 黑色对象,对上面提到的灰色对象的援用对象曾经全部扫描过了,下次不用再扫描它了。
当渣滓回收开端时,Go 会把根对象标志为灰色,其他对象标志为白色,然后从根对象遍历搜索,依照上面的定义去不时的对灰色对象停止扫描标志。当没有灰色对象时,表示一切对象已扫描过,然后就能够开端肃清白色对象了。
6. channel 的内部完成是怎样样的?
channel 内部维护了两个 goroutine 队列,一个是待发送数据的 goroutine 队列,另一个是待读取数据的 goroutine 队列。
每当对 channel 的读写操作超越了可缓冲的 goroutine 数量,那么当前的 goroutine 就会被挂到对应的队列上,直到有其他 goroutine 执行了与之相反的读写操作,将它重新唤起。
7. 对曾经关闭的 channel 停止读写,会怎样样?
当 channel 被关闭后,假如继续往里面写数据,程序会直接 panic 退出。假如是读取关闭后的 channel,不会产生 pannic,还能够读到数据。但关闭后的 channel 没有数据可读取时,将得到零值,即对应类型的默许值。
为了能晓得当前 channel 能否被关闭,能够运用下面的写法来判别。
if v, ok := <-ch; !ok {
fmt.Println("channel 已关闭,读取不到数据")
}
还能够运用下面的写法不时的获取 channel 里的数据:
for data := range ch {
// get data dosomething
}
这种用法会在读取完 channel 里的数据后就完毕 for 循环,执行后面的代码。
8. map 为什么是不平安的?
map 在扩缩容时,需求停止数据迁移,迁移的过程并没有采用锁机制避免并发操作,而是会对某个标识位标志为 1,表示此时正在迁移数据。假如有其他 goroutine 对 map 也停止写操作,当它检测到标识位为 1 时,将会直接 panic。
假如我们想要并发平安的 map,则需求运用 sync.map。
9. map 的 key 为什么得是可比拟类型的?
map 的 key、value 是存在 buckets 数组里的,每个 bucket 又能够包容 8 个 key 和 8 个 value。当要插入一个新的 key - value 时,会对 key 停止 hash 运算得到一个 hash 值,然后依据 hash 值 的低几位(取几位取决于桶的数量,比方一开端桶的数量是 5,则取低 5 位)来决议命中哪个 bucket。
在命中某个 bucket 后,又会依据 hash 值的高 8 位来决议是 8 个 key 里的哪个位置。假如不巧,发作了 hash 抵触,即该位置上曾经有其他 key 存在了,则会去其他空位置寻觅插入。假如全都满了,则运用 overflow 指针指向一个新的 bucket,反复刚刚的寻觅步骤。
从上面的流程能够看出,在判别 hash 抵触,即该位置能否已有其他 key 时,肯定是要停止比拟的,所以 key 必需得是可比拟类型的。像 slice、map、function 就不能作为 key。
10. mutex 的正常形式、饥饿形式、自旋?
正常形式
当 mutex 调用 Unlock() 办法释放锁资源时,假如发现有正在阻塞并等候唤起的 Goroutine 队列时,则会将队头的 Goroutine 唤起。队头的 goroutine 被唤起后,会采用 CAS 这种悲观锁的方式去修正占有标识位,假如修正胜利,则表示占有锁资源胜利了,当前占有胜利的 goroutine 就能够继续往下执行了。
自旋
假如 Goroutine 占用锁资源的时间比拟短,那么每次释放资源后,都调用信号量来唤起正在阻塞等候的 goroutine,将会很糜费资源。
因而在契合一定条件后,mutex 会让等候的 Goroutine 去空转 CPU,在空转完后再次调用 CAS 办法去尝试性的占有锁资源,直到不满足自旋条件,则最终才参加到等候队列里。
11. Go 的逃逸行为是指?
在传统的编程言语里,会依据程序员指定的方式来决议变量内存分配是在栈还是堆上,比方声明的变量是值类型,则会分配到栈上,或者 new 一个对象则会分配到堆上。
在 Go 里变量的内存分配方式则是由编译器来决议的。假如变量在作用域(比方函数范围)之外,还会被援用的话,那么称之为发作了逃逸行为,此时将会把对象放到堆上,即便声明为值类型;假如没有发作逃逸行为的话,则会被分配到栈上,即便 new 了一个对象。
12 context 运用场景及留意事项
Go 里的 context 有 cancelCtx 、timerCtx、valueCtx。它们分别是用来通知取消、通知超时、存储 key - value 值。context 的 留意事项如下:
- context 的 Done() 办法常常需求配合 select {} 运用,以监听退出。
- 尽量经过函数参数来暴露 context,不要在自定义构造体里包含它。
- WithValue 类型的 context 应该尽量存储一些全局的 data,而不要存储一些可有可无的部分 data。
- context 是并发平安的。
- 一旦 context 执行取消动作,一切派生的 context 都会触发取消。
13. context 是如何一层一层通知子 context
当 ctx, cancel := context.WithCancel(父Context)时,会将当前的 ctx 挂到父 context 下,然后开个 goroutine 协程去监控父 context 的 channel 事情,一旦有 channel 通知,则本身也会触发本人的 channel 去通知它的子 context, 关键代码如下
go func() {
select {
case <-parent.Done():
child.cancel(false, parent.Err())
case <-child.Done():
}
}()
14. waitgroup 原理
waitgroup 内部维护了一个计数器,当调用 wg.Add(1) 办法时,就会增加对应的数量;当调用 wg.Done() 时,计数器就会减一。直到计数器的数量减到 0 时,就会调用
runtime_Semrelease 唤起之前由于 wg.Wait() 而阻塞住的 goroutine。
15. sync.Once 原理
内部维护了一个标识位,当它 == 0 时表示还没执行过函数,此时会加锁修正标识位,然后执行对应函数。后续再执行时发现标识位 != 0,则不会再执行后续动作了。关键代码如下:
type Once struct {
done uint32
m Mutex
}
func (o *Once) Do(f func()) {
// 原子加载标识值,判别能否已被执行过
if atomic.LoadUint32(&o.done) == 0 {
o.doSlow(f)
}
}
func (o *Once) doSlow(f func()) { // 还没执行过函数
o.m.Lock()
defer o.m.Unlock()
if o.done == 0 { // 再次判别下能否已被执行过函数
defer atomic.StoreUint32(&o.done, 1) // 原子操作:修正标识值
f() // 执行函数
}
}
16. 定时器原理
一开端,timer 会被分配到一个全局的 timersBucket 时间桶。每当有 timer 被创立出来时,就会被分配到对应的时间桶里了。
为了不让一切的 timer 都集中到一个时间桶里,Go 会创立 64 个这样的时间桶,然后依据 当前 timer 所在的 Goroutine 的 P 的 id 去哈希到某个桶上:
// assignBucket 将创立好的 timer 关联到某个桶上
func (t *timer) assignBucket() *timersBucket {
id := uint8(getg().m.p.ptr().id) % timersLen
t.tb = &timers[id].timersBucket
return t.tb
}
接着 timersBucket 时间桶将会对这些 timer 停止一个最小堆的维护,每次会选择出时间最快要到达的 timer。假如选择出来的 timer 时间还没到,那就会停止 sleep 休眠;假如 timer 的时间到了,则执行 timer 上的函数,并且往 timer 的 channel 字段发送数据,以此来通知 timer 所在的 goroutine。
17. gorouinte 走漏有哪些场景
gorouinte 里有关于 channel 的操作,假如没有正确处置 channel 的读取,会招致 channel 不断阻塞住, goroutine 不能正常完毕
18. Slice 留意点
Slice 的扩容机制
假如 Slice 要扩容的容量大于 2 倍当前的容量,则直接按想要扩容的容量来 new 一个新的 Slice,否则继续判别当前的长度 len,假如 len 小于 1024,则直接按 2 倍容量来扩容,否则不断循环新增 1/4,直到大于想要扩容的容量。主要代码如下:
newcap := old.cap
doublecap := newcap + newcap
if cap > doublecap {
newcap = cap
} else {
if old.len < 1024 {
newcap = doublecap
} else {
for newcap < cap {
newcap += newcap / 4
}
}
}
除此之外,还会依据 slice 的类型做一些内存对齐的调整,以肯定最终要扩容的容量大小。
Slice 的一些留意写法
// =========== 第一种
a := make([]string, 5)
fmt.Println(len(a), cap(a)) // 输出5 5
a = append(a, "aaa")
fmt.Println(len(a), cap(a)) // 输出6 10
// 总结: 由于make([]string, 5) 则默许会初始化5个 空的"", 因而后面 append 时,则需求2倍了
// =========== 第二种
a:=[]string{}
fmt.Println(len(a), cap(a)) // 输出0 0
a = append(a, "aaa")
fmt.Println(len(a), cap(a)) // 输出1 1
// 总结:由于[]string{}, 没有其他元素, 所以append 按 需求扩容的 cap 来
// =========== 第三种
a := make([]string, 0, 5)
fmt.Println(len(a), cap(a)) // 输出0 5
a = append(a, "aaa")
fmt.Println(len(a), cap(a)) // 输出1 5
// 总结:留意和第一种的区别,这里不会默许初始化5个,所以后面的append容量是够的,不用扩容
// =========== 第四种
b := make([]int, 1, 3)
a := []int{1, 2, 3}
copy(b, a)
fmt.Println(len(b)) // 输出1
// 总结:copy 取决于较短 slice 的 len, 一旦最小的len完毕了,也就不再复制了
range slice
以下代码的执行是不会不断循环下去的,缘由在于 range 的时分会 copy 这个 slice 上的 len 属性到一个新的变量上,然后依据这个 copy 值去遍历 slice,因而遍历期间即便 slice 添加了元素,也不会改动这个变量的值了。
v := []int{1, 2, 3}
for i := range v {
v = append(v, i)
}
另外,range 一个 slice 的时分是停止一个值拷贝的,假如 slice 里存储的是指针汇合,那在 遍历里修正是有效的,假如 slice 存储的是值类型的汇合,那么就是在 copy 它们的副本,期间的修正也只是在修正这个副本,跟原来的 slice 里的元素是没有关系的。