make 和 new 的区别
一句话: new(T) 分配内存并返回 *T(零值指针),只用于任意类型。make 只用于 slice/map/channel,返回初始化后的 T(不是指针),并完成内部初始化。
p := new(int) // p 是 *int,指向 0
s := make([]int, 3, 5) // s 是 []int,len=3, cap=5
m := make(map[string]int) // m 初始化了 hmap
// m2 := new(map[string]int) // m2 是 *map[string]int,指向 nil map
值传递与引用语义
一句话: Go 所有函数传参都是值传递。但 Slice、Map、Channel 的内部包含指针,修改它们指向的数据会影响原变量——这看起来像"引用传递",但本质是值传递了指针。
// 1. Slice:复制了 slice header(ptr, len, cap),但底层数组共享
func modifySlice(s []int) {
s[0] = 999 // ✅ 影响原切片(共享底层数组)
}
// 2. Map:复制了指向 hmap 的指针
func modifyMap(m map[string]int) {
m["key"] = 999 // ✅ 影响原 map
}
// 3. 结构体:完整复制
type User struct { Name string }
func modifyStruct(u User) {
u.Name = "changed" // ❌ 不影响原变量
}
for range 变量陷阱
一句话: Go 1.21 及之前,for range 中循环变量是同一个变量地址,每次迭代只改值。取地址会拿到同一个地址。Go 1.22 修复了这个问题。
// Go 1.21 及之前 ❌
var ptrs []*int
for _, v := range []int{1, 2, 3} {
ptrs = append(ptrs, &v) // 全部指向同一个地址,值都是 3
}
// 修复方式(Go 1.21 及之前)
for _, v := range []int{1, 2, 3} {
v := v // 创建新变量
ptrs = append(ptrs, &v) // 正确
}
// Go 1.22+ ✅ 自动修复,每次迭代 v 是新变量
struct{} 空结构体
一句话: struct{} 占用 0 字节内存,是有且仅有一个值的类型。用于不需要存储值、只传递信号的场景。
// 用途 1:信号 Channel(不传数据,只通知)
done := make(chan struct{})
go func() { done <- struct{}{} }()
<-done
// 用途 2:Set 实现
set := make(map[string]struct{})
set["a"] = struct{}{}
_, exists := set["a"]
// 用途 3:占位符(方法集)
type Noop struct{}
func (Noop) Do() {}
init 函数
一句话: init() 在包初始化时自动执行,先于 main。同一包内按文件名字母序执行,同一文件内按声明顺序。不同包按 import 依赖顺序。
// 执行顺序: a 包 init → b 包 init → main 包 init → main()
// 每个源文件可以有多个 init
// init 不能被调用、不能有参数/返回值
接口与 nil
一句话: 接口值由 (动态类型, 动态值) 组成。只有两者都是 nil 时接口才等于 nil。
var p *int = nil // p 是 nil 指针
var i interface{} = p // i ≠ nil!因为动态类型是 *int(非 nil)
fmt.Println(i == nil) // false
// ✅ 真 nil 接口
var j interface{} // 动态类型=nil,动态值=nil
fmt.Println(j == nil) // true
字符串拼接性能
五种拼接方式的性能对比(拼接 10000 次短字符串):
Benchmark 结果
// go test -bench=. -benchmem
// BenchmarkSprintf-8 10000 150000 ns/op 16384 B/op 200 allocs/op ← 最慢
// BenchmarkPlus-8 10000 45000 ns/op 16384 B/op 200 allocs/op ← 慢
// BenchmarkBuilder-8 500000 3500 ns/op 4096 B/op 0 allocs/op ← 最快
// BenchmarkBuffer-8 300000 4000 ns/op 4096 B/op 0 allocs/op ← 快
// BenchmarkJoin-8 500000 3200 ns/op 4096 B/op 1 allocs/op ← 最快(已知切片时)
各方式分析
// 1. 直接 +:每次 + 都分配新内存(O(n²) 复制)
s := "a" + "b" + "c" // 最少 2 次内存分配
for i := 0; i < 10000; i++ {
s += "x" // ❌ 每次循环都重新分配,性能灾难
}
// 2. fmt.Sprintf:反射解析格式串,额外开销大
s := fmt.Sprintf("%s-%d-%v", str, num, obj) // ❌ 热路径避免
// 3. strings.Builder(Go 1.10+):最佳选择
var sb strings.Builder
sb.Grow(1024) // 预分配减少扩容
for i := 0; i < 10000; i++ {
sb.WriteString("x") // 直接追加到内部 []byte
}
result := sb.String() // unsafe 转换,零分配
// 4. bytes.Buffer:和 Builder 类似,但更重(额外字段)
var buf bytes.Buffer
buf.WriteString("x")
// 5. strings.Join:已知所有片段时最优
parts := []string{"a", "b", "c"}
s := strings.Join(parts, ",") // 一次计算总长度 → 一次分配
strings.Builder 底层原理
// Builder 的核心:[]byte 直接转 string(零分配)
type Builder struct {
addr *Builder // 逃逸检测(防止栈上 Builder 被复制)
buf []byte // 内部缓冲区
}
func (b *Builder) String() string {
// unsafe: 把 []byte 直接转成 string,不复制内存
return *(*string)(unsafe.Pointer(&b.buf))
}
// Grow 的作用:一次分配到位
func (b *Builder) Grow(n int) {
b.copyCheck()
if n <= cap(b.buf)-len(b.buf) {
return // 容量够,不用扩
}
buf := make([]byte, len(b.buf), 2*cap(b.buf)+n)
copy(buf, b.buf)
b.buf = buf
}
选型指南
逃逸分析判定规则
逃逸分析判断变量应该分配在栈还是堆。在栈上分配免 GC,函数退出自动回收;堆上分配需要 GC。
核心规则
// 规则 1:返回局部变量的指针 → 逃逸到堆
func f1() *int {
x := 42
return &x // x escapes to heap(函数退出后 x 必须活着)
}
// 规则 2:interface 参数 → 通常逃逸
func f2(v interface{}) {
fmt.Println(v) // v escapes to heap
}
func caller() {
x := 100
f2(x) // x escapes to heap(编译器保守,认为 f2 可能持有引用)
}
// 规则 3:闭包捕获变量 → 变量逃逸
func f3() func() int {
x := 0
return func() int {
x++ // x escapes to heap(闭包持有 x 的引用)
return x
}
}
// 规则 4:大对象 → 可能逃逸到堆(超过栈空间限制)
func f4() {
var arr [1 << 20]byte // 1MB → 逃逸(goroutine 栈默认 2KB~1GB,但编译期阈值较小)
_ = arr
}
// 规则 5:slice 扩容 → 新底层数组在堆上
func f5() {
s := make([]int, 0, 10)
s = append(s, 1, 2, 3) // 扩容后的底层数组在堆上
}
逃逸判定原则
如果一个值在函数返回后还被引用,它必须逃逸到堆
如果编译器无法证明值不逃逸,保守地让值逃逸
栈上分配必须是编译期确定大小的
逃逸分析验证
# 看哪些变量逃逸了
go build -gcflags="-m" main.go 2>&1 | grep "escapes to heap"
# 更详细的逃逸信息
go build -gcflags="-m -m" main.go
常见优化
// ❌ 返回切片导致底层数组逃逸
func getData() []byte {
return []byte("hello") // "hello" 是只读数据,但切片头指向的数组会逃逸
}
// ✅ 返回数组(值类型,不逃逸)
func getData() [5]byte {
return [5]byte{'h', 'e', 'l', 'l', 'o'} // 在栈上分配
}
// ❌ fmt.Println 导致参数逃逸(因为接收 interface{})
fmt.Println(x) // x escapes
// ✅ 用具体类型的 print 函数
func printInt(n int) { _ = n } // n 在栈上
select{} vs for{}
两个极易混淆的空循环,行为截然不同。
// select{}:阻塞当前 goroutine,永不返回
func main() {
fmt.Println("hello")
select{} // 阻塞!CPU 占用 0%(G 被 gopark 永久挂起)
fmt.Println("never printed")
}
// for{}:死循环,CPU 100%
func main() {
fmt.Println("hello")
for {} // CPU 100%!无限循环不挂起 G
fmt.Println("never printed")
}
底层原理
// select{} 编译为 runtime.block()
// → 调用 gopark,G 进入 _Gwaiting 状态,调度器不调度它
// → CPU 零占用
// for{} 编译为无条件跳转(JMP 指令)
// → G 一直在 _Grunning 状态,永远不被调度器切换
// → CPU 100%(单核)或占用一个核的 100%(多核)
为什么需要 select{}
// 场景:main goroutine 不需要做任何事,其他 goroutine 在工作
func main() {
go server.Listen()
go metrics.Expose()
select{} // main 不退出,让子 goroutine 继续跑
// 比 for{} 好,不浪费 CPU
}
// 或者用 channel 等待信号
func main() {
go server.Listen()
ch := make(chan os.Signal, 1)
signal.Notify(ch, syscall.SIGINT, syscall.SIGTERM)
<-ch // 等待退出信号(比 select{} 更优雅,可响应退出)
}
iota 高级用法
// 基础:自增常量
const (
A = iota // 0
B // 1(隐式复制上一行的表达式)
C // 2
)
// 高级 1:位掩码(配合 <<)
const (
FlagRead = 1 << iota // 1 << 0 = 1
FlagWrite // 1 << 1 = 2
FlagExec // 1 << 2 = 4
FlagAll = FlagRead | FlagWrite | FlagExec // 7 — iota 不会重置
)
// 高级 2:跳过值(_)
const (
_ = iota // 0(丢弃)
KB = 1 << (10 * iota) // 1 << 10 = 1024
MB // 1 << 20 = 1048576
GB // 1 << 30
)
// 高级 3:多常量表达式(iota 每行递增)
const (
a, b = iota, iota + 1 // a=0, b=1
c, d // c=1, d=2
e, f = iota * 2, iota * 2 + 1 // e=4, f=5
)
// 高级 4:自定义类型
type Weekday int
const (
Sunday Weekday = iota // 0
Monday // 1
Tuesday // 2
// ...
)
func (d Weekday) String() string {
return [...]string{"Sunday", "Monday", "Tuesday", "Wednesday",
"Thursday", "Friday", "Saturday"}[d]
}
// 高级 5:iota 从 1 开始(跳过 0 值)
const (
StatusActive = iota + 1 // 1
StatusInactive // 2
StatusDeleted // 3
)
Go 编译速度为什么快
五大原因
显式依赖声明:import 语句精确声明依赖,编译器不需要像 C/C++ 那样递归扫描头文件。依赖图从 import 直接得出,不存在循环依赖。
无头文件:Go 编译器一次解析
.go文件即可得出所有类型信息,不需要像 C++ 那样处理头文件展开(有时#include展开几万行代码)。高效的对象文件格式:编译中间产物(
_pkg_.a)格式紧凑,比 C/C++ 的.o文件小得多。Go 1.20+ 更是优化了对象文件的序列化格式。并行编译:每个包可以独立编译,Go 编译器自动利用多核并行编译不同包。依赖 DAG 决定了顶层并行度。
无模板展开(Go 1.18 之前):没有 C++ 模板那样的编译时展开机制。Go 1.18 泛型引入 GC Shape Stenciling(GC shape 模板化),不是 C++ 的完全单态化,二进制膨胀有限。
编译速度数据(近似)
零值的设计哲学
Go 所有类型都有零值——声明变量但不初始化时自动赋予的默认值。
var i int // 0
var s string // ""(空字符串,不是 nil,Go 的 string 不可为 nil)
var b bool // false
var p *int // nil
var sl []int // nil(但 len(sl)==0,可以 append)
var m map[K]V // nil(但读取返回零值,写入会 panic!)
var ch chan int // nil(读写都会永久阻塞)
var f func() // nil(调用会 panic)
零值的精妙之处
// 1. nil slice 可以直接 append(不需要 make)
var users []string // nil slice
users = append(users, "alice") // ✅ 正常!nil slice 的 len=0
// 2. nil map 读取不 panic,但写入会 panic
var cache map[string]int // nil map
v := cache["key"] // ✅ 返回 0(零值)
// cache["key"] = 1 // ❌ panic: assignment to entry in nil map
// 3. sync.Mutex 零值可用
type Config struct {
mu sync.Mutex // 零值就是解锁状态的 Mutex,直接用
data map[string]string
}
// 不需要 NewConfig() 初始化 mu
// 4. strings.Builder 零值可用
var sb strings.Builder // 不需要 make 或 New
sb.WriteString("hello")
零值 vs 构造函数
数组和切片的底层差异
虽然用起来像,但底层完全不同。
关键差异
// 1. 数组:类型包含长度,大小固定,值类型
var a1 [3]int // [3]int 是类型
var a2 [5]int // [5]int 是不同类型!
a1 = a2 // ❌ 编译错误:类型不匹配
// 2. 数组:赋值时复制整个数组(值语义)
arr1 := [3]int{1, 2, 3}
arr2 := arr1 // 复制了 24 字节(3*8),arr1 和 arr2 不共享内存
arr2[0] = 999
fmt.Println(arr1[0]) // 1(不受影响)
// 3. 切片:赋值只复制 slice header(24 字节),共享底层数组
s1 := []int{1, 2, 3}
s2 := s1 // 复制了 slice header(ptr, len, cap),底层数组共享
s2[0] = 999
fmt.Println(s1[0]) // 999(受影响!)
// 4. 数组:可以作为 map key(值类型,可比较)
m := map[[3]int]string{}
m[[3]int{1, 2, 3}] = "found" // ✅ 数组可比较
// 5. 切片:不能作为 map key(不可比较)
// m2 := map[[]int]string{} // ❌ 编译错误
底层结构对比
数组 [3]int:
┌────┬────┬────┐
│ 1 │ 2 │ 3 │ ← 24 字节直接在栈或堆上
└────┴────┴────┘
切片 []int{1, 2, 3}:
┌───────┬─────┬─────┐
│ ptr ──┼──▶ │ 底层数组(堆上)
│ len=3 │ │ 1 2 3 ...
│ cap=3 │ │
└───────┴─────┴─────┘
24 字节 header
何时用数组
// 1. 固定大小且不需要扩容
type IPv4 [4]byte // IP 地址永远 4 字节
// 2. 作为 map key
type Point [2]int // 二维坐标
// 3. 避免堆分配(小数组可以全部在栈上)
var arr [16]byte // 16 字节,编译器可以放栈上
// 4. 内存布局紧凑(结构体中内嵌数组)
type Header struct {
Magic [4]byte // 精确 4 字节
Size uint32
}
面试题
Q1:for{} 和 select{} 有什么区别?什么时候用哪个?
30 秒回答: for{} 是死循环,CPU 100%,对应汇编的无限 JMP 循环,G 不挂起。select{} 编译为 runtime.block(),调用 gopark 挂起 G,CPU 零占用。主程序只起子 goroutine 时,用 select{} 保持不退出,但更推荐 <-make(chan struct{}) 或 signal.Notify 等待退出信号。
追问: "select{} 里有 case 但一个也不就绪呢?"
答:那就是空 select(
select {}),等价于永久阻塞。所有带 case 但不带 default 且无一就绪的 select 也会阻塞,但唤醒后可继续。空 select 编译时就被识别为block()。
Q2:strings.Builder 的 String() 方法为什么是零分配的?
30 秒回答: String() 用 unsafe.Pointer 直接把 Builder 内部的 []byte 转成 string。Go 的 string 和 []byte 底层内存布局兼容(都是 pointer + length),所以可以直接转换而不复制。这是 Go 标准库里少有的 unsafe 优化之一。
追问: "为什么不能直接在旧的 Builder 上调用 WriteString?"
答:
String()返回的 string 和 Builder 共享底层[]byte。如果在调用String()之后又WriteString,可能会触发Grow扩容——分配新[]byte,旧的 string 指向的底层数组还在(string 不变)。如果WriteString不扩容就改了共享数组,那 string 的内容也变了(非预期)。所以最佳实践是拿到 string 后不修改 Builder。
Q3:nil map 读不 panic 但写 panic,为什么这样设计?
30 秒回答: 读 nil map 返回零值,这是设计选择——方便做"有缓存就读,没缓存查 DB"的懒加载模式。写 nil map 会 panic,因为没有可放置数据的内存。如果写 nil map 静默成功,对 nil 的写入就丢失了,更难排查。panic 是一种明确的防御。
追问: "nil slice append 不 panic 但 nil map 写 panic,为什么?"
答:因为 append 内部遇到 nil slice 会分配新底层数组(
mallocgc),有明确的"创造内存"动作。而 map 写操作期望底层hmap存在——nil 时没有可操作的哈希桶。Go 选择"需要 make"的语义:slice 可以不 make(append 自动分配),map 写入必须 make。