Go语言指针与内存分配深度解析:从指针本质到 new、make 的底层实现

发布于:2025-08-17 ⋅ 阅读:(12) ⋅ 点赞:(0)

Go语言指针与内存分配深度解析:从指针本质到 newmake 的底层实现

在 Go 语言的世界里,函数是实现行为逻辑的核心载体,而指针则是打开内存管理大门的关键钥匙。

上一篇专栏预告中我们提到,本次将深入探讨 Go 语言的指针与内存分配机制。接下来,就让我们一同揭开它们的神秘面纱,从指针的本质讲起,逐步深入到 newmake 函数的底层实现。

一、指针的本质:内存地址的“代言人” 📌

指针,简单来说就是存储内存地址的变量。

在计算机中,所有的数据都存储在内存的某个位置,这个位置被称为内存地址,通常用一个十六进制的数字表示。

而指针变量就像是一个“标签”,它记录着某个数据在内存中的具体地址,通过这个“标签”我们可以找到并访问对应的数据。

比如,当我们声明一个变量 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 的区别

  1. new make 二者都是用来做内存分配的。
  2. make 只用于切片(slice)、映射(map)和通道(channel)的初始化,返回的还是这三个引用类型本身。
  3. new 用于类型的内存分配,并且内存对应的值为类型零值,返回的是指向类型的指针。

八、总结

通过本文的讲解,我们深入了解了 Go 语言指针的本质、基本用法以及它在函数中的应用,同时也探讨了内存分配的基础概念,以及 new 和 make 函数的底层实现。指针为我们提供了间接访问内存的方式,而 new 和 make 函数则帮助我们更方便地进行内存分配,它们在 Go 语言的内存管理中都扮演着重要的角色。

下一篇专栏预告

掌握了Go 语言中的指针与内存分配的知识后,我们将进入 Go 语言中数据组织的重要部分。下一篇专栏我们将聚焦 Go 语言中的结构体,结构体是自定义数据类型的核心,它可以将不同类型的数据组合在一起,实现更复杂的数据结构。无论你是想了解结构体的定义与初始化、字段的访问与修改,还是结构体的嵌套与方法等,下一篇内容都将为你详细解读,敬请期待!😊


网站公告

今日签到

点亮在社区的每一天
去签到