Go内存管理

发布于:2025-05-20 ⋅ 阅读:(20) ⋅ 点赞:(0)

内存管理

何为内存?

  1. 来源:基于内存条
  2. 作用:程序逻辑临时使用时用的变量、全局变量、静态库、函数跳转地址、执行代码、临时开辟的结构体对象

内存为什么需要管理?

  1. 内存中存储的数据越来越多,导致物理内存不足[每个程序索要的内存是程序的极限内存](需要提高内存的利用率和合理分配性)

对于一些语言如:go、java、python,为了能让开发者更关注程序本身的逻辑,提供了一套针对自身程序的内存管理模式:虚拟内存

内存管理的方式

操作系统存储模型

观察上图,我们可以从中捕捉到的关键词是:

  • 多级模型
  • 动态切换

操作系统是怎么管理内存的?

存在问题:

  1. 物理内存无法最大化使用
  2. 程序逻辑内存使用空间独立
  3. 物理内存不足

解决手段:

  1. 读时共享
  2. 写时复制
  3. 内存不足时部分磁盘虚拟化

虚拟内存与物理内存

虚拟内存:一连串连续的逻辑内存,逻辑内存地址映射真实物理内存地址

物理内存:真实基于内存条的实际内存。

认识虚拟内存

虚拟内存的作用:

  • 在用户与硬件间添加中间代理层(没有什么是加一个中间层解决不了的)
  • 优化用户体验(进程感知到获得的内存空间是“连续”的)
  • “放大”可用内存(虚拟内存可以由物理内存+磁盘补足,并根据冷热动态置换,用户无感知)

分页管理

操作系统中通常会将虚拟内存和物理内存切割成固定的尺寸,于虚拟内存而言叫作“页”,于物理内存而言叫作“帧”,原因及要点如下:

  • 提高内存空间利用(以页为粒度后,消灭了不稳定的外部碎片,取而代之的是相对可控的内部碎片)
  • 提高内外存交换效率(更细的粒度带来了更高的灵活度)
  • 与虚拟内存机制呼应,便于建立虚拟地址->物理地址的映射关系(聚合映射关系的数据结构,称为页表)
  • linux 页/帧的大小固定,为 4KB(这实际是由实践推动的经验值,太粗会增加碎片率,太细会增加分配频率影响效率)

内部碎片:比如:页内的内存浪费(7字节可能分配8字节的空间;内存对齐补齐的内存;内存池的预留内存)

外部碎片:比如:内存分配对象大小、释放时机(不可控)[需要考虑物理内存的连续性,比如:剩余两块内存,4KB和2KB的内存块。现在需要6KB的连续物理空间,不存在则分配内存失败]

但是,此时如果存在分页管理,外部的空闲内存块以页帧为单位管理。只要页帧足够,就可以进行内存分配,不需要连续的内存块。这就实现了彻底消除外部碎片的效果。

Golang 内存模型

首先,golang中借用操作系统的存储模型和TCMalloc。理解 TCMalloc 可以有助于理解 go内存管理。虽然之后 Go的内存管理 与TCMalloc不一致地方在不断扩大,但其主要思想、原理和概念都是和TCMalloc一致的…

  1. 借鉴了操作系统的多级缓存和分页管理的机制
  2. 借鉴了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也是要加锁的。

img

前文提到了小、中、大对象,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-classmspan,避免频繁加锁访问全局资源。
    • 直接为Goroutine分配对象,提升分配效率。
    • 数量和P相关(1:1)
  • span-class 定义mspan管理的对象大小范围和属性(如是否含指针),是内存分层管理的核心规格。
    • span-class对应的是小对象(大对象是单独独占一个span)
    • 67种

它们通过分层协作,实现了高效的并发内存分配与管理。

  1. object size:代码里简称size,指申请内存的对象大小。
  2. size class:代码里简称class,它是size的级别,相当于把size归类到一定大小的区间段,比如size[1,8]属于size class 1,size(8,16]属于size class 2。
  3. span class:指span的级别,但span class的大小与span的大小并没有正比关系。span class主要用来和size class做对应,1个size class对应2个span class,2个span class的span大小相同,只是功能不同,1个用来存放包含指针的对象,一个用来存放不包含指针的对象,不包含指针对象的Span就无需GC扫描了。
  4. 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),同时保证程序的内存安全。

二、逃逸分析的核心策略

逃逸分析的决策逻辑可总结为:

  1. 外部引用原则

    • 如果变量在函数外部存在引用(如返回指针、赋值给全局变量等),必须分配在堆上

    • 示例:

      func escapeToHeap() *int {
          x := 10  // x逃逸到堆
          return &x
      }
      
  2. 栈优先原则

    • 如果变量仅在函数内部使用(无外部引用),优先分配在栈上

    • 示例:

      func stayOnStack() int {
          x := 10  // x分配在栈上
          return x
      }
      
  3. 栈容量限制

    • 即使无外部引用,如果变量占用内存过大(超过栈空间限制),仍会分配到堆上
    • 具体阈值与 Go 版本和平台有关(通常为几 KB 到几十 KB)
  4. 动态类型约束

    • 当变量类型为interface{}时(动态类型),由于编译期无法确定实际类型大小,必须逃逸到堆

    • 示例:

      func dynamicTypeEscape() interface{} {
          x := struct{}{}  // x逃逸到堆
          return x
      }
      
  5. 引用类型发生二次间接引用极大可能逃逸

    引用类型: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可能导致扩容,底层数组逃逸到堆
    }
    
四、逃逸分析的影响
  1. 性能影响
    • 堆分配比栈分配慢(涉及 GC 开销)
    • 过度逃逸会导致 GC 压力增大,降低程序性能
  2. 内存布局
    • 栈上对象随函数返回自动回收
    • 堆上对象需要 GC 周期回收,可能导致内存碎片
  3. 代码优化建议
    • 尽量避免返回局部变量的指针
    • 减少使用 interface {} 类型
    • 预估切片容量,减少动态扩容
    • 优先使用值接收者而非指针接收者(除非需要修改原对象)
五、如何检测逃逸
  1. 编译命令

    go build -gcflags="-m -m" main.go
    
    • -m 选项显示逃逸分析信息
    • 多个-m会输出更详细的分析
  2. 典型输出示例

    ./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)机制,但由于代码逻辑缺陷,仍可能产生内存泄漏问题 。

常见涉及语言
  1. 手动内存管理语言(如 C/C++):开发者需手动使用malloc/freenew/delete等函数分配和释放内存。一旦遗漏释放操作,或者释放逻辑错误(如重复释放、释放时机不当),就会造成内存泄漏。例如在 C 语言中,若动态分配内存后忘记调用free函数:
int* ptr = (int*)malloc(sizeof(int));
// 使用ptr
// 未调用free(ptr),导致内存泄漏
  1. 自动 GC 语言(如 Go、Java):这类语言依赖自动垃圾回收机制来管理内存,理论上无需开发者手动释放内存。然而,若代码中存在对象的无效引用、循环引用、资源未关闭等情况,也会导致内存无法被 GC 回收,从而引发内存泄漏。例如在 Go 语言中,即使有 GC,错误的代码逻辑仍会导致泄漏。
常见场景
  1. 对象长期引用但不使用
    • 原理:程序中存在对象被长期持有引用,但后续不再使用该对象,导致 GC 无法回收其占用的内存。例如,将对象放入全局变量或静态集合中,却不再对其进行任何操作。
    • 示例
var globalList []*MyObject

func createObject() {
    obj := &MyObject{}
    globalList = append(globalList, obj)
    // 后续不再使用obj,但globalList持续引用,导致obj无法被回收
}
  1. 循环引用
    • 原理:多个对象之间形成环形引用关系,导致每个对象都被其他对象引用,即使这些对象实际已不再被程序使用,GC 也无法识别并回收它们。虽然 Go 语言的 GC 采用三色标记法,一定程度上能处理循环引用,但复杂的数据结构仍可能出现问题。
    • 示例
type Node struct {
    data int
    next *Node
}

func createCycle() {
    a := &Node{}
    b := &Node{}
    a.next = b
    b.next = a
    // a和b形成循环引用,若后续无其他引用,仍可能导致内存泄漏
}
  1. goroutine 永久性阻塞
    • 原理:启动的 goroutine 因死锁、等待永远不会满足的条件(如无缓冲通道接收操作但无发送操作)等原因被永久阻塞,且 goroutine 内部持有的资源无法释放,随着大量阻塞 goroutine 的创建,会消耗大量内存资源。
    • 示例
func blockedGoroutine() {
    ch := make(chan int)
    go func() {
        <-ch // 等待接收,但无其他协程向ch发送数据,导致该协程永久阻塞
    }()
}
  1. 资源未关闭
    • 原理:在操作文件、网络连接、数据库连接等资源时,若未调用对应的关闭方法释放资源,资源相关的内存及系统句柄等会持续占用,无法被回收。
    • 示例
func readFileWithoutClose() {
    file, err := os.Open("test.txt")
    if err != nil {
        return
    }
    // 未调用file.Close()关闭文件,可能导致内存泄漏及文件句柄资源占用
    data := make([]byte, 1024)
    file.Read(data)
}
  1. 定时器未清理
    • 原理:创建的定时器(time.Timertime.Ticker)若不再使用却未停止或清理,会持续占用内存及系统资源,导致泄漏。
    • 示例
func leakTimer() {
    ticker := time.NewTicker(time.Second)
    go func() {
        for {
            <-ticker.C
            // 业务逻辑,但未停止ticker,若该协程持续运行,会导致泄漏
        }
    }()
}
内存泄漏检测与解决
总的来说
  1. 定期内存分析:使用 pprof 定期监控内存使用情况,对比不同时间点的内存快照。
  2. goroutine 数量监控:统计活跃 goroutine 数量,异常增长可能意味着存在阻塞。
  3. 资源管理原则:
    • 打开资源后立即使用 defer 关闭
    • 使用 context.Context 控制 goroutine 生命周期
    • 优先使用无状态或短生命周期的对象
  4. 测试覆盖:编写压力测试,模拟长时间运行场景,检测内存增长趋势。

通过结合工具检测和代码优化,可以有效避免 Go 程序中的内存泄漏问题。

工具使用
  1. 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)
}

检测步骤

  1. 启动程序go run main.go

  2. 触发泄漏:多次访问 http://localhost:6060/leak

  3. 采集内存快照:

    go tool pprof http://localhost:6060/debug/pprof/heap
    
  4. 分析结果:

    (pprof) top 10  # 查看内存占用最高的10个函数
    (pprof) list leakHandler  # 查看leakHandler函数的内存分配情况
    (pprof) web  # 可视化分析(需安装graphviz)
    

关键指标

  • inuse_space:当前堆上占用的内存
  • alloc_space:程序运行期间累计分配的内存
  • 若两者差距持续增大,可能存在泄漏。
  1. 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 {} // 主协程阻塞
}

检测步骤

  1. 生成跟踪文件go run main.go

  2. 分析跟踪文件:

    go tool trace trace.out
    
  3. 在浏览器中查看:

    • 选择 Goroutine analysis 查看阻塞的 goroutine
    • 选择 Network blocking profile 查看网络阻塞情况

网站公告

今日签到

点亮在社区的每一天
去签到