后浪笔记一零二四

Go 语言笔试面试题(实现原理)

golang interview questions

Q1 init() 函数是什么时候执行的?

答案

init() 函数是 Go 程序初始化的一部分。Go 程序初始化先于 main 函数,由 runtime 初始化每个导入的包,初始化顺序不是按照从上到下的导入顺序,而是按照解析的依赖关系,没有依赖的包最先初始化。

每个包首先初始化包作用域的常量和变量(常量优先于变量),然后执行包的 init() 函数。同一个包,甚至是同一个源文件可以有多个 init() 函数。init() 函数没有入参和返回值,不能被其他函数调用,同一个包内多个 init() 函数的执行顺序不作保证。

一句话总结: import –> const –> var –> init() –> main()

示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
package main

import "fmt"

func init()  {
	fmt.Println("init1:", a)
}

func init()  {
	fmt.Println("init2:", a)
}

var a = 10
const b = 100

func main() {
	fmt.Println("main:", a)
}
// 执行结果
// init1: 10
// init2: 10
// main: 10

Q2 Go 语言的局部变量分配在栈上还是堆上?

答案

由编译器决定。Go 语言编译器会自动决定把一个变量放在栈还是放在堆,编译器会做逃逸分析(escape analysis),当发现变量的作用域没有超出函数范围,就可以在栈上,反之则必须分配在堆上。

1
2
3
4
5
6
7
8
9
func foo() *int {
	v := 11
	return &v
}

func main() {
	m := foo()
	println(*m) // 11
}

foo() 函数中,如果 v 分配在栈上,foo 函数返回时,&v 就不存在了,但是这段函数是能够正常运行的。Go 编译器发现 v 的引用脱离了 foo 的作用域,会将其分配在堆上。因此,main 函数中仍能够正常访问该值。

Q3 2 个 interface 可以比较吗?

答案

Go 语言中,interface 的内部实现包含了 2 个字段,类型 T 和 值 V,interface 可以使用 ==!= 比较。2 个 interface 相等有以下 2 种情况

  1. 两个 interface 均等于 nil(此时 V 和 T 都处于 unset 状态)
  2. 类型 T 相同,且对应的值 V 相等。

看下面的例子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
type Stu struct {
	Name string
}

type StuInt interface{}

func main() {
	var stu1, stu2 StuInt = &Stu{"Tom"}, &Stu{"Tom"}
	var stu3, stu4 StuInt = Stu{"Tom"}, Stu{"Tom"}
	fmt.Println(stu1 == stu2) // false
	fmt.Println(stu3 == stu4) // true
}

stu1stu2 对应的类型是 *Stu,值是 Stu 结构体的地址,两个地址不同,因此结果为 false。 stu3stu4 对应的类型是 Stu,值是 Stu 结构体,且各字段相等,因此结果为 true。

Q4 两个 nil 可能不相等吗?

答案

可能。

接口(interface) 是对非接口值(例如指针,struct等)的封装,内部实现包含 2 个字段,类型 T 和 值 V。一个接口等于 nil,当且仅当 T 和 V 处于 unset 状态(T=nil,V is unset)。

  • 两个接口值比较时,会先比较 T,再比较 V。
  • 接口值与非接口值比较时,会先将非接口值尝试转换为接口值,再比较。
1
2
3
4
5
6
7
func main() {
	var p *int = nil
	var i interface{} = p
	fmt.Println(i == p) // true
	fmt.Println(p == nil) // true
	fmt.Println(i == nil) // false
}

上面这个例子中,将一个 nil 非接口值 p 赋值给接口 i,此时,i 的内部字段为(T=*int, V=nil),i 与 p 作比较时,将 p 转换为接口后再比较,因此 i == p,p 与 nil 比较,直接比较值,所以 p == nil

但是当 i 与 nil 比较时,会将 nil 转换为接口 (T=nil, V=nil),与i (T=*int, V=nil) 不相等,因此 i != nil。因此 V 为 nil ,但 T 不为 nil 的接口不等于 nil。

Q5 简述 Go 语言GC(垃圾回收)的工作原理

答案

最常见的垃圾回收算法有标记清除(Mark-Sweep) 和引用计数(Reference Count),Go 语言采用的是标记清除算法。并在此基础上使用了三色标记法和写屏障技术,提高了效率。

标记清除收集器是跟踪式垃圾收集器,其执行过程可以分成标记(Mark)和清除(Sweep)两个阶段:

  • 标记阶段 — 从根对象出发查找并标记堆中所有存活的对象;
    • 标记清除算法的一大问题是在标记期间,需要暂停程序(Stop the world,STW),标记结束之后,用户程序才可以继续执行
    • Go采用的三色标记法是如何解决这个问题的?
      • 三色标记算法将程序中的对象分成白色、黑色和灰色三类。
        • 白色:对象未标记。
        • 灰色:对象已经标记,但是子对象未标记。
        • 黑色:对象已经标记,且子对象也已经标记。
      • 三色标记的大概流程:
        • 所有对象最开始都是白色
        • 根结点 遍历所有可触达的对象,标记为灰色,放入灰色队列中
        • 遍历灰色对象队列,将其引用对象标记为灰色,将自身标记为黑色
        • 继续遍历直到灰色对象队列为空,此时所有对象只有白色和黑色两种,完成标记
      • 三色标记法因为多了一个白色的状态来存放不确定对象,所以后续的标记阶段可以并发地执行
  • 清除阶段 — 遍历堆中的全部对象,回收未被标记的垃圾对象并将回收的内存加入空闲链表。

三色标记法并发执行时存在一个问题,即在 GC 过程中,对象指针发生了改变。比如下面的例子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// 1. 此时D对象最终会被标记为黑色
A () -> B () -> C () -> D ()

// 2. 标记阶段和用户程序是并发执行的,此时如果
// 用户程序删除了 C 对 D 的引用,而 A 获得了 D 的引用
A () -> B () -> C () 
 D ()

// 3. 由于A已经被标记为黑色了,而D又不是根结点,所以D就没有机会被标记为黑色了。
// 而写屏障(Write Barrier)就是更新对象指针、新增对象时的一个钩子函数,将其着色为灰色。

一次完整的 GC 分为四个阶段:

  • 1)标记准备(Mark Setup,需 STW),打开写屏障(Write Barrier)
  • 2)使用三色标记法标记(Marking, 并发)
  • 3)标记结束(Mark Termination,需 STW),关闭写屏障。
  • 4)清理(Sweeping, 并发)

如何实现stw(stop the world)?

    1. go的GMP模型里面,所有G都需要绑定到P上面才可以执行,而P存在空闲和运行中两种状态。
    1. 所以,第一步,需要通知空闲状态的P不再接活。
    1. 正在运行中的P里面可能有好几个G在运行,这时候就需要终止所有正在运行中的G。
    • Go的抢占式调度并非如操作系统那样可以直接换出线程,Go实际上用的是伪抢占式:
    • runtime给G设置抢占标志,G在发生系统调用、io操作、channel操作等一系列阻塞操作及函数调用的时候将自己的控制权让出来。
    1. 所以,第二步,设置正在运行的G的抢占标志位,等待G主动停止运行,G的停止时机是发生系统调用、阻塞操作及函数调用。

gc的触发时机有3种:

    1. 阈值:默认内存扩大一倍的时候触发一次gc
    1. 定时:默认两分钟执行一次gc
    1. 手动:手动调用runtime.GC()
  • 参考 fullstack

Q6 函数返回局部变量的指针是否安全?

答案

这在 Go 中是安全的,Go 编译器将会对每个局部变量进行逃逸分析。如果发现局部变量的作用域超出该函数,则不会将内存分配在栈上,而是分配在堆上。

Q7 非接口非接口的任意类型 T() 都能够调用 *T 的方法吗?反过来呢?

答案
  • 一个T类型的值可以调用为*T类型声明的方法,但是仅当此T的值是可寻址(addressable) 的情况下。编译器在调用指针属主方法前,会自动取此T值的地址。因为不是任何T值都是可寻址的,所以并非任何T值都能够调用为类型*T声明的方法。
  • 反过来,一个*T类型的值可以调用为类型T声明的方法,这是因为解引用指针总是合法的。事实上,你可以认为对于每一个为类型 T 声明的方法,编译器都会为类型*T自动隐式声明一个同名和同签名的方法。

哪些值是不可寻址的呢?

  • 字符串中的字节;
  • map 对象中的元素(slice 对象中的元素是可寻址的,slice的底层是数组);
  • 常量;
  • 包级别的函数等。

举一个例子,定义类型 T,并为类型 *T 声明一个方法 hello(),变量 t1 可以调用该方法,但是常量 t2 调用该方法时,会产生编译错误。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
type T string

func (t *T) hello() {
	fmt.Println("hello")
}

func main() {
	var t1 T = "ABC"
	t1.hello() // hello
	const t2 T = "ABC"
	t2.hello() // error: cannot call pointer method on t
}

Q8 Go是如何使用GMP模型实现协程的?

答案
  • 为什么要增加CPU的有效吞吐量
    • CPU执行指令的速度是非常快的。
      • 在3.0GHz主频的单颗CPU核心上,大部分简单指令的执行仅需要1个时钟周期,一个时钟周期也就是三分之一纳秒
      • 也就是说,1s可以执行30亿条简单指令,这个速度是非常快的。
    • CPU慢在对外部数据的读/写上
      • 外部I/O的速度慢和阻塞是导致CPU使用效率不高的最大原因
  • 如何增加CPU的有效吞吐量
    • 尽可能让每个CPU核心都有事情做
      • 要求工作的线程要大于CPU的核心数
      • 因为CPU要和外部设备通信,单个线程经常会被阻塞,包括I/O等待、缺页中断、等待网络等。
    • 尽可能提高每个CPU核心做事情的效率
      • 现代OS都将线程作为最小调度单位,进程作为资源分配的最小单位。
      • 当线程数大于CPU核心的时候,就存在线程切换的问题。
      • 线程切换需要保存现场,频繁地切换很耗时。
      • 如何尽量让线程跑满操作系统分配的时间片呢?避免没有必要的线程切换呢?
        • 多进程模型:
          • 优点:每个进程都有自己独立的内存空间,隔离性好、健壮性好
          • 缺点:进程切换消耗较大,进程间的通信需要多次在内核区和用户区之间复制数据
        • 多线程模型:
          • 优点:通过共享内存来进行通信,不需要多次在内核区和用户区之间复制数据 线程切换消耗较小,因为多个线程间共享内存空间。
          • 缺点:因为共享内存空间,所以极易导致数据访问混乱,隔离性、健壮性不高
        • 用户级多线程模型(协程模型):
          • M:1
            • M个协程对应一个内核线程,就像nginx一样,使用异步单线程的模式。
            • 缺点:不能利用机器多核的优势。
          • M:N
            • M个协程对应N个内核线程,这种模式效率最高,Go采取的就是这种模式。
            • 不能无限制地增加内核线程数,内核线程过多会导致单个线程的单位时间内被分配的运行时间片减少。
            • 每个内核线程对应多个协程,当一个协程执行体阻塞了,调度器就会调度另一个协程执行,最大限度地利用操作系统分配给内核线程的时间片
          • 协程:
            • 协程的调度完全由用户态程序控制,协程拥有自己的寄存器上下文和栈
            • 协程调度切换时,将寄存器上下文和栈保存到其他地方
            • 在切回来的时候,恢复先前保存的寄存器上下文和栈
  • MPG模型
    • M(Machine): 代表OS内核线程,最大值是10000。
    • P(Processor): P代表M运行G所需要的的资源,是对资源的一种抽象和管理。
      • P的个数等于runtime.GOMAXPROCS()函数设置的值,默认等于CPU核心数
      • P持有G的队列,P和M绑定相当于将M和一串G绑在了一起,P和M的绑定解除相当于解除了M对一串G的调用。
      • Work Stealing: 当M和P绑定之后,G队列中的G会依次被M调度执行
        • 如果本地队列空了,就会去全局队列偷取一部分G
        • 如果全局队列也是空的,则去其他的P中偷取一部分G
    • G(Goroutine): G是Go运行时对goroutine的抽象描述,G中存放并发执行的代码入口地址、上下文、运行环境(关联的P和M)、运行栈等执行相关的元信息。
      • G的新建、休眠、恢复、停止都受到Go运行时的管理。
      • Go运行时的监控线程会监控G的调度,发生阻塞的G会被放在G队列的末尾,并切换到G队列的排w头G上继续执行。

专题:

本文发表于 2022-09-13,最后修改于 2022-09-13。

本站永久域名「 jiavvc.top 」,也可搜索「 后浪笔记一零二四 」找到我。


上一篇 « Go 语言笔试面试题汇总 下一篇 » Go 语言笔试面试题(基础语法)

赞赏支持

请我吃鸡腿 =^_^=

i ysf

云闪付

i wechat

微信

推荐阅读

Big Image