[参考来源:硬核课堂](Golang 内存分配原理_哔哩哔哩_bilibili)
[内存分配文档](Golang 内存分配 - 飞书云文档 (feishu.cn))
内存管理
TCMalloc
-
tcmalloc 内存分配分为ThreadCache、CentralCache、PageHeap 三个层次。
-
ThreadCache 是每一个线程的缓存,分配时不需要加锁,速度比较快。ThreadCache中对于每一个size class维护一个单独的FreeList,缓存还没有分配的空闲对象。
-
8Bytes 16Bytes 32Bytes 。。。32KB
-
CentralCache 也同样为每一个size class维护一个FreeList,但是是所有线程公用的,分配时需要加锁。
-
8Bytes 16Bytes 32Bytes 。。。32KB
-
CentralCache 中内存不够时,会从PageHeap中申请。CentralCache 从PageHeap 中申请的内存,可能来自于PageHeap 的缓存,也可能是PageHeap 从操作系统中申请的新的内存。PageHeap 内部,对于128个Page 以内的span,会都用一个链表来缓存,超过了128个Page的span,则存储于一个有序的set。
- 减少锁的争用
- 减少了系统调用,上下文的切换
内存管理组件
Go 语言的内存分配器包含内存管理单元、线程缓存、中心缓存和页堆几个重要组件,本节将介绍这几种最重要组件对应的数据结构 mspan
、mcache
、mcentral
和 mheap
Golang中也实现了内存分配器,原理简单的说:维护一块大的全局内存,每个线程(Golang中为P)维护一块小的私有内存,私有内存不足再从全局申请。
所有的 Go 语言程序都会在启动时初始化如上图所示的内存布局,**每一个处理器(P)都会分配一个线程缓存 mcache
**用于处理微对象和小对象的分配,它们会持有内存管理单元 mspan
。
每个类型的内存管理单元都会管理特定大小的对象,(8B,16B,32B…32KB)当内存管理单元中不存在空闲对象时,它们会从 mcentral
中获取新的内存单元,中心缓存属于全局的堆结构体 mheap
,mheap
会从操作系统中申请内存。
内存管理单元 mspan
mspan是内存管理的一个基本单元,每个mspan会对应一个大小等级(67种),小对象(16B-32KB)类型的堆对象会根据其大小分配到相应设定好大小等级mspan上分配内存。
mspan是Go语言内存管理的基本单元,串联后的结构体是一个双向链表。
页和内存:
Span(跨度):一段连续的内存空间
每个 mspan
都管理 npages
个大小为 8KB 的页
type mspan struct {
startAddr uintptr // 起始地址
npages uintptr // 页数
freeindex uintptr
allocBits *gcBits
gcmarkBits *gcBits
allocCache uint64
...
}
当结构体管理的内存不足时,运行时会以页为单位向堆申请内存
跨度类 Spanclass
spanClass
是 mspan
的跨度类,它决定了内存管理单元中存储的对象大小和个数:
Spanclass – 标记 span 类型
type mspan struct {
...
spanclass spanClass
...
}
Go 语言的内存管理模块中一共包含 67 种跨度类(8B 到 32KB),每一个跨度类都会存储特定大小的对象并且包含特定数量的页数以及对象 .
除了上述 67 个跨度类之外,运行时中还包含 ID 为 0 的特殊跨度类,它能够管理大于 32KB 的特殊对象.
spanClass
是一个 uint8
类型的整数,它的前 7 位存储着跨度类的 ID,最后一位表示是否包含指针,该类型提供的两个方法能够帮我们快速获取对应的字段.
线程缓存mcache
mcache
是 Go 语言中的线程缓存,它会与线程上的处理器(GMP - P)一一绑定。每个线程,分配一个mcache用于处理微对象和小对象的分配。因为是每个线程独有的,不需要加锁。
-
mcache会持有tiny相关字段用于微对象内存分配。
-
mcache会持有mspan用于小对象内存分配
alloc:
用于分配内存的mspan数组。
组大小为span类型总数的2倍(67 * 2),即每种span类型都有两个mspan,一个表示的对象中包含了指针(noscan),另一个中表示的对象不含有指针(scan)。
mcache在刚刚被初始化时alloc中mspan是空的占位符emptymspan。当mcache中mspan的空闲内存不足时,会向mcentral组件请求获取mspan。
微分配器 TinyAllocator
中心缓存 mcentral
mcentral
是内存分配器的中心缓存,与mcache不同,mcentral是公共资源,会有多个线程的mcache向mcentral申请mspan,因此访问mcentral中的mspan时需要使用互斥锁。
每个中心缓存都会管理某个跨度类的内存管理单元,它会同时持有两个 runtime.spanSet
,分别存储包含空闲对象和不包含空闲对象的内存管理单元。
从 mcentral 中申请资源的时候,会按照有空闲空间的 span 列表 --> 无空闲空间的 span 列表的顺序申请 mspan,如果获取失败,会从 mheap 中申请 mspan
页堆 mheap
内存分配的核心组件,包含mcentral和heapArena,堆上所有mspan都是从mheap结构分配来的。
- arenas:heapArena数组,用于管理一个个内存块
预申请的内存划分为spans、bitmap、arena三部分。其中arena即为所谓的堆区,应用中需要的内存从这里分配。其中spans和bitmap是为了管理arena区而存在的。
内存分配
-
微对象
(0, 16B)
— 先使用微型分配器,再依次尝试线程缓存、中心缓存和堆分配内存; -
小对象
[16B, 32KB]
— 依次尝试使用线程缓存、中心缓存和堆分配内存; -
大对象
(32KB, +∞)
— 直接在堆上分配内存;
微对象
Go 语言运行时将小于 16 字节的对象划分为微对象,它会使用线程缓存上的微分配器提高微对象分配的性能,我们主要使用它来分配较小的字符串以及逃逸的临时变量。
微分配器可以将多个较小的内存分配请求合入同一个内存块中,只有当内存块中的所有对象都需要被回收时,整片内存才可能被回收。
微分配器管理的对象不可以是指针类型,管理多个对象的内存块大小 maxTinySize
是可以调整的
小对象
- 确定分配对象的大小以及跨度类
runtime.spanClass
; - 从线程缓存、中心缓存或者堆中获取内存管理单元并从内存管理单元找到空闲的内存空间;
- 调用
runtime.memclrNoHeapPointers
清空空闲内存中的所有数据;
大对象
运行时对于大于 32KB 的大对象会单独处理,我们不会从线程缓存或者中心缓存中获取内存管理单元,而是直接调用 mcache.allocLarge
分配大片内存
申请内存时会创建一个跨度类为 0 的 spanClass
并调用 mheap.alloc
分配一个管理对应内存的管理单元。
总结:
Go 的内存分配器在分配对象时,根据对象的大小,分成三类:小对象(小于等于 16B)、一般对象(大于 16B,小于等于 32KB)、大对象(大于 32KB)。
大体上的分配流程:
1.32KB 的对象,直接从 mheap 上分配;
2.<=16B 的对象使用 mcache 的 tiny 分配器分配;
3.(16B,32KB] 的对象,首先计算对象的规格大小,然后使用 mcache 中相应规格大小的 mspan 分配;
4.如果 mcache 没有相应规格大小的 mspan,则向 mcentral 申请
5.如果 mcentral 没有相应规格大小的 mspan,则向 mheap 申请
6.如果 mheap 中也没有合适大小的 mspan,则向操作系统申请
逃逸分析
逃逸分析是一种静态分析,在编译阶段执行。每当函数中申请新的对象,编译器会跟据该对象是否被函数外部引用来分析,决定变量应该在栈上分配,还是在堆上分配。
遵循两个不变性:
- 指向栈对象的指针不能存在于堆中;
- 指向栈对象的指针不能在栈对象回收后存活;
主要策略:
- 如果函数外部没有引用,则优先放到栈中;
- 如果函数外部存在引用,则必定放到堆中;
场景:
- 函数返回局部变量指针引起的逃逸
函数返回局部变量指针,则对应内存会发生逃逸:
package main
type Person struct {
Name string
Age int
}
func NewPerson(name string, age int) *Person {
p := new(Person)
p.Name = name
p.Age = age
return p
}
func main() {
NewPerson("liuxin.me", 18)
}
➜ test go build -gcflags=-m
# code.byted.org/webarch/test
./test.go:8:6: can inline NewPerson
./test.go:14:6: can inline main
./test.go:15:11: inlining call to NewPerson
./test.go:8:16: leaking param: name
./test.go:9:10: new(Person) escapes to heap
./test.go:15:11: new(Person) does not escape
- 动态类型引起的逃逸
package main
import (
"fmt"
)
func main() {
p := "liuxin.me"
fmt.Println(p)
}
➜ test go build -gcflags=-m
# code.byted.org/webarch/test
./test.go:9:13: inlining call to fmt.Println
./test.go:9:13: p escapes to heap
./test.go:9:13: []interface {} literal does not escape
<autogenerated>:1: .this does not escape
- 栈空间不足引起的逃逸
package main
func NewSlice() {
s1 := make([]int, 100, 100)
for index, _ := range s1 {
s1[index] = index
}
s2 := make([]int, 100000, 100000)
for index, _ := range s2 {
s2[index] = index
}
}
func main() {
NewSlice()
}
➜ test go build -gcflags=-m
# code.byted.org/webarch/test
./test.go:15:6: can inline main
./test.go:4:12: make([]int, 100, 100) does not escape
./test.go:9:12: make([]int, 100000, 100000) escapes to heap
- 闭包引用的逃逸
闭包函数中没有定义变量i的,而是引用了它所在函数f中的变量i,变量i发生逃逸。
package main
func f(i int) func() int {
return func() int {
i++
return i
}
}
func main() {
add := f(1)
add()
add()
}
➜ test go build -gcflags=-m
# code.byted.org/webarch/test
./test.go:4:9: can inline add.func1
./test.go:3:10: moved to heap: i
./test.go:4:9: func literal escapes to heap
如何利用逃逸分析提升性能
传值 VS 传指针
传值会拷贝整个对象,而传指针只会拷贝指针地址,指向的对象是同一个。传指针可以减少值的拷贝,但是会导致内存分配逃逸到堆中,增加垃圾回收(GC)的负担。在对象频繁创建和删除的场景下,传递指针导致的 GC 开销可能会严重影响性能。
一般情况下,对于需要修改原对象值,或占用内存比较大的结构体,选择传指针。对于只读的占用内存较小的结构体,直接传值能够获得更好的性能
ne add.func1
./test.go:3:10: moved to heap: i
./test.go:4:9: func literal escapes to heap
## 如何利用逃逸分析提升性能
### 传值 VS 传指针
传值会拷贝整个对象,而传指针只会拷贝指针地址,指向的对象是同一个。传指针可以减少值的拷贝,但是会导致内存分配逃逸到堆中,增加垃圾回收(GC)的负担。在对象频繁创建和删除的场景下,传递指针导致的 GC 开销可能会严重影响性能。
一般情况下,对于需要修改原对象值,或占用内存比较大的结构体,选择传指针。对于只读的占用内存较小的结构体,直接传值能够获得更好的性能