开发者

一文搞懂Go语言堆内存原理小结

目录
  • 一、基本概念理解
    • 1.1 什么是堆内存?
    • 1.2 堆内存的特点
    • 1.3 前置知识:栈与堆的根本区别
    • 1.4 堆内存如何工作?
    • 1.5 核心原理:Go 对象如何分配到堆上?
    • 1.6 Go 内存分配器:TCMalloc 的继承与演进
  • 二、Go 如何管理堆内存
    • 2.1 案例
    • 2.2 内存分配中的系统调用
    • 2.3 内存分配过程的各个阶段:
  • 三、 堆内存管理:三级结构
    • 3.1 第编程客栈一级:mcache (Per-P Cache)
    • 3.2 第二级:mcentral (Central Cache)
    • 3.3 第三级:mheap (Heap Manager)
    • 3.4 基础单元:mspan (Memory Span)

一、基本概念理解

1.1 什么是堆内存?

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

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

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

                     进程内存布局

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

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

1.2 堆内存的特点

动态分配:内存在运行时申请、释放。

可变大小:分配的内存大小可以变化。

基于指针的管理:使用指针访问和控制内存。

+ - - - - - - - - - - -+
| 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.3 前置知识:栈与堆的根本区别

要理解堆,必先理解栈。在 Go 程序中,每个 Goroutine 都有一个独立的,而所有 Goroutine 共享一个

特性
所有权Goroutine 独有进程内所有 Goroutine 共享
分配与释放编译器/运行时自动管理,函数入栈时分配,出栈时释放,速度极快垃圾回收器管理,分配相对较慢,释放时机不确定(GC时)
大小小而固定(初始几KB,可动态增长,但有上限)非常大(可达 TB 级别,受限于系统内存)
存储数据函数参数、局部变量、返回地址等生命周期明确的数据生命周期不确定的数据,比如函数返回后仍需被访问的对象
访问速度快(连续内存,CPU缓存友好)慢(内存不连续,可能涉及系统调用)

1.4 堆内存如何工作?

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

主要组成部分:

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

1.5 核心原理:Go 对象如何分配到堆上?

当一个 Goroutine 需要在堆上分配内存时,流程如下:

  1. 获取 P:该 Goroutine 绑定到某个 P(逻辑处理器)上。
  2. 查找 mcache:从 P 中获取其专属的内存缓存 mcache
  3. 大小分级:根据要分配的对象大小,选择合适的规格:
    • 微小对象 (< 16 bytes):直接在 mcache 中用一个专门的 tiny 对象来处理,避免浪费。
    • 小对象 (< 32KB):在 mcache 中寻找对应大小规格的 mspan(内存跨度)来分配。
    • 大对象 (>= 32KB):直接跳过 mcachemcentral,向全局的 mheap 申请内存。

      关键点:绝大多数对象都是小对象,它们的分配都可以在 mcache 中无锁完成,速度极快。

核心问题:编译器如何决定一个对象放栈上还是堆上?

答案是:逃逸分析

Go 编译器会分析代码,如果一个局部变量的作用域超出了它所在的函数(即“逃逸”了),那么它就必须被分配在堆上。如:

// 情况一:不逃逸,分配在栈上
func stackExample() int {
    x := 10  // x 的生命周期只在 stackExample 函数内
    return x
}
// 情况二:逃逸,分配在堆上
func heapExample() *int {
    x := 10  // x 的指针被返回,作用域超出了函数,x 逃逸到堆上
    return &x
}

可以使用 go build -gcflags="-m" 命令来查看逃逸分析的结果:

$ go build -gcflags="-m" main.go
# command-line-arguments
./main.go:10:6: can inline heapExample
./main.go:11:2: leaking param: x
./main.go:11:2: moved to heap: x

moved to heap: x 明确告诉我们变量 x 被分配到了堆上。

1.6 Go 内存分配器:TCMalloc 的继承与演进

Go 的内存分配器高度借鉴了 Google 的 TCMalloc。其核心思想是:避免多线程竞争,将内存管理工作分摊到每个处理器(P)上

这带来了两个核心设计:

  1. 无锁化:每个 P 都有自己的本地内存缓存,大部分分配操作都在 P 内部完成,无需加锁。
  2. 分级管理:将内存按大小分为不同级别,用不同策略管理,提高分配效率。

二、Go 如何管理堆内存

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

2.1 案例

我们通过一个简单的 Go 程序来理解,该程序为整数片段分配内存、初始化数值并打印。(main.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(Centos系统可通过:yum install strace安装)来跟踪 Go 程序进行的系统调用。

2.2 内存分配中的系统调用

$ 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) = 0x7f50caa6b000
mmap(NULL, 131072, PROT_NONE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f50caa4b000
mmap(NULL, 10javascript48576, PROT_NONE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f50ca94b000
mmap(NULL, 8388608, PROT_NONE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f50ca14b000
mmap(NULL, 67108864, PROT_NONE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f50c614b000
mmap(NULL, 536870912, PROT_NONE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f50a614b000
mmap(NULL, 536870912, PROT_NONE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f508614b000
mmap(0xc000000000, 67108864, PROT_NONE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0xc000000000
mmap(NULL, 33554432, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f508414b000
mmap(NULL, 69648, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f5084139000
mmap(0xc000000000, 4194304, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_ANONYMOUS, -1, 0) = 0xc000000000
mmap(0x7f50caa4b000, 131072, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_ANONYMOUS, -1, 0) = 0x7f50caa4b000
mmap(0x7f50ca9cb000, 4096, PROT_READ|PROT_WRITE, javascriptMAP_PRIVATE|MAP_FIXED|MAP_ANONYMOUS, -1, 0) = 0x7f50ca9cb000
mmap(0x7f50ca551000, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_ANONYMOUS, -1, 0) = 0x7f50ca551000
mmap(0x7f50c817b000, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_ANONYMOUS, -1, 0) = 0x7f50c817b000
mmap(0x7f50b62cb000, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_ANONYMOUS, -1, 0) = 0x7f50b62cb000
mmap(0x7f50962cb000, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_ANONYMOUS, -1, 0) = 0x7f50962cb000
mmap(NULL, 1048576, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f5084039000
mmap(NULL, 65536, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f5084029000
mmap(NULL, 65536, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f5084019000
mmap(NULL, 1439992, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f5083eb9000
strace: Process 1425438 attached
[pid 1425437] mmap(NULL, 262144, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f5083e79000
strace: Process 1425439 attached
strace: Process 1425440 attached
[pid 1425437] mmap(NULL, 262144, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f5083e39000
strace: Process 1425441 attached
5 5 5 5 5 5 5 5 5 5 
[pid 1425437] mmap(NULL, 65536, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f5083e29000
[pid 1425440] +++ exited with 0 +++
[pid 1425439] +++ exited with 0 +++
[pid 1425438] +++ exited with 0 +++
[pid 1425441] +++ 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 标记,指定内存应映射到特定地址,通常用于直接管理特定内存区域。

2.3 内存分配过程的各个阶段:

+ - - - - - - - - - - -+
| Ini编程客栈tialize 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             | ←-javascript 强制垃圾回收
| - - - - - - - - - - -|

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

  • 1、初始化切片[0, 0, 0, 0, 0, 0, 0, 0, 0, 0],切片(动态数组)的初始状态为 10 个元素,全部设置为 0。这一步展示了 Go 如何为切片分配内存。
  • 2、设置值[5, 5, 5, 5, 5, 5, 5, 5, 5, 5] ,然后,在切片的每个元素中填入值 5。这一步演示了如何初始化和使用分配的内存。
  • 3、打印值5 5 5 5 5 5 5 5 5 5,打印切片的值,确认内存分配和初始化成功。这一步验证程序是否正确访问和使用了分配的内存。
  • 4、强制 GC(垃圾回收):手动触发垃圾回收器,释放不再使用的内存。这一步强调 Go 的自动内存管理和清理过程,确保了资源的有效利用。

三、 堆内存管理:三级结构

Go 的堆内存管理是一个精密的三级(或四级)结构,理解它就理解了 Go 内存管理的核心。

3.1 第一级:mcache (Per-P Cache)

  • 是什么:每个 P 都有一个独立的 mcache
  • 作用:存储各种大小规格的 mspan 的空闲列表。
  • 特点无锁分配。因为 P 同一时间只能被一个 Goroutine 占用,所以从 mcache 分配内存是线程安全的,无需加锁。这是 Go 高并发内存分配性能高的关键。

3.2 第二级:mcentral (Central Cache)

  • 是什么:全局的内存中心,所有 P 共享。它按 spanclass(大小规格)分为多个 mcentral
  • 作用:当 mcache 中的 mspan 不够用时,P 会向对应的 mcentral 申请新的 mspan
  • 特点需要加锁。因为多个 P 可能同时向同一个 mcentral 申请内存,所以需要加锁保证线程安全。

3.3 第三级:mheap (Heap Manager)

  • 是什么:全局唯一的堆内存管理器,掌管着所有从操作系统申请来的大块内存。
  • 作用
    1. 管理 mcentral,当 mcentralmspan 不足时,向 mheap 申请。
    2. 直接处理大对象(>= 32KB)的分配请求。
    3. 当内存不足时,向操作系统申请更多内存(调用 mmap)。

3.4 基础单元:mspan (Memory Span)

  • 是什么mcachemcentralmheap 之间流转的基本单位。它是一段连续的内存地址,由多个页组成。
  • 作用mspan 会被划分为特定大小的块,用于存储同一种规格的对象。例如,一个 mspan 可能专门用来存放所有 16 字节大小的对象。

流程串联: Goroutine -> mcache (无锁) -> mcentral (加锁) -> mheap (全局锁) -> OS

到此这篇关于一文搞懂Go语言堆内存原理小结的文章就介绍到这了,更多相关Go语言 堆内存内容请搜索编程客栈(www.devze.com)以前的文章或继续浏览下面的相关文章希望大家以后多多支持编程客栈(www.devze.com)!

0

上一篇:

下一篇:没有了

精彩评论

暂无评论...
验证码 换一张
取 消

最新开发

开发排行榜