编程进阶网 编程进阶网
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接

杨充

专注编程 · 终身学习者
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接
  • README
  • C语言入门精通

  • Cpp入门到精通

  • Java入门精通

  • Go入门到精通

    • 入门教程

      • README
      • Go简史
      • 基础语法
      • 数据类型
      • 运算符
      • 复合类型
      • 流程语句
      • 函数
      • 指针与逃逸
        • 目录介绍
        • 8.1 本章学习目标
        • 8.2 为什么 Go 还有指针
        • 8.3 取址 & 与解引用 *
          • 8.3.1 基本语法
          • 8.3.2 哪些表达式可取址
          • 8.3.3 自动解引用:p.Field 等价 (*p).Field
        • 8.4 指针类型 *T
          • 8.4.1 类型严格匹配
          • 8.4.2 指针的零值是 nil
        • 8.5 new(T) 与 &T{} 的差异
        • 8.6 nil 指针与 panic
        • 8.7 为什么 Go 不允许指针算术
        • 8.8 函数传指针 vs 传值
          • 8.8.1 Go 全部是值传递
          • 8.8.2 何时该传指针
          • 8.8.3 内置类型的"看似引用"——slice / map / chan
        • 8.9 逃逸分析速览
          • 8.9.1 什么是逃逸
          • 8.9.2 用 -gcflags="-m" 看逃逸
          • 8.9.3 常见的逃逸触发场景
          • 8.9.4 反常识:返回局部变量地址在 Go 是合法的
        • 8.10 unsafe.Pointer 速览(卷三详讲)
        • 8.11 综合示例:指针传递 vs 值传递的性能对比
        • 8.12 本章底层原理(简介)
        • 8.13 Go 新手陷阱 Top 5
          • ❌ 陷阱 1:nil 指针解引用
          • ❌ 陷阱 2:以为大对象传值就慢,于是滥用指针接收者
          • ❌ 陷阱 3:循环里 &v 取循环变量地址(Go 1.21 及之前)
          • ❌ 陷阱 4:*p++ 期望"指针自增"
          • ❌ 陷阱 5:unsafe.Pointer 强转后假定字段顺序
        • 8.14 思考题
        • 8.15 推荐阅读
          • 卷内
          • 跨卷
          • 外部资料
      • 结构体与方法
      • 接口与多态
      • 错误处理
      • 并发goroutine
      • 通道channel
      • 同步sync包
      • IO和文件
      • 标准库与泛型
      • 工程化与模块
      • 特性图谱
    • 综合案例

    • 专栏博客

    • 开发技巧

  • JavaScript入门

  • CodeX
  • Go入门到精通
  • 入门教程
杨充
2026-05-21
目录

指针与逃逸

# 第 8 章 指针与逃逸

Go 的指针是"安全的指针":可取址、可解引用,禁止算术,由 GC 管理。Go 的逃逸分析帮你自动决定变量在栈还是堆。 关键词:& / *、不能 p++、new(T) vs &T{}、栈逃逸到堆、-gcflags="-m"、返回局部变量地址合法


# 目录介绍

  • 8.1 本章学习目标
  • 8.2 为什么 Go 还有指针
  • 8.3 取址 & 与解引用 *
    • 8.3.1 基本语法
    • 8.3.2 哪些表达式可取址
    • 8.3.3 自动解引用:p.Field 等价 (*p).Field
  • 8.4 指针类型 *T
    • 8.4.1 类型严格匹配
    • 8.4.2 指针的零值是 nil
  • 8.5 new(T) 与 &T{} 的差异
  • 8.6 nil 指针与 panic
  • 8.7 为什么 Go 不允许指针算术
  • 8.8 函数传指针 vs 传值
    • 8.8.1 Go 全部是值传递
    • 8.8.2 何时该传指针
    • 8.8.3 内置类型的"看似引用"——slice / map / chan
  • 8.9 逃逸分析速览
    • 8.9.1 什么是逃逸
    • 8.9.2 用 -gcflags="-m" 看逃逸
    • 8.9.3 常见的逃逸触发场景
    • 8.9.4 反常识:返回局部变量地址在 Go 是合法的
  • 8.10 unsafe.Pointer 速览(卷三详讲)
  • 8.11 综合示例:指针传递 vs 值传递的性能对比
  • 8.12 本章底层原理(简介)
  • 8.13 Go 新手陷阱 Top 5
  • 8.14 思考题
  • 8.15 推荐阅读

# 8.1 本章学习目标

  • ✅ 说清 Go 指针与 C 指针的 4 个关键差异(无算术、有 GC、有零值约束、不可强转)
  • ✅ 能用 -gcflags="-m" 在终端看到哪些变量逃逸到堆,并解释每条逃逸原因
  • ✅ 知道 new(T) 与 &T{} 在语义、性能、可读性三方面是否等价
  • ✅ 解释为什么"返回局部变量地址"在 C 是 UB,在 Go 是合法且推崇的写法
  • ✅ 给出选择"指针接收者 vs 值接收者"的工程标准(卷一第 9 章会再次用到)
  • ✅ 知道 unsafe.Pointer 是边界工具,能列举它何时可用、何时绝对不能用

# 8.2 为什么 Go 还有指针

很多从 Java/Python 迁过来的同学第一反应是:"Go 是现代语言,怎么还有指针?"——而很多从 C/C++ 迁过来的同学第一反应是:"Go 的指针怎么阉割得这么彻底?"两边都对一半。

Go 保留指针是为了表达**"我想共享这块内存"**的语义,但去掉了 C 指针中所有"不安全"的能力:

能力 C / C++ Go
取址 &x / 解引用 *p ✅ ✅
指针算术 p++ / p + 4 ✅ ❌ 编译错
任意类型强转 (int*)p ✅ ❌(要绕道 unsafe.Pointer)
手动 free ✅(必须) ❌(GC 管理)
悬垂指针(指向已释放) ⚠️ 容易 ❌(GC 阻止)
返回局部变量地址 ❌ UB ✅(编译器自动逃逸)

可以理解为:Go 把"危险动作"砍掉,但保留"我要共享这个对象"的表达力。所以 Go 里指针的语义只剩三类:

  1. 避免大结构体复制——传指针进函数 / 作为接收者
  2. 修改调用方变量——出参用指针
  3. 可选值表达——*int 区分"没设置"(nil)和"设置为 0"(指向 0)

# 8.3 取址 & 与解引用 *

# 8.3.1 基本语法

x := 42
p := &x         // p 的类型是 *int,值是 x 的地址
fmt.Println(*p) // 42

*p = 100        // 通过指针写
fmt.Println(x)  // 100
1
2
3
4
5
6
操作 含义 结果类型
&x 取 x 的地址 *T
*p 解引用 p,读出指向的值 T
*p = v 把 v 写到 p 指向的位置 —

# 8.3.2 哪些表达式可取址

不是所有"看起来像值"的东西都能取址。可寻址(addressable)的有:

  • 变量:&x
  • 指针解引用结果:&(*p)(其实就是 p)
  • slice 元素:&s[i]
  • 结构体字段:&user.Name、&p.Name(p 是结构体或结构体指针都行)
  • 数组元素(数组本身是变量时):&arr[i]

不可寻址的有:

&42                     // ❌ 字面量
&(a + b)                // ❌ 表达式结果
&f()                    // ❌ 函数返回值
&m["key"]               // ❌ map 元素(这是 Go 故意的)
&"hello"[0]             // ❌ 字符串元素
const C = 1; &C         // ❌ 常量
1
2
3
4
5
6

为什么 map 元素不可取址? 因为 map 在扩容/搬迁时元素地址会变,给你一个会过期的地址比不给更危险。要修改 map 中的结构体字段,必须取出来改完再放回:

m := map[string]User{"alice": {Age: 20}}
// m["alice"].Age = 21        // ❌ 编译错:cannot assign to struct field
u := m["alice"]               // ✅ 取出
u.Age = 21
m["alice"] = u                // ✅ 放回
1
2
3
4
5

# 8.3.3 自动解引用:p.Field 等价 (*p).Field

C 里访问结构体指针字段必须写 p->field,Go 简化了:点运算符自动解引用。

type User struct{ Name string }

u := User{Name: "alice"}
p := &u

fmt.Println(p.Name)   // ✅ 等价于 (*p).Name
p.Name = "bob"        // ✅ 等价于 (*p).Name = "bob"
fmt.Println(u.Name)   // bob
1
2
3
4
5
6
7
8

这条糖让指针接收者方法调用与值接收者一样自然——这是 Go 没引入 -> 的关键。同样地:

arr := &[3]int{1, 2, 3}
fmt.Println(arr[1]) // ✅ 自动解引用,输出 2
1
2

但切片不会——因为切片本身就是引用语义,写 s := &mySlice; s[0] 会编译失败,必须 (*s)[0]。


# 8.4 指针类型 *T

# 8.4.1 类型严格匹配

*int 与 *int32 是完全不同的两个类型,不能相互赋值,也不能比较:

var a int = 1
var b int32 = 1

p1 := &a   // *int
p2 := &b   // *int32

p1 = p2    // ❌ 编译错:cannot use p2 (*int32) as *int
p1 == p2   // ❌ 编译错:mismatched types
1
2
3
4
5
6
7
8

类型别名(type vs =)也不通用——type MyInt int,*MyInt ≠ *int。

要"突破类型墙"只有 unsafe.Pointer 这一条路(见 §8.10),不属于日常代码。

# 8.4.2 指针的零值是 nil

var p *int
fmt.Println(p == nil) // true
fmt.Println(p)        // <nil>

// *p = 1              // ❌ 运行时 panic:invalid memory address
1
2
3
4
5

未初始化的指针 = nil,解引用 nil 指针会触发 runtime panic(类型为 runtime.Error,消息 invalid memory address or nil pointer dereference)。

判 nil 的两种正确姿势:

if p != nil {
    fmt.Println(*p)
}

// 或者保证一定有值
p := &x
fmt.Println(*p) // 安全
1
2
3
4
5
6
7

# 8.5 new(T) 与 &T{} 的差异

Go 提供两种创建指针的方式:

// 写法 A:new
p1 := new(User)         // 类型 *User,指向零值 User

// 写法 B:复合字面量取地址
p2 := &User{}           // 类型 *User,指向零值 User
p3 := &User{Name: "x"}  // 类型 *User,指向带初值的 User
1
2
3
4
5
6

关键事实——编译产物完全相同:

维度 new(T) &T{} 备注
返回类型 *T *T 相同
是否在堆 由逃逸分析决定 同左 相同
性能 一致 一致 编译后等价指令
能否带初值 ❌ 仅零值 ✅ 任意字段 唯一差异
是否能给基本类型 ✅ new(int) ❌ &int{} 不合法
可读性 "构造一个零值 X" "构造一个 X" 偏好题

惯用法(Go 社区共识):

// ✅ 结构体几乎都用 &T{...}
u := &User{Name: "alice", Age: 20}

// ✅ 基本类型 / 数组才用 new
counter := new(int)        // *int 指向 0
arr := new([1024]byte)     // *[1024]byte 全零

// ❌ 别这么写——啰嗦
u := new(User)
u.Name = "alice"
u.Age = 20
1
2
3
4
5
6
7
8
9
10
11

结论:new 几乎只剩三个使用场景——基本类型、数组、new(sync.Mutex) 这类零值即可用的内置类型。结构体一律 &T{...}。


# 8.6 nil 指针与 panic

*p 在 p == nil 时 panic,这是日常 bug 的高发区。Go 的处理哲学是让它早 panic 而不是悄悄出错——这与 C 的"段错误且很难定位"形成鲜明对比。

type Cache struct{ store map[string]string }

func (c *Cache) Get(k string) string {
    return c.store[k] // ❌ 如果 c == nil 这里 panic
}

var c *Cache
c.Get("x") // panic: runtime error: invalid memory address
1
2
3
4
5
6
7
8

防御写法:

func (c *Cache) Get(k string) (string, bool) {
    if c == nil || c.store == nil {
        return "", false
    }
    v, ok := c.store[k]
    return v, ok
}
1
2
3
4
5
6
7

注意 Go 的一个反常识特性——nil 接收者方法调用是合法的:只要方法体不解引用 nil,就不会 panic。这给了我们设计"nil-safe API"的空间,标准库 bytes.Buffer 就是案例:

var b *bytes.Buffer  // nil
b.String()           // 合法,返回 ""
// b.Write([]byte{1}) // ❌ Write 内部会解引用 nil
1
2
3

# 8.7 为什么 Go 不允许指针算术

C/C++ 里 p++ / p + 4 是日常写法,Go 直接砍掉。原因有四:

  1. GC 兼容——指针算术后产生的"中间地址"对 GC 没意义,且会让 GC 误判存活对象,破坏内存安全。
  2. 数组越界变成可静态检查——没有 *(p + 100) 这种动态偏移,越界访问只能通过 s[i],可以由运行时 bounds check 兜住。
  3. 简化逃逸分析——编译器无法跟踪算术后的指针指向何处,禁掉它后逃逸分析才能精确。
  4. 字段重排自由——编译器允许结构体字段重排(虽然 Go 当前不重排,但语言规范允许),指针算术会假死布局。

替代方案——slice:

// C 风格遍历
// for (int *p = arr; p < arr + n; p++) { use(*p); }

// Go 风格
for i := range arr { use(arr[i]) }
for _, v := range arr { use(v) }
1
2
3
4
5
6

需要"裸内存操作"(解析二进制协议、与 cgo 交互)的,unsafe.Pointer + uintptr 算术是逃生口,但只在边界用,详见 §8.10 与卷三第 2 章。


# 8.8 函数传指针 vs 传值

# 8.8.1 Go 全部是值传递

这是最容易被新人误解的一条——Go 没有"引用传递",传指针本质也是把指针这个值复制一份进函数。

func setOne(x int)   { x = 1 }            // 改的是局部副本
func setOnePtr(p *int) { *p = 1 }         // 通过指针副本写到原地址

a := 0
setOne(a)
fmt.Println(a) // 0

b := 0
setOnePtr(&b)
fmt.Println(b) // 1
1
2
3
4
5
6
7
8
9
10

setOnePtr 内部 p 仍是指针的副本,但因为它和外部 &b 指向同一块内存,写过去就改了原值。

# 8.8.2 何时该传指针

工程经验法则:

情况 选择 原因
需要修改调用方变量 指针 唯一选项
结构体大(>100 B 或字段 ≥ 5) 指针 避免复制成本
结构体含 sync.Mutex、sync.WaitGroup 指针 复制 Mutex 是 bug,go vet 报警
结构体小且不可变(如 time.Time) 值 复制比逃逸便宜,且语义清晰
实现的方法集要包含指针接收者 指针 详见第 9 章
不需要共享、不需要修改 值 默认选择,更安全

➡ 第 9 章 方法与接收者 会从"接收者选型"角度再展开。

反例——盲目用指针接收者:

type Point struct{ X, Y float64 } // 16 字节

// ❌ 没必要——传值更快也更安全
func (p *Point) Distance(q *Point) float64 { ... }

// ✅ 小结构体值接收者
func (p Point) Distance(q Point) float64 { ... }
1
2
3
4
5
6
7

# 8.8.3 内置类型的"看似引用"——slice / map / chan

func appendOne(s []int)            { s = append(s, 1) }
func setKey(m map[string]int)      { m["x"] = 1 }
func sendOne(ch chan int)          { ch <- 1 }

s := []int{}
appendOne(s)
fmt.Println(s) // [] —— 没改!

m := map[string]int{}
setKey(m)
fmt.Println(m) // map[x:1] —— 改了

ch := make(chan int, 1)
sendOne(ch)
fmt.Println(<-ch) // 1 —— 收到了
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

为什么?

  • map / chan:底层是 *hmap / *hchan,传值时复制的是这个指针——还指向同一块底层结构,所以"看起来像引用"。
  • slice:本质是 {ptr, len, cap} 三字段结构。传值时整个结构体被复制,ptr 共享底层数组,所以改元素会同步(s[0] = 1 会影响外部),但append 可能扩容,触发新分配后 ptr 改变,但函数内的 s 是副本,外部看不到。

结论:

类型 改元素 改"自身(如 append)"
slice 传值 影响外部 不影响外部
map 传值 影响外部 影响外部(map 没有 append 概念)
chan 传值 影响外部 影响外部

要让 append 影响外部,必须传 *[]int 或者 return s 让外部接收:

// ✅ 推荐:返回新 slice
func appendOne(s []int) []int { return append(s, 1) }
s = appendOne(s)

// ⚠️ 不推荐但可行:传 *[]int
func appendOne(s *[]int) { *s = append(*s, 1) }
1
2
3
4
5
6

# 8.9 逃逸分析速览

# 8.9.1 什么是逃逸

C/C++ 里栈/堆由程序员手动选择(Foo f vs Foo *f = malloc(...))。Go 让编译器做这个决策:

  • 栈分配:变量生命周期不超出当前函数 → 留栈,函数返回自动清理,零 GC 成本
  • 堆分配:变量被外部引用、生命周期超出本函数 → 逃逸到堆,由 GC 回收

判定过程叫逃逸分析(escape analysis),编译期完成。这意味着:

  • 你写代码时不需要关心变量在栈还是堆——Go 替你想
  • 但你写性能敏感代码时应该关心——避免不必要的堆分配能减少 GC 压力

# 8.9.2 用 -gcflags="-m" 看逃逸

# 简版:只看是否逃逸
go build -gcflags="-m" main.go

# 详细版:看分析每一步
go build -gcflags="-m -m" main.go
1
2
3
4
5

例子:

// main.go
package main

func newUser(name string) *User {
    u := User{Name: name}
    return &u
}

type User struct{ Name string }

func main() {
    u := newUser("alice")
    _ = u
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ go build -gcflags="-m" main.go
./main.go:3:6: can inline newUser
./main.go:4:7: moved to heap: u    ← 关键:u 逃逸了
./main.go:11:14: inlining call to newUser
1
2
3
4

moved to heap: u 表示:本来 u 是栈变量,但因为它的地址被 return 出去了,编译器自动把它分配到堆。

# 8.9.3 常见的逃逸触发场景

场景 是否逃逸 原因
return &localVar ✅ 逃逸 调用方持有引用
把 &localVar 存进全局 / 闭包 / map / slice ✅ 逃逸 生命周期延长
局部变量作为接口值 var i interface{} = x ⚠️ 多数逃逸 接口变量需保留类型信息
fmt.Println(x)(接口入参) ⚠️ 逃逸 同上
闭包捕获 ✅ 逃逸 闭包对象需要它
slice 长度编译期不可知(动态 make([]T, n)) ✅ 逃逸 编译期不知大小,无法栈分配
变量太大(默认 > 64KB) ✅ 逃逸 栈空间限制
仅在函数内使用、不取址 ❌ 留栈 默认

💡 重要观察:fmt.Println(x) 让 x 逃逸——这是为什么基准测试中要避免在热点循环里 Println,否则数据全跑到堆。

# 8.9.4 反常识:返回局部变量地址在 Go 是合法的

// C:经典 UB(未定义行为)
int* badGetInt() {
    int x = 42;
    return &x; // 函数返回后栈帧销毁,指针悬垂
}
1
2
3
4
5
// Go:完全合法且推崇
func goodGetInt() *int {
    x := 42
    return &x // 编译器自动把 x 逃逸到堆
}
1
2
3
4
5

Go 的逃逸分析看到 "x 的地址会被外部使用",自动把它分配到堆,函数返回后由 GC 管理。这是 Go 没有"构造函数"语法却能轻松工厂方法的根本原因:

func NewUser(name string) *User {
    return &User{Name: name} // 完全合法
}
1
2
3

代价是:这次分配走的是堆,比纯栈慢一些。但 Go 的内存分配器(mcache/mcentral/mheap 三级架构)和 GC 都为此优化得很激进,绝大多数业务代码无感知。


# 8.10 unsafe.Pointer 速览(卷三详讲)

unsafe.Pointer 是 Go 提供的类型擦除指针,相当于 C 的 void *。它打破指针类型墙,是与"裸内存"打交道的唯一合法工具。

import "unsafe"

var x int64 = 0x12345678
p := unsafe.Pointer(&x)              // *int64 → unsafe.Pointer
pi := (*int32)(p)                    // unsafe.Pointer → *int32
fmt.Printf("%x\n", *pi)              // 取低 32 位(小端)
1
2
3
4
5
6

它的合法转换(unsafe.Pointer 文档列出了 6 条规则):

  1. *T1 → unsafe.Pointer → *T2
  2. unsafe.Pointer → uintptr(单步,立即用,不能存)
  3. uintptr + 偏移 → unsafe.Pointer(用 unsafe.Add)
  4. unsafe.Pointer 在 syscall.Syscall 中传递
  5. reflect 中获取
  6. unsafe.Slice / unsafe.String(Go 1.20+)

核心红线:

uintptr 不是指针,GC 不跟踪它。如果你把 unsafe.Pointer 转成 uintptr 存到变量里,GC 可能在中间把对象搬走或回收,等你再用时就是悬垂——这是 Go 里少数的"段错误"来源。

// ❌ 经典 BUG
addr := uintptr(unsafe.Pointer(&x))
runtime.GC()           // 假设这里搬迁了
p := unsafe.Pointer(addr) // 悬垂
*(*int)(p) = 1            // 可能写到错误位置

// ✅ 正确:单步完成
p := unsafe.Pointer(uintptr(unsafe.Pointer(&x)) + 8)
*(*int)(p) = 1
1
2
3
4
5
6
7
8
9

何时该用 unsafe.Pointer:

  • 解析二进制协议(与 encoding/binary (opens new window) 配合)
  • 实现高性能字符串/字节切片零拷贝转换(Go 1.20 前的 *(*string)(unsafe.Pointer(&b)))
  • 与 cgo 交互
  • 实现底层数据结构(atomic 包的某些用法)

何时绝对不要:

  • 业务代码
  • 简单替代 interface{} / 泛型
  • "为了一点性能"——Go 1.20+ 已有 unsafe.String、unsafe.Slice、unsafe.SliceData 这些更安全的替代,性能等价

➡ 卷三第 2 章 指针与逃逸分析 会完整讲 6 条转换规则、uintptr GC 风险、reflect.SliceHeader 在 1.20 后被弃用的历史。


# 8.11 综合示例:指针传递 vs 值传递的性能对比

写一个微基准,对比"小结构体值传递"与"大结构体值/指针传递"。

// pointer_bench_test.go
package bench

import "testing"

type Small struct{ A, B int64 } // 16 B

type Big struct {
    A, B, C, D, E, F, G, H int64
    Buf                    [128]byte
} // 192 B

//go:noinline
func sumSmallVal(s Small) int64 { return s.A + s.B }

//go:noinline
func sumSmallPtr(s *Small) int64 { return s.A + s.B }

//go:noinline
func sumBigVal(b Big) int64 { return b.A + b.H }

//go:noinline
func sumBigPtr(b *Big) int64 { return b.A + b.H }

func BenchmarkSmallVal(b *testing.B) {
    s := Small{A: 1, B: 2}
    var x int64
    for i := 0; i < b.N; i++ {
        x += sumSmallVal(s)
    }
    _ = x
}

func BenchmarkSmallPtr(b *testing.B) {
    s := Small{A: 1, B: 2}
    var x int64
    for i := 0; i < b.N; i++ {
        x += sumSmallPtr(&s)
    }
    _ = x
}

func BenchmarkBigVal(b *testing.B) {
    big := Big{A: 1, H: 2}
    var x int64
    for i := 0; i < b.N; i++ {
        x += sumBigVal(big)
    }
    _ = x
}

func BenchmarkBigPtr(b *testing.B) {
    big := Big{A: 1, H: 2}
    var x int64
    for i := 0; i < b.N; i++ {
        x += sumBigPtr(&big)
    }
    _ = x
}
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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59

运行:

go test -bench=. -benchmem
1

典型结果(M1 Mac,Go 1.22):

BenchmarkSmallVal-8     1000000000      0.31 ns/op       0 B/op
BenchmarkSmallPtr-8     1000000000      0.32 ns/op       0 B/op
BenchmarkBigVal-8       100000000      11.20 ns/op       0 B/op
BenchmarkBigPtr-8       1000000000      0.31 ns/op       0 B/op
1
2
3
4

结论:

  • 小结构体(≤ 16 B):值/指针几乎无差异,优先用值(更安全、不涉及逃逸)
  • 大结构体(>128 B):指针快 30 倍以上,必须用指针
  • 阈值大致在 64 B 附近——但具体看缓存行命中、是否被内联,工程上用 >=4 个字段或 >=64 B 作为粗略经验线

需要严格判断时永远用 benchmark 实测,别凭感觉。


# 8.12 本章底层原理(简介)

主题 一句话简介 详解
栈/堆决策 由编译器逃逸分析在 SSA 阶段决定 卷三第 1 章
逃逸分析算法 数据流分析,标记"指针流向函数外"的变量 卷三第 2 章
堆分配器 三级架构:mcache(per-P)→ mcentral(per-size)→ mheap(global) 卷三第 1 章
GC 类型 三色标记 + 写屏障 + 并发清扫,STW 通常 < 1ms 卷三第 3 章
unsafe.Pointer 类型擦除指针,6 条合法转换规则 卷三第 2 章
uintptr 与 GC uintptr 不被 GC 跟踪,转换必须单步 卷三第 2 章

# 8.13 Go 新手陷阱 Top 5

# ❌ 陷阱 1:nil 指针解引用

var p *int
*p = 1 // panic: invalid memory address or nil pointer dereference
1
2

修复:用前判 nil,或保证总是初始化。结构体字段含指针时,构造函数里显式初始化所有指针字段,别依赖零值。

# ❌ 陷阱 2:以为大对象传值就慢,于是滥用指针接收者

很多人"大结构体一律指针接收者",但指针接收者的副作用是会让结构体逃逸到堆。如果对象本应留栈、且生命周期短,传指针反而更慢。

// 对小结构体来说,值接收者 + 值传递,整个流程不涉及堆
func (p Point) Distance(q Point) float64 { ... }
1
2

判断标准见 §8.8.2。

# ❌ 陷阱 3:循环里 &v 取循环变量地址(Go 1.21 及之前)

type User struct{ Name string }
users := []User{{"a"}, {"b"}, {"c"}}

ptrs := []*User{}
for _, u := range users {
    ptrs = append(ptrs, &u) // ❌ 全部指向同一个 u
}
// ptrs[0].Name == ptrs[1].Name == ptrs[2].Name == "c"
1
2
3
4
5
6
7
8

修复:

  • Go 1.22+:go.mod 声明 go 1.22,自动每轮新建 u,问题消失
  • Go 1.21 及之前:手动重新声明
    for _, u := range users {
        u := u // 重新声明
        ptrs = append(ptrs, &u)
    }
    
    1
    2
    3
    4
  • 或者直接用索引
    for i := range users {
        ptrs = append(ptrs, &users[i])
    }
    
    1
    2
    3

# ❌ 陷阱 4:*p++ 期望"指针自增"

p := &x
*p++  // ❌ 实际是把 *p 自增(即 x++),不是 p 移到下一个元素
1
2

Go 没有指针算术,*p++ 永远等价于 (*p)++。要遍历内存请用 slice。

# ❌ 陷阱 5:unsafe.Pointer 强转后假定字段顺序

type V1 struct{ A, B int }
type V2 struct{ B, A int } // 字段顺序不同

a := V1{A: 1, B: 2}
b := *(*V2)(unsafe.Pointer(&a))
fmt.Println(b) // {1 2} —— 直接按内存复制,不是按字段名
1
2
3
4
5
6

unsafe.Pointer 强转完全不看字段名,只按内存布局。任何依赖字段顺序、对齐、padding 的代码都极脆弱——加字段、Go 升级、不同架构都可能炸。


# 8.14 思考题

  1. 给出一段代码,让 go build -gcflags="-m" 输出 escapes to heap 至少 3 条不同原因的报告,并解释每条原因。
  2. new(T) 与 &T{} 编译产物相同,为什么 Go 不去掉 new?查 Go FAQ / 官方博客找出至少一条合理解释。
  3. 解释为什么"返回 &localVar"在 C 是 UB 而在 Go 合法。一旦 Go 编译器决定让这个变量逃逸,函数调用栈与堆上对象分别长什么样?
  4. 写一个最小可复现例子,演示"slice 传值改元素影响外部,但 append 不影响外部"。请用图解释 slice 头在调用前后的状态变化。
  5. fmt.Println(x) 让 x 逃逸到堆——为什么?尝试改写成不逃逸的等价代码,对比 benchmark。
  6. unsafe.Pointer → uintptr 必须"单步使用"是什么意思?写一段错误代码(中间存了 uintptr)演示 GC 搬迁后的悬垂风险。
  7. 设计一个"指针接收者"的 nil-safe 结构体方法,要求:var c *Cache; c.Get("x") 不 panic 且返回零值。

# 8.15 推荐阅读

# 卷内

  • 第 5 章 复合类型(slice/map/struct 内存布局基础)
  • 第 7 章 函数(闭包逃逸、defer 与命名返回)
  • 第 9 章 方法与接收者(接收者选型)
  • 第 10 章 接口与多态(接口值导致的逃逸)

# 跨卷

  • 卷三第 1 章 内存模型与栈堆布局
  • 卷三第 2 章 指针与逃逸分析
  • 卷三第 3 章 GC 三色标记与写屏障
  • 卷四第 9 章 性能优化实战

# 外部资料

  • Go FAQ - When are function parameters passed by value? (opens new window)
  • Go FAQ - Why is there both new and make? (opens new window)
  • Allocation efficiency in high-performance Go services - Segment (opens new window)
  • unsafe package documentation (opens new window)
  • Go 1.22 release notes - for loop semantics (opens new window)
上次更新: 2026/06/10, 11:13:41
函数
结构体与方法

← 函数 结构体与方法→

最近更新
01
信号崩溃快速排查
06-15
02
CoreDump破案
06-15
03
perf火焰图实战
06-15
更多文章>
Theme by Vdoing | Copyright © 2019-2026 杨充 | MIT License | 桂ICP备2024034950号 | 桂公网安备45142202000030
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式