Go 基础面试题

1. 什么是协程?

协程(Goroutine)是 Go 语言中的一种用户态的轻量级线程。它是线程调度的基本单位。
一个协程会以一个很小的栈启动,当遇到占空间不足时,栈会自动伸缩,因此可以轻易实现成千上万个 goroutine 同时启动。

2. 进程、线程和协程之间的区别?

  • 进程:进程是资源分配的最小单位。每个进程都有自己独立的内存空间,不同进程通过进程间通信来通信。由于进程之间的内存空间是独立的,所以进程切换的开销较大。
  • 线程:线程是 CPU 调度的最小单位。一个进程可以包含多个线程,这些线程共享进程的内存空间。线程切换的开销较小,但需要注意线程安全问题。
  • 协程:协程是一种用户态的轻量级线程,协程的调度完全由用户来控制。协程拥有自己的寄存器上下文和栈,直接操作栈则基本没有内核切换的开销,因此协程的切换效率非常高。

进程、线程和协程之间的区别

3. Golang 中 make 和 new 的区别?

  • make

    • 用于初始化并分配内存,只能用于创建slicemapchannel类型。
    • 返回的是一个已经初始化后的数据结构,而不是指针。
  • new

    • 用于分配内存,但不会初始化内存。可以用于任何类型。
    • 返回的是指向该内存的指针。

示例:

1
2
3
4
5
// 使用 make 创建一个 slice
s := make([]int, 5) // 创建一个长度为 5 的 slice

// 使用 new 创建一个 int 类型的指针
p := new(int) // 创建一个 int 类型的指针,初始值为 0

4. Golang 中数组和切片的区别?

  • 数组:数组是一个固定长度的序列,长度在定义时就确定了。数组的长度是类型的一部分,因此不同长度的数组是不同的类型。数组是通过值传递的,传递数组会复制整个数组。

  • 切片:切片是一个动态长度的序列,长度可以在运行时改变。切片有三个属性:指针、长度、容量。切片不需要指定大小。切片在传参时按值传递的是 slice header(包含指针、长度和容量)的副本。切片可以通过数组来初始化,也可以通过 make 来初始化,初始化时 len = cap,然后进行扩容。

示例:

1
2
3
4
5
// 定义一个数组
var arr [5]int

// 定义一个切片
s := []int{1, 2, 3, 4, 5}

5. 使用 for range 时,它的地址会发生变化吗?

在 Go 1.22 之前,对于 for range 循环中的迭代变量,其内存地址是不会发生变化的(在每次迭代中复用同一个变量)。

Go 1.22 起,循环变量改为 per-iteration (每次迭代都是新变量),地址也会发生变化,不再共享内存。注意:该行为依赖于 go.mod 中声明的 Go 版本 (go directive ≥1.22)才会生效。

6. 如何高效地拼接字符串?

在 Go 中一共有五种拼接字符串的方法:

  1. +
    使用 + 运算符直接拼接字符串,会对字符串减小遍历,并开辟一个新的空间来存储原来的两个字符串。

  2. fmt.Sprintf
    由于采用了接口参数,必须要用反射获取值,因此有性能损耗。

  3. strings.Builder
    WriteString() 进行拼接。内部实现是指针 + 切片,同时 String() 返回拼接后的字符串,他直接把 []byte 转成 string,性能较好。

  4. bytes.Buffer
    是一个缓冲 byte 类型的缓冲器,底层也是一个 []byte 切片,性能较好。

  5. strings.Join
    是基于 strings.Builder 实现的,并且可以自定义分隔符,在 join 方法内部调用了 b.Grow(n) 来进行初步的容量分配,可以减少内存分配,性能较好。

性能比较:

strings.Joinstrings.Builder > bytes.Buffer > + > fmt.Sprintf

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
func main() {
a := []string{"a", "b", "c"}

// 方式一:+
ret := a[0] + a[1] + a[2]

// 方式二:fmt.Sprintf
ret := fmt.Sprintf("%s%s%s", a[0], a[1], a[2])

// 方式三:strings.Builder
var sb strings.Builder
sb.WriteString(a[0])
sb.WriteString(a[1])
sb.WriteString(a[2])
ret := sb.String()

// 方式四:bytes.Buffer
buf := new(bytes.Buffer)
buf.Write(a[0])
buf.Write(a[1])
buf.Write(a[2])
ret := buf.String()

// 方式五:strings.Join
ret := strings.Join(a, "")
}

7. defer 的执行顺序是怎样的?defer 的作用或者使用场景是什么?

defer 语句会将函数调用推迟到包含它的函数返回之后执行,不论包含 defer 的函数是通过 return 正常结束,还是由于 panic 导致的异常结束。多个 defer 语句会按照后进先出(LIFO)的顺序执行。

defer 的使用场景包括:

  • defer 语句经常被用于处理成对的操作,如打开、关闭、连接、断开连接、加锁、释放锁。
  • 通过 defer 机制,不论函数逻辑多复杂,都能保证在任何执行路径下,资源被释放。
  • 释放资源的 defer 应该直接跟在请求资源的语句后。

8. 什么是 rune 类型?

Go 语言的字符有以下两种:

  • uint8 类型,或者叫 byte 型,代表了 ASCII 码的一个字符。
  • rune 类型,代表一个 Unicode 码点(code point),当需要处理中文、日文或者其他非 ASClII 字符时,则需要用到 rune 类型。rune 是 int32 的别名(UTF-8 是 Go 字符串的底层编码方式,rune 表示的是 Unicode 标量值,两者概念不同)。
1
2
3
4
5
6
7
8
9
10
func main() {
var str string = "hello 你好"

// go 中 string 底层是通过 byte 数组实现的,len() 函数返回的是字符串的字节长度
// 一个汉字占3个字节,所以 len(str) 的结果是 5 + 1 + 3*2 = 12
fmt.Println("len(str):", len(str)) // 12

// 通过 rune 类型处理 unicode 字符
fmt.Println("rune:", len([]rune(str))) // 8
}

9. Go 中的 tag 有什么用?

tag 可以为结构体成员提供属性。常见的:

  1. json 序列化或反序列化时字段的名称
  2. db: sqlx 模块中对应的数据库字段名
  3. form: gin 框架中对应的前端的数据字段名
  4. binding: 搭配 form 使用,默认如果没查找到结构体中的某个字段则不报错值为空, binding 为 required 代表没找到返回错误给前端

详见:Go 语言结构体 Tag 完全指南:从入门到自定义

10. Go 打印时 %v、%+v、%#v 的区别?

  • %v:只输出所有的值

  • %+v:先输出字段名,再输出字段值

  • %#v:先输出结构体名字值,在输出结构体(字段名字 + 字段的值)

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
type student struct {
id int32
name string
}

func main() {
s := &student{id: 1, name: "hansen"}

fmt.Printf("%v\n", s) // &{1 hansen}
fmt.Printf("%+v\n", s) // &{id:1 name:hansen}
fmt.Printf("%#v\n", s) // &main.student{id:1, name:"hansen"}

}

11. Go 中空 struct{} 占用空间吗?

Go 中的空 struct{} 不占用空间。

空 struct{} 是一个零大小的类型,它没有任何字段,因此它不需要分配内存来存储数据。

空 struct{} 通常用于表示一个事件或者一个信号,或者作为一个占位符来表示某个值的存在与否。

12. Go 中空 struct{} 有什么用?

  1. 用 map 模拟 set,那么就要把值置为 struct{},struct{} 本身不占任何空间,可以避免任何多余的内存分配。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
type Set struct map[string]struct{}


func main() {
set := make(Set)

for _, item := range []string{"A", "A", "B", "C"} {
set[item] = struct{}{}
}
fmt.Println(len(set)) // 3
if _, ok := set["A"]; ok {
fmt.Println("A exists") // A exists
}
}
  1. 作为 channel 的信号量,表示一个事件的发生或者一个任务的完成。
1
2
3
4
5
6
7
8
9
10
func main() {
ch := make(chan struct{}, 1)
go func() {
<- ch
// do something
}()

ch <- struct{}{} // 发送一个信号,表示事件发生或者任务完成
// ...
}
  1. 表示仅有方法的结构体
1
type MyStruct struct{}

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

简答:在 main 函数之前执行

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

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

执行顺序:import -> const -> var -> init() -> main()

一个文件可以有多个 init() 函数

init() 函数执行顺序

14. 2个 interface 可以比较吗?

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

  1. 两个 interface 都是 nil
  2. 类型 T 相同,且对应的值 V 也相等

15. Go 函数传参是值类型还是引用类型?

  • 在 Go 语言中只存在值传递,要么是值的副本,要么是指针的副本。无论是值类型的变量还是引用类型的变量亦或是指针类型的变量作为参数传递都会发生值拷贝,开辟新的内存空间。
  • 另外值传递、引用传递和值类型、引用类型是两个不同的概念,不要混淆了。引用类型作为变量传递可以影响到函数外部是因为发生值拷贝后新旧变量指向了相同的内存地址。

16. 如何知道一个对象是分配在栈上还是堆上?

Go 和 C++ 不同,Go 局部变量会进行逃逸分析。如果变量离开作用域后没有被引用,则优先分配到栈上,否则分配到堆上。那么如何判断是否发生了逃逸呢?

go build -gcflags '-m -m -1' xxx.go

关于逃逸的可能情况:变量大小不确定,变量类型不确定,变量分配的内存超过用户栈最大值,暴露给了外部指针。

示例:

1
2
3
4
func test() *int {
a := 10
return &a
}
  • a 本来应该在栈上
  • 但你返回了 &a(地址)
  • 函数结束后外部仍然能访问它

👉 a 必须逃逸到堆上

17. Go 语言的多返回值是如何实现的?

Go 语言的多返回值是通过在函数调用栈帧上预留空间并进行值复制来实现的。

在函数调用发生时,Go 编译器会计算出函数所有返回值的总大小。在为该函数创建栈帧时,就会在调用方(caller)的栈帧上,为这些返回值预留出连续的内存空间。

当函数执行到 return 语句时,它会将其要返回的各个值复制到这些预留好的栈空间中。函数执行完毕后,控制权返回给调用方。此时,调用方可以直接从它自己的栈帧上(即之前为返回值预留的空间)获取这些返回的值。

一句话回答:** 在调用方的栈帧上预留空间,函数返回时进行值复制。**

18. Go 语言中 “_” 的作用是什么?

  1. 忽略多返回值

  2. 当你导入一个包时,通常会使用它的某个功能。但有时你可能只想执行包的 init() 函数(例如,注册驱动、初始化全局变量等),而不需要直接使用包中的任何导出成员。这时,你就可以使用 _ 来进行匿名导入

1
2
3
4
import (
"fmt"
_ "net/http/pprof" // 导入 pprof 包,只为了执行其 init 函数注册 profiling 接口
)

19. Go 语言普通指针和 unsafe.Pointer 的区别?

普通指针比如 *int*string,它们有明确的类型信息,编译器会进行类型检查和垃圾回收跟踪。不同类型的指针之间不能直接转换,这是 Go 类型安全的体现。

unsafe.Pointer 是 Go 的通用指针类型,它绕过了 Go 的类型系统。unsafe.Pointer 可以与任意类型的指针相互转换,也可以与 uintptr 进行转换来做指针运算。

另外,普通指针受 GC 管理和类型约束,unsafe.Pointer 不受类型约束但仍受 GC 跟踪。

20. unsafe.Pointer 与 uintptr 的区别和联系?

unsafe.Pointer 和 uintptr 可以相互转换,这是 Go 提供的唯一合法的指针运算方式。典型用法是先将 unsafe.Pointer 转为 uintptr 做算术运算,然后再转回 unsafe.Pointer 使用。

最关键的区别在于 GC 跟踪。unsafe.Pointer 会被垃圾回收器跟踪,它指向的内存不会被错误回收;而 uintptr 只是个普通整数,GC 完全不知道它指向什么,如果没有其他引用,对应内存可能随时被回收。

所以记住:unsafe.Pointer 有 GC 保护,uintptr 没有,这是它们最本质的区别。


参考资料: