文章

Go 面试八股速通(C++ 程序员版)

面向 C++ 工程师整理 8 个 Go 面试高频问题,配套可运行示例代码与回答思路

Go 面试八股速通(C++ 程序员版)

如果你是 C++ 程序员,准备转 Go 岗位面试,最容易卡住的不是语法,而是语言设计取舍。这篇文章用“八股问答 + 小代码”的方式,帮你快速建立 Go 面试表达框架。

建议回答结构:

  1. 先说一句结论;
  2. 再和 C++ 做对比;
  3. 最后给一个工程实践建议。

1. Go 的值传递和引用语义怎么理解?

一句话版:Go 只有值传递,但有些值里“包着指针”(如 slice、map、chan、interface),看起来像引用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package main

import "fmt"

func changeArray(a [3]int) {
	a[0] = 100
}

func changeSlice(s []int) {
	s[0] = 100
}

func main() {
	a := [3]int{1, 2, 3}
	s := []int{1, 2, 3}

	changeArray(a)
	changeSlice(s)

	fmt.Println("array:", a) // [1 2 3]
	fmt.Println("slice:", s) // [100 2 3]
}

面试可展开:

  • 数组 [N]T 是完整值,拷贝成本高,行为更像 C++ 的 std::array 按值传参。
  • slice 是三元描述符(指针、长度、容量),按值拷贝的是描述符,但底层数组共享。
  • 工程上,大对象优先用指针或 slice,避免无意义拷贝。

2. slice 和数组的区别?扩容发生了什么?

一句话版:数组定长且值语义;slice 是动态视图,append 可能触发扩容并更换底层数组。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package main

import "fmt"

func main() {
	s := make([]int, 0, 2)
	s = append(s, 1, 2)

	alias := s
	s = append(s, 3) // 触发扩容后,s 可能指向新数组

	alias[0] = 99
	fmt.Println("alias:", alias) // [99 2]
	fmt.Println("s:", s)         // [1 2 3] 或 [99 2 3](取决于是否扩容)
}

面试可展开:

  • 重点不是背扩容倍数,而是知道 append 后旧切片可能“脱钩”
  • 类比 C++ vector:扩容后迭代器/引用可能失效。
  • 传 slice 给函数并不总是“安全修改原数据”,要看是否重新分配。

3. map 为什么并发不安全?怎么解决?

一句话版:Go 原生 map 不是并发容器;并发读写会 panic 或数据竞争。

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
32
33
34
35
36
37
38
39
package main

import (
	"fmt"
	"sync"
)

type Counter struct {
	mu sync.RWMutex
	m  map[string]int
}

func (c *Counter) Inc(k string) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.m[k]++
}

func (c *Counter) Get(k string) int {
	c.mu.RLock()
	defer c.mu.RUnlock()
	return c.m[k]
}

func main() {
	c := &Counter{m: map[string]int{}}
	var wg sync.WaitGroup

	for i := 0; i < 1000; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			c.Inc("go")
		}()
	}

	wg.Wait()
	fmt.Println(c.Get("go"))
}

面试可展开:

  • 读多写少可用 RWMutex,高竞争可评估分片锁或 sync.Map
  • C++ 常见方案是 unordered_map + mutex,思路一致。
  • 面试加分点:顺带提 go test -race 检测数据竞争。

4. goroutine 和 OS 线程是什么关系?

一句话版:goroutine 是用户态轻量协程,由 Go 调度器映射到少量内核线程(G-M-P 模型)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package main

import (
	"fmt"
	"time"
)

func worker(id int) {
	for i := 0; i < 3; i++ {
		fmt.Printf("worker-%d: %d\n", id, i)
		time.Sleep(10 * time.Millisecond)
	}
}

func main() {
	for i := 0; i < 3; i++ {
		go worker(i)
	}
	time.Sleep(100 * time.Millisecond)
}

面试可展开:

  • C++ std::thread 通常一对一映射内核线程,创建和切换成本更高。
  • goroutine 初始栈小且可增长,适合高并发 I/O。
  • 但 goroutine 不是“免费”:阻塞、泄漏、无界创建都会出问题。

5. channel 的本质和常见坑?

一句话版:channel 是带同步语义的队列;无缓冲强调“交接”,有缓冲强调“削峰”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package main

import "fmt"

func main() {
	ch := make(chan int, 2)
	ch <- 1
	ch <- 2
	close(ch)

	for v := range ch {
		fmt.Println(v)
	}

	// 再发送会 panic:send on closed channel
	// ch <- 3
}

面试可展开:

  • 关闭原则:通常由发送方关闭,且只关闭一次。
  • 从已关闭 channel 接收会立刻返回零值,可配合 v, ok := <-ch 判断。
  • 用 channel 不代表不需要锁,状态共享场景仍可能需要 mutex。

6. defer 的执行时机与代价?

一句话版:defer 在函数返回前按 LIFO 执行,参数在 defer 声明时求值。

1
2
3
4
5
6
7
8
9
10
package main

import "fmt"

func main() {
	x := 1
	defer fmt.Println("defer x:", x) // 这里已拷贝 x=1
	x = 2
	fmt.Println("now x:", x)
}

输出:

1
2
now x: 2
defer x: 1

面试可展开:

  • 最常用于文件关闭、解锁、埋点收尾。
  • 在极高频热路径里,大量 defer 可能有额外开销,需要 profiling 再优化。
  • 类比 C++ RAII:一个是语法机制,一个是对象生命周期机制,目标都是“别忘记释放资源”。

7. new 和 make 的区别?

一句话版new(T) 返回 *T 零值指针;make 用于初始化 slice/map/chan 并返回其本体。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package main

import "fmt"

func main() {
	p := new(int)
	fmt.Println(*p) // 0

	m := make(map[string]int)
	m["k"] = 1

	s := make([]int, 3, 5)
	ch := make(chan int, 1)
	ch <- 42

	fmt.Println(m, s, <-ch)
}

面试可展开:

  • 不能对 map/slice/chan 只用 new 就直接使用其语义能力。
  • var m map[string]int 仅声明 nil map,写入会 panic。
  • 这是 Go 面试非常爱考的“可用性初始化”问题。

8. 垃圾回收(GC)会不会让延迟不可控?

一句话版:Go 是并发三色标记清除 GC,延迟通常可控,但你仍要减少逃逸和短命对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package main

import "fmt"

type Big struct {
	buf [1024]byte
}

func alloc(n int) []*Big {
	res := make([]*Big, 0, n)
	for i := 0; i < n; i++ {
		res = append(res, &Big{})
	}
	return res
}

func main() {
	x := alloc(10000)
	fmt.Println(len(x))
}

面试可展开:

  • C++ 习惯手工控制生命周期,Go 则更依赖逃逸分析和 GC。
  • 可用 go build -gcflags="-m" 观察逃逸,用 pprof 看分配热点。
  • 优化优先级:先减少分配,再谈对象池(如 sync.Pool),避免过早复杂化。

面试收尾话术(给 C++ 程序员)

你可以这样总结:

Go 不是在“性能绝对值”上替代 C++,而是在工程效率、并发模型、交付稳定性上给出更高性价比。我的迁移策略是:复用 C++ 的工程化习惯(抽象、测试、性能分析),接受 Go 在内存与并发语义上的新约束。

这句总结能体现两点:你理解 Go 的设计哲学,也保留了 C++ 工程师的优势。

本文由作者按照 CC BY 4.0 进行授权