Go 语言面试题集锦(1)
1. Go 中除了加 Mutex 锁以外还有哪些方式安全读写共享变量?
除了互斥锁(sync.Mutex)和读写锁(sync.RWMutex),Go 还提供了以下方式:
- Channel(通道):遵循“通过通信来共享内存”的原则,是 Go 最推荐的方式。
- Atomic(原子操作):位于
sync/atomic包。对于简单的计数器、标志位(如int32,int64)的读写,原子操作比锁更轻量、性能更高(CAS 机制)。
2. Golang 中 new 和 make 的区别?
这是经典的内存分配问题。
- make:仅用于
slice、map、chan三种引用类型的内存创建和初始化。它返回的是引用本身(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)阶段。
- 扫描成本:GC 需要遍历根对象引用的所有对象来标记存活状态。如果堆上有数百万个小对象,扫描器必须逐个检查它们,这会消耗大量的 CPU(Scanning time)。
- 元数据开销:每个对象都需要分配额外的元数据(span、bitmap 等)来记录其状态,小对象过多会导致元数据占比变高。
- 优化:减少指针数量,或者使用对象池(
sync.Pool)复用对象。
6. Channel 为什么可以做到线程安全?
Channel 并非魔法,它在 Runtime 内部有一个 hchan 结构体,其中包含一个互斥锁 lock mutex。
- 原子性:当 Goroutine 进行发送或接收操作时,Runtime 会先获取这把锁,保证了对内部环形队列(buf)操作的原子性。
- 设计哲学:虽然底层用了锁,但在应用层,它提供了高级的同步抽象,让开发者从复杂的锁管理中解脱出来。
7. GC 的触发条件?
- 主动触发:调用
runtime.GC(),主要用于测试或特殊场景,会阻塞调用者直到 GC 完成。 - 被动触发:
- 步调算法 (Pacing):根据环境变量
GOGC(默认 100)。当当前堆内存大小达到上次 GC 结束时堆大小的 200%(即增长了一倍)时触发。 - 时间阈值:由
sysmon监控,如果超过 2 分钟(默认)没有发生 GC,会强制触发一次,防止长时间无 GC 导致内存无法释放。
- 步调算法 (Pacing):根据环境变量
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。
Add(n):添加计数。Done():任务结束,计数减 1(通常在 defer 中调用)。Wait():阻塞直到计数归零。
注意:WaitGroup传递给函数时必须传递指针,否则会拷贝一个新的 WaitGroup,导致死锁。
13. Go 的 Slice 如何扩容?
Go 1.18 之前和之后的扩容策略有所调整,为了更平滑:
- 核心逻辑:
- 如果新申请容量(cap)大于 2 倍旧容量,直接扩容到新申请容量。
- 否则,如果旧长度 < 阈值(旧版 1024,新版 256),直接翻倍。
- 如果旧长度 >= 阈值,则按照一定公式循环增加(旧版是 1.25 倍,新版公式更平滑),直到满足需求。
- 内存对齐:计算出的容量还会根据内存分配器的规格(span class)进行向上取整,以提高内存利用率。
14. Go 中的 map 如何实现顺序读取?
Go 的 Map 本质是哈希表,遍历顺序是随机的(官方甚至特意加入了随机因子防止开发者依赖顺序)。
实现顺序读取:
- 收集所有
key放入一个 Slice。 - 对 Slice 进行排序(
sort包)。 - 遍历有序的 Slice,按 Key 取 Map 的值。
15. 值接收者和指针接收者的区别?
- 值接收者 (
func (p Person)):- 调用时会发生值拷贝。
- 方法内修改接收者,不影响外部原对象。
- 适用:小对象、不可变对象。
- 指针接收者 (
func (p *Person)):- 不拷贝值,传递地址。
- 方法内修改,会影响外部原对象。
- 适用:大对象(避免拷贝开销)、需要修改状态、包含互斥锁等不可拷贝字段的对象。
16. 在 Go 函数中为什么会发生内存泄露?
Go 有 GC,但依然会泄漏,常见原因:
- Goroutine 泄漏:启动了协程但因 channel 阻塞或死循环永远无法退出。
- 未停止的 Ticker:
time.NewTicker不调用Stop(),资源永远不释放。 - 切片引用:大数组切出一个小 Slice,导致底层大数组无法回收。
17. Goroutine 发生了泄漏如何检测?
- pprof:最通用的工具。访问
/debug/pprof/goroutine查看协程栈,寻找异常增多的函数调用。 - Gops:命令行诊断工具。
- 单元测试:使用
goleak库在测试结束后检测是否有遗留协程。
18. Go 中两个 Nil 可能不相等吗?
可能。这是 Interface 的经典陷阱。
Interface 底层由 (Type, Value) 两个字段组成。
- 只有当
Type == nil且Value == 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}%#v:Go 语法格式,打印类型 + 字段名 + 值。&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{} 的用途?
- Set 集合:
map[string]struct{}。只关注 Key,Value 不占内存。 - 信号通知:
chan struct{}。发送空结构体作为信号,语义明确且开销最小。 - 仅包含方法的结构体:有时候我们需要一种类型来组织方法,但不需要存储状态。