内存管理
何为内存?
- 来源:基于内存条
- 作用:程序逻辑临时使用时用的变量、全局变量、静态库、函数跳转地址、执行代码、临时开辟的结构体对象
内存为什么需要管理?
- 内存中存储的数据越来越多,导致物理内存不足[每个程序索要的内存是程序的极限内存](需要提高内存的利用率和合理分配性)
对于一些语言如:go、java、python,为了能让开发者更关注程序本身的逻辑,提供了一套针对自身程序的内存管理模式:虚拟内存
内存管理的方式
操作系统存储模型
观察上图,我们可以从中捕捉到的关键词是:
- 多级模型
- 动态切换
操作系统是怎么管理内存的?
存在问题:
- 物理内存无法最大化使用
- 程序逻辑内存使用空间独立
- 物理内存不足
解决手段:
- 读时共享
- 写时复制
- 内存不足时部分磁盘虚拟化
虚拟内存与物理内存
虚拟内存:一连串连续的逻辑内存,逻辑内存地址映射真实物理内存地址
物理内存:真实基于内存条的实际内存。
认识虚拟内存
虚拟内存的作用:
- 在用户与硬件间添加中间代理层(没有什么是加一个中间层解决不了的)
- 优化用户体验(进程感知到获得的内存空间是“连续”的)
- “放大”可用内存(虚拟内存可以由物理内存+磁盘补足,并根据冷热动态置换,用户无感知)
分页管理
操作系统中通常会将虚拟内存和物理内存切割成固定的尺寸,于虚拟内存
而言叫作“页”
,于物理内存
而言叫作“帧”
,原因及要点如下:
- 提高内存空间利用(以页为粒度后,消灭了不稳定的外部碎片,取而代之的是相对可控的内部碎片)
- 提高内外存交换效率(更细的粒度带来了更高的灵活度)
- 与虚拟内存机制呼应,便于建立虚拟地址->物理地址的映射关系(聚合映射关系的数据结构,称为页表)
- linux 页/帧的大小固定,为 4KB(这实际是由实践推动的经验值,太粗会增加碎片率,太细会增加分配频率影响效率)
内部碎片:比如:页内的内存浪费(7字节可能分配8字节的空间;内存对齐补齐的内存;内存池的预留内存)
外部碎片:比如:内存分配对象大小、释放时机(不可控)[需要考虑物理内存的连续性,比如:剩余两块内存,4KB和2KB的内存块。现在需要6KB的连续物理空间,不存在则分配内存失败]
但是,此时如果存在分页管理,外部的空闲内存块以页帧为单位管理。只要页帧足够,就可以进行内存分配,不需要连续的内存块。这就实现了彻底消除外部碎片的效果。
Golang 内存模型
首先,golang中借用操作系统的存储模型和TCMalloc。理解 TCMalloc 可以有助于理解 go内存管理。虽然之后 Go的内存管理 与TCMalloc不一致地方在不断扩大,但其主要思想、原理和概念都是和TCMalloc一致的…
- 借鉴了操作系统的多级缓存和分页管理的机制
- 借鉴了TCMalloc。TCMalloc是 Google 开发的一款高效内存分配器,TCMalloc 通过独特设计减少锁竞争,提升多线程环境下内存分配和释放效率 。
TCMalloc核心概念
- Page
操作系统对内存管理以页为单位,TCMalloc也是这样,只不过TCMalloc里的Page大小与操作系统里的大小并不一定相等,而是倍数关系。《TCMalloc解密》里称x64下Page大小是8KB。
- Span
一组连续的Page被称为Span,比如可以有2个页大小的Span,也可以有16页大小的Span,Span比Page高一个层级,是为了方便管理一定大小的内存区域,Span是TCMalloc中内存管理的基本单位。
- ThreadCache
ThreadCache是每个线程各自的Cache,一个Cache包含多个空闲内存块链表,每个链表连接的都是内存块,同一个链表上内存块的大小是相同的,也可以说按内存块大小,给内存块分了个类,这样可以根据申请的内存大小,快速从合适的链表选择空闲内存块。由于每个线程有自己的ThreadCache,所以ThreadCache访问是无锁的。
- CentralCache
CentralCache是所有线程共享的缓存,也是保存的空闲内存块链表,链表的数量与ThreadCache中链表数量相同,当ThreadCache的内存块不足时,可以从CentralCache获取内存块;当ThreadCache内存块过多时,可以放回CentralCache。由于CentralCache是共享的,所以它的访问是要加锁的。
- PageHeap
PageHeap是对堆内存的抽象,PageHeap存的也是若干链表,链表保存的是Span。当CentralCache的内存不足时,会从PageHeap获取空闲的内存Span,然后把1个Span拆成若干内存块,添加到对应大小的链表中并分配内存;当CentralCache的内存过多时,会把空闲的内存块放回PageHeap中。
如下图所示,分别是1页Page的Span链表,2页Page的Span链表等,最后是large span set,这个是用来保存中大对象的。毫无疑问,PageHeap也是要加锁的。
前文提到了小、中、大对象,Go内存管理中也有类似的概念,我们看一眼TCMalloc的定义:
- 小对象大小:0~256KB
- 中对象大小:257~1MB
- 大对象大小:>1MB
小对象的分配流程:ThreadCache -> CentralCache -> HeapPage,大部分时候,ThreadCache缓存都是足够的,不需要去访问CentralCache和HeapPage,无系统调用配合无锁分配,分配效率是非常高的。
中对象分配流程:直接在PageHeap中选择适当的大小即可,128 Page的Span所保存的最大内存就是1MB。
大对象分配流程:从large span set选择合适数量的页面组成span,用来存储数据。
go内存管理核心概念
free
(空闲内存管理相关)scav
(垃圾回收相关 )arenas
区域是堆内存的具体分区,用于存放实际数据Page
与TCMalloc中的Page相同,x64架构下1个Page的大小是8KB。上图的最下方,1个浅蓝色的长方形代表1个Page。
- Span(即mspan)
Span与TCMalloc中的Span相同,Span是内存管理的基本单位,代码中为mspan,一组连续的Page组成1个Span,所以上图一组连续的浅蓝色长方形代表的是一组Page组成的1个Span,另外,1个淡紫色长方形为1个Span。
mcache与TCMalloc中的ThreadCache类似,mcache保存的是各种大小的Span,并按Span class分类,小对象直接从mcache分配内存,它起到了缓存的作用,并且可以无锁访问。但是mcache与ThreadCache也有不同点,TCMalloc中是每个线程1个ThreadCache,Go中是每个P拥有1个mcache。因为在Go程序中,当前最多有GOMAXPROCS个线程在运行,所以最多需要GOMAXPROCS个mcache就可以保证各线程对mcache的无锁访问,线程的运行又是与P绑定的,把mcache交给P刚刚好。
- mcentral
mcentral与TCMalloc中的CentralCache类似,是所有线程共享的缓存,需要加锁访问。它按Span级别对Span分类,然后串联成链表,当mcache的某个级别Span的内存被分配光时,它会向mcentral申请1个当前级别的Span。
但是mcentral与CentralCache也有不同点,CentralCache是每个级别的Span有1个链表,mcentral是每个级别的Span有2个链表,这和mcentral申请内存有关,有指针(contains pointers)的 Span 链表[存储包含 Go 指针的对象,这些 Span 需要参与垃圾回收标记过程。]和无指针(no pointers)的 Span 链表[存储纯值类型(如整数、浮点数、字符串等),这些 Span 在垃圾回收时可以直接跳过扫描,提高 GC 效率。].
mheap与TCMalloc中的PageHeap类似,它是堆内存的抽象,把从OS申请出的内存页组织成Span,并保存起来。当mcentral的Span不够用时会向mheap申请内存,而mheap的Span不够用时会向OS申请内存。mheap向OS的内存申请是按页来的,然后把申请来的内存页生成Span组织起来,同样也是需要加锁访问的。
但是mheap与PageHeap也有不同点:mheap把Span组织成了树结构,而不是链表,并且还是2棵树,然后把Span分配到heapArena进行管理,它包含地址映射和span是否包含指针等位图,这样做的主要原因是为了更高效的利用内存:分配、回收和再利用。
总体来说:
mheap
是全局内存池,管理所有堆内存,并包含所有mcentral
实例。(只有一个)mcentral
按span-class(对象大小规格 + 是否含指针)维护 mspan 链表:- 每个
span-class
对应一个mcentral
,负责为该规格分配 / 回收mspan
。 - 向
mheap
申请大块内存(mspan
),切割为小块对象后,供mcache
缓存。 - 数量和 span-class 相关(1:1)
- 每个
mcache
是每个P
独占的本地缓存,包含各 span-class 对应的 mspan 链表:- 从
mcentral
获取特定span-class
的mspan
,避免频繁加锁访问全局资源。 - 直接为
Goroutine
分配对象,提升分配效率。 - 数量和P相关(1:1)
- 从
span-class
定义mspan
管理的对象大小范围和属性(如是否含指针),是内存分层管理的核心规格。- span-class对应的是小对象(大对象是单独独占一个span)
- 67种
它们通过分层协作,实现了高效的并发内存分配与管理。
- object size:代码里简称size,指申请内存的对象大小。
- size class:代码里简称class,它是size的级别,相当于把size归类到一定大小的区间段,比如size[1,8]属于size class 1,size(8,16]属于size class 2。
- span class:指span的级别,但span class的大小与span的大小并没有正比关系。span class主要用来和size class做对应,1个size class对应2个span class,2个span class的span大小相同,只是功能不同,1个用来存放包含指针的对象,一个用来存放不包含指针的对象,不包含指针对象的Span就无需GC扫描了。
- num of page:代码里简称npage,代表Page的数量,其实就是Span包含的页数,用来分配内存。
GO内存分配
按对象大小分配:
0~16B,不包含指针的对象 Tiny分配(span-class)
0~16B,包含指针的对象 正常分配(span-class)
16B~32KB 正常分配(span-class)
32KB ~ ∞ 大对象分配(mspan独占)
分配调用流程:
GO 内存逃逸机制
一、内存逃逸的本质
Go 语言中的内存逃逸是编译器优化技术,核心目标是自动决定变量应该分配在栈上还是堆上。其触发条件是变量的生命周期超出当前函数的作用域。这种机制允许 Go 开发者无需手动管理内存(如 C++ 中的 new/delete),同时保证程序的内存安全。
二、逃逸分析的核心策略
逃逸分析的决策逻辑可总结为:
外部引用原则
如果变量在函数外部存在引用(如返回指针、赋值给全局变量等),必须分配在堆上
示例:
func escapeToHeap() *int { x := 10 // x逃逸到堆 return &x }
栈优先原则
如果变量仅在函数内部使用(无外部引用),优先分配在栈上
示例:
func stayOnStack() int { x := 10 // x分配在栈上 return x }
栈容量限制
- 即使无外部引用,如果变量占用内存过大(超过栈空间限制),仍会分配到堆上
- 具体阈值与 Go 版本和平台有关(通常为几 KB 到几十 KB)
动态类型约束
当变量类型为
interface{}
时(动态类型),由于编译期无法确定实际类型大小,必须逃逸到堆示例:
func dynamicTypeEscape() interface{} { x := struct{}{} // x逃逸到堆 return x }
引用类型发生二次间接引用极大可能逃逸
引用类型:func()函数类型;interface{}接口类型;slice;map;channel;*(指针类型);
二次间接引用场景举例:func(
[]string
) ; map[string]interface{}
; slice[*int
] ; chan[]stirng
…其中标中的类型为将要逃逸的类型
三、常见逃逸场景解析
1. 指针逃逸
场景:函数返回局部变量的指针
原因:指针在函数外部被引用,生命周期超出函数范围
示例:
func createPtr() *int { x := 10 // x逃逸到堆 return &x }
2. 栈空间不足逃逸
场景:创建大型数组或结构体
原因:栈空间有限(通常为几 MB),大型对象会导致栈溢出
示例:
func largeArray() { arr := [1000000]int{} // 可能逃逸到堆 }
3. 动态类型逃逸
场景:将对象赋值给 interface {} 类型
原因:interface {} 的底层实现需要指针和类型信息,编译期无法确定实际大小
示例:
func printAny(x interface{}) { fmt.Println(x) } func callPrintAny() { num := 42 // num逃逸到堆 printAny(num) }
4. 闭包引用逃逸
场景:闭包引用外部函数的局部变量
原因:闭包可能在外部函数返回后执行,需要保证变量生命周期
示例:
func closureEscape() func() int { x := 10 // x逃逸到堆 return func() int { return x } }
5. 接口方法调用
场景:通过接口调用方法
原因:接口调用需要动态分派,可能导致接收者逃逸
示例:
type Printer interface { Print() } type Data struct { Value int } func (d Data) Print() { fmt.Println(d.Value) } func interfaceEscape(p Printer) { p.Print() // 可能导致p的接收者逃逸 }
6. 切片动态扩容
场景:append 操作导致切片容量超过预分配值
原因:扩容时需要分配新的底层数组,并复制数据
示例:
func sliceEscape() { s := make([]int, 0, 1) s = append(s, 1) // 第一次append可能不逃逸 s = append(s, 2) // 第二次append可能导致扩容,底层数组逃逸到堆 }
四、逃逸分析的影响
- 性能影响
- 堆分配比栈分配慢(涉及 GC 开销)
- 过度逃逸会导致 GC 压力增大,降低程序性能
- 内存布局
- 栈上对象随函数返回自动回收
- 堆上对象需要 GC 周期回收,可能导致内存碎片
- 代码优化建议
- 尽量避免返回局部变量的指针
- 减少使用 interface {} 类型
- 预估切片容量,减少动态扩容
- 优先使用值接收者而非指针接收者(除非需要修改原对象)
五、如何检测逃逸
编译命令
go build -gcflags="-m -m" main.go
-m
选项显示逃逸分析信息- 多个
-m
会输出更详细的分析
典型输出示例
./main.go:10:9: &x escapes to heap: ./main.go:10:9: flow: ~r0 = &x: ./main.go:10:9: from &x (address-of) at ./main.go:11:9 ./main.go:10:9: from return &x (return) at ./main.go:11:2
通过理解和利用逃逸分析机制,Go 开发者可以写出更高效、更符合语言特性的代码,避免不必要的堆分配和 GC 压力。
GO 内存泄漏
内存泄漏定义
内存泄漏指程序中已分配的内存,在使用完毕后因未被正确释放或无法被回收,导致这部分内存资源持续占用,无法重新分配利用。随着时间推移,泄漏的内存不断累积,最终会耗尽系统资源,引发程序性能严重下降、响应迟缓,甚至导致程序崩溃或系统不稳定。在 Go 语言中,尽管拥有自动垃圾回收(GC)机制,但由于代码逻辑缺陷,仍可能产生内存泄漏问题 。
常见涉及语言
- 手动内存管理语言(如 C/C++):开发者需手动使用
malloc
/free
、new
/delete
等函数分配和释放内存。一旦遗漏释放操作,或者释放逻辑错误(如重复释放、释放时机不当),就会造成内存泄漏。例如在 C 语言中,若动态分配内存后忘记调用free
函数:
int* ptr = (int*)malloc(sizeof(int));
// 使用ptr
// 未调用free(ptr),导致内存泄漏
- 自动 GC 语言(如 Go、Java):这类语言依赖自动垃圾回收机制来管理内存,理论上无需开发者手动释放内存。然而,若代码中存在对象的无效引用、循环引用、资源未关闭等情况,也会导致内存无法被 GC 回收,从而引发内存泄漏。例如在 Go 语言中,即使有 GC,错误的代码逻辑仍会导致泄漏。
常见场景
- 对象长期引用但不使用
- 原理:程序中存在对象被长期持有引用,但后续不再使用该对象,导致 GC 无法回收其占用的内存。例如,将对象放入全局变量或静态集合中,却不再对其进行任何操作。
- 示例:
var globalList []*MyObject
func createObject() {
obj := &MyObject{}
globalList = append(globalList, obj)
// 后续不再使用obj,但globalList持续引用,导致obj无法被回收
}
- 循环引用
- 原理:多个对象之间形成环形引用关系,导致每个对象都被其他对象引用,即使这些对象实际已不再被程序使用,GC 也无法识别并回收它们。虽然 Go 语言的 GC 采用三色标记法,一定程度上能处理循环引用,但复杂的数据结构仍可能出现问题。
- 示例:
type Node struct {
data int
next *Node
}
func createCycle() {
a := &Node{}
b := &Node{}
a.next = b
b.next = a
// a和b形成循环引用,若后续无其他引用,仍可能导致内存泄漏
}
- goroutine 永久性阻塞
- 原理:启动的 goroutine 因死锁、等待永远不会满足的条件(如无缓冲通道接收操作但无发送操作)等原因被永久阻塞,且 goroutine 内部持有的资源无法释放,随着大量阻塞 goroutine 的创建,会消耗大量内存资源。
- 示例:
func blockedGoroutine() {
ch := make(chan int)
go func() {
<-ch // 等待接收,但无其他协程向ch发送数据,导致该协程永久阻塞
}()
}
- 资源未关闭
- 原理:在操作文件、网络连接、数据库连接等资源时,若未调用对应的关闭方法释放资源,资源相关的内存及系统句柄等会持续占用,无法被回收。
- 示例:
func readFileWithoutClose() {
file, err := os.Open("test.txt")
if err != nil {
return
}
// 未调用file.Close()关闭文件,可能导致内存泄漏及文件句柄资源占用
data := make([]byte, 1024)
file.Read(data)
}
- 定时器未清理
- 原理:创建的定时器(
time.Timer
、time.Ticker
)若不再使用却未停止或清理,会持续占用内存及系统资源,导致泄漏。 - 示例:
- 原理:创建的定时器(
func leakTimer() {
ticker := time.NewTicker(time.Second)
go func() {
for {
<-ticker.C
// 业务逻辑,但未停止ticker,若该协程持续运行,会导致泄漏
}
}()
}
内存泄漏检测与解决
总的来说
- 定期内存分析:使用
pprof
定期监控内存使用情况,对比不同时间点的内存快照。 - goroutine 数量监控:统计活跃 goroutine 数量,异常增长可能意味着存在阻塞。
- 资源管理原则:
- 打开资源后立即使用
defer
关闭 - 使用
context.Context
控制 goroutine 生命周期 - 优先使用无状态或短生命周期的对象
- 打开资源后立即使用
- 测试覆盖:编写压力测试,模拟长时间运行场景,检测内存增长趋势。
通过结合工具检测和代码优化,可以有效避免 Go 程序中的内存泄漏问题。
工具使用
- pprof 工具
作用:分析内存分配和使用情况,定位内存泄漏点。
示例代码(存在内存泄漏):
package main
import (
"net/http"
_ "net/http/pprof"
)
var data []byte
func leakHandler(w http.ResponseWriter, r *http.Request) {
// 每次请求都会追加1MB数据,但从不清理
data = append(data, make([]byte, 1024*1024)...)
w.Write([]byte("Memory leaked!"))
}
func main() {
http.HandleFunc("/leak", leakHandler)
// 启动pprof服务
http.ListenAndServe(":6060", nil)
}
检测步骤:
启动程序:
go run main.go
触发泄漏:多次访问
http://localhost:6060/leak
采集内存快照:
go tool pprof http://localhost:6060/debug/pprof/heap
分析结果:
(pprof) top 10 # 查看内存占用最高的10个函数 (pprof) list leakHandler # 查看leakHandler函数的内存分配情况 (pprof) web # 可视化分析(需安装graphviz)
关键指标:
inuse_space
:当前堆上占用的内存alloc_space
:程序运行期间累计分配的内存- 若两者差距持续增大,可能存在泄漏。
- go tool trace
作用:可视化程序运行过程,发现阻塞的 goroutine。
示例代码(存在 goroutine 阻塞):
package main
import (
"os"
"runtime/trace"
)
func main() {
f, _ := os.Create("trace.out")
defer f.Close()
trace.Start(f)
defer trace.Stop()
ch := make(chan int)
go func() {
<-ch // 无发送操作,永久阻塞
}()
select {} // 主协程阻塞
}
检测步骤:
生成跟踪文件:
go run main.go
分析跟踪文件:
go tool trace trace.out
在浏览器中查看:
- 选择
Goroutine analysis
查看阻塞的 goroutine - 选择
Network blocking profile
查看网络阻塞情况
- 选择