指针与内存:&、*、逃逸分析初识
本课所属:第二模块:复合数据类型与面向对象 | 课时:m02-l01 | 难度:入门 学完本课你将能够:理解指针语义与内存模型,掌握 new 与 make 的区别,并能通过逃逸分析优化代码性能。
一、学习目标
- 能正确使用
&和*进行指针的取址与解引用。 - 能准确区分
new与make的适用场景及返回值差异。 - 能解释值传递与指针传递在内存中的行为差异。
- 能使用
-gcflags=-m进行逃逸分析,理解栈与堆的分配逻辑。
二、知识讲解
2.1 指针语义:& 与 *
指针是一个变量的内存地址。在 Go 中,指针类型写作 *T。
- 取址符 &:获取变量的内存地址。例如 &x 返回 x 的地址,类型为 *T。
- 解引用符 *:通过地址访问或修改值。例如 *p 访问 p 指向的值。
- nil 指针:零值指针,不指向任何有效内存。对 nil 指针解引用会导致 panic。
- 指针大小:占用一个机器字,64 位平台为 8 字节,32 位平台为 4 字节。
2.2 new 与 make 的区别
Go 中只有两个内置函数用于分配内存,但用途截然不同:
- new(T):分配零值内存,返回类型为 *T 的指针。它不初始化内部结构,仅置零。
- make(T, args):仅用于 slice、map、chan 三种内置引用类型。它分配内部数据结构并初始化,返回类型即为 T 本身(而非指针)。
重要事实:new 并非不能用于切片/map,而是不该:它只分配零值(得到 *[]int 指向 nil 切片、*map[K]V 指向 nil map),解引用后才能用;创建可用的引用类型应使用 make。
2.3 值传递 vs 指针传递
Go 函数参数默认是值传递。
- 值传递:拷贝变量的值。对于 int、struct 等,修改副本不影响原值。
- 指针传递:拷贝地址。修改 *p 指向的内容,会影响原变量。
- 切片是值类型:切片头(指针、长度、容量)是值传递,但底层数组共享。因此修改切片元素会影响原切片,但重新赋值(如 s = append(s, 1))不会改变原切片头。
2.4 栈与堆:逃逸分析
- 栈(Stack):随函数调用自动分配与释放,速度快,空间有限。
- 堆(Heap):由 GC 管理,生命周期长,空间大,分配与回收有成本。
- 逃逸分析(Escape Analysis):编译器决定变量分配在栈还是堆的过程。如果变量在函数返回后仍被引用,则“逃逸”到堆。
- 工具:使用
go build -gcflags=-m或go run -gcflags=-m查看逃逸分析结果。
三、示例代码
示例 1:指针基础与 new/make 对比
演示指针取址、解引用,以及 new 与 make 创建切片和 map 的差异。
package main
import (
"fmt"
)
func main() {
// 1. 指针基础
var x int = 10
p := &x // 取址,p 类型为 *int
fmt.Printf("x 的值: %d, 地址: %p\n", x, p)
*p = 20 // 解引用修改
fmt.Printf("修改后 x 的值: %d\n", x)
// 2. new 的使用
// new 返回指向零值类型的指针
pInt := new(int)
*pInt = 30
fmt.Printf("new 分配的 int 值: %d\n", *pInt)
// 3. make 的使用
// make 用于 slice, map, chan,返回初始化后的实例
s := make([]int, 0, 5) // 长度0,容量5
s = append(s, 1)
fmt.Printf("make 创建的切片: %v\n", s)
m := make(map[string]int, 10) // hint 只是初始容量提示,map 无 cap 概念
m["key"] = 100
fmt.Printf("make 创建的 map: %v\n", m)
}
运行结果(预期输出):
x 的值: 10, 地址: 0xc000012110(每次运行不同)
修改后 x 的值: 20
new 分配的 int 值: 30
make 创建的切片: [1]
make 创建的 map: map[key:100]
示例 2:逃逸分析初识
演示变量何时会逃逸到堆,通过 -gcflags=-m 观察编译器行为。
package main
import "fmt"
// escapeExample 演示逃逸
func escapeExample() *int {
x := 10
// x 在函数返回后被返回,因此逃逸到堆
return &x
}
func main() {
p := escapeExample()
fmt.Println(*p)
}
运行结果(预期输出):
10
(注:运行 go run -gcflags=-m example2.go 可查看逃逸信息。实测输出中会包含 ./example2.go:9:2: moved to heap: x,表明 x 逃逸到堆。同时会看到 a escapes to heap / b escapes to heap,那是 fmt.Printf 的 ...any 参数导致的装箱逃逸,与返回值逃逸是两回事。行号随文件内容变化、每次编译还可能多出 inlining 相关提示。)
四、常见错误与避坑指南
-
误用 new 创建切片/Map 并直接操作 - 错误写法:
s := new([]int); s = append(s, 1)- 原因:new([]int)返回*[]int,它指向一个 nil 切片,本身合法;但append的第一个实参必须是切片,写append(s, 1)会编译失败(first argument to append must be a slice)。要用就得显式解引用:s := new([]int); *s = append(*s, 1)。工程上仍建议直接用make([]int, 0)或var s []int。 - 正确做法:使用make创建切片或 map。 -
误以为传切片就是传副本 - 错误认知:
func modifySlice(s []int) { s[0] = 99 }调用后原切片不变。 - 原因:s[0] = 99会修改底层数组,因此原切片可见(实测输出[99 2 3]);传指针*[]int只在需要改切片头本身(长度/容量/重新赋值)时才有必要。切片是‘描述符按值传递 + 底层数组共享’。 - 正确做法:理解切片头的值传递特性与底层数组的共享特性。 -
对指针类型的 map 进行索引 - 错误写法:
m := new(map[string]int); m["key"] = 1- 原因:m是*map[string]int类型,不能直接索引。编译器报错:invalid operation: cannot index m (variable of type *map[string]int)。 - 正确做法:*m["key"] = 1或更简单地直接使用make。
五、最佳实践
- 优先使用值传递:对于小结构体或基本类型,值传递更直观且避免指针空指针风险。
- 大结构体或需修改原值时使用指针:避免拷贝开销,或明确需要修改调用方数据。
- 在热路径上注意不必要的小对象逃逸:用
go build -gcflags=-m核查;但构造并返回对象指针(如func NewX() *X)是 Go 的惯用法,此时堆分配是设计使然,不必规避。 - 切片使用 make 初始化:除非明确需要
nil切片,否则使用make确保切片可用。 - 不要依赖指针地址的稳定性:地址和相对偏移每次运行都可能不同,不要依赖。
六、练习题
- 概念题:解释
new([]int)和make([]int, 0)在内存分配上的区别,以及它们返回值的类型差异。 - 编码题:编写一个函数
swap(a, b *int),交换两个整数的值。并在main中测试。 - 进阶/调试题:以下代码能正确修改切片元素吗?请说明切片作为参数传递时的内存语义,并判断这里使用
*[]int是否必要。go func modify(s *[]int) { (*s)[0] = 99 } func main() { s := []int{1, 2, 3} modify(&s) fmt.Println(s) }
七、练习参考答案
参考答案(点击展开 / 收起)
第 1 题
new([]int) 返回 *[]int,它在堆或栈上分配了一块内存用于存放 []int 的零值(即 nil 切片头),并返回指向该内存的指针。它不分配底层数组。
make([]int, 0) 返回 []int,它分配了切片头(指针、长度、容量)并初始化,对于长度为 0 的切片,通常不分配底层数组(共享 zerobase)。
区别在于:new 返回指针,需解引用;make 返回值本身,可直接使用。
第 2 题
package main
import "fmt"
func swap(a, b *int) {
*a, *b = *b, *a
}
func main() {
x, y := 10, 20
swap(&x, &y)
fmt.Printf("x: %d, y: %d\n", x, y)
}
说明:通过传入指针,函数内部解引用并交换值,从而修改原变量。
第 3 题
该代码在现行 Go 中完全合法(*[]int 是普通指针类型)。
分析:
- 切片是值类型,但底层数组共享。
- modify 接收 *[]int,通过 (*s)[0] 访问底层数组元素并修改。
- 使用 *[]int 是必要的吗?如果只修改元素,传 []int 即可(因为底层数组共享)。但如果需要修改切片头(如 *s = append(*s, ...) 改变长度/容量),则必须传指针。
- 本题中仅修改元素,传 []int 更惯用,但传 *[]int 也完全正确且合法。
八、本课小结
- 指针通过
&取址,*解引用,零值为nil。 new分配零值并返回指针,make初始化引用类型并返回实例。- 切片是值类型,但底层数组共享,修改元素影响原切片。
- 逃逸分析决定变量在栈还是堆,
-gcflags=-m是重要调试工具。 - 下一课预告:结构体定义与字段访问。