GOLANG面试八股文-代码面试题整理
1. 切片底层数据结构问题
1 2 3 4 5 6 7 | func main() { s := []int{1, 2, 3, 4, 5} a := s[:2] b := s[2:] a = append(a, 100) fmt.Println(b[0]) } |
很多人脱口而出”3”。答案是100。
`a` 和 `b` 共享同一个底层数组。`a := s[:2]` 之后,a的长度是2,但容量是5——还剩三个位置。 `append(a, 100)` 不会触发扩容,直接往底层数组的第三个位置写了100,刚好是 `b[0]` 指向的位置。
这题考的是slice的底层结构:一个指针、一个长度、一个容量。只有append的时候容量不够了才会分配新数组,容量够的时候就是原地修改。
追问通常是”怎么避免这种问题”——用三下标切片 `a := s[:2:2]`,把容量也限制成2,append的时候就一定会触发扩容,分配新的底层数组。
2. goroutine泄漏问题
1 2 3 4 5 6 7 8 | func fetchData(ctx context.Context) <-chan int { ch := make(chan int) go func() { result := doSomething() ch <- result }() return ch } |
这段代码在正常路径下没问题。但如果调用方的context超时了,不再从ch里读数据,`ch <- result` 就永远阻塞了——这个goroutine泄漏了,永远不会被回收。 改法:
1 2 3 4 5 6 7 8 9 10 11 | func fetchData(ctx context.Context) <-chan int { ch := make(chan int, 1) // 带缓冲 go func() { result := doSomething() select { case <- result: case <-ctx.Done(): } }() return ch } |
两个改动:channel加了1的缓冲(就算没人读也不阻塞),select里加了context取消分支。
面试官真正想听的不是这段代码怎么改,而是你怎么发现线上有goroutine泄漏。`runtime.NumGoroutine()` 可以拿到当前goroutine数量,如果这个数字一直涨不下来,大概率是泄漏了。用 `pprof` 的 goroutine profile 可以看到每个goroutine卡在哪一行:
1 | go tool pprof http://localhost:6060/debug/pprof/goroutine |
然后 `top` 看哪些函数创建的goroutine最多、`list` 看具体卡在哪行。
3. defer的返回值陷阱
1 2 3 4 5 6 | func f() (result int) { defer func() { result++ }() return 0 } |
返回值是1,不是0。
Go的return不是原子操作。`return 0` 拆成两步:先把0赋给返回值 `result`,然后执行defer,defer里 `result++` 把它改成了1,最后函数才真正返回。
换一种写法:
1 2 3 4 5 6 7 | func f() int { result := 0 defer func() { result++ }() return result } |
这次返回0。因为返回值是匿名的,`return result` 先把result的值复制给匿名返回值,然后defer修改的是局部变量result,匿名返回值不受影响。
这两段代码的区别就在于返回值有没有名字。命名返回值的defer能修改返回结果,匿名返回值的defer改不了。面试中能把这个讲清楚,基本说明你理解了Go的函数调用栈帧。
4. map的并发崩溃
1 2 3 4 5 6 | m := make(map[int]int) for i := 0; i < 10; i++ { go func(n int) { m[n] = n }(i) } |
这段代码不是”可能出bug”,是直接 `fatal error: concurrent map writes`,进程挂掉,recover都救不了。
Go的map不是并发安全的,runtime里做了检测——发现并发写直接fatal,连panic都不给你,没法recover。这是Go团队的设计选择:与其让你默默写出数据竞争然后在生产环境查三天,不如直接让你挂掉。
三种解法:`sync.Mutex` 加锁、`sync.RWMutex`(读多写少的场景)、`sync.Map`(Go 1.9之后提供的并发安全map)。
`sync.Map` 不是万能的。它针对两种场景做了优化:key只写一次但读很多次,或者多个goroutine读写不同的key集合。如果你的场景是大量goroutine频繁读写相同的key,`sync.RWMutex` + 普通map反而更快。
5. sync.Pool 内存池污染问题
下面这段代码运行后会出现什么问题?
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 27 28 29 30 31 | var pool = sync.Pool{New: func() interface{} { return new(bytes.Buffer) }} func testMemAlloc() { go func() { for { processRequest(1 << 28) // 256MiB } }() for i := 0; i < 1000; i++ { go func() { for { processRequest(1 << 10) // 1MiB } }() } var stats runtime.MemStats for i := 0; ; i++ { runtime.ReadMemStats(&stats) fmt.Printf("Cycle %d: %dB\n", i, stats.Alloc) time.Sleep(time.Second) runtime.GC() } } func processRequest(size int) { b := pool.Get().(*bytes.Buffer) time.Sleep(500 * time.Millisecond) b.Grow(size) pool.Put(b) time.Sleep(1 * time.Millisecond) } |
实际的运行结果是,内存会持续增长,如果机器物理内存小于256GB,最终会OOM。啥?你说你内存有1TB,那没事了!
问题的本质在于sync.Pool的污染,理论上池子里面有1000个1MiB的buffer,但是上面的那个goroutine会从池子里面随机拿一个buffer进行复用,开始极大概率会拿到1MB的,然后grow到256MB,然后放回去,导致内存一直增长。
6. Channel未初始化导致的阻塞问题
下面这段代码运行后会出现什么问题?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | func testChannel() { var ch chan int go func() { ch = make(chan int, 1) ch <- 1 }() go func(ch chan int) { time.Sleep(time.Second) <-ch }(ch) c := time.Tick(1 * time.Second) for range c { fmt.Printf("#goroutines: %d\n", runtime.NumGoroutine()) } } |
运行结果为,循环输出#goroutines: 2。
这是因为对未初始化的channel进行读写操作会导致goroutine阻塞,第二个goroutine会一直阻塞,第一个goroutine在初始化之后会向ch中发送数据而正常退出。
加上主goroutine,总共是2个goroutine。
如果把ch = make(chan int, 1)修改为ch = make(chan int),则会输出#goroutines: 3。