谈一👋:golang内存管理

1,041 阅读16分钟

前提必读: 由于文章篇幅原因,引用的代码块都是不完整的,但是所有的函数名都列出来了,强烈建议读者看一遍源码

在讲golang的内存分配之前,我们先来看看一般程序的内存分配:

栈区(stack):由程序系统调用向操作系统申请,由操作系统善后,每个线程有自己的栈区,速度快,使用方便,程序员无法控制。

堆区(heap):程序运行期间动态的分配任意大小的内存,一般由程序员分配释放,若程序员不释放,程序结束后可能由OS回收。

全局区(静态区static):此内存区在编译的时候就已经分配好,速度快,程序结束后由操作系统释放

文字常量区:常量字符串存放在这一区域。程序结束后由系统释放。

程序代码区:存放函数体的二进制代码。

分配栈内存时栈空间是由高向低地址增长的,其中高地址的部分保存着进程的环境变量和命令行参数,低地址的部分保存函数栈帧,

在堆中分配内存时,是正常的由低到高分配

golang的内存分配基本策略:

  1. 每次从操作系统申请一大块内存(比如1MB)以减少系统调用
  2. 将申请到的大块内存按照特定大小预先切分成小块,构成链表
  3. 为对象分配内存时,只需从大小合适的链表提取一个小块即可
  4. 回收对象内存时,将该小块重新归还到原链表,以便复用
  5. 如果闲置内存过多,则尝试归还部分内存给操作系统,降低整体开销

内存分配器只管理内存块,并不关心对象状态。它不会主动回收内存,垃圾回收器在完成清理操作后,触发内存分配器的回收操作

分配器将其管理的内存分为两种:

  • span:由多个地址连续的页(每一页的大小为8kb) 组成的大块内存
  • object:将span按特定大小切分成多个小块,每个小块存储一个对象 按照其用途,span面向内部管理,object面向对象分配

分配器按页数来区分不同大小的span,以页数为单位将span存放到管理数组中,需要时就以页数的索引来查找。

span大小并非不变,在没有获取到合适大小的闲置span时,返回页数更多的span,然后进行剪裁,多余的页数构成新的span,放回管理数组。

mspan是go内存管理的最基本单元,但是内存的使用最终还是要落到“对象”上。mspan和对象是什么关系呢?其实“对象”肯定也放到page中,毕竟page是内存存储的基本单元。

我们来看下面这个图:

当要分配p4时,已经没有连续的内存块了,该如何分配?这种会出现内存碎片的分配情况,go是如何解决的呢?

对于上面的问题,go语言用mspan来解决。

我们知道mspan是由一片连续的8KB的页组成的大块内存。 注意,这里的页和操作系统本身的页并不是一回事,它一般是操作系统页大小的几倍。一句话概括:mspan是一个包含起始地址、mspan规格、页的数量等内容的双端链表。

// path: runtime/mheap.go

type mspan struct {
    
    next *mspan //链表前向指针,用于将span链接起来
    
    prev *mspan //链表前向指针,用于将span链接起来
    
    startAddr uintptr // 起始地址,也即所管理页的地址
   
    npages uintptr  // 管理的页数
    
    manualFreeList gclinkptr //待分配的object链表
   
    nelems uintptr  // 块个数,表示有多少个块可供分配

    allocBits *gcBits //分配位图,每一位代表一个块是否已分配

    allocCount uint16  // 已分配块的个数
   
    spanclass spanClass   // class表中的class ID,和sizeClasss相关

    elems  // class表中的对象大小,也即块大小
    ...
}

mspan的自身属性spanclass是由sizeClass组成,sizeClass决定object大小。每个mspan按照sizeClass的大小分割成若干个object,每个object可存储一个对象。 并且会使用一个位图来标记其尚未使用的object。mspan只会分配给和object尺寸大小接近的对象,当然,对象的大小要小于object大小。

如下图,mspan由一组连续的页组成,按照一定大小划分成object。 mspan的sizeClass共有67种,每种mspan分割的object大小是8的倍数。

var class_to_size = [_NumSizeClasses]uint16{0, 8, 16, 32, 48, 64, 80, 96, 112, 128, 144, 160, 176, 192, 208, 224, 240, 256, 288, 320, 352, 384, 416, 448, 480, 512, 576, 640, 704, 768, 896, 1024, 1152, 1280, 1408, 1536, 1792, 2048, 2304, 2688, 3072, 3200, 3456, 4096, 4864, 5376, 6144, 6528, 6784, 6912, 8192, 9472, 9728, 10240, 10880, 12288, 13568, 14336, 16384, 18432, 19072, 20480, 21760, 24576, 27264, 28672, 32768}

比如说大小为32B的object可用来存储范围在17~32字节的对象,这种方式虽然会造成一些内存浪费,但分配器只需要面对几种有限的小块内存,优化来分配和复用策略。

对象分配的时候,根据对象的大小选择大小相近的span,这样,碎片问题就解决了。

值得一提的是数组里最大的数是32768,也就是32KB,超过此大小就是大对象了,它会被特别对待。实际上sizeClass为0就表示大对象,它直接由堆内存分配,小对象都通过mspan来分配。

对于mspan来说,它所能分到的页数,这也是写死在代码里的:

var class_to_allocnpages = [_NumSizeClasses]uint8{0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 2, 1, 2, 1, 3, 2, 3, 1, 3, 2, 3, 4, 5, 6, 1, 7, 6, 5, 4, 3, 5, 7, 2, 9, 7, 5, 8, 3, 10, 7, 4}

比如当我们要申请一个object大小为32B的mspan的时候,在class_to_size里对应的索引是3,而索引3在class_to_allocnpages数组里对应的页数就是1。

管理组件

优秀的内存分配器必须要在性能和内存利用率之间做到平衡,Go的起点很高,直接采用了tcmalloc的成熟架构。

Go分配器由三种组件组成:

  • cache:每个运行期工作线程都会绑定一个cache,用于无锁object分配。
  • central:为所有cache提供切分好的后备span资源。
  • heap:管理闲置span,需要时向操作系统申请新内存。
type mheap struct {
	...
 	largefree   uint64   // bytes freed for large objects (>maxsmallsize)
	nlargefree  uint64   // number of frees for large objects (>maxsmallsize)
	nsmallfree  [_NumSizeClasses]uint64 //32kb以内的闲置span链表数组 
	
    central [numSpanClasses]struct {
		mcentral mcentral
		pad      [cpu.CacheLinePadSize - unsafe.Sizeof(mcentral{})%cpu.CacheLinePadSize]byte
	}  //每个central对应一个sizeclass
	...
}
type mcentral struct {
	lock      mutex
	spanclass spanClass  //规格

	
	nonempty mSpanList // 链表,尚有空闲object的span
	empty    mSpanList // 链表,没有空闲object,或已被cache取走的span
	...
}
type mcache struct {
	...
	alloc [numSpanClasses]*mspan // 以spanClass 为索引管理多个用于分配的span
	...
}

分配器的分配流程大致分为:

  1. 计算待分配对象的对应规格(size class)
  2. 从 mcache.alloc 数组中找到规格相同的span
  3. 从 mspan.manualFreeList 链表中提取可用的object
  4. 如果 mspan.manualFreeList 为空,从central中获取新span
  5. 如果 mcentral.nonempty为空,从 nsmallfree 获取,并切分成object链表
  6. 如果 heap 没有合适的闲置span,向操作系统申请新内存

释放内存流程如下:

  1. 把标记为可回收的object交还给所属 mspan.manualFreeList
  2. 该span放回central,可供任意cache重新获取使用
  3. 如果span已回收完全部object,则将其交还给heap,已便重新切分复用
  4. 定期扫描heap 里长时间闲置的span,释放其占用的内存

以上不包含大对象,大对象直接从heap分配和回收

作为工作线程私有且不被共享的cache是实现高性能无锁分配的核心,为了避免多线程申请内存时不断的加锁,goroutine为每个线程分配了span内存块的缓存,这个缓存就是mcache,每个goroutine都会绑定的一个mcache,各个goroutine申请内存时不存在锁竞争的情况。

golang的并发调度模型MPG,运行期间一个goroutine只能和一个P关联,而mcache就在P上,所以,不可能有锁的竞争。

central的作用是在多个cache间提高object的利用率,避免内存浪费。

假如一个cache1获取一个span后,仅仅使用了一部分object,那么剩余的空间就可能被浪费。而回收操作将该span交还给central后,该span完全可以被cache2,cache3获取使用,此时,cache1已不再持有该span,完全不会造成问题。

初始化

讲内存初始化之前先来看一张图:

arena区域,就是我们所谓的堆区,Go动态分配的内存都是在这个区域,它把内存分割成8KB大小的页,一些页组合起来就是上面讲的mspan。

bitmap区域,作用是标记标记arena(即heap)中的对象。一是的标记对应地址中是否存在对象,另外是标记此对象是否被gc标记过。一个功能一个bit位,所以 bitmaps用两个bit位,bitmap中一个byte大小的内存对应arena区域中4个指针大小的内存。 bitmap的地址是由高地址向低地址增长的。

spans区域,存放mspan的指针,每个指针对应一页,所以spans区域的大小就是512GB/8KB*8B=512MB,除以8KB是计算有多少页,而最后乘以8是计算spans区域所有指针的大小(一个指针1byte)创建mspan的时候,按页填充对应的spans区域, 在回收object时,根据地址很容易就能找到它所属的mspan。

上图可以看到有两个S指向了同一个mspan,因为这两个S指向的P是同属一个mspan的。所以,通过arena上的地址可以快速找到指向它的S,通过S就能找到mspan,回忆一下前面我们说的mspan区域的每个指针对应一页。

假设最左边第一个mspan的Size Class等于10,根据前面的class_to_size数组,得出这个msapn分割的object大小是144B(数组中第10个),算出每页可分配的对象个数是8KB/144B=56.89个,取整56个,所以会有一些内存浪费掉了,Go的源码里有所有Size Class的mspan浪费的内存的大小;再根据class_to_allocnpages数组,得到这个mspan只由1个page组成;假设这个mspan是分配给无指针对象的,那么spanClass等于20。

startAddr直接指向arena区域的某个位置,表示这个mspan的起始地址,allocBits指向一个位图,每位代表一个块是否被分配了对象;allocCount则表示总共已分配的对象个数。

简单的说,就是使用三个数组组成一个高性能内存管理结构。

  1. 使用arena地址向操作系统申请内存(虚拟内存,并不少真正的分配内存),其大小决定了可分配用户内存的上限
  2. 位图bitmap为每个对象提供2bit标记位,用以保存指针,GC信息
  3. 创建span时,按照页填充对应的spans区域。在回收object时,只需将其地址按照页对齐后就可以找到所属的span。分配器还用此访问相邻的span,做合并操作。

内存分配

为对象分配内存须在栈上还是堆上完成,通常情况下,编译器有责任尽可能使用寄存器和栈来存储对象,这有助于提升性能,减少垃圾回收器的压力。

千万不要以为用了new 函数就一定会分配在堆上,即使是相同源码也有不同结果

package main

func main() {
	println(test())
}

func test() *int {
	x := new(int)
	*x = 0xAABB
	return x
}

当编译器禁用内联优化时

 go build -gcflags "-l" -o test main.go //关闭内联优化
 go tool objdump -s  "main\.test" test  //解析可执行文件test,将其中的 main 包的 test 方法转成汇编代码。

TEXT main.test(SB) /Users/xxx/test/main.go
  main.go:7             0x105c660               65488b0c2530000000      MOVQ GS:0x30, CX                        
  main.go:7             0x105c669               483b6110                CMPQ 0x10(CX), SP                       
  main.go:7             0x105c66d               7639                    JBE 0x105c6a8                           
  main.go:7             0x105c66f               4883ec18                SUBQ $0x18, SP                          
  main.go:7             0x105c673               48896c2410              MOVQ BP, 0x10(SP)                       
  main.go:7             0x105c678               488d6c2410              LEAQ 0x10(SP), BP                       
  main.go:8             0x105c67d               488d059c6d0000          LEAQ runtime.rodata+28000(SB), AX       
  main.go:8             0x105c684               48890424                MOVQ AX, 0(SP)                          
  main.go:8             0x105c688               e8b3eefaff              CALL runtime.newobject(SB)  //在堆上分配             
  main.go:8             0x105c68d               488b442408              MOVQ 0x8(SP), AX                        
  main.go:9             0x105c692               48c700bbaa0000          MOVQ $0xaabb, 0(AX)                     
  main.go:10            0x105c699               4889442420              MOVQ AX, 0x20(SP)                       
  main.go:10            0x105c69e               488b6c2410              MOVQ 0x10(SP), BP                       
  main.go:10            0x105c6a3               4883c418                ADDQ $0x18, SP                          
  main.go:10            0x105c6a7               c3                      RET                                     
  main.go:7             0x105c6a8               e8d3b1ffff              CALL runtime.morestack_noctxt(SB)       
  main.go:7             0x105c6ad               ebb1                    JMP main.test(SB)                   

当使用默认参数时,函数test会被main内联

go build -o test main.go 
go tool objdump -s  "main\.main" test
TEXT main.main(SB) /Users/xxx/test/main.go
  main.go:3             0x105c5e0               65488b0c2530000000      MOVQ GS:0x30, CX                        
  main.go:3             0x105c5e9               483b6110                CMPQ 0x10(CX), SP                       
  main.go:3             0x105c5ed               764a                    JBE 0x105c639                           
  main.go:3             0x105c5ef               4883ec18                SUBQ $0x18, SP                          
  main.go:3             0x105c5f3               48896c2410              MOVQ BP, 0x10(SP)                       
  main.go:3             0x105c5f8               488d6c2410              LEAQ 0x10(SP), BP                       
  main.go:4             0x105c5fd               48c744240800000000      MOVQ $0x0, 0x8(SP)                      
  main.go:9             0x105c606               48c7442408bbaa0000      MOVQ $0xaabb, 0x8(SP)                   
  main.go:4             0x105c60f               e80c17fdff              CALL runtime.printlock(SB)              
  main.go:4             0x105c614               488d442408              LEAQ 0x8(SP), AX                        
  main.go:4             0x105c619               48890424                MOVQ AX, 0(SP)                          
  main.go:4             0x105c61d               0f1f00                  NOPL 0(AX)                              
  main.go:4             0x105c620               e8bb20fdff              CALL runtime.printpointer(SB)           
  main.go:4             0x105c625               e8b619fdff              CALL runtime.printnl(SB)                
  main.go:4             0x105c62a               e87117fdff              CALL runtime.printunlock(SB)            
  main.go:5             0x105c62f               488b6c2410              MOVQ 0x10(SP), BP                       
  main.go:5             0x105c634               4883c418                ADDQ $0x18, SP                          
  main.go:5             0x105c638               c3                      RET                                     
  main.go:3             0x105c639               e842b2ffff              CALL runtime.morestack_noctxt(SB)       
  main.go:3             0x105c63e               6690                    NOPW                                    
  main.go:3             0x105c640               eb9e                    JMP main.main(SB)      

内联优化之后的代码,没有调用newobject在堆上分配内存,编译器这么做道理很简单,没有内联时,需要在两个栈帧之间传递对象,因此会在堆上分配而不是返回一个失效栈帧里的数据。而当内联后,它实际上就成了main栈帧内的局部变量, 无须去堆上操作。

go编译器支持逃逸分析,它会在编译期间调用图来分析局部变量是否会被外部引用,从而决定是否分配在栈上。编译参数 -gcflags "-m" 可输出编译优化信息,其中包括内联和逃逸分析。

// 内置函数new的实现
func newobject(typ *_type) unsafe.Pointer {
	return mallocgc(typ.size, typ, true)
}

func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
	...
    // 当前线程所绑定的cache
    if mp.p != 0 {
        c = mp.p.ptr().mcache
    } else {
        // 在没有P的情况下被调用,这时我们使用mcache0,它是在mallocinit中设置的。当引导完成时,通过proclesize清除mcache0。
        c = mcache0
        if c == nil {
            throw("malloc called with no P")
        }
    }
    // 小对象
    if size <= maxSmallSize {
        // 无需扫描非指针微小对象(小于16)
        if noscan && size < maxTinySize {
        ...
        } else {
          ...
          size = uintptr(class_to_size[sizeclass])
          spc := makeSpanClass(sizeclass, noscan)
          span = c.alloc[spc]  //小对象从cache.alloc获取内存
        }
    } else {
    	// 大对象
    	shouldhelpgc = true
        systemstack(func() {
            span = largeAlloc(size, needzero, noscan) //大对象直接从heap获取内存
        })
    }
    ...
}

整理 mallocgc 代码的基本思路:

  • 大对象直接从heap获取span
  • 小对象从cache.alloc[sizeclass]中获取span,从span的manualFreeList获取object
  • 微小对象组合使用cache.tiny.object 分配算法不算复杂,接下来关注一下资源不足时如何扩张(central) 在聊central之前,得先了解一下sweepgen这个概念。垃圾回收每次都会累加这个类似代领的计数值,每个等待处理的span也有该属性:
type mheap struct {
	sweepgen  uint32    // sweep generation, see comment in mspan; written during STW
	sweepdone uint32    // all spans are swept
	sweepers  uint32    // number of active sweepone calls
    
}
type mspan struct {
	// sweep generation:
	// if sweepgen == h->sweepgen - 2, the span needs sweeping
	// if sweepgen == h->sweepgen - 1, the span is currently being swept
	// if sweepgen == h->sweepgen, the span is swept and ready to use
	// if sweepgen == h->sweepgen + 1, the span was cached before sweep began and is still cached, and needs sweeping
	// if sweepgen == h->sweepgen + 3, the span was swept and then cached and is still cached
	// h->sweepgen is incremented by 2 after every GC

	sweepgen    uint32
}

在heap里闲置的span不会被垃圾回收器关注,但central里的span却有可能正在被清理。所以当cache从central提取span时,该属性就非常重要。

func (c *mcentral) oldCacheSpan() *mspan {
	//	清理(sweep)垃圾
	sg := mheap_.sweepgen

retry:
	var s *mspan   
 	//遍历nonempty(尚有空闲object的链表)
	for s = c.nonempty.first; s != nil; s = s.next {
		if s.sweepgen == sg-2 && atomic.Cas(&s.sweepgen, sg-2, sg-1) {
			
			//要交给cache使用,转移到empty
			c.nonempty.remove(s)
			c.empty.insertBack(s)
			unlock(&c.lock)
			s.sweep(true)
			goto havespan
		}
		if s.sweepgen == sg-1 {
			// 该span已经被后台sweeper扫描了,则跳过
			continue
		}
		// 当拥有没有被扫描的 nonempty span,从中分配
		c.nonempty.remove(s)
		c.empty.insertBack(s)
		unlock(&c.lock)
		goto havespan
	}
    
	//遍历empty(没有空闲object,或已被cache取走)
	for s = c.empty.first; s != nil; s = s.next {
		if s.sweepgen == sg-2 && atomic.Cas(&s.sweepgen, sg-2, sg-1) {
			// 扫描empty,看能否在里面腾出一些空间
			c.empty.remove(s)
			c.empty.insertBack(s)
			
			freeIndex := s.nextFreeIndex()
			//清理后有可用的object
			if freeIndex != s.nelems {
				s.freeindex = freeIndex
				goto havespan
			}
			// 清理后依然没有可用的object,重试
			goto retry
		}
		if s.sweepgen == sg-1 {
			// 跳过正在被清理的span
			continue
		}
		// 已经扫描清理完 empty span,
		// 所有后续的也必须清扫或在清扫过程中,循环已没有意义,跳出
		break
	}
	//如果为空,补充central
	s = c.grow()
	if s == nil {
		return nil
	}
	lock(&c.lock)
	//新补充的span将被cache使用,所以放到empty链表尾部
	c.empty.insertBack(s)
	unlock(&c.lock)
	...
}

可以看出,从central里获取span时,优先取用已有资源,哪怕是要执行清理操作。只有当现有资源都无法满足时,才去heap获取span,并重新切分成object对象。

从heap获取span的核心算法是找到大小最合适的块。首先从页数相等的链表查找,没有结果则从页数更多的链表提取,直至超大块或申请新块。为了避免浪费,会将多余部分切出来重新放回heap链表,同时尝试合并相邻闲置的span空间,减少碎片。

至此,内存分配操作流程结束

内存回收

内存回收的源头是垃圾清理操作。整个内存分配器的核心是内存复用,不再使用的内存会被放回合适的位置,等下次分配时再使用,只有当空闲内存资源过多时,才会考虑释放。

基于效率考虑,回收操作自然不会直接盯着单个对象,而是以span为基本单位。通过比对bitmap里的扫描标记,逐步将object收回原span,最终上交central或heap复用。

调用mspan的sweep方法,来引发内存分配器回收流程。

总的来说,针对大小不同的object,golang采用了不同的内存回收策略。小对象使用频繁,故而将回收的内存块归还给central以便其他cache复用,而大对象相对来说分配频率较少,直接从heap分配,回收时直接归还给heap。

至此,回收操作结束,被回收的span并不会释放,而是等待复用,在运行时入口函数 main.main 里,会专门启动一个监控任务 sysmon,它每隔一段时间就会检查 heap 里的闲置内存块,当闲置时间超过阈值,则释放其关联的物理内存。