14-Go 面试题速查
💡 使用指南:这份文档收录了 Go 高频面试题,每题附带简答版和详解版,方便快速复习。
1. 为什么选择 Go?相比 Java 有哪些优势?
| 维度 | Go | Java |
|---|---|---|
| 并发模型 | Goroutine(2KB 起步,百万级轻松) | Thread(1MB 起步,万级就吃力) |
| 内存占用 | 低,静态编译无 VM | 高,JVM 吃内存 |
| 启动速度 | 毫秒级 | 秒级(JVM 预热) |
| 部署 | 单二进制文件,无依赖 | 需要 JRE/JDK |
| 语法 | 极简(25 个关键字) | 复杂(泛型、注解、反射…) |
| 适用场景 | 云原生、微服务、CLI 工具 | 企业级、大型单体、复杂业务 |
面试简答:
“Go 的协程更轻量(2KB vs 1MB)、编译快、部署简单(单二进制)、天然适合云原生和微服务场景。Java 在复杂业务逻辑和生态成熟度上更有优势。“
2. Goroutine 过多的问题 & 内存泄漏场景
Goroutine 过多的问题
1. 内存暴涨:每个 Goroutine 至少 2KB 栈,100万个 = 2GB
2. 调度开销:频繁切换消耗 CPU
3. 资源耗尽:如果都在做 I/O,可能耗尽文件描述符
什么是文件描述符 (File Descriptor)? 操作系统用来追踪”打开的资源”的编号。每次打开文件、建立网络连接,系统都会分配一个小整数(FD)。
- 默认限制:Linux 每进程 1024 个
- 耗尽后果:
too many open files错误- 预防方法:及时
Close()、调大ulimit -n
常见内存泄漏场景
// 1️⃣ Goroutine 泄漏(最常见!)
func leak() {
ch := make(chan int)
go func() {
val := <-ch // 永远阻塞,没人往 ch 发数据
fmt.Println(val)
}()
// 函数返回,但 goroutine 永远在等
}
// 2️⃣ 忘记关闭资源
resp, _ := http.Get(url)
// 忘记 resp.Body.Close()
// 3️⃣ time.Ticker 忘记 Stop
ticker := time.NewTicker(time.Second)
// 忘记 ticker.Stop()
// 💡 time.Ticker 是周期定时器,每隔固定时间往 ticker.C 发一个值
// 用完不 Stop,它会一直在后台跑,造成 Goroutine 泄漏
// 4️⃣ 切片底层数组未释放
data := make([]byte, 1<<20) // 1MB
small := data[:10] // small 仍引用整个 1MB
// 5️⃣ map 只增不删
cache := make(map[string]interface{})
// 不断 Add,从不 Delete,即使值为 nil 也占内存面试简答:
“Goroutine 过多会导致内存暴涨和调度开销。常见泄漏场景包括:Goroutine 阻塞在 channel 上无法退出、忘记关闭 HTTP Body、time.Ticker 忘记 Stop、切片引用大数组。“
3. GMP 调度模型
三个核心组件
G = Goroutine(协程,用户态轻量线程)
M = Machine(OS 线程,真正执行代码)
P = Processor(逻辑处理器,持有本地运行队列,默认 = GOMAXPROCS = CPU 核心数)
架构图
┌──────────────────────────────────────────────────┐
│ 全局队列 (Global Queue):存放等待运行的 G │
└──────────────────────────────────────────────────┘
↓ 偶尔获取(每 61 次调度检查一次)
┌─────────┐ ┌─────────┐ ┌─────────┐
│ P1 │ │ P2 │ │ P3 │ ← 逻辑处理器
│ 本地队列 │ │ 本地队列 │ │ 本地队列 │ ← 无锁访问!
└────┬────┘ └────┬────┘ └────┬────┘
│ │ │
↓ ↓ ↓
┌────────┐ ┌────────┐ ┌────────┐
│ M1 │ │ M2 │ │ M3 │ ← OS 线程
│ 执行 G │ │ 执行 G │ │ 执行 G │
└────────┘ └────────┘ └────────┘
核心优化设计
| 设计 | 作用 |
|---|---|
| P 的本地队列 | 无锁访问,减少全局竞争 |
| Work Stealing | P 空闲时偷其他 P 的任务,负载均衡 |
| Hand Off | M 阻塞时,P 交给其他 M 继续工作 |
| M:N 调度 | 少量 OS 线程承载大量 Goroutine |
| 抢占式调度 | 每 10ms 检查,防止 G 长时间霸占 |
面试简答:
“GMP 是 Go 的调度模型。G 是协程,M 是 OS 线程,P 是逻辑处理器。P 持有本地队列实现无锁调度,空闲时通过 Work Stealing 偷其他 P 的任务。M 阻塞时会 Hand Off,把 P 交给其他 M,保证 P 上的 G 不被饿死。“
4. Work-Stealing 机制
流程概览
初始状态:
P1 队列: [G1, G2, G3, G4] ← 很忙
P2 队列: [] ← 空闲
Work Stealing 触发:
1. P2 发现自己没活干
2. 随机选一个 P(比如 P1)
3. 从 P1 队列尾部偷一半 → [G3, G4]
结果:
P1 队列: [G1, G2]
P2 队列: [G3, G4] ← 负载均衡了!
底层实现原理
1. 数据结构:本地队列是环形数组
// runtime/runtime2.go
type p struct {
runqhead uint32 // 头指针(本地 P 从这取)
runqtail uint32 // 尾指针(新 G 放这)
runq [256]guintptr // 环形数组,固定 256 个槽位
}2. 偷取时的原子操作(简化版)
// 简化版 runtime/proc.go 的 runqsteal 函数
func runqsteal(p, p2 *p) *g {
// 1. 原子读取目标 P 的 head 和 tail
h := atomic.LoadAcq(&p2.runqhead)
t := atomic.LoadAcq(&p2.runqtail)
// 2. 计算要偷的数量(一半)
n := t - h
n = n - n/2
// 3. 从 tail 端批量拷贝到自己的队列
for i := uint32(0); i < n; i++ {
g := p2.runq[(h+i) % 256]
p.runq[(p.runqtail+i) % 256] = g
}
// 4. CAS 更新目标 P 的 head(关键!无锁并发安全)
if !atomic.CasRel(&p2.runqhead, h, h+n) {
return nil // 别人先偷了,重试
}
return firstStolenG
}核心设计原理
| 设计 | 原理 | 好处 |
|---|---|---|
| 环形数组 | 固定 256 槽位 | 避免动态分配,内存局部性好 |
| 头尾分离 | 本地从头取,别人从尾偷 | 减少并发冲突 |
| CAS 原子操作 | 偷取时用 CAS 更新 head | 无锁并发安全 |
| 偷一半 | 批量偷取 | 减少偷取次数,均衡负载 |
| 随机选择 | 随机选目标 P | 避免多个 P 同时偷同一个 |
并发冲突如何解决?
场景 1:多个 P 同时偷同一个目标
- P2 和 P3 都计算出要偷
G1~G5(head=0到head=5) - P2 先执行 CAS 成功,
head变为 5 - P3 执行 CAS 失败(因期望
head=0但实际是 5),P3 放弃或重试 - 结果:G 只能被一个人偷走,不会重复执行
场景 2:偷窃者和本地 P 撞车
- P2 准备偷一半
- P1 自己飞快地处理完了这些 G
- P2 执行 CAS 失败(因
head已经被 P1 改了) - 结果:P2 偷窃失败,重新计算
为什么从尾部偷?
本地 P 从头部取(LIFO)→ 刚创建的 G 优先执行,缓存热
外部 P 从尾部偷(FIFO)→ 老的 G 被偷走,减少缓存冲突
面试简答:
“Work Stealing 底层用环形数组存储本地队列,偷取时通过 CAS 原子操作更新 head 指针,实现无锁并发。本地 P 从头取,外部 P 从尾偷,减少冲突。每次偷一半,随机选目标,实现负载均衡。“
5. 协程栈演进
| 版本 | 栈实现 | 特点 |
|---|---|---|
| Go 1.2 前 | 分段栈 | 栈不够就新分配一段,链表连接 |
| Go 1.3+ | 连续栈 | 栈不够就整体复制到更大的空间 |
分段栈的问题
函数 A 调用 B,B 需要更多栈空间
→ 分配新段
→ B 返回
→ 释放新段
→ A 再调用 B
→ 又分配新段...
热点函数反复触发 → "栈震荡"(Hot Split),性能差
连续栈优势
栈不够 → 分配 2 倍大的新栈 → 整体复制 → 更新指针
一次复制,长期稳定
当前实现:初始 2KB,最大 1GB,按需 2 倍扩容。
6. I/O 调度流程(以网络 I/O 为例)
1. G 调用 net.Read()
2. Go runtime 检查:数据没准备好
3. 把 G 从 P 上摘掉,注册到 netpoller(epoll/kqueue)
4. M 继续执行 P 上的其他 G(不阻塞!关键!)
5. 内核数据就绪
6. netpoller 感知,把 G 放回某个 P 的队列
7. G 继续执行
关键点
❌ 传统 Thread:线程阻塞在 I/O,什么都不能干
✅ Go Goroutine:G 去等 I/O,M 继续干活
这就是为什么 Go 能用少量线程处理大量并发连接!
7. 互斥锁能否重入?
不能! Go 的 sync.Mutex 是不可重入锁。
var mu sync.Mutex
func A() {
mu.Lock()
B() // 💀 死锁!B 里又 Lock 同一把锁
mu.Unlock()
}
func B() {
mu.Lock() // 永远等不到,A 还没 Unlock
mu.Unlock()
}为什么不支持可重入?
Go 设计哲学:简单 > 功能多
可重入锁会掩盖设计问题,让错误更难发现
正确做法:拆分锁保护的逻辑,或者用不加锁的内部函数
8. 并发安全队列设计
type SafeQueue struct {
items []interface{}
mu sync.Mutex
cond *sync.Cond
}
func NewSafeQueue() *SafeQueue {
q := &SafeQueue{}
q.cond = sync.NewCond(&q.mu)
return q
}
func (q *SafeQueue) Push(item interface{}) {
q.mu.Lock()
q.items = append(q.items, item)
q.cond.Signal() // 唤醒一个等待的消费者
q.mu.Unlock()
}
func (q *SafeQueue) Pop() interface{} {
q.mu.Lock()
for len(q.items) == 0 {
q.cond.Wait() // 队列空就等待
}
item := q.items[0]
q.items = q.items[1:]
q.mu.Unlock()
return item
}进阶方案
1. 无锁队列:atomic + 环形数组
2. 分片队列:减少锁竞争
3. 直接用 channel:Go 原生的并发安全队列
9. 高并发优化经验(示例回答)
1. 对象复用:使用 sync.Pool 减少 GC 压力
- 效果:QPS 从 5000 提升到 8000
2. 批量处理:合并多次小请求为一次大请求
- 效果:数据库压力降低 70%
3. 异步化:非关键路径用 channel + worker 池
- 效果:接口延迟 P99 从 200ms 降到 50ms
4. 本地缓存:热点数据用 sync.Map 或单机缓存
- 效果:Redis 访问量降低 80%
5. 连接池:复用数据库/HTTP 连接
- 效果:连接建立耗时从 10ms 降为 0
10. 已关闭 Channel 的读写
| 操作 | 已关闭的 Channel | 结果 |
|---|---|---|
| 写 | ch <- v | panic: send on closed channel |
| 读 | v := <-ch | 返回零值,ok 为 false |
| 关闭 | close(ch) | panic: close of closed channel |
ch := make(chan int, 1)
ch <- 1
close(ch)
v1 := <-ch // v1 = 1, ok = true
v2, ok := <-ch // v2 = 0, ok = false(已关闭且空)
ch <- 2 // panic!11. 如何检测 Channel 是否已关闭?
// 方法1:读取时检查第二个返回值
v, ok := <-ch
if !ok {
fmt.Println("channel 已关闭")
}
// 方法2:for-range 自动处理
for v := range ch {
fmt.Println(v) // channel 关闭后自动退出循环
}
// ⚠️ 没有直接检测 closed 的函数!
// 因为检测后状态可能立即变化,会产生竞态12. Select 底层原理
核心逻辑
1. 编译器把所有 case 转成 scase 数组
2. 运行时打乱 case 顺序(随机化!)
3. 按乱序依次检查每个 case 是否就绪
4. 如果有多个就绪,随机选一个执行
5. 如果都没就绪:
- 有 default → 执行 default
- 无 default → 阻塞,把 G 挂到所有 channel 的等待队列
随机化解决饥饿问题
// 如果固定顺序,case1 总是优先
select {
case <-ch1: // 总是先检查这个
case <-ch2: // 如果 ch1 一直有数据,ch2 可能永远得不到执行
}
// 随机化后,每次检查顺序不同,所有 case 机会均等13. 多个 Case 同时就绪时的执行逻辑
随机选择一个执行!
不是按代码顺序,也不是按就绪时间
是真正的伪随机选择,保证公平性
固定 Case 顺序的潜在问题
// ❌ 错误假设:以为 ch1 优先级更高
select {
case <-ch1:
// 处理高优先级消息
case <-ch2:
// 处理低优先级消息
}
// 实际:随机选择,不保证顺序
// 如果需要优先级,要自己实现逻辑14. 参数传递方式
核心结论:Go 只有值传递
但要理解”值”是什么:
| 类型 | 传递的”值” | 函数内修改效果 |
|---|---|---|
| int, bool, struct | 整个数据的拷贝 | 不影响外部 |
| 指针 *T | 指针地址的拷贝 | 通过指针修改会影响外部 |
| slice | SliceHeader 拷贝(ptr, len, cap) | 修改元素影响外部,append 可能不影响 |
| map | *hmap 指针拷贝 | 修改 key-value 影响外部 |
| channel | *hchan 指针拷贝 | 同一个 channel |
Slice 的特殊情况
func modify(s []int) {
s[0] = 100 // ✅ 影响外部,因为底层数组是同一个
s = append(s, 999) // ❌ 可能不影响外部!
// append 可能触发扩容,s 指向新数组,外部还是老数组
}15. Slice 和 Map 底层结构
Slice 底层
type slice struct {
array unsafe.Pointer // 底层数组指针
len int // 当前长度
cap int // 容量
}
// 传递 slice 时,复制的是这个 24 字节的结构体
// 但 array 指针指向同一块内存!Map 底层
// make(map[K]V) 返回的就是 *hmap 指针
type hmap struct {
count int // 元素数量
buckets unsafe.Pointer // 桶数组
// ...
}
// 所以 map 传入函数,修改会影响外部
// 因为传的本来就是指针!面试简答:
“Slice 底层是 SliceHeader(指针+len+cap),传递时复制 header 但共享底层数组。Map 本质是 *hmap 指针,所以传入函数修改会影响外部。”