面试官问:go语言的内存分配机制?

1,258 阅读12分钟

你好,我是小小酥,今天给大家分享的是go语言面试大概率会被问到的问题之一,go语言的内存分配,在今天的文章里,我们将首先从全局的角度去了解go语言内存管理的整体设计,接着会对内存管理的各个组件从功能到数据结构一一进行剖析,最后我们会通过分析源码的方式将各个组件串起来从而学习到go语言中一块内存到底是怎么分配的,对于核心的代码我都加上了注释。

鸟瞰全局

go语言的内存分配采用了TCMalloc的算法,并在此基础上进行了一定程度的优化,其核心思想如下

内存切割

众所周知,一大片内存如果按照顺序分配给不同的对象,当对象被回收时,这一大块内存会产生不同大小的内存碎片,降低了内存的利用率。

因此go语言中通过将一大片内存切割成多个不同规格(size class)的小块内存,称为mspan,然后创建对象时根据实际需要按需分配,很大程度上减少了内存碎片提高内存的利用率。

多级管理

go语言采用多级内存分配的方式进行内存管理:每个P都会维护一个本地的内存池,称为mcache,mcache内包含了所有规格的mspan,

当mcache本地没有足够内存空间时,mcache会从一套称为mcentral的内存组件里申请内存,每一种规格的mspan都有一个mcentral对象,每个mcentral中都包含了对应规格的mspan对象列表。

那么当mcentral中的mspan对象链表也没有空闲的内存空间了呢?这时mcental会从一个称为mheap的对象中申请内存,mheap内部维护的不再是mspan而是一页一页的内存了,当mcentral向mheap申请内存时,mheap会根据mcentral对应规格划分一连串页作为新的mspan分配给mcentral。

到这里我们已经知道了mcache维护了每种规格大小的一个mspan对象,mcentral维护的是对应规格mspan的对象列表,mheap维护的是一页一页的内存。进行内存分配时优先从本地mcache中分配,mcache维护在P上,一个P绑定的M统一时间只会执行一个goroutine,因此这里不会有锁竞争,当mcache内存不足时向对应规格的mcentral申请mspan,此时也只会在对应规格的mcentral上加锁,只有当mcentral的内存空间也不足时,才会向全局的mheap申请内存而加全局锁。

通过将内存进行了多级管理,内存分配时降低了锁的粒度,提升了内存分配的性能。

除了内存切割和多级管理,go语言在内存分配中还加入了针对微小对象的分配器(tiny allocator),为mspan加上gc标记等方式来做内存优化,这些在后面的内容中都会进行详细的讲解,就不在这里展开了,本节的最后根据上述的内容,我们再来看看由mcache,mcentral,mheap组成的结构图。

go内存分配架构.drawio.png

组件剖析

mcache

每个工作线程都会绑定一个mcache

mcache用span classes作为索引管理多个用于分配的mspan,它包含所有规格的mspan

mcache在初始化的时候是没有任何mspan资源的,在使用的过程中,会动态的从mcentral申请,之后会缓存下来,当对象小于等于32kb时,使用mcache对相应规格的mspan进行分配。

源码

type mcache struct {
  nextSample uintptr // 分配多少大小的堆时会触发堆采样
  scanAlloc	 uintptr // 分配的可扫描堆字节数
  tiny			 uintptr // 堆指针,指向当前tiny块的起始指针,如果当前无tiny块,则为nil
  tinyoffset uintptr // 当前tiny块的位置
  alloc			 [numSpanClasses]*mspan // 当前p的分配规格信息
  stackcache [_NumStackOrders]stackfreelsit 
  flushGen	 uint32
}

mspan

mspan是GO中内存管理的基本单元,是由一片连续8kb的页组成的大块内存。

mspan是一个包含起始地址、mspan规格、页的数量等内容的双端链表。

每个mspan按照它自身的属性size class的大小分割成若干个object,每个object可存储一个对象,并且会使用一个位图来标记其尚未使用的object。属性size class决定object大小,而mspan只会分配给和object尺寸大小接近的对象。

每个size class有两个mspan,也就是有两个span class,其中一个分配给含有指针的对象,另一个分配给不含有指针的对象。

size class

mspan的size class有67种,每种mspan分割的object大小是8*2n的倍数

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}


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}

根据mspan的size class可以得到它划分的object大小,比如size class等于3,object 大小就是32b,32b大小的object可以存储对象大小范围是17b-32b,在class_to_allocnpages数组里对应的页数是1

源码

type mspan struct {
  next *mspan // 链表向后的指针
  prev *mspan // 链表向前的指针
  startAddr uintptr // 起始地址,也既管理页的地址,指向arena区域的某个位置,表示这个mspan的起始地址
  npages 	  uintptr // 管理的页数
  nelems uintptr // 块个数,标识有多少个块可供分配
  allocBits *gcBits // 分配的位图,每一位代表一个快是否已分配
  allocCount uint16 // 已分配的块数
  spanclass spanClass // class表中的class id,和size class相关
  elemsize uintptr // class表中的对象大小,也既块大小
}

mcentral

为所有mcache提供切分好的mspan资源,每个central保存一个特定大小的全局mspan列表,包括已分配的和未分配的,每个mcentral对应一种mspan,mcentral被所有mcache共同享有,存在多个goroutine竞争的情况,因此会消耗锁资源。

每个mcentral是两个mspans列表,空闲对象c->notempty和完全分配对象c->empty

2097953042-12f5f86f4398b6f9_fix732.webp

源码

type mcentral struct {
  spanclass spanClass // 当前规格大小
  partial [2]spanSet // 存在空闲对象的spans列表
  full	[2]spanSet // 无空闲对象的spans列表
}

type spanSet struct {
  spineLock mutex
  spine			unsafe.Pointer
  spineLen	uintptr
  spineCap	uintptr
  
  index			headTailIndex
}

func (c *mcentral) init(spc spanClass) {
  c.spanclass = spc
    lockInit(&c.partial[0].spineLock, lockRankSpanSetSpine)
    lockInit(&c.partial[1].spineLock, lockRankSpanSetSpine)
    lockInit(&c.full[0].spineLock, lockRankSpanSetSpine)
    lockInit(&c.full[1].spineLock, lockRankSpanSetSpine)
}

mheap

如果mcache和mcentral都没有适宜规则的大小内存,这时候就会向mheap申请一块内存,然后按指定规格划分为一些列表,并将其添加到相同规格大小的mecntral的not empty list后面。

go没法使用工作线程的本地缓存mcache和mecntral尚管理超过32kb的内存分配,所以对那些超过32kb的内存申请,会直接从堆上分配对应数量的内存页。

源码

type mheap struct {
	lock  mutex // 全局锁
	pages pageAlloc // 页面分配的数据结构

	sweepgen     uint32 // 清扫完成
	sweepDrained uint32 // 清扫完成标记
	sweepers     uint32 // 活动清扫调用sweepone数

	allspans []*mspan // 所有申请过的mspan都会放在allspans

	_ uint32 // align uint64 fields on 32-bit for atomics


	pagesInUse         uint64  // 统计mSpanInUse中的spans页数
	pagesSwept         uint64  // 本轮清扫的页数
	pagesSweptBasis    uint64  // 用作清扫率atomically
	sweepHeapLiveBasis uint64  
	sweepPagesPerByte  float64 
	scavengeGoal uint64


	reclaimIndex uint64
	
	reclaimCredit uintptr

	
	arenas [1 << arenaL1Bits]*[1 << arenaL2Bits]*heapArena // 堆arena 映射。它指向整个可用虚拟地址空间的每个 arena 帧的堆元数据;

	
	heapArenaAlloc linearAlloc


	arenaHints *arenaHint


	arena linearAlloc


	allArenas []arenaIdx // 是每个映射arena的arenaIndex 索引。可以用以遍历地址空间。


	sweepArenas []arenaIdx // []arenaIdx 指在清扫周期开始时保留的 allArenas 快照

	markArenas []arenaIdx // []arenaIdx 指在标记周期开始时保留的 allArenas 快照


	curArena struct {
		base, end uintptr
	}

	_ uint32

	central [numSpanClasses]struct { // mcentral
		mcentral mcentral
		pad      [cpu.CacheLinePadSize - unsafe.Sizeof(mcentral{})%cpu.CacheLinePadSize]byte
	}

	spanalloc             fixalloc 
	cachealloc            fixalloc 
	specialfinalizeralloc fixalloc 
	specialprofilealloc   fixalloc 
	specialReachableAlloc fixalloc 
	speciallock           mutex    
	arenaHintAlloc        fixalloc 

	unused *specialfinalizer 
}

源码分析

newobject

newobject用于在队中分配内存,内部调用了mallocgc

runtime/malloc.go

func newobject(typ *_type) unsafe.Pointer {
  return mallocgc(typ.size, typ, true)
}

mallocgc

mallocgc是分配内存的关键函数,是分配内存的入口,它会根据需要分配内存大小的不同将请求分发到不同的方法上去,执行流程如下

  • 如果需要分配的内存小于16b,则执行tiny allocator,微小对象分配器

    • tiny allocator首先会从当前mcache的tiny指针指向的内存空间中获取内存,获取成功就直接返回
    • 如果mcahce.tiny指针指向的内存空间中内存不足,则调用mcache.nextFreeFast方法获取tinySpanClass规格大小的mspan来重置tiny指针指向的内存空间
    • 如果mcache.alloc[tinySpanClass]也没有足够的内存空间了,则需要调用mcache.nextFree方法申请从mcentral中获取对应规格大小的span
  • 如果需要分配的内存大于16b且小于32kb,则执行small allocator,小对象分配器、

    • ·small allocator先根据需要分配的内存大小计算出对应映射的span规格大小
    • 接着从mcache.alloc[spanClass]中获取对应大小的object
    • 如果mcache.alloc[spanClass]中的内存不足,则依然调用mcache.nextFree方法从mcentral中获取对应规格大小的span
  • 如果需要分配的内存大于等于32kb,则执行big allocatrot,大对象分配器

    • go内存分配对于大于32kb的内存申请,会直接调用mcache.allocLarge方法,从mheap上直接分配内存

下面是对mallocgc源码注释,为了方便查看,其中省略了与内存分配没有直接关系的代码

runtime/malloc.go

func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
	if gcphase == _GCmarktermination {
		throw("mallocgc called with gcphase == _GCmarkterminiation")
	}

	if size == 0 {
		return unsafe.Pinter(&zerbase)
	}

	... //gc相关判断

	c := getMcache() // 获取当前p绑定的mcache
	if c == nil {
		throw("mallocgc called without a P or outside bootstrapping")
	}

	var span *mspan
	var x unsafe.Pointer
	noscan := typ == nil || typ.ptrdata == 0
	isZeroed := true

	// 小于32kb,执行小对象分配流程
	if size <= maxSmallSize {

		// 无指针 && 小于16b,执行微小对象分配流程
		if noscan && size < maxTinySize {
			// 对齐操作
			off := c.tinyoffset
			if size&7 == 0 {
				off = alignUp(off, 8)
			} else if sys.PtrSize == 4 && size == 12 {
				off = alignUp(off, 2)
			} else if size&1 == 0 {
				off = alignUp(off, 2)
			}

			// 判断对齐后的大小依然小于16b && mcache.tiny != 0
			if off+size <= maxTinySize && c.tiny != 0 {

				x = unsafe.Pointer(c.tiny + off) // 根据对齐的地址获取一片内存
				c.tinyoffset = off + size        // 调整mcache.tinyoffset
				c.tinyAllocs++                   // 调整mcache.tinyAllocs
				mp.mallocing = 0
				releasem(mp) // 释放当前m
				return x     //返回对象
			}
			span = c.alloc[tinySpanClass]
			v := nextFreeFast(span) // 如果当前mcache.tiny已经没有足够的内存了,则需要从mcache.alloc中拿一部分tinySpanClass对应的内存
			if v == 0 {
				v, span, shouldhepgc = c.nextFree(tinySpanClass) // 如果mcache.alloc[tinySpanClass]也没有,则要通过c.nextFree到mcentral中获取对应的mspan了
			}

			x = unsafe.Pointer(v)
			(*[2]uint64)(x)[0] = 0
			(*[2]uint64)(x)[1] = 0
			if !raceenabled && (size < c.tinyoffset || c.tiny == 0) {
				c.tiny = uintptr(x) // 调整mcache.tiny
				c.tinyoffset = size // 调整mcache.tinyoffset
			}
			size = maxTinySize
		} else {
			var sizeclass uint8
			if size <= smallSizeMax-8 { // 需要分配的size<1kb,则从size_to_class8获取到对应的sizeclass
				sizeclass = size_to_class8[divRoundUp(size, smallSizeDiv)]
			} else { //需要分配的size>1kb,则从size_to_class128获取到对应的sizeclass
				sizeclass = size_to_class128[divRoundUp(size-smallSizeMax, largeSizeDiv)]
			}
			size = uintptr(class_to_size[sizeclass]) // 根据sizeclass映射到实际分配的大小
			spc := makeSpanClass(sizeclass, noscan)
			span = c.alloc[spc]
			v := nextFreeFast(span) // 从mcache.alloc获取到对应规格的mspan
			if v == 0 {
				v, span, shouldhelpgc = c.nextFree(spc) // mcache.alloc未获取到则从mcentral中获取
			}
			x = unsafe.Pointer(v) // 分配内存
		}

	} else { // 需要的内存大于32kb
		... // gc相关
		span, isZeroed = c.allocLarge(size, needzero && !noscan, noscan) // 调用mcache.allocLarge方法从mheap获取内存
		x = unsafe.Pointer(span.base())
	}

	... // gc相关

	return x
}

nextfree

前文提到,当mcache.alloc中对于规格的span也没有空闲object的时候,则需要通过nextFree从mcentral中申请新的mspan,核心流程如下

  • 通过nextFreeIndex()获取当前span下一个空闲object的索引
  • 判断获取到的索引值等于该规格mspan.nelems证明当前mcache.alloc[spanClass]确实没有空闲的object了
  • 调用mcache.refiil从mcentral中申请新的mspan
  • 申请到mspan以后做一些长度的校验并再次调用nextFreeIndex()获取一个空闲的object,同时更新mspan.allocCount

下面是对nextfree源码注释

runtime/malloc.go

func (c *mcache) nextFree(spc spanClass) (v gclinkPtr, s *mspan, shouldhelpgc) {
	s = c.alloc[spc]
	shouldhelpgc = false
	freeIndex := s.nextFreeIndex() //s.nextFreeIndex()用于获取span下一个空闲object的索引
	if freeIndex == s.nelems { // 如果获取到的索引等于当前span的规格长度(s.nelems),说明这个span已经没有空闲的object了
		c.refill(spc) // 调用mcache.refill从mcentral申请新的mspan
		shouldhelpgc = true // gc相关
		s = c.alloc[spc] // 再次从mcache.alloc中获取mspan

		freeIndex = s.nextFreeIndex() // 获取mspan中下一个空闲object的索引
	}

	
	if freeIndex >= s.nelems { // 再次进行长度的校验
		throw("freeIndex is not valid")
	}

	v = gclinkptr(freeIndex*s.elemsize + s.base()) //根据object的索引获取对应指针的地址
	
  s.allocCount++ //mspan分配出去的对象数+1
  
	return
}

refill

当mcache.alloc[spanClass],会调用refill方法再通过mcentral.cacheSpan()从mcentral中申请新的mspan

下面是对refill源码注释,为了方便查看,其中省略了与内存分配没有直接关系的代码

runtime/mcache.go

func (c *mcache) refill(spc spanClass) {
	s := c.alloc[spc] // 从mcache获取对应规格的mspan
	if uintptr(s.allocCount) != s.nelems { // 如果对应mspan的allocCount != mspan的规格大小,证明该mspan依然还有空闲的object
		throw("refill of span with free space remaining")
	}

	if s != &emptymspan { //emptymspan是一个不包含空闲object的虚拟mspan,如果和当前mspan不相等
		mheap_.central[spc].mcentral.uncacheSpan(s) //对mceantral的span队列进行清理
	}

	s = mheap_.central[spc].mcentral.cacheSpan() // 从对应规格的mcentral中获取一个新的mspan
	if s == nil {
		throw("out of memory")
	}
	if uintptr(s.allocCount) == s.nelems {
		throw("span has no free space")
	}

	... // gc相关

	c.alloc[spc] = s // 将s放入c.alloc[spc]中了
}

cachespan

cacheSpan负责从对应规格的mcentral中获取mspan,执行流程如下:

  • 尝试从清理过的、包含空闲对象的spanSet结构中获取mspan

    • 对mspan做初始化的操作,并返回
  • 尝试从未清理过、包含空闲对象的spanSet结构中获取mspan

    • 获取到以后触发sweep进行清理
    • 对mspan做初始化的操作,并返回
  • 尝试从未清理过、不包含空闲对象的spanSet结构中获取mspan

    • 获取到以后触发sweep进行清理
    • 清理以后判断获取到的mspan是否还有空闲的object
    • 对mspan做初始化的操作,并返回
  • 以上方式都未获取到说明mcentral对应规格的mspan列表中也没有空闲内存了,这时通过调用mchentral.grow方法申请从mheap中分配内存

    • 对mspan做初始化的操作,并返回

下面是对cacheSpan源码注释,为了方便查看,其中省略了与内存分配没有直接关系的代码

runtime/mcentral.go

func (c *mcentral) cacheSpan() {
	
	var s *mspan // 定义一个mspan的指针
	sl := newSweepLocker() // 因为mcentral是公共的,需要加锁
	sg := sl.sweepGen

	spanBudget := 100 // 限制从spanSet中获取mspan的次数


	if s = c.partialSwept(sg).pop(); s != nil { // 尝试从清理过的、包含空闲对象的spanSet结构中获取mspan
		goto havespan // 如果获取到了则直接执行havespan
	}


	for ; spanBudget >= 0; spanBudget-- {  // 尝试100次
		s = c.partialUnswept(sg).pop() // 尝试从未清理过的、包含空闲对象的spanSet结构中获取mspan
		if s == nil {
			break
		}
		if s, ok := sl.tryAcquire(s); ok {
			s.sweep(true) // 触发sweep进行清理
			sl.dispose()
			goto havespan // 执行havespan
		}
	}

	for ; spanBudget >= 0; spanBudget-- {
		s = c.fullUnswept(sg).pop() // 尝试从未清理过的、不包含空闲对象的spanSet结构中获取mspan
		if s == nil {
			break
		}
		if s, ok := sl.tryAcquire(s); ok {

			s.sweep(true) // 触发清理

			freeIndex := s.nextFreeIndex()  // 获取mspan下一个空闲object的索引
			if freeIndex != s.nelems { // 如果索引值和mspan的规格不一致,说明有空闲对象
				s.freeindex = freeIndex
				sl.dispose()
				goto havespan // 执行havespan
			}
			// Add it to the swept list, because sweeping didn't give us any free space.
			c.fullSwept(sg).push(s.mspan)
		}
		// See comment for partial unswept spans.
	}

	s = c.grow() // 如果从mcentral中都没有获取到合适的mspan,那只能到mheap中去获取了
	if s == nil {
		return nil
	}

havespan:
	n := int(s.nelems) - int(s.allocCount)
	if n == 0 || s.freeindex == s.nelems || uintptr(s.allocCount) == s.nelems {
		throw("span has no free objects")
	}
	// 对mspan进行一些初始化的操作
	freeByteBase := s.freeindex &^ (64 - 1)
	whichByte := freeByteBase / 8
	s.refillAllocCache(whichByte)
	s.allocCache >>= s.freeindex % 64

	return s
}

grow

当mcentral也没有相应spanClass的内存时,从mheap申请分配对应span

runtime/mcentral.go

func (c *mcentral) grow() *mspan {
  npages := uintptr(class_to_allocnpages[c.spanclass.sizeclass()]) // 获取待分配的页数
  size := uintptr()
  
  s, _ := mheap_.alloc(npages, c.spanclass, true) // 从堆栈中分配新的mspan
  if s == nil {
    return nil
  }
  
  // 初始化操作
  n := s.divideByElemSize(npages << _PageShift)
  s.limit = s.base() + size * n
  heapBitsForAddr(s.base()).initSpan(s)
  return s
}

参考

下期预告

到这里,相信你已经对go语言的内存分配有了一定程度上的理解了,面对面试官的提问你也一定能侃侃而谈了吧~,下一次我将继续为你分享go语言面试常问的go语言的垃圾回收,敬请期待!

如果你喜欢我的文章,欢迎关注我的公众号,万分感谢!

qrcode_for_gh_83255ce34399_344.jpg