Go语言指针与内存分配深度解析:从指针本质到 new
、make
的底层实现
在 Go 语言的世界里,函数是实现行为逻辑的核心载体,而指针则是打开内存管理大门的关键钥匙。
上一篇专栏预告中我们提到,本次将深入探讨 Go 语言的指针与内存分配机制。接下来,就让我们一同揭开它们的神秘面纱,从指针的本质讲起,逐步深入到 new
、make
函数的底层实现。
一、指针的本质:内存地址的“代言人” 📌
指针,简单来说就是存储内存地址的变量。
在计算机中,所有的数据都存储在内存的某个位置,这个位置被称为内存地址,通常用一个十六进制的数字表示。
而指针变量就像是一个“标签”,它记录着某个数据在内存中的具体地址,通过这个“标签”我们可以找到并访问对应的数据。
比如,当我们声明一个变量 a := 10
时,计算机会在内存中开辟一块空间来存储整数 10,同时给这块空间分配一个内存地址,假设是 0xc00001a078
。
而如果我们声明一个指针变量 b := &a
,那么 b
中存储的就是 a
的内存地址 0xc00001a078
,此时我们就说 b
指向了 a
。
二、指针的基本用法:声明、取值与取地址
1. 指针的声明
在 Go 语言中,声明指针变量的语法为:var 指针变量名 *数据类型
。其中 *
表示这是一个指针类型,后面的“数据类型”表示该指针指向的数据的类型。
案例 1:声明指针变量
package main
import "fmt"
func main() {
var a int = 20
var p *int // 声明一个指向 int 类型的指针变量 p
p = &a // 将 a 的地址赋值给 p
fmt.Println("a 的值:", a)
fmt.Println("a 的地址:", &a)
fmt.Println("指针 p 存储的地址:", p)
fmt.Println("指针 p 指向的值:", *p) // 通过 * 取值
}
运行结果:
a 的值: 20
a 的地址: 0xc00000a0d8
指针 p 存储的地址: 0xc00000a0d8
指针 p 指向的值: 20
在这个案例中,我们先声明了一个 int 类型的变量 a
并赋值为 20,然后声明了一个指向 int 类型的指针变量 p
,通过 &a
获取 a
的内存地址并赋值给 p
。最后分别打印了 a
的值、a
的地址、指针 p
存储的地址以及指针 p
指向的值,可以看到指针 p
存储的地址与 a
的地址相同,通过 *p
可以获取到 a
的值。
2. 取地址操作(&)
&
操作符用于获取变量的内存地址,它的操作数必须是一个变量,不能是常量或表达式。如上述案例中 &a
就是获取变量 a
的内存地址。
3. 取值操作(*)
*
操作符用于获取指针所指向的变量的值,称为指针解引用。在案例 1 中,*p
就是获取指针 p
所指向的变量 a
的值。
三、指针与函数:实现数据的间接修改
在 Go 语言中,函数的参数传递是值传递,即当我们将一个变量作为参数传递给函数时,函数内部会创建一个该变量的副本,对副本的修改不会影响原变量的值。而通过指针作为函数参数,可以实现对原变量的间接修改。
案例 2:指针作为函数参数修改原变量的值
package main
import "fmt"
// 通过值传递修改变量,无法改变原变量
func changeByValue(num int) {
num = 100
}
// 通过指针传递修改变量,可以改变原变量
func changeByPointer(num *int) {
*num = 100
}
func main() {
a := 20
fmt.Println("调用 changeByValue 前 a 的值:", a)
changeByValue(a)
fmt.Println("调用 changeByValue 后 a 的值:", a)
fmt.Println("调用 changeByPointer 前 a 的值:", a)
changeByPointer(&a)
fmt.Println("调用 changeByPointer 后 a 的值:", a)
}
运行结果:
调用 changeByValue 前 a 的值: 20
调用 changeByValue 后 a 的值: 20
调用 changeByPointer 前 a 的值: 20
调用 changeByPointer 后 a 的值: 100
在这个案例中,changeByValue
函数接收一个 int 类型的参数,函数内部对参数的修改只是针对副本,原变量 a
的值没有发生变化。
而 changeByPointer
函数接收一个指向 int 类型的指针,通过 *num
可以获取到原变量的地址并修改其值,所以原变量 a
的值发生了改变。
四、内存分配基础:堆与栈的“分工合作” 🧠
在 Go 语言中,内存分配主要分为栈内存分配和堆内存分配。
栈内存是由编译器自动管理的,它的分配和释放速度非常快。通常情况下,函数内部的局部变量、函数参数等会被分配在栈上。当函数执行结束后,这些变量所占用的栈内存会被自动释放。
堆内存的分配和释放相对复杂,它由 Go 语言的垃圾回收器负责管理。当变量需要在函数调用结束后仍然存在,或者变量的大小不确定时,变量会被分配在堆上。堆内存的分配需要向操作系统申请,而释放则由垃圾回收器在适当的时候进行回收。
案例 3:栈内存与堆内存分配示例
package main
import "fmt"
// 函数返回局部变量的指针,变量会被分配在堆上
func createNum() *int {
num := 50
return &num
}
func main() {
// 局部变量 a 分配在栈上
a := 10
fmt.Println("a 的值:", a)
// 通过函数获取堆上变量的指针
p := createNum()
fmt.Println("堆上变量的值:", *p)
}
在这个案例中,变量 a
是 main 函数的局部变量,它会被分配在栈上。而 createNum
函数中的变量 num
,由于函数返回了它的指针,在函数执行结束后还需要被访问,所以它会被分配在堆上。
五、new 函数:分配内存并返回指针 🔨
new
函数是 Go 语言提供的一个用于内存分配的内置函数,它的语法为:new(类型)
。
new
函数会为指定类型的变量分配一块内存空间,并初始化为该类型的零值,然后返回指向该内存空间的指针。
1. new 函数的基本用法
案例 4:new 函数的使用
package main
import "fmt"
func main() {
// 使用 new 函数为 int 类型分配内存
p := new(int)
fmt.Println("new(int) 返回的指针:", p)
fmt.Println("指针指向的值(零值):", *p)
// 为指针指向的内存赋值
*p = 100
fmt.Println("赋值后指针指向的值:", *p)
}
运行结果:
new(int) 返回的指针: 0xc00001a0c0
指针指向的值(零值): 0
赋值后指针指向的值: 100
在这个案例中,new(int)
为 int 类型分配了一块内存,初始值为 0(int 类型的零值),并返回指向该内存的指针 p
。我们可以通过 *p
来访问和修改这块内存的值。
2. new 函数的底层实现
从底层实现来看,new
函数的工作流程相对简单。当我们调用 new(T)
时,Go 语言的运行时系统会在堆上为类型 T
分配一块足够大的内存空间,然后将该内存空间初始化为类型 T
的零值,最后返回指向该内存空间的指针。
new
函数主要用于为基本数据类型、结构体等分配内存,它返回的永远是一个指针,指针指向的内存中的值为该类型的零值。
六、make 函数:专为引用类型分配内存 🔨
make
函数也是 Go 语言中用于内存分配的内置函数,但它与 new
函数不同,make
函数只用于为切片(slice)、映射(map)和通道(channel)这三种引用类型分配内存,并初始化它们的内部数据结构。make
函数的语法为:make(类型, 长度, 容量)
(对于不同类型,参数可能有所不同)。
1. make 函数的基本用法
案例 5:make 函数创建切片
package main
import "fmt"
func main() {
// 使用 make 函数创建切片,长度为 3,容量为 5
s := make([]int, 3, 5)
fmt.Println("切片 s 的值:", s)
fmt.Println("切片 s 的长度:", len(s))
fmt.Println("切片 s 的容量:", cap(s))
// 向切片中添加元素
s = append(s, 1, 2)
fmt.Println("添加元素后切片 s 的值:", s)
fmt.Println("添加元素后切片 s 的长度:", len(s))
fmt.Println("添加元素后切片 s 的容量:", cap(s))
}
运行结果:
切片 s 的值: [0 0 0]
切片 s 的长度: 3
切片 s 的容量: 5
添加元素后切片 s 的值: [0 0 0 1 2]
添加元素后切片 s 的长度: 5
添加元素后切片 s 的容量: 5
在这个案例中,make([]int, 3, 5)
创建了一个 int 类型的切片,长度为 3,容量为 5。切片的初始值为 [0 0 0](int 类型的零值),通过 append
函数可以向切片中添加元素。
案例 6:make 函数创建映射
package main
import "fmt"
func main() {
// 使用 make 函数创建映射
m := make(map[string]int)
fmt.Println("映射 m 的初始值:", m)
// 向映射中添加键值对
m["one"] = 1
m["two"] = 2
fmt.Println("添加键值对后映射 m 的值:", m)
}
运行结果:
映射 m 的初始值: map[]
添加键值对后映射 m 的值: map[one:1 two:2]
make(map[string]int)
创建了一个键为 string 类型、值为 int 类型的映射,初始为空,我们可以通过键值对的形式向其中添加数据。
案例 7:make 函数创建通道
package main
import "fmt"
func main() {
// 使用 make 函数创建通道
ch := make(chan int, 2)
fmt.Println("通道 ch 的容量:", cap(ch))
// 向通道中发送数据
ch <- 1
ch <- 2
fmt.Println("从通道中接收数据:", <-ch)
fmt.Println("从通道中接收数据:", <-ch)
}
运行结果:
通道 ch 的容量: 2
从通道中接收数据: 1
从通道中接收数据: 2
make(chan int, 2)
创建了一个能存储 int 类型数据、容量为 2 的带缓冲通道,我们可以通过 <-
操作符向通道发送和接收数据。
2. make 函数的底层实现
make
函数的底层实现比 new
函数复杂,因为它需要根据不同的引用类型来初始化内部数据结构。
- 对于切片,
make
函数会分配一个数组作为切片的底层存储,然后初始化切片的指针(指向数组的起始位置)、长度和容量。 - 对于映射,
make
函数会创建一个哈希表结构,并初始化相关的元数据,如桶数组、大小等。 - 对于通道,
make
函数会创建一个包含缓冲区、发送队列、接收队列等数据结构的通道对象。
七、new 与 make 的区别
new
与make
二者都是用来做内存分配的。make
只用于切片(slice)、映射(map)和通道(channel)的初始化,返回的还是这三个引用类型本身。- 而
new
用于类型的内存分配,并且内存对应的值为类型零值,返回的是指向类型的指针。
八、总结
通过本文的讲解,我们深入了解了 Go 语言指针的本质、基本用法以及它在函数中的应用,同时也探讨了内存分配的基础概念,以及 new 和 make 函数的底层实现。指针为我们提供了间接访问内存的方式,而 new 和 make 函数则帮助我们更方便地进行内存分配,它们在 Go 语言的内存管理中都扮演着重要的角色。
下一篇专栏预告
掌握了Go 语言中的指针与内存分配的知识后,我们将进入 Go 语言中数据组织的重要部分。下一篇专栏我们将聚焦 Go 语言中的结构体,结构体是自定义数据类型的核心,它可以将不同类型的数据组合在一起,实现更复杂的数据结构。无论你是想了解结构体的定义与初始化、字段的访问与修改,还是结构体的嵌套与方法等,下一篇内容都将为你详细解读,敬请期待!😊