本文主要解释了堆内存的概念,介绍了 Linux 堆内存的工作原理,以及 Golang 如何管理堆内存。原文: Understanding Heap Memory in Linux with Go

你想过为什么堆内存被称为 "堆" 吗?想象一下杂乱堆放的对象,与此类似,在计算机中,堆内存是动态分配和释放内存的空间,通常会导致内存块的无序排列。我们可以利用这种相似性和无序排列来理解堆内存,并探讨堆内存的概念及其在计算中的意义。

什么是堆内存?

堆内存是程序内存中用于动态内存分配的部分。堆内存不是在编译过程中预先确定的,而是在程序运行过程中动态管理的。程序在执行过程中可以根据需要从堆中申请、释放内存。

进程的内存布局

在继续介绍之前,我们先退一步,试着了解一下进程的内存布局,如下图所示,可以简单了解大致的内存布局。

+ - - - - - - - - - - - - - - - +
| Stack                         | ←- 栈,静态分配
| - - - - - - - - - - - - - - - | 
| Heap                          | ←- 堆,动态分配
| - - - - - - - - - - - - - - - | 
| Uninitialized Data            | ←- 未初始化数据
| - - - - - - - - - - - - - - - | 
| Initialized Data              | ←- 初始化数据
| - - - - - - - - - - - - - - - | 
| Code                          | ←- 代码(文本段)
+ - - - - - - - - - - - - - - - +

                     进程内存布局

我们来分解一下进程的内存布局,看看它们是如何协同工作的:

  • 栈(Stack):这部分内存用于静态内存分配,是存储局部变量和函数调用信息的地方,会随着函数的调用和返回而自动增大和缩小。
  • 堆(Heap):这是动态内存分配区域。当程序需要申请未预先定义的内存时,就会向堆申请空间。这里的内存可以在运行时分配和释放,为程序提供了处理数组、链表等动态数据结构所需的灵活性。
  • 未初始化数据(BSS 段):该段存放开发者已声明但并未初始化的全局变量和静态变量。程序启动时,操作系统会将这些变量初始化为零。
  • 初始化数据:该区域包含开发者已初始化的全局变量和静态变量。程序一开始运行,这些变量就可以立即使用。
  • 代码(文本段):该段存储程序的可执行指令。通常这部分内存是只读的,以防止意外修改程序指令。

通过简单介绍,可以看到内存是如何有效组织,以满足运行进程的静态和动态需求。堆的作用对于动态内存分配尤为重要,从而允许程序灵活高效的管理内存。

堆内存的特点

动态分配:内存在运行时申请、释放。
可变大小:分配的内存大小可以变化。
基于指针的管理:使用指针访问和控制内存。

下图演示了如何通过将堆内存划分为多个空闲块和已分配块来动态管理堆内存:

+ - - - - - - - - - - -+
| Heap Memory.         | ←- 堆内存
| - - - - - - - - - - -| 
| Free Block           | ←- 空闲块
| - - - - - - - - - - -| 
| Allocated Block 1    | ←- 已分配块1
| [Pointer -> Data]    |
| - - - - - - - - - - -| 
| Free Block           | ←- 空闲块
| - - - - - - - - - - -| 
| Allocated Block 2    | ←- 已分配块2
| [Pointer -> Data]    |
| - - - - - - - - - - -| 
| Free Block.          | ←- 空闲块
+ - - - - - - - - - - -+

                   动态分配
  • 空闲块(Free Blocks):这些是当前未分配的内存块,可供将来使用。当程序请求内存时,可以从这些空闲块中获取。
  • 已分配块(Allocated Blocks):这些部分已分配给程序并储存了数据。每个已分配块通常都包含一个指向其所含数据的指针。

多个空闲块和已分配块的存在表明,内存的分配和释放在程序运行过程中不断发生。由于内存分配和释放的时间不同,导致空闲内存段和已用内存段交替出现,堆就会出现这种碎片化现象。

堆内存如何工作?

堆内存由操作系统管理。当程序请求内存时,操作系统会从进程的堆内存段中分配内存。这一过程涉及多个关键组件和功能:

主要组成部分:

  1. 堆内存段:进程内存中保留用于动态分配的部分
  2. mmap:调整数据段末尾以增加或减少堆大小的系统调用
  3. malloc 和 free:C 库提供的函数,用于分配和释放堆上的内存
  4. 内存管理器:C 库的一个组件,用于管理堆,跟踪已分配和已释放的内存块。

Go 如何管理堆内存

Go 为堆内存管理提供了内置函数和数据结构,如 newmakeslicesmapschannels。这些函数和数据结构抽象掉了底层细节,在内部与操作系统的内存管理机制进行了交互。

实例

我们通过一个简单的 Go 程序来理解,该程序为整数片段分配内存、初始化数值并打印。

package main

import (
    "fmt"
    "runtime"
)

func main() {
    // 为包含10个整数的切片分配内存(动态数组)
    memorySize := 10
    slice := make([]int, memorySize)

    // 初始化并使用分配的内存
    for i := 0; i < len(slice); i++ {
        slice[i] = 5 // 为每个元素赋值
    }

    // 打印值
    for i := 0; i < len(slice); i++ {
        fmt.Printf("%d ", slice[i])
    }
    fmt.Println()

    // 通过强制垃圾收集演示内存释放
    runtime.GC()
}

为了了解 Go 如何与 Linux 内存管理库交互,可以使用 strace(我最喜欢的工具)来跟踪 Go 程序进行的系统调用。

内存分配中的系统调用
$ go build -o memory_allocation main.go
$ strace -f -e trace=mmap,munmap ./memory_allocation
mmap(NULL, 262144, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0xffff94da0000
mmap(NULL, 131072, PROT_NONE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0xffff94d80000
mmap(NULL, 1048576, PROT_NONE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0xffff94c80000
mmap(NULL, 8388608, PROT_NONE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0xffff94400000
mmap(NULL, 67108864, PROT_NONE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0xffff90400000
mmap(NULL, 536870912, PROT_NONE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0xffff70400000
mmap(NULL, 536870912, PROT_NONE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0xffff50400000
mmap(0x4000000000, 67108864, PROT_NONE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x4000000000
mmap(NULL, 33554432, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0xffff4e400000
mmap(NULL, 68624, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0xffff94c6f000
mmap(0x4000000000, 4194304, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_ANONYMOUS, -1, 0) = 0x4000000000
mmap(0xffff94d80000, 131072, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_ANONYMOUS, -1, 0) = 0xffff94d80000
mmap(0xffff94c80000, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_ANONYMOUS, -1, 0) = 0xffff94c80000
mmap(0xffff94402000, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_ANONYMOUS, -1, 0) = 0xffff94402000
mmap(0xffff90410000, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_ANONYMOUS, -1, 0) = 0xffff90410000
mmap(0xffff70480000, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_ANONYMOUS, -1, 0) = 0xffff70480000
mmap(0xffff50480000, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_ANONYMOUS, -1, 0) = 0xffff50480000
mmap(NULL, 1048576, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0xffff4e300000
mmap(NULL, 65536, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0xffff94c5f000
mmap(NULL, 65536, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0xffff94c4f000
strace: Process 1141999 attached
strace: Process 1142000 attached
strace: Process 1142001 attached
[pid 1141998] --- SIGURG {si_signo=SIGURG, si_code=SI_TKILL, si_pid=1141998, si_uid=0} ---
[pid 1142000] mmap(NULL, 262144, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0xffff94c0f000
strace: Process 1142002 attached
5 5 5 5 5 5 5 5 5 5
[pid 1142001] mmap(NULL, 262144, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0xffff4e2c0000
[pid 1141998] --- SIGURG {si_signo=SIGURG, si_code=SI_TKILL, si_pid=1141998, si_uid=0} ---
[pid 1142000] mmap(NULL, 65536, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0xffff4e2b0000
[pid 1141998] mmap(NULL, 262144, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0xffff4e270000
[pid 1142002] +++ exited with 0 +++
[pid 1142001] +++ exited with 0 +++
[pid 1142000] +++ exited with 0 +++
[pid 1141999] +++ exited with 0 +++
+++ exited with 0 +++
+ - - - - - - - - - - -+
| Go Program           | ←- Go 程序
| - - - - - - - - - - -| 
| Calls Go Runtime     | ←- 调用 Go 运行时
| - - - - - - - - - - -| 
| Uses syscalls:       | ←- 系统调用:mmap,munmap
| mmap, munmap         |
| - - - - - - - - - - -| 
| Interacts with OS    | ←- 与操作系统内存管理器交互
| Memory Manager       |
+ - - - - - - - - - - -+
                      系统调用的简化示例
strace 输出解释
  • mmap 调用mmap 系统调用用于分配内存页。输出中的每个 mmap 调用都是请求操作系统分配特定数量(用 size 参数指定,例如 262144、131072 字节)的内存,。
  • 内存保护(Memory Protections):参数 PROT_READ|PROT_WRITE 表示分配的内存应是可读和可写的。
  • 匿名映射(Anonymous Mapping)MAP_PRIVATE|MAP_ANONYMOUS 标记表示内存没有任何文件支持,所做更改对进程来说是私有的。
  • 固定地址映射(Fixed Address Mapping):有些 mmap 调用使用 MAP_FIXED 标记,指定内存应映射到特定地址,通常用于直接管理特定内存区域。
内存分配过程的各个阶段
+ - - - - - - - - - - -+
| Initialize Slice     | ←- 初始化切片
| [0, 0, 0, 0, 0, 0, 0, 0, 0, 0] |
| - - - - - - - - - - -|
| Set Values           | ←- 设置值
| [5, 5, 5, 5, 5, 5, 5, 5, 5, 5] |
| - - - - - - - - - - -| 
| Print Values         | ←- 打印值
| 5 5 5 5 5 5 5 5 5 5  |
| - - - - - - - - - - -| 
| Force GC             | ←- 强制垃圾回收
| - - - - - - - - - - -|

上图说明了 Go 动态内存分配和管理的逐步过程。

  1. 初始化切片:
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0]

切片(动态数组)的初始状态为 10 个元素,全部设置为 0。这一步展示了 Go 如何为切片分配内存。

  1. 设置值:
[5, 5, 5, 5, 5, 5, 5, 5, 5, 5]

然后,在切片的每个元素中填入值 5。这一步演示了如何初始化和使用分配的内存。

  1. 打印值:
5 5 5 5 5 5 5 5 5 5

打印切片的值,确认内存分配和初始化成功。这一步验证程序是否正确访问和使用了分配的内存。

  1. 强制 GC(垃圾回收)

手动触发垃圾回收器,释放不再使用的内存。这一步强调 Go 的自动内存管理和清理过程,确保了资源的有效利用。

总结

堆内存是现代计算的重要方面,它实现了动态内存分配,使程序能在运行时有效管理内存。这种灵活性对于处理链表、树、图等动态数据结构至关重要,因为这些结构无法在编译时预先确定。了解堆内存对于开发人员编写高效、稳健的应用至关重要,可确保有效使用内存,并在不再需要时释放资源。

通过探讨堆内存在 Linux 中的工作原理以及 Go 如何管理动态内存分配,希望本文能为你提供有关内存管理内部运作的宝贵见解。掌握这些概念不仅有助于编写更好的代码,还有助于调试和优化应用程序。


你好,我是俞凡,在Motorola做过研发,现在在Mavenir做技术工作,对通信、网络、后端架构、云原生、DevOps、CICD、区块链、AI等技术始终保持着浓厚的兴趣,平时喜欢阅读、思考,相信持续学习、终身成长,欢迎一起交流学习。为了方便大家以后能第一时间看到文章,请朋友们关注公众号"DeepNoMind",并设个星标吧,如果能一键三连(转发、点赞、在看),则能给我带来更多的支持和动力,激励我持续写下去,和大家共同成长进步!

本文由mdnice多平台发布


俞凡
13 声望11 粉丝

你好,我是俞凡,在Motorola做过研发,现在在Mavenir做技术工作,对通信、网络、后端架构、云原生、DevOps、CICD、区块链、AI等技术始终保持着浓厚的兴趣,平时喜欢阅读、思考,相信持续学习、终身成长,欢迎一起...