make 和 new 的区别

一句话: new(T) 分配内存并返回 *T(零值指针),只用于任意类型。make 只用于 slice/map/channel,返回初始化后的 T(不是指针),并完成内部初始化。

操作

new

make

适用范围

任意类型

仅 slice / map / channel

返回值

*T(指针)

T(值本身)

是否初始化

只清零内存

初始化内部结构(如 hmap、hchan)

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
}

选型指南

场景

推荐

循环内大量拼接

strings.Builder + Grow()

少量拼接(<5 个)

直接 +(编译器优化后也不差)

格式化输出

fmt.Sprintf(正确性优先)

已知所有片段

strings.Join

字符串拼接作为 map key

strings.Builder


逃逸分析判定规则

逃逸分析判断变量应该分配在栈还是堆。在栈上分配免 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)  // 扩容后的底层数组在堆上
}

逃逸判定原则

  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 编译速度为什么快

五大原因

  1. 显式依赖声明:import 语句精确声明依赖,编译器不需要像 C/C++ 那样递归扫描头文件。依赖图从 import 直接得出,不存在循环依赖。

  2. 无头文件:Go 编译器一次解析 .go 文件即可得出所有类型信息,不需要像 C++ 那样处理头文件展开(有时 #include 展开几万行代码)。

  3. 高效的对象文件格式:编译中间产物(_pkg_.a)格式紧凑,比 C/C++ 的 .o 文件小得多。Go 1.20+ 更是优化了对象文件的序列化格式。

  4. 并行编译:每个包可以独立编译,Go 编译器自动利用多核并行编译不同包。依赖 DAG 决定了顶层并行度。

  5. 无模板展开(Go 1.18 之前):没有 C++ 模板那样的编译时展开机制。Go 1.18 泛型引入 GC Shape Stenciling(GC shape 模板化),不是 C++ 的完全单态化,二进制膨胀有限。

编译速度数据(近似)

语言

10 万行项目编译时间

Go

~3 秒

C++ (含模板)

~30-60 秒

Rust

~20-40 秒

Java

~5-10 秒


零值的设计哲学

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 构造函数

零值可用

需要构造函数

sync.Mutex, sync.WaitGroup, sync.Once

sync.Pool(需要设置 New)

strings.Builder, bytes.Buffer

sync.Cond(需要传入 Locker)

map(只读)

map(写入)

chan(nil 阻塞)

chan(发送/接收)

slice(append)

slice(按索引赋值)


数组和切片的底层差异

虽然用起来像,但底层完全不同。

关键差异

// 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。