Golang 面试题 III

在面试题中成长,在笔试题中查漏补缺

基础语法

Q1 =:= 的区别?

:= 声明+赋值
= 仅赋值

Q2 指针的作用

指针用来保存变量的地址。

1
2
3
var x = 5
var p *int = &x
fmt.Printf("x = %d", *p) // x 可以用 *p 访问
  • *运算符: 也称为解引用运算符,用于访问地址中的值。
  • &运算符: 也称为地址运算符,用于返回变量的地址

Q3 Go允许多个返回值吗?

允许

Q4 Go有异常类型吗?

Go 没有异常类型,只有错误类型(Error),通常使用返回值来表示异常状态。
panic支持抛出任意类型的异常(而不仅仅是 error 类型的错误),recover 函数调用的返回值和 panic 函数的输入参数类型一致,它们的函数签名如下:

1
2
func panic(interface{})
func recover() interface{}
  • 捕获异常转成错误
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func foo() (err error) {
defer func() {
if r := recover(); r != nil {
switch x := r.(type) {
case string:
err = errors.New(x)
case error:
err = x
default:
err = fmt.Errorf("Unknown panic: %v", r)
}
}
}()

panic("TODO")
}

Q5 什么是协程(Goroutine)

Goroutine 是与其他函数或方法同时运行的函数或方法。
Goroutines 可以被认为是轻量级的线程。
与线程相比,创建 Goroutine 的开销很小。Go应用程序同时运行数千个 Goroutine 是非常常见的做法。

Q6 如何高效地拼接字符串

Go 语言中,字符串是只读的,也就意味着每次修改操作都会创建一个新的字符串。如果需要拼接多次,应使用 strings.Builder,最小化内存拷贝次数。

1
2
3
4
5
var str strings.Builder
for i := 0; i < 1000; i++ {
str.WriteString("a")
}
fmt.Println(str.String())

Q7 什么是 rune 类型

rune是Go语言中一种特殊的数据类型,它是int32的别名,几乎在所有方面等同于int32,用于区分字符值和整数值。
补充:golang中的字符有两种,uint8(byte)代表ASCII的一个字符,rune代表一个utf-8字符。

Q8 如何判断map中是否包含某个key

1
2
3
if val, ok := dict["foo"]; ok {
//do something here
}

dict["foo"] 有 2 个返回值,val 和 ok,如果 ok 等于 true,则说明 dict 包含 key “foo”,val 将被赋予 “foo” 对应的值。

Q9 Go支持默认参数或可选参数吗?

Go语言不支持可选参数(Python, PHP 支持),也不支持方法重载(Java支持)。
Go支持可变参数

1
2
3
4
5
func myfunc(args ...int) {
for _, arg := range args {
fmt.Println(arg)
}
}

形如...type格式的类型只能作为函数的参数类型存在,并且必须是最后一个参数,它是一个语法糖(syntactic sugar),即这种语法对语言的功能并没有影响,但是更方便程序员使用,通常来说,使用语法糖能够增加程序的可读性,从而减少程序出错的可能。

从内部实现机理上来说,类型...type本质上是一个数组切片,也就是[]type,这也是为什么上面的参数 args 可以用 for 循环来获得每个传入的参数。

Q10 defer 的执行顺序

多个 defer 语句,遵从后进先出(Last In First Out,LIFO)的原则,最后声明的 defer 语句,最先得到执行。
deferreturn 语句之后执行,但在函数退出之前,defer 可以修改返回值。

Q11 如何交换2个变量的值?

x, y := y, x

Q12 Go语言tag的用处?

tag 可以理解为 struct 字段的注解,可以用来定义字段的一个或多个属性。框架/工具可以通过反射获取到某个字段定义的属性,采取相应的处理方式。

Q13 如何判断2个字符串切片(slice) 是相等的?

go 语言中可以使用反射 reflect.DeepEqual(a, b) 判断 a、b 两个切片是否相等,但是通常不推荐这么做,使用反射非常影响性能。

通常采用的方式如下,遍历比较切片中的每一个元素(注意处理越界的情况)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func StringSliceEqualBCE(a, b []string) bool {
if len(a) != len(b) {
return false
}

if (a == nil) != (b == nil) {
return false
}

b = b[:len(a)]
for i, v := range a {
if v != b[i] {
return false
}
}

return true
}

Q14 字符串打印时,%v%+v%#v 的区别

%v%+v%#v 都可以用来打印 struct 的值

  • 区别在于:
    • %v 仅打印各个字段的值
    • %+v 还会打印各个字段的名称。
    • %#v 还会打印结构体的名称
1
2
3
4
5
6
7
8
9
type Stu struct {
Name string
}

func main() {
fmt.Printf("%v\n", Stu{"Tom"}) // {Tom}
fmt.Printf("%+v\n", Stu{"Tom"}) // {Name:Tom}
fmt.Printf("%#v\n", Stu{"Tom"}) // main.Stu{Name:"Tom"}
}

Q15 Go 语言中如何表示枚举值(enums)?

通常使用常量(const) 来表示枚举值。

Q16 空struct{}的用途

使用空结构体 struct{} 可以节省内存,一般作为占位符使用,表明这里并不需要一个值。

1
fmt.Println(unsafe.Sizeof(struct{}{})) // 0

比如使用 map 表示集合时,只关注 keyvalue 可以使用 struct{} 作为占位符。如果使用其他类型作为占位符,例如 intbool,不仅浪费了内存,而且容易引起歧义。

再比如,使用信道(channel)控制并发时,我们只是需要一个信号,但并不需要传递值,这个时候,也可以使用 struct{} 代替。

1
2
3
4
5
6
7
8
9
func main() {
ch := make(chan struct{}, 1)
go func() {
<-ch
// do something
}()
ch <- struct{}{}
// ...
}

实现原理

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

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

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

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

Q18 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 函数中仍能够正常访问该值。

逃逸分析

  • -gcflags=-m
1
2
3
4
5
6
7
go run -gcflags=-m echo.go
# command-line-arguments
./echo.go:3:6: can inline foo
./echo.go:8:6: can inline main
./echo.go:9:10: inlining call to foo
./echo.go:4:2: moved to heap: v
11

Q19 2个interface可以比较吗

可以
Go 语言中,interface 的内部实现包含了 2 个字段,类型T和 值Vinterface 可以使用==!=比较。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

Q20 2个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

因此 Vnil ,但 T 不为 nil 的接口不等于 nil。

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

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

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

    1. 标记准备(Mark Setup, 需STW), 打开写屏障(Write Barrier)
    1. 使用三色标记法去标记(Marking, 并发)
    1. 标记结束(Mark Termination, 需STW), 关闭写屏障
    1. 清理(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
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
}

并发编程

Q24 无缓冲的channel和有缓冲的channel的区别?

对于无缓冲的 channel,发送方将阻塞该信道,直到接收方从该信道接收到数据为止,而接收方也将阻塞该信道,直到发送方将数据发送到该信道中为止。
对于有缓存的 channel,发送方在没有空插槽(缓冲区使用完)的情况下阻塞,而接收方在信道为空的情况下阻塞。

1
2
3
4
5
6
7
8
9
10
11
func main() {
st := time.Now()
ch := make(chan bool)
go func () {
time.Sleep(time.Second * 2)
<-ch
}()
ch <- true // 无缓冲,发送方阻塞直到接收方接收到数据。
fmt.Printf("cost %.1f s\n", time.Now().Sub(st).Seconds())
time.Sleep(time.Second * 5)
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
func main() {
st := time.Now()
ch := make(chan bool, 2)
go func() {
time.Sleep(time.Second * 2)
<-ch
}()
ch <- true
ch <- true // 缓冲区为 2,发送方不阻塞,继续往下执行
fmt.Printf("cost %.1f s\n", time.Now().Sub(st).Seconds()) // cost 0.0 s
ch <- true // 缓冲区使用完,发送方阻塞,2s 后接收方接收到数据,释放一个插槽,继续往下执行
fmt.Printf("cost %.1f s\n", time.Now().Sub(st).Seconds()) // cost 2.0 s
time.Sleep(time.Second * 5)
}

Q25 什么是协程泄露(Goroutine Leak)?

协程泄露是指协程创建后,长时间得不到释放,并且还在不断地创建新的协程,最终导致内存耗尽,程序崩溃。常见的导致协程泄露的场景有以下几种:

  • 缺少接收器,导致发送阻塞

这个例子中,每执行一次 query,则启动1000个协程向信道 ch 发送数字 0,但只接收了一次,导致 999 个协程被阻塞,不能退出。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func query() int {
ch := make(chan int)
for i := 0; i < 1000; i++ {
go func() { ch <- 0 }()
}
return <-ch
}

func main() {
for i := 0; i < 4; i++ {
query()
fmt.Printf("goroutines: %d\n", runtime.NumGoroutine())
}
}
  • 缺少发送器,导致接收阻塞

那同样的,如果启动 1000 个协程接收信道的信息,但信道并不会发送那么多次的信息,也会导致接收协程被阻塞,不能退出。

  • 死锁(dead lock)

两个或两个以上的协程在执行过程中,由于竞争资源或者由于彼此通信而造成阻塞,这种情况下,也会导致协程被阻塞,不能退出。

  • 无限循环(infinite loops)

这个例子中,为了避免网络等问题,采用了无限重试的方式,发送 HTTP 请求,直到获取到数据。那如果 HTTP 服务宕机,永远不可达,导致协程不能退出,发生泄漏。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
func request(url string, wg *sync.WaitGroup) {
i := 0
for {
if _, err := http.Get(url); err == nil {
// write to db
break
}
i++
if i >= 3 {
break
}
time.Sleep(time.Second)
}
wg.Done()
}

func main() {
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go request(fmt.Sprintf("https://127.0.0.1:8080/%d", i), &wg)
}
wg.Wait()
}

Q26 Go可以限制运行时操作系统线程的数量吗?

可以; 可以使用环境变量 GOMAXPROCSruntime.GOMAXPROCS(num int) 设置

1
runtime.GOMAXPROCS(1) // 限制同时执行Go代码的操作系统线程数为 1

参考

Powered by Hexo and Hexo-theme-hiker

Copyright © 2017 - 2023 Keep It Simple And Stupid All Rights Reserved.

访客数 : | 访问量 :