关于面试:golang-面试总结

8次阅读

共计 6842 个字符,预计需要花费 18 分钟才能阅读完成。

前言

前段时间找工作搜寻 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;如果其余 P 上也没有,则会从全局队列上获取 goroutine。

3. goroutine 的协程有什么特点,和线程相比?

goroutine 十分的 轻量,初始调配只有 2KB,当栈空间不够用时,会主动扩容。同时,本身存储了执行 stack 信息,用于在调度时能复原上下文信息。

而线程比拟重,个别初始大小有几 MB(不同零碎调配不同),线程是由操作系统调度,是操作系统的调度根本单位。而 golang 实现了本人的调度机制,goroutine 是它的调度根本单位。

4. Go 的垃圾回收机制?

Go 采纳的是三色标记法,将内存里的对象分为了三种:

  • 红色对象:未被应用的对象;
  • 灰色对象:以后对象有援用对象,然而还没有对援用对象持续扫描过;
  • 彩色对象,对下面提到的灰色对象的援用对象曾经全副扫描过了,下次不必再扫描它了。

当垃圾回收开始时,Go 会把根对象标记为灰色,其余对象标记为红色,而后从根对象遍历搜寻,依照下面的定义去一直的对灰色对象进行扫描标记。当没有灰色对象时,示意所有对象已扫描过,而后就能够开始革除红色对象了。

5. go 的内存调配是怎么样的?

Go 的内存调配借鉴了 Google 的 TCMalloc 调配算法,其核心思想是内存池 + 多级对象治理。内存池次要是事后分配内存,缩小向零碎申请的频率;多级对象有:mheap、mspan、arenas、mcentral、mcache。它们以 mspan 作为根本调配单位。具体的调配逻辑如下:

  • 当要调配大于 32K 的对象时,从 mheap 调配。
  • 当要调配的对象小于等于 32K 大于 16B 时,从 P 上的 mcache 调配,如果 mcache 没有内存,则从 mcentral 获取,如果 mcentral 也没有,则向 mheap 申请,如果 mheap 也没有,则从操作系统申请内存。
  • 当要调配的对象小于等于 16B 时,从 mcache 上的微型分配器上调配。

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 唤起后并不是间接的占用资源,而是应用 CAS 办法去尝试性占有锁资源。如果此时有新来的 Goroutine,那么它也会调用 CAS 办法去尝试性的占有资源。对于 Go 的并发调度机制来讲,会比拟偏差于 CPU 占有工夫较短的 Goroutine 先运行,即新来的 Goroutine 比拟容易占有资源,而队头的 Goroutine 始终占用不到,导致饿死。

针对这种状况,Go 采纳了饥饿模式。即通过判断队头 Goroutine 在超过肯定工夫后还是得不到资源时,会在 Unlock 开释锁资源时,间接将锁资源交给队头 Goroutine,并且将以后状态改为饥饿模式。

前面如果有新来的 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 里的元素是没有关系的。

slice 入参留神点

如果 slice 作为函数的入参,通常心愿对 slice 的操作能够影响到底层数据,然而如果在函数外部 append 数据超过了 cap,导致重新分配底层数组,这时批改的 slice 将不再是原来入参的那个 slice 了。因而通常不倡议在函数外部对 slice 有 append 操作,若有须要则显示的 return 这个 slice。

19. make 和 new 的区别

new 是返回某个类型的指针,将会申请某个类型的内存。而 make 只能用于 slice, map, channel 这种 golang 外部的数据结构,它们能够只申明不初始化,或者初始化时指定一些特定的参数,比方 slice 的长度、容量;map 的长度;channel 的缓冲数量等。

20. defer、panic、recover 三者的用法

defer 函数调用的程序是后进先出,当产生 panic 的时候,会先执行 panic 后面的 defer 函数后才真的抛出异样。个别的,recover 会在 defer 函数里执行并捕捉异样,避免程序解体。

package main

import "fmt"

func main() {defer func(){fmt.Println("b")
    }()

    defer func() {if err := recover(); err != nil {fmt.Println("捕捉异样:", err)
        }
    }()

    panic("a")
}

// 输入
// 捕捉异样: a
// b

21 slice 和 array 的区别

array 是固定长度的数组,并且是值类型的,也就是说是拷贝复制的,slice 是一个援用类型,指向了一个动静数组的指针,会进行动静扩容。


感兴趣的敌人能够搜一搜公众号「阅新技术」,关注更多的推送文章。
能够的话,就顺便点个赞、留个言、分享下,感激各位反对!
阅新技术,浏览更多的新常识。

正文完
 0