Go 语言的内存分配机制是一个复杂且高效的系统,旨在为程序提供快速和安全的内存管理。理解 Go 的内存分配有助于编写更高效的代码,并优化程序性能。
一、内存区域
-
栈(Stack)
- 栈用于函数调用时的临时变量分配。
- 栈上的内存在函数返回后自动释放,因此栈上的分配非常快速。
- Go 编译器会尽量将局部变量放在栈上,但如果变量需要在函数返回后继续存在,则会被提升到堆上。
- 每个 goroutine 都有自己的栈,初始大小较小(通常为几 KB),可以动态增长。
-
堆(Heap)
- 堆用于动态内存分配,生命周期由垃圾回收器管理。
- 在 Go 中,通过
new
或make
函数进行显式或隐式堆上对象创建。
示例代码:
package mainimport "fmt"func main() {stackTest()heapTest()
}func stackTest() {stackVar := 1123fmt.Println(stackVar)
}func heapTest() *int {heapVar := 1234fmt.Println(heapVar)return &heapVar
}
变量 stackVar
是一个局部变量,分配在栈上。当 stackTest
函数返回时,stackVar
的内存会自动释放。
当变量需要在函数返回后继续存在时,它们会被分配到堆上,所以变量 heapVar
需要在 heapTest
函数返回后继续存在,Go 编译器通过逃逸分析决定将 heapVar
分配到堆上。
二、内存管理
-
逃逸分析(Escape Analysis)
- 编译器通过逃逸分析决定变量应该放置在栈还是堆上。如果一个变量被认为“逃逸”出其作用域,它将被提升到堆中。
- 可以使用
go build -gcflags="-m"
来查看编译器的逃逸分析结果。
-
垃圾回收(Garbage Collection, GC)
- Go 使用并发标记-清除算法进行垃圾回收,以减少停顿时间和提高吞吐量。
- 垃圾回收自动处理不再使用的对象,从而避免手动释放资源导致的问题。
-
同步与并发支持
- Goroutine 是轻量级线程,由运行时调度器管理,通常共享同一地址空间以减少上下文切换开销。
2.1 逃逸分析
使用 go build -gcflags="-m"
分析上面代码的逃逸结果
➜ _43memory go build -gcflags="-m" test_main.go
# command-line-arguments
./test_main.go:13:13: inlining call to fmt.Println
./test_main.go:18:13: inlining call to fmt.Println
./test_main.go:13:13: ... argument does not escape
./test_main.go:13:14: stackVar escapes to heap
./test_main.go:17:2: moved to heap: heapVar
./test_main.go:18:13: ... argument does not escape
./test_main.go:18:14: heapVar escapes to heap
- Inlining call to fmt.Println: 这表明Go编译器尝试内联优化对
fmt.Println
的调用。内联是一种常见的优化技术,可以减少函数调用开销。 - … argument does not escape: 表示传入
fmt.Println()
的参数没有从该方法中逃逸出去。即使如此,由于可能性和潜在的持久引用问题,“不逃逸”并不意味着不会移动到堆上。 - stackVar escapes to heap: 尽管参数本身没有从打印方法中直接“逃脱”,但由于某种原因(如下所述),它仍然需要被移到堆上以确保正确性。
- moved to heap: heapVar: 明确指出由于返回其地址的需求,
heapVar
被移动到了堆空间。
为什么 stackVar
会逃逸?
stackVar
是一个局部变量,理论上应该在栈上分配。- 然而,在编译时发现
fmt.Println(stackVar)
可能需要该变量逃逸到堆上。原因是fmt.Println
可能会保留对其参数的引用(例如,在内部传递给其他函数或存储于全局变量中),导致stackVar
需要在函数返回后继续存在。
三、内存池化
sync.Pool
是 Go 语言标准库中的一个缓存池类型,它用于存储和复用临时对象,以减少内存分配和垃圾回收的压力。例如,在频繁创建和销毁大量小对象场景下,可以使用 sync.Pool
来提高性能。
sync.Pool
是一个并发安全的类型,可以在多个 goroutine 之间共享。
3.1 关键特性
-
自动垃圾回收:
sync.Pool
中存储的对象会在不再使用时被垃圾回收器自动清理。这意味着即使你忘记将对象放回池中,也不会导致内存泄漏。
-
并发安全:
sync.Pool
是线程安全的,可以在多个 goroutine 中同时使用,而无需额外同步机制。
-
生命周期管理:
- 对象从池中获取后,其生命周期由调用者管理。当调用者不再需要该对象时,应将其放回池中以供后续重用。
-
惰性初始化:
- 当从
sync.Pool
获取一个空闲对象时,如果没有可用实例,且定义了New()
函数,则会调用该函数创建一个新实例。
- 当从
-
GC 清理行为:
- 每次进行垃圾回收(GC)时,所有未被引用到外部变量或结构体字段中的 pool 对象都会被清除。这是为了避免长时间持有不必要的数据而导致内存浪费。
3.2 使用方法
type User struct {Id stringName string
}func Test1(t *testing.T) {// 创建一个对象池,并定义 New 函数pool := sync.Pool{New: func() interface{} {return User{Id: uuid.New().String(),Name: "A",}},}// 通过 Get() 方法获取对象obj := pool.Get().(User)fmt.Println(obj.Id, obj.Name)// 通过 Put() 方法放回对象pool.Put(obj)objNew := pool.Get().(User)fmt.Println(objNew.Id, objNew.Name)
}
输出
31c09d56-f851-4f08-a151-d340164ca5cb A
31c09d56-f851-4f08-a151-d340164ca5cb A
四、分配策略
4.1 Arena 和 Span
4.1.1 什么是 Span?
Span
是一组连续的页(page),这些页被作为一个整体来管理。是内存分配器用来管理堆上内存的一种基本单位。- 在 64 位系统上,一个页通常为 8KB,因此一个 span 可以由多个这样的页面组成。
Span
用于为特定大小类别的小对象提供内存空间。
4.1.2 什么是 Arena?
Arena
是一大片连续的虚拟地址空间,用来为堆上的对象提供基础设施支持。- 一个
Arena
包含多个Span
。 Arenas
提供了一个大的、可控范围,而Spans
则负责具体的小对象或特定大小类别对象的分配。
4.1.3 Span 分割策略
-
按大小类别划分:
- 内存分配器将对象划分为不同的大小类别(size class),每个 size class 对应一组 span。
- 小对象(通常小于 32KB)会被放置在预先定义好的 size class 中,以便快速查找和复用。
-
固定大小块:
- 每个 span 被进一步划分成固定大小的块,这些块用于实际对象的放置。
- 块的大小取决于它所属的 size class。例如,一个用于 16 字节对象的 span 会被划分成多个 16 字节的小块。
-
空闲与使用状态:
- 一个 span 可以处于多种状态:完全空闲、部分使用或完全使用。
- 当所有小块都被释放后,span 将返回到 mcentral 或 mheap,以供其他请求使用。
-
动态调整与合并:
- 如果某个 size class 的需求增加,Go 会从全局堆中获取新的页面,并创建更多相应类型的新 spans。
- 如果某些 spans 长时间未被使用,它们可能会合并回更大的可用空间,以减少碎片化并提高效率。
-
垃圾回收协作:
- 在垃圾回收过程中,会扫描所有活跃 spans 并标记其中仍然有效的数据;
- 回收无效数据所占据资源,并将其重新加入到 free list 中待后续利用;
4.2 三层架构
Go 语言的内存分配器采用了一个三层架构,包括 mcache
、mcentral
和 mheap
,以高效地管理内存分配和回收。这个架构旨在优化小对象的分配速度,并减少锁竞争,从而提高并发性能。
三层架构可以简单的抽象理解为三层缓存!
4.2.1 mcache
-
概念:
mcache
是每个 P(Processor,即 Go 的调度器中的逻辑处理器)私有的缓存,用于快速分配小对象。
-
作用:
- 提供线程本地化(Thread-local)的内存缓存,以减少全局锁争用,提高小对象分配效率。
-
工作机制:
- 每个 P 都有一个独立的
mcache
,用于缓存从mcentral
获取的小块内存(span)。 - 当 goroutine 请求一个小对象时,首先尝试从其所在 P 的
mcache
中获取。如果没有可用空间,则从mcentral
获取新的 span。
- 每个 P 都有一个独立的
4.2.2 mcentral
-
概念:
mcentral
是多个 P 共享的中央缓存,用于管理相同大小类的小块内存。
-
作用:
- 管理和组织不同大小类的小块内存,为各个 P 的
mcache
提供资源。
- 管理和组织不同大小类的小块内存,为各个 P 的
-
工作机制:
- 按照不同大小类别组织 span,每种大小类别都有对应的 free list。
- 当某个 P 的
mcache
缺少特定大小类别时,会向对应的mcentral
请求新的 span。
4.2.3 mheap
-
概念:
- 全局堆,是整个程序共享的一大片虚拟地址空间,用于大规模或长生命周期数据块请求来源地。
-
作用:
- 管理所有空闲的大型数据块和未使用页;
- 为上层提供基础设施支持;
-
工作机制
- 在需要大型数据块或者无法通过上两级满足请求情况下,直接与操作系统交互申请更多物理页;
- 将新申请到物理页划分成更细粒度单位并加入到相应 free list 中待后续使用;
4.2.4 内部运作流程
-
当一个 goroutine 发起一次小型对象请求时,它会首先检查当前所属 Processor 上是否存在足够容量可用自有资源池中;
-
如果找不到合适位置,则向中央资源池发起进一步请求以获取所需类型 Span;
-
若仍然无法满足需求则最终通过全局堆与底层操作系统进行直接交互来扩展整体虚拟地址空间范围;
4.3 小对象大对象
4.3.1 小对象
- 范围一般指大小在 16 字节到 32KB 之间的对象。
- 分配策略
- 小对象通过
mcache
和mcentral
来进行快速分配。 - 使用固定大小类别(size class)来组织这些小型数据块,每个 size class 对应一个特定大小的 span。
- 当请求一个小型数据块时,Go 会从相应 size class 的 span 中获取可用空间。
- 小对象通过
4.3.2 大对象
- 范围:指大小大于 32KB 的对象。
- 分配策略:
- 大对象直接从全局堆(mheap)中进行分配,而不是通过 mcache 或 mcentral。
- 因为大对象可能会跨越多个页面,因此需要专门处理以确保它们能够被高效地回收和重用。
- 由于可能并发访问,所以通常需要加锁获取。