Go 语言面试题集锦(1)

1. Go 中除了加 Mutex 锁以外还有哪些方式安全读写共享变量?

除了互斥锁(sync.Mutex)和读写锁(sync.RWMutex),Go 还提供了以下方式:

  1. Channel(通道):遵循“通过通信来共享内存”的原则,是 Go 最推荐的方式。
  2. Atomic(原子操作):位于 sync/atomic 包。对于简单的计数器、标志位(如 int32, int64)的读写,原子操作比锁更轻量、性能更高(CAS 机制)。

2. Golang 中 new 和 make 的区别?

这是经典的内存分配问题。

  • make:仅用于 slicemapchan 三种引用类型的内存创建和初始化。它返回的是引用本身(Type),因为这三种类型底层都需要初始化相关的数据结构(如 map 的 bucket,slice 的 array)才能使用。
  • new:可分配任意类型的数据。它根据传入的类型申请一块内存,并将该内存置零(Zeroed),返回指向这块内存的指针(*Type)。

3. Go 中对 nil Slice 和空 Slice 的处理是一致的吗?

不一致,特别是在 JSON 序列化时区别明显。

  • Nil Slice (var s []int):底层指针为 nil,没有分配底层数组。JSON 序列化结果为 null
  • Empty Slice (s := make([]int, 0)s := []int{}):底层指针不为 nil(指向一个 zerobase 地址),长度为 0。JSON 序列化结果为 []

建议:如果函数可能返回空集合,且前端希望收到 [] 而不是 null,应显式初始化为空 Slice。

4. 协程、线程和进程的区别?

  • 进程 (Process):资源分配的最小单位。拥有独立的堆栈、内存空间,隔离性好,但切换开销大(涉及虚拟内存、文件句柄等)。
  • 线程 (Thread):CPU 调度的最小单位。依附于进程,共享进程内存。切换开销优于进程,但仍需内核态介入。
  • 协程 (Goroutine):用户态的轻量级线程。
    • 调度:由 Go Runtime(用户态)管理,无需操作系统内核频繁干涉。
    • :初始仅 2KB(线程通常 2MB),可动态扩容。
    • 切换:仅需保存少量寄存器,开销极低(纳秒级)。

5. Go 内存模型中为什么小对象多了会造成 GC 压力?

这里要注意,GC 的压力主要来自扫描(Scan)阶段。

  1. 扫描成本:GC 需要遍历根对象引用的所有对象来标记存活状态。如果堆上有数百万个小对象,扫描器必须逐个检查它们,这会消耗大量的 CPU(Scanning time)。
  2. 元数据开销:每个对象都需要分配额外的元数据(span、bitmap 等)来记录其状态,小对象过多会导致元数据占比变高。
  3. 优化:减少指针数量,或者使用对象池(sync.Pool)复用对象。

6. Channel 为什么可以做到线程安全?

Channel 并非魔法,它在 Runtime 内部有一个 hchan 结构体,其中包含一个互斥锁 lock mutex

  • 原子性:当 Goroutine 进行发送或接收操作时,Runtime 会先获取这把锁,保证了对内部环形队列(buf)操作的原子性。
  • 设计哲学:虽然底层用了锁,但在应用层,它提供了高级的同步抽象,让开发者从复杂的锁管理中解脱出来。

7. GC 的触发条件?

  1. 主动触发:调用 runtime.GC(),主要用于测试或特殊场景,会阻塞调用者直到 GC 完成。
  2. 被动触发
    • 步调算法 (Pacing):根据环境变量 GOGC(默认 100)。当当前堆内存大小达到上次 GC 结束时堆大小的 200%(即增长了一倍)时触发。
    • 时间阈值:由 sysmon 监控,如果超过 2 分钟(默认)没有发生 GC,会强制触发一次,防止长时间无 GC 导致内存无法释放。

8. 怎么查看和限制 Goroutine 的数量?

  • 查看:使用 runtime.NumGoroutine() 函数可以获取当前活跃的协程数量。注意: GOMAXPROCS 控制的是最大并行运行的**系统线程(P)**数量,而不是协程数。
  • 限制:Go 运行时没有直接限制 G 数量的参数。通常通过带缓冲的 Channel(令牌桶原理)或协程池(Worker Pool)来控制并发数,防止 G 数量暴涨导致 OOM。

9. Channel 是同步的还是异步的?

取决于是否有缓冲:

  • 无缓冲 (make(chan T))同步。发送方必须等到接收方准备好,否则阻塞;反之亦然。类似于“握手”。
  • 有缓冲 (make(chan T, N))异步。只要缓冲区未满,发送方将数据放入后立即返回,无需等待接收方。

Channel 操作状态表:

操作 nil channel 已关闭 channel 正常 channel
关闭 panic panic 成功
发送 永久阻塞 panic 阻塞或成功
接收 永久阻塞 永不阻塞 (返零值+false) 阻塞或成功

10. Goroutine 和线程的区别(补充)

除了内存和调度开销外:

  • M:N 模型:Go 实现了 M 个协程映射到 N 个内核线程上。一个线程阻塞(如 Syscall),其他协程会被调度到其他线程运行。
  • 栈大小:线程固定(如 2MB),容易 StackOverflow 或浪费;协程按需伸缩(2KB -> 1GB)。

11. Go 的 Struct 能不能比较?

  • 能比较:如果结构体的所有字段都是可比较的(如 int, string, bool, pointer),那么该结构体可使用 == 比较。
  • 不能比较:如果结构体包含切片(slice)映射(map)函数类型的字段,则编译不通过。
    • 特例:可以使用 reflect.DeepEqual() 进行比较,但性能较差。

12. Go 主协程如何等其余协程完再操作?

标准做法是使用 sync.WaitGroup

  1. Add(n):添加计数。
  2. Done():任务结束,计数减 1(通常在 defer 中调用)。
  3. Wait():阻塞直到计数归零。
    注意:WaitGroup 传递给函数时必须传递指针,否则会拷贝一个新的 WaitGroup,导致死锁。

13. Go 的 Slice 如何扩容?

Go 1.18 之前和之后的扩容策略有所调整,为了更平滑:

  • 核心逻辑
    1. 如果新申请容量(cap)大于 2 倍旧容量,直接扩容到新申请容量。
    2. 否则,如果旧长度 < 阈值(旧版 1024,新版 256),直接翻倍。
    3. 如果旧长度 >= 阈值,则按照一定公式循环增加(旧版是 1.25 倍,新版公式更平滑),直到满足需求。
  • 内存对齐:计算出的容量还会根据内存分配器的规格(span class)进行向上取整,以提高内存利用率。

14. Go 中的 map 如何实现顺序读取?

Go 的 Map 本质是哈希表,遍历顺序是随机的(官方甚至特意加入了随机因子防止开发者依赖顺序)。
实现顺序读取

  1. 收集所有 key 放入一个 Slice。
  2. 对 Slice 进行排序(sort 包)。
  3. 遍历有序的 Slice,按 Key 取 Map 的值。

15. 值接收者和指针接收者的区别?

  • 值接收者 (func (p Person))
    • 调用时会发生值拷贝
    • 方法内修改接收者,不影响外部原对象。
    • 适用:小对象、不可变对象。
  • 指针接收者 (func (p *Person))
    • 不拷贝值,传递地址。
    • 方法内修改,会影响外部原对象。
    • 适用:大对象(避免拷贝开销)、需要修改状态、包含互斥锁等不可拷贝字段的对象。

16. 在 Go 函数中为什么会发生内存泄露?

Go 有 GC,但依然会泄漏,常见原因:

  1. Goroutine 泄漏:启动了协程但因 channel 阻塞或死循环永远无法退出。
  2. 未停止的 Tickertime.NewTicker 不调用 Stop(),资源永远不释放。
  3. 切片引用:大数组切出一个小 Slice,导致底层大数组无法回收。

17. Goroutine 发生了泄漏如何检测?

  1. pprof:最通用的工具。访问 /debug/pprof/goroutine 查看协程栈,寻找异常增多的函数调用。
  2. Gops:命令行诊断工具。
  3. 单元测试:使用 goleak 库在测试结束后检测是否有遗留协程。

18. Go 中两个 Nil 可能不相等吗?

可能。这是 Interface 的经典陷阱。
Interface 底层由 (Type, Value) 两个字段组成。

  • 只有当 Type == nilValue == nil 时,Interface 才等于 nil
  • 如果我们将一个具体的空指针(如 var p *int = nil)赋值给 Interface,此时 Interface 内部是 (*int, nil)
  • 结果:Interface != nil

19. Go 语言函数传参是值类型还是引用类型?

Go 只有值传递 (Pass by Value)

  • 即使传递的是指针,也是拷贝了一份指针的副本(地址相同)。
  • 即使传递的是 Slice/Map,也是拷贝了 SliceHeader/MapPtr 的副本,但由于它们内部包含指向底层数据的指针,所以修改元素会影响外部。

20. Go 语言中的内存对齐了解吗?

CPU 访问内存是按字长(Word Size,如 64位系统为 8字节)读取的。

  • 目的:为了减少 CPU 访问内存的次数,提高原子性。
  • 现象:编译器会自动在结构体字段间填充空白字节(Padding),使得字段的偏移量是其自身大小的整数倍。
  • 优化:定义结构体时,将宽字段(如 int64)放在前面,窄字段(如 bool)放在后面,可以减少 Padding,节省内存。

21. 两个 interface 可以比较吗?

  • 情况 1:如果底层类型是可比较的(如 int, string),可以比较。
  • 情况 2:如果底层类型不可比较(如 slice, map),运行时会 Panic
  • 安全做法:使用 reflect.DeepEqual(a, b),虽然慢但安全。

22. go 打印时 %v %+v %#v 的区别?

s := &Student{id:1, name:"Go"} 为例:

  • %v:只打印值。&{1 Go}
  • %+v:打印字段名 + 值。&{id:1 name:Go}
  • %#vGo 语法格式,打印类型 + 字段名 + 值。&main.Student{id:1, name:"Go"}(调试神器)。

23. 什么是 rune 类型?

  • byte (uint8):代表一个 ASCII 字符(1 字节)。
  • rune (int32):代表一个 UTF-8 字符。Go 处理中文等宽字符时必须用 rune,否则按 byte 截取会出现乱码。
    • len("你好") = 6 (bytes)
    • len([]rune("你好")) = 2 (chars)

24. 空 struct{} 占用空间么?

0 字节
可以使用 unsafe.Sizeof(struct{}{}) 验证,结果为 0。Go 编译器对空结构体做了专门优化,不分配内存,这就是为什么它适合做占位符。

25. 空 struct{} 的用途?

  1. Set 集合map[string]struct{}。只关注 Key,Value 不占内存。
  2. 信号通知chan struct{}。发送空结构体作为信号,语义明确且开销最小。
  3. 仅包含方法的结构体:有时候我们需要一种类型来组织方法,但不需要存储状态。