首页 > 后端开发 > Golang > 正文

Go:指针和内存管理

Patricia Arquette
发布: 2024-11-22 01:51:14
原创
438 人浏览过

Go: Pointers & Memory Management

TL;DR:通过示例探索 Go 的内存处理,包括指针、堆栈和堆分配、逃逸分析和垃圾收集

当我第一次开始学习 Go 时,我对其内存管理方法很感兴趣,尤其是在指针方面。 Go 以一种既高效又安全的方式处理内存,但如果你不深入了解它的本质,它可能有点像一个黑匣子。我想分享一些关于 Go 如何使用指针、堆栈和堆管理内存以及逃逸分析和垃圾收集等概念的见解。在此过程中,我们将查看在实践中说明这些想法的代码示例。

了解堆栈和堆内存

在深入研究 Go 中的指针之前,了解堆栈和堆的工作原理会很有帮助。这是两个可以存储变量的内存区域,每个区域都有自己的特点。

  • 堆栈:这是一个以后进先出方式操作的内存区域。它快速且高效,用于存储具有短期作用域的变量,例如函数内的局部变量。
  • :这是一个更大的内存池,用于存储需要超出函数范围的变量,例如从函数返回并在其他地方使用的数据。

在 Go 中,编译器根据变量的使用方式决定是在堆栈还是堆上分配变量。这个决策过程称为逃逸分析,我们稍后将更详细地探讨。

按值传递:默认行为

在 Go 中,当您将整数、字符串或布尔值等变量传递给函数时,它们自然是按值传递的。这意味着创建了变量的副本,并且该函数可以使用该副本。这意味着,对函数内部变量所做的任何更改都不会影响其作用域之外的变量。

这是一个简单的例子:

package main

import "fmt"

func increment(num int) {
    num++
    fmt.Printf("Inside increment(): num = %d, address = %p \n", num, &num)
}

func main() {
    n := 21
    fmt.Printf("Before increment(): n = %d, address = %p \n", n, &n)
    increment(n)
    fmt.Printf("After increment(): n = %d, address = %p \n", n, &n)
}
登录后复制
登录后复制
登录后复制
登录后复制

输出:

Before increment(): n = 21, address = 0xc000012070 
Inside increment(): num = 22, address = 0xc000012078 
After increment(): n = 21, address = 0xc000012070
登录后复制
登录后复制
登录后复制
登录后复制

在此代码中:

  • increment() 函数接收 n 的副本。
  • main()中的n和increment()中的num的地址不同。
  • 修改increment()中的num不会影响main()中的n。

要点:按值传递是安全且直接的,但对于大型数据结构,复制可能会变得低效。

指针简介:通过引用传递

要修改函数内的原始变量,可以传递一个指针给它。指针保存变量的内存地址,允许函数访问和修改原始数据。

以下是如何使用指针:

package main

import "fmt"

func incrementPointer(num *int) {
    (*num)++
    fmt.Printf("Inside incrementPointer(): num = %d, address = %p \n", *num, num)
}

func main() {
    n := 42
    fmt.Printf("Before incrementPointer(): n = %d, address = %p \n", n, &n)
    incrementPointer(&n)
    fmt.Printf("After incrementPointer(): n = %d, address = %p \n", n, &n)
}

登录后复制
登录后复制
登录后复制
登录后复制

输出:

Before incrementPointer(): n = 42, address = 0xc00009a040 
Inside incrementPointer(): num = 43, address = 0xc00009a040 
After incrementPointer(): n = 43, address = 0xc00009a040 
登录后复制
登录后复制

在此示例中:

  • 我们将 n 的地址传递给incrementPointer()。
  • main() 和incrementPointer() 都引用相同的内存地址。
  • 修改incrementPointer()中的num会影响main()中的n。

要点:使用指针允许函数修改原始变量,但它引入了有关内存分配的注意事项。

使用指针分配内存

当你创建一个指向变量的指针时,Go 需要确保该变量与指针一样存活。这通常意味着在 上分配变量,而不是 堆栈

考虑这个函数:

package main

import "fmt"

func increment(num int) {
    num++
    fmt.Printf("Inside increment(): num = %d, address = %p \n", num, &num)
}

func main() {
    n := 21
    fmt.Printf("Before increment(): n = %d, address = %p \n", n, &n)
    increment(n)
    fmt.Printf("After increment(): n = %d, address = %p \n", n, &n)
}
登录后复制
登录后复制
登录后复制
登录后复制

这里,num 是 createPointer() 中的局部变量。如果 num 存储在堆栈中,一旦函数返回,它就会被清除,留下一个悬空指针。为了防止这种情况,Go 在堆上分配 num ,以便在 createPointer() 退出后它仍然有效。

悬挂指针

当指针引用已释放的内存时,就会出现悬空指针

Go 通过其垃圾收集器防止悬空指针,确保内存在仍被引用时不会被释放。然而,在某些情况下,持有指针的时间超过必要的时间可能会导致内存使用量增加或内存泄漏。

逃逸分析:决定堆栈与堆分配

逃逸分析确定变量是否需要存在于其函数范围之外。如果一个变量被返回、存储在指针中或被 goroutine 捕获,它就会逃逸并分配在堆上。但是,即使变量没有转义,编译器也可能出于其他原因(例如优化决策或堆栈大小限制)将其分配在堆上。

变量转义示例:

Before increment(): n = 21, address = 0xc000012070 
Inside increment(): num = 22, address = 0xc000012078 
After increment(): n = 21, address = 0xc000012070
登录后复制
登录后复制
登录后复制
登录后复制

在此代码中:

  • createSlice() 中的切片数据会转义,因为它在 main() 中返回并使用。
  • 切片的底层数组分配在

使用 go build -gcflags '-m' 了解转义分析

你可以通过使用 -gcflags '-m' 选项来查看 Go 编译器的决定:

package main

import "fmt"

func incrementPointer(num *int) {
    (*num)++
    fmt.Printf("Inside incrementPointer(): num = %d, address = %p \n", *num, num)
}

func main() {
    n := 42
    fmt.Printf("Before incrementPointer(): n = %d, address = %p \n", n, &n)
    incrementPointer(&n)
    fmt.Printf("After incrementPointer(): n = %d, address = %p \n", n, &n)
}

登录后复制
登录后复制
登录后复制
登录后复制

这将输出指示变量是否逃逸到堆的消息。

Go 中的垃圾收集

Go 使用垃圾收集器来管理堆上的内存分配和释放。它会自动释放不再引用的内存,有助于防止内存泄漏。

示例:

Before incrementPointer(): n = 42, address = 0xc00009a040 
Inside incrementPointer(): num = 43, address = 0xc00009a040 
After incrementPointer(): n = 43, address = 0xc00009a040 
登录后复制
登录后复制

在此代码中:

  • 我们创建一个包含 1,000,000 个节点的链表。
  • 每个 Node 都分配在堆上,因为它逃逸了 createLinkedList() 的范围。
  • 当不再需要列表时,垃圾收集器会释放内存。

要点:Go 的垃圾收集器简化了内存管理,但会带来开销。

指针的潜在陷阱

虽然指针很强大,但如果使用不小心,它们可能会导致问题。

悬空指针(续)

尽管 Go 的垃圾收集器有助于防止悬空指针,但如果持有指针的时间超过必要的时间,仍然可能会遇到问题。

示例:

package main

import "fmt"

func increment(num int) {
    num++
    fmt.Printf("Inside increment(): num = %d, address = %p \n", num, &num)
}

func main() {
    n := 21
    fmt.Printf("Before increment(): n = %d, address = %p \n", n, &n)
    increment(n)
    fmt.Printf("After increment(): n = %d, address = %p \n", n, &n)
}
登录后复制
登录后复制
登录后复制
登录后复制

在此代码中:

  • data 是分配在堆上的一个大切片。
  • 通过保留对它的引用 ([]int),我们可以防止垃圾收集器释放内存。
  • 如果管理不当,可能会导致内存使用量增加。

并发问题 - 与指针的数据争用

下面是直接涉及指针的示例:

Before increment(): n = 21, address = 0xc000012070 
Inside increment(): num = 22, address = 0xc000012078 
After increment(): n = 21, address = 0xc000012070
登录后复制
登录后复制
登录后复制
登录后复制

为什么此代码失败:

  • 多个 goroutine 取消引用并递增指针 counterPtr,无需任何同步。
  • 这会导致数据竞争,因为多个 goroutine 在没有同步的情况下同时访问和修改同一内存位置。 *counterPtr 操作涉及多个步骤(读取、递增、写入)并且不是线程安全的。

修复数据争用:

我们可以通过添加互斥体同步来解决这个问题:

package main

import "fmt"

func incrementPointer(num *int) {
    (*num)++
    fmt.Printf("Inside incrementPointer(): num = %d, address = %p \n", *num, num)
}

func main() {
    n := 42
    fmt.Printf("Before incrementPointer(): n = %d, address = %p \n", n, &n)
    incrementPointer(&n)
    fmt.Printf("After incrementPointer(): n = %d, address = %p \n", n, &n)
}

登录后复制
登录后复制
登录后复制
登录后复制

此修复的工作原理:

  • mu.Lock() 和 mu.Unlock() 确保一次只有一个 goroutine 访问和修改指针。
  • 这可以防止竞争条件并确保计数器的最终值是正确的。

Go 的语言规范怎么说?

值得注意的是,Go 的语言规范并没有直接规定变量是分配在栈上还是堆上。这些是运行时和编译器实现细节,允许根据 Go 版本或实现的不同而变化的灵活性和优化。

这意味着:

  • 不同版本的 Go 之间管理内存的方式可能会有所不同。
  • 您不应依赖在特定内存区域中分配的变量。
  • 专注于编写清晰正确的代码,而不是试图控制内存分配。

示例:

即使您希望在堆栈上分配变量,编译器也可能会根据其分析决定将其移至堆。

package main

import "fmt"

func increment(num int) {
    num++
    fmt.Printf("Inside increment(): num = %d, address = %p \n", num, &num)
}

func main() {
    n := 21
    fmt.Printf("Before increment(): n = %d, address = %p \n", n, &n)
    increment(n)
    fmt.Printf("After increment(): n = %d, address = %p \n", n, &n)
}
登录后复制
登录后复制
登录后复制
登录后复制

要点:由于内存分配细节是一种内部实现,而不是 Go 语言规范的一部分,因此这些信息只是一般准则,而不是以后可能会更改的固定规则。

平衡性能和内存使用

在决定按值传递还是按指针传递时,我们必须考虑数据的大小和性能影响。

按值传递大型结构:

Before increment(): n = 21, address = 0xc000012070 
Inside increment(): num = 22, address = 0xc000012078 
After increment(): n = 21, address = 0xc000012070
登录后复制
登录后复制
登录后复制
登录后复制

通过指针传递大型结构:

package main

import "fmt"

func incrementPointer(num *int) {
    (*num)++
    fmt.Printf("Inside incrementPointer(): num = %d, address = %p \n", *num, num)
}

func main() {
    n := 42
    fmt.Printf("Before incrementPointer(): n = %d, address = %p \n", n, &n)
    incrementPointer(&n)
    fmt.Printf("After incrementPointer(): n = %d, address = %p \n", n, &n)
}

登录后复制
登录后复制
登录后复制
登录后复制

注意事项:

  • 按值传递安全且简单,但对于大型数据结构可能效率低下。
  • 通过指针传递可以避免复制,但需要仔细处理以避免并发问题。

从现场经验来看:

在早期职业生涯中,我记得有一次我正在优化处理大量数据的 Go 应用程序。最初,我按值传递大型结构,假设这会简化代码的推理。然而,我碰巧注意到内存使用率相对较高,并且垃圾收集频繁暂停。

在与我的学长结对编程中使用 Go 的 pprof 工具对应用程序进行分析后,我们发现复制大型结构是一个瓶颈。我们重构了代码以传递指针而不是值。这显着减少了内存使用并提高了性能。

但这一改变并非没有挑战。我们必须确保我们的代码是线程安全的,因为多个 goroutine 现在正在访问共享数据。我们使用互斥锁实现了同步,并仔细检查了代码中潜在的竞争条件。

经验教训:尽早了解 Go 如何处理内存分配可以帮助您编写更高效的代码,因为平衡性能提升与代码安全性和可维护性至关重要。

最后的想法

Go 的内存管理方法(就像其他地方的做法一样)在性能和简单性之间取得了平衡。通过抽象出许多低级细节,它使开发人员能够专注于构建强大的应用程序,而不必陷入手动内存管理的困境。

要记住的要点:

  • 按值传递很简单,但对于大型数据结构可能效率低下。
  • 使用指针可以提高性能,但需要仔细处理以避免数据争用等问题。
  • 逃逸分析确定变量是分配在栈上还是堆上,但这是内部细节。
  • 垃圾收集有助于防止内存泄漏,但可能会带来开销。
  • 并发涉及共享数据时需要同步。

通过牢记这些概念并使用 Go 的工具来分析和分析您的代码,您可以编写高效且安全的应用程序。


我希望对 Go 使用指针进行内存管理的探索会有所帮助。无论您是刚刚开始使用 Go 还是希望加深理解,尝试代码并观察编译器和运行时的行为都是一种很好的学习方式。

请随意分享您的经验或您可能遇到的任何问题 - 我总是热衷于讨论、学习和撰写更多有关 Go 的内容!

奖励内容 - 直接指针支持

你知道吗?可以为某些数据类型直接创建指针,但对于某些数据类型则不能。这张短桌子涵盖了它们。


Type Supports Direct Pointer Creation? Example
Structs ✅ Yes p := &Person{Name: "Alice", Age: 30}
Arrays ✅ Yes arrPtr := &[3]int{1, 2, 3}
Slices ❌ No (indirect via variable) slice := []int{1, 2, 3}; slicePtr := &slice
Maps ❌ No (indirect via variable) m := map[string]int{}; mPtr := &m
Channels ❌ No (indirect via variable) ch := make(chan int); chPtr := &ch
Basic Types ❌ No (requires a variable) val := 42; p := &val
time.Time (Struct) ✅ Yes t := &time.Time{}
Custom Structs ✅ Yes point := &Point{X: 1, Y: 2}
Interface Types ✅ Yes (but rarely needed) var iface interface{} = "hello"; ifacePtr := &iface
time.Duration (Alias of int64) ❌ No duration := time.Duration(5); p := &duration
类型 支持直接创建指针吗? 示例 标题> 结构 ✅ 是的 p := &Person{姓名:“Alice”,年龄:30} 数组 ✅ 是的 arrPtr := &[3]int{1, 2, 3} 切片 ❌否(通过变量间接) 切片 := []int{1, 2, 3}; slicePtr := &切片 地图 ❌否(通过变量间接) m := map[string]int{}; mPtr := &m 频道 ❌否(通过变量间接) ch := make(chan int); chPtr := &ch 基本类型 ❌否(需要变量) val := 42; p := &val time.Time(结构) ✅ 是的 t := &time.Time{} 自定义结构 ✅ 是的 点 := &Point{X: 1, Y: 2} 接口类型 ✅ 是(但很少需要) var iface 接口{} = "你好"; ifacePtr := &iface time.Duration(int64 的别名) ❌没有 持续时间 := 时间.持续时间(5); p := &持续时间 表>

如果您喜欢这个,请在​​评论中告诉我;我会尝试在以后的文章中添加此类奖励内容。

感谢您的阅读!想了解更多内容,请考虑关注。

愿代码与你同在:)

我的社交链接:LinkedIn | GitHub | ? (原推特)|子栈 |开发者 |哈希节点

以上是Go:指针和内存管理的详细内容。更多信息请关注PHP中文网其他相关文章!

来源:dev.to
本站声明
本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系admin@php.cn
作者最新文章
热门教程
更多>
最新下载
更多>
网站特效
网站源码
网站素材
前端模板