在面试题中成长,在笔试题中查漏补缺
基础语法
Q1 =
和 :=
的区别?
:=
声明+赋值=
仅赋值
Q2 指针的作用
指针用来保存变量的地址。
1 | var x = 5 |
*
运算符: 也称为解引用运算符,用于访问地址中的值。&
运算符: 也称为地址运算符,用于返回变量的地址
Q3 Go允许多个返回值吗?
允许
Q4 Go有异常类型吗?
Go 没有异常类型,只有错误类型(Error),通常使用返回值来表示异常状态。
panic
支持抛出任意类型的异常(而不仅仅是error
类型的错误),recover
函数调用的返回值和panic
函数的输入参数类型一致,它们的函数签名如下:
1 | func panic(interface{}) |
- 捕获异常转成错误
1 | func foo() (err error) { |
Q5 什么是协程(Goroutine)
Goroutine
是与其他函数或方法同时运行的函数或方法。Goroutines
可以被认为是轻量级的线程。
与线程相比,创建Goroutine
的开销很小。Go应用程序同时运行数千个Goroutine
是非常常见的做法。
Q6 如何高效地拼接字符串
Go 语言中,字符串是只读的,也就意味着每次修改操作都会创建一个新的字符串。如果需要拼接多次,应使用 strings.Builder
,最小化内存拷贝次数。
1 | var str strings.Builder |
Q7 什么是 rune
类型
rune
是Go语言中一种特殊的数据类型,它是int32
的别名,几乎在所有方面等同于int32
,用于区分字符值和整数值。
补充:golang中的字符有两种,uint8(byte)代表ASCII的一个字符,rune
代表一个utf-8字符。
Q8 如何判断map
中是否包含某个key
?
1 | if val, ok := dict["foo"]; ok { |
dict["foo"]
有 2 个返回值,val 和 ok,如果 ok 等于 true,则说明dict
包含 key “foo”,val 将被赋予 “foo” 对应的值。
Q9 Go支持默认参数或可选参数吗?
Go
语言不支持可选参数(Python, PHP 支持),也不支持方法重载(Java支持)。
Go支持可变参数
1 | func myfunc(args ...int) { |
形如...type
格式的类型只能作为函数的参数类型存在,并且必须是最后一个参数,它是一个语法糖(syntactic sugar
),即这种语法对语言的功能并没有影响,但是更方便程序员使用,通常来说,使用语法糖能够增加程序的可读性,从而减少程序出错的可能。
从内部实现机理上来说,类型...type
本质上是一个数组切片,也就是[]type
,这也是为什么上面的参数 args
可以用 for
循环来获得每个传入的参数。
Q10 defer
的执行顺序
多个
defer
语句,遵从后进先出(Last In First Out,LIFO
)的原则,最后声明的defer
语句,最先得到执行。defer
在return
语句之后执行,但在函数退出之前,defer
可以修改返回值。
Q11 如何交换2个变量的值?
x, y := y, x
Q12 Go语言tag
的用处?
tag
可以理解为struct
字段的注解,可以用来定义字段的一个或多个属性。框架/工具可以通过反射获取到某个字段定义的属性,采取相应的处理方式。
Q13 如何判断2
个字符串切片(slice
) 是相等的?
go 语言中可以使用反射
reflect.DeepEqual(a, b)
判断 a、b 两个切片是否相等,但是通常不推荐这么做,使用反射非常影响性能。
通常采用的方式如下,遍历比较切片中的每一个元素(注意处理越界的情况)
1 | func StringSliceEqualBCE(a, b []string) bool { |
Q14 字符串打印时,%v
和 %+v
和 %#v
的区别
%v
和%+v
和%#v
都可以用来打印 struct 的值
- 区别在于:
%v
仅打印各个字段的值%+v
还会打印各个字段的名称。%#v
还会打印结构体的名称
1 | type Stu struct { |
Q15 Go 语言中如何表示枚举值(enums)?
通常使用常量(const) 来表示枚举值。
Q16 空struct{}
的用途
使用空结构体
struct{}
可以节省内存,一般作为占位符使用,表明这里并不需要一个值。
1 | fmt.Println(unsafe.Sizeof(struct{}{})) // 0 |
比如使用 map
表示集合时,只关注 key
,value
可以使用 struct{}
作为占位符。如果使用其他类型作为占位符,例如 int
,bool
,不仅浪费了内存,而且容易引起歧义。
再比如,使用信道(channel)控制并发时,我们只是需要一个信号,但并不需要传递值,这个时候,也可以使用 struct{}
代替。
1 | func main() { |
实现原理
Q17 init()
函数是什么时候执行的?
init()
函数是 Go 程序初始化的一部分。
Go程序初始化先于main
函数,由runtime
初始化每个导入的包,初始化顺序不是按照从上到下的导入顺序,而是按照解析的依赖关系,没有依赖的包最先初始化。
一句话总结:import
–> const
–> var
–>init()
-> main()
每个包首先初始化包作用域的常量和变量(常量优先于变量),然后执行包的init()
函数。同一个包,甚至是同一个源文件可以有多个init()
函数。init()
函数没有入参和返回值,不能被其他函数调用,同一个包内多个init()
函数的执行顺序不作保证。
Q18 Go语言的局部变量分配在栈上还是堆上?
由编译器决定。
Go 语言编译器会自动决定把一个变量放在栈还是放在堆,编译器会做逃逸分析(escape analysis),当发现变量的作用域没有超出函数范围,就可以在栈上,反之则必须分配在堆上。
1 | func foo() *int { |
foo()
函数中,如果 v 分配在栈上,foo
函数返回时,&v
就不存在了,但是这段函数是能够正常运行的。Go 编译器发现 v 的引用脱离了 foo
的作用域,会将其分配在堆上。因此,main
函数中仍能够正常访问该值。
逃逸分析
-gcflags=-m
1 | go run -gcflags=-m echo.go |
Q19 2个interface
可以比较吗
可以
Go 语言中,interface
的内部实现包含了 2 个字段,类型T
和 值V
,interface
可以使用==
或!=
比较。2 个interface
相等有以下 2 种情况
- 两个 interface 均等于 nil(此时 V 和 T 都处于 unset 状态)
- 类型 T 相同,且对应的值 V 相等。
1 | type Stu struct { |
stu1
和stu2
对应的类型是*Stu
,值是 Stu
结构体的地址,两个地址不同,因此结果为 false。stu3
和stu4
对应的类型是Stu
,值是 Stu
结构体,且各字段相等,因此结果为 true。
Q20 2个nil
可能不相等吗?
可能
接口(interface) 是对非接口值(例如指针,struct等)的封装,内部实现包含 2 个字段,类型
T
和 值V
。一个接口等于 nil,当且仅当 T 和 V 处于 unset 状态(T=nil,V is unset)。
- 两个接口值比较时,会先比较 T,再比较 V。
- 接口值与非接口值比较时,会先将非接口值尝试转换为接口值,再比较。
1 | func main() { |
上面这个例子中,将一个 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。
Q21 简述 Go 语言GC(垃圾回收)的工作原理
最常见的垃圾回收算法有标记清除(Mark-Sweep) 和引用计数(Reference Count),Go 语言采用的是标记清除算法。并在此基础上使用了三色标记法和写屏障技术,提高了效率。
一次完整的 GC 分为四个阶段:
- 标记准备(
Mark Setup
, 需STW), 打开写屏障(Write Barrier
)
- 标记准备(
- 使用三色标记法去标记(
Marking
, 并发)
- 使用三色标记法去标记(
- 标记结束(
Mark Termination
, 需STW), 关闭写屏障
- 标记结束(
- 清理(
Sweeping
, 并发)
- 清理(
标记清除
标记清除收集器是跟踪式垃圾收集器,其执行过程可以分成标记(Mark)和清除(Sweep)两个阶段:
- 标记阶段: 从根对象出发查找并标记堆中所有存活的对象;
- 清除阶段: 遍历堆中的全部对象,回收未被标记的垃圾对象并将回收的内存加入空闲链表。
三色标记法
三色标记算法将程序中的对象分成白色、黑色和灰色三类。
- 白色: 不确定对象
- 灰色: 存活对象, 子对象待处理
- 黑色: 存活对象
Q22 函数返回局部变量的指针是否安全?
这在 Go 中是安全的,Go 编译器将会对每个局部变量进行逃逸分析。如果发现局部变量的作用域超出该函数,则不会将内存分配在栈(stack)上,而是分配在堆(heap)上。
Q23 非接口的任意类型T()
都能够调用*T
的方法吗?反过来呢?
一个T类型的值可以调用为
*T
类型声明的方法,但是仅当此T的值是可寻址(addressable) 的情况下。编译器在调用指针属主方法前,会自动取此T值的地址。因为不是任何T值都是可寻址的,所以并非任何T值都能够调用为类型*T
声明的方法
反过来,一个*T
类型的值可以调用为类型T声明的方法,这是因为解引用指针总是合法的。事实上,你可以认为对于每一个为类型T
声明的方法,编译器都会为类型*T
自动隐式声明一个同名和同签名的方法。
不可寻址
- 字符串中的字节;
- map 对象中的元素(slice 对象中的元素是可寻址的,slice的底层是数组)
- 常量
- 包级别的函数等
举一个例子,定义类型 T
,并为类型*T
声明一个方法hello()
,变量 t1
可以调用该方法,但是常量 t2
调用该方法时,会产生编译错误。
1 | type T string |
并发编程
Q24 无缓冲的channel
和有缓冲的channel
的区别?
对于无缓冲的
channel
,发送方将阻塞该信道,直到接收方从该信道接收到数据为止,而接收方也将阻塞该信道,直到发送方将数据发送到该信道中为止。
对于有缓存的channel
,发送方在没有空插槽(缓冲区使用完)的情况下阻塞,而接收方在信道为空的情况下阻塞。
1 | func main() { |
1 | func main() { |
Q25 什么是协程泄露(Goroutine Leak
)?
协程泄露是指协程创建后,长时间得不到释放,并且还在不断地创建新的协程,最终导致内存耗尽,程序崩溃。常见的导致协程泄露的场景有以下几种:
- 缺少接收器,导致发送阻塞
这个例子中,每执行一次 query,则启动1000个协程向信道 ch 发送数字 0,但只接收了一次,导致 999 个协程被阻塞,不能退出。
1 | func query() int { |
- 缺少发送器,导致接收阻塞
那同样的,如果启动 1000 个协程接收信道的信息,但信道并不会发送那么多次的信息,也会导致接收协程被阻塞,不能退出。
- 死锁(
dead lock
)
两个或两个以上的协程在执行过程中,由于竞争资源或者由于彼此通信而造成阻塞,这种情况下,也会导致协程被阻塞,不能退出。
- 无限循环(
infinite loops
)
这个例子中,为了避免网络等问题,采用了无限重试的方式,发送 HTTP 请求,直到获取到数据。那如果 HTTP 服务宕机,永远不可达,导致协程不能退出,发生泄漏。
1 | func request(url string, wg *sync.WaitGroup) { |
Q26 Go可以限制运行时操作系统线程的数量吗?
可以; 可以使用环境变量
GOMAXPROCS
或runtime.GOMAXPROCS(num int)
设置
1 | runtime.GOMAXPROCS(1) // 限制同时执行Go代码的操作系统线程数为 1 |