指针
问题
Go 的指针与 C 指针有什么区别?unsafe.Pointer 和 uintptr 分别是什么?
答案
指针基础
Go 有指针但不支持指针运算(不能 p++),这是与 C 最大的区别:
x := 42
p := &x // p 是 *int 类型,指向 x
fmt.Println(*p) // 42(解引用)
*p = 100 // 通过指针修改值
fmt.Println(x) // 100
// Go 不支持指针运算
// p++ // 编译错误!
// p + 1 // 编译错误!
零值:指针的零值是 nil,解引用 nil 指针会 panic:
var p *int // nil
// *p = 1 // panic: runtime error: invalid memory address
值传递 vs 指针传递
Go 只有值传递,传指针的效果等同于"引用传递":
// 值传递:函数内修改的是副本
func tryModify(x int) {
x = 100 // 修改副本,不影响原值
}
// 指针传递:函数内修改的是原值
func modify(p *int) {
*p = 100 // 通过指针修改原值
}
x := 42
tryModify(x)
fmt.Println(x) // 42(不变)
modify(&x)
fmt.Println(x) // 100(被修改)
什么时候用指针?
| 场景 | 用指针 *T | 用值 T |
|---|---|---|
| 需要修改原值 | ✅ | ❌ |
| 大结构体传参 | ✅(避免拷贝) | ❌(拷贝开销大) |
| 小结构体(< 64字节) | 可选 | ✅(逃逸分析可能栈分配更快) |
| 方法接收者 | ✅(需修改或结构体大) | ✅(不修改且结构体小) |
| 表示"可能为空" | ✅(nil 表示无值) | ❌ |
| 并发场景 | ⚠️ 需要同步 | ✅(拷贝天然安全) |
指针可能导致堆分配(逃逸分析),而值传递的小结构体可以留在栈上。栈分配比堆分配快得多,且不需要 GC。所以小结构体传值反而可能更快。
new 和 & 取地址
// & 取地址(更常用)
x := 42
p := &x // p 指向 x
// new 分配零值并返回指针
p2 := new(int) // *int,指向 0
*p2 = 42
// 结构体:两种方式等价
p3 := &User{Name: "Alice", Age: 30} // 更常用
p4 := new(User) // 零值 User 的指针
unsafe.Pointer
unsafe.Pointer 是 Go 的通用指针类型,可以在任意指针类型之间转换:
import "unsafe"
// 任意指针 ↔ unsafe.Pointer
x := 42
p := unsafe.Pointer(&x) // *int → unsafe.Pointer
q := (*float64)(p) // unsafe.Pointer → *float64(危险!)
// 计算结构体字段偏移
type User struct {
Name string
Age int
}
u := User{Name: "Alice", Age: 30}
// 获取 Age 字段的指针(绕过导出限制)
agePtr := (*int)(unsafe.Pointer(
uintptr(unsafe.Pointer(&u)) + unsafe.Offsetof(u.Age),
))
fmt.Println(*agePtr) // 30
Go 官方定义了 6 种合法的 unsafe.Pointer 使用模式,其他用法可能导致未定义行为:
*T1→unsafe.Pointer→*T2(类型转换)unsafe.Pointer→uintptr(仅用于打印,不能转回来)unsafe.Pointer→uintptr+ 算术运算 →unsafe.Pointer(指针运算,必须在同一表达式中)syscall.Syscall参数reflect.Value.Pointer/reflect.Value.UnsafeAddrreflect.SliceHeader/reflect.StringHeader
最重要的规则:uintptr 转回 unsafe.Pointer 必须在同一个表达式中完成,不能把 uintptr 存到变量中再转回来——因为 GC 可能在中间移动对象。
uintptr
uintptr 是一个整数类型,大小足以存放指针值。它与 unsafe.Pointer 的关键区别:
| 维度 | unsafe.Pointer | uintptr |
|---|---|---|
| 本质 | 指针类型 | 整数类型 |
| GC 是否追踪 | ✅ GC 知道这是指针 | ❌ GC 不追踪 |
| 指针运算 | ❌ 不支持 | ✅ 支持加减 |
| 安全性 | 相对安全 | ⚠️ 可能导致悬垂指针 |
// ❌ 危险:uintptr 不被 GC 追踪
p := uintptr(unsafe.Pointer(&x))
// GC 可能移动 x 的内存位置,p 变成野指针
_ = (*int)(unsafe.Pointer(p)) // 可能指向错误的地址
// ✅ 正确:在同一表达式中完成
p := (*int)(unsafe.Pointer(uintptr(unsafe.Pointer(&u)) + offset))
指针与逃逸分析
Go 编译器通过逃逸分析(Escape Analysis) 决定变量分配在栈还是堆上。返回局部变量的指针会导致逃逸到堆:
// x 逃逸到堆(函数返回后仍被引用)
func newInt() *int {
x := 42
return &x // Go 允许返回局部变量的指针(C 不允许!)
}
// x 留在栈上(不逃逸)
func noEscape() int {
x := 42
return x
}
查看逃逸分析结果:
go build -gcflags="-m" main.go
# ./main.go:3:2: moved to heap: x
常见面试问题
Q1: Go 可以返回局部变量的指针吗?跟 C 有什么区别?
答案:
可以。Go 和 C 在这点上截然不同:
- C:局部变量在栈上,函数返回后栈帧销毁,返回的指针变成悬垂指针(dangling pointer)
- Go:编译器通过逃逸分析发现局部变量被返回指针引用后,会自动将其分配到堆上,由 GC 管理,不会出现悬垂指针
func newUser() *User {
u := User{Name: "Alice"} // 逃逸到堆
return &u // 安全!
}
Q2: Go 的指针不能运算,那如何实现类似 C 的指针偏移?
答案:
通过 unsafe.Pointer + uintptr 实现指针运算。常见场景:
- 访问结构体的未导出字段
- 实现高性能数据结构(如 Go runtime 内部)
- 与 C 代码互操作(CGO)
// 通过偏移访问结构体字段
type User struct {
name string // 未导出
age int // 未导出
}
u := User{"Alice", 30}
namePtr := (*string)(unsafe.Pointer(&u))
fmt.Println(*namePtr) // "Alice"
agePtr := (*int)(unsafe.Pointer(
uintptr(unsafe.Pointer(&u)) + unsafe.Sizeof(u.name),
))
fmt.Println(*agePtr) // 30
在日常开发中应避免使用 unsafe 包,它会绕过 Go 的类型安全检查。
Q3: 什么是逃逸分析?什么情况下变量会逃逸?
答案:
逃逸分析是编译器在编译时判断变量应该分配在栈还是堆上的过程。常见的逃逸场景:
- 函数返回局部变量的指针
- 闭包引用局部变量
- interface 类型赋值(不确定具体类型大小)
- 切片/map 存储指针
- 发送到 channel 的数据
- 变量大小在编译时未知(如
make([]int, n)中 n 是变量)
通过 go build -gcflags="-m" 查看逃逸分析结果。减少堆分配(即减少逃逸)是 Go 性能优化的重要手段。
Q4: nil 指针和空指针有区别吗?
答案:
Go 中 nil 指针就是空指针,是同一个概念。指针的零值是 nil,表示"不指向任何对象"。
var p *int // nil
// 判断
if p == nil { // true
}
// 解引用 nil 指针会 panic
// *p = 1 // panic: runtime error: invalid memory address or nil pointer dereference
在接口中,nil 有更复杂的语义:一个接口值为 nil 仅当类型和值都是 nil。这是常见的面试陷阱。