Go内存管理(下)

页缓存 — runtime.pageCache

在每个P中都有一个pageCache类型的字段pcache,我们称为页的缓存。就是它保存了一组等待被使用的连续的页。对于要分配的的page数小于16page的内存,我们都会先从pcache中查找是否有空闲的空间可供分配,pageCache结构定义如下:

// go 1.20.3 path: /src/runtime/runtime2.go
type p struct {
    ......
    pcache      pageCache
    ......
}

// go 1.20.3 path: /src/runtime/mpagecache.go
// pageCache 用于存储和管理内存页的缓存信息。
type pageCache struct {
    base  uintptr // base 字段是一个无符号整型指针,通常用于存储内存页缓存的基地址。
    cache uint64  // cache 字段是一个无符号64位整数,可能用于标识或跟踪缓存中的内存页状态,比如哪些页是空闲的,哪些是已分配的。
    scav  uint64  // scav 字段也是一个无符号64位整数,可能用于记录被清理(scavenged)的内存页信息。这通常涉及到回收和重用内存页的策略。
}

pageCache 结构体被用于实现 P(处理器)的本地内存页缓存,这种结构体的设计有助于提高内存分配的效率和性能,特别是在多线程或并发环境下。

结合pageCache的结构定义,base表示这一组连续页的起始地址,cache是一个uint64对象,在64位机器上是8个字节,即64bitcache中每个bit表示一个page是否是空闲的还是被分配出去了,1表示空闲,0表示已分配出去了。scav表示已清除页面的64位位图。每个页面大小为8KB, 所以pcache管理的这片内存大小为64*8K=512KB

如下图:

image-20231225122214393

再来看看pageCache的相关操作函数:

runtime.pageCache.empty — 判断pageCache是否为空

pageCache.empty方法用来快速检查 pageCache 实例中是否包含任何缓存的内存页。在内存管理的上下文中,如果 pageCache 是空的,可能意味着当前没有可用的内存页面缓存,系统可能需要从其他地方获取页面或执行某些回收策略。

源码如下:

// go 1.20.3 path: /src/runtime/mpagecache.go

// empty 方法用于检查 pageCache 是否为空,返回一个布尔值,表示 pageCache 中的 cache 字段是否为 0。
func (c *pageCache) empty() bool {
    // 如果 c.cache 等于 0,表示没有缓存的页面,即 pageCache 是空的,返回 true。
    // 否则,表示 pageCache 中有缓存的页面,返回 false。
    return c.cache == 0
}

runtime.pageCache.alloc — 从pageCache分配内存

runtime.pageCache.alloc 函数是 pageCache 结构体的一个关键方法,用于从局部页面缓存中分配内存,这可以提高内存分配的效率,尤其是在并发环境下。通过这种方式,Go 运行时尝试最大限度地减少对全局内存分配器的依赖,从而降低锁的竞争和提高性能。

runtime.pageCache.alloc 源码如下:

// go 1.20.3 path: /src/runtime/mpagecache.go

// alloc 方法用于从 pageCache 中分配指定数量的页面。
func (c *pageCache) alloc(npages uintptr) (uintptr, uintptr) {
    // 如果 pageCache 的 cache 字段为 0,表示没有可用的页面,因此返回两个 0。
    if c.cache == 0 {
        return 0, 0
    }

    // 如果请求分配的页面数为 1。
    if npages == 1 {
        // 找到 cache 中第一个可用页面的索引。
        i := uintptr(sys.TrailingZeros64(c.cache))

        // 检查该页面是否被标记为已清理(scavenged)。
        scav := (c.scav >> i) & 1

        // 更新 cache,标记该页面已被分配。
        c.cache &^= 1 << i

        // 更新 scav,标记该页面已不再是已清理状态。
        c.scav &^= 1 << i

        // 计算并返回分配的页面的地址和该页面是否是已清理页面的标记。
        return c.base + i*pageSize, uintptr(scav) * pageSize
    }

    // 如果请求分配的页面数大于 1,则调用 allocN 方法处理。
    return c.allocN(npages)
}

这个方法的主要流程如下:

  1. 首先检查缓存是否为空(c.cache == 0)。如果为空,则直接返回两个零值,表示没有可分配的页面;

  2. 如果请求分配单个页面(npages == 1),方法会找到第一个可用的页面,更新页面缓存和清理(scavenged)状态,然后返回该页面的地址和清理状态;

    那如何找到第一个可用的页面呢?其实就是从c.cache中找到一个bit1的位置,该位置对应的page是未分配出去的,这里有一个算法,就是从cache右边向左边找(理解为从低位向高位),对应的函数就是sys.TrailingZeros64,该函数接收1uint64的整数,实现的功能是从最低位到第一个非零位之前的零的个数。通俗来说,就是对于一个数x, 它对应的二进制为ax,然后从右往左统计ax0的个数,遇到1则结束统计。

    举个栗子说明:

    假设输入数字是 8,其二进制表示为 1000。从右边数,最低的三位是零,所以 sys.TrailingZeros64(8) 将返回 3

    接着再来说下&^的位运算,该符号做的操作就是将运算符左边数据相异的位保留,相同位清零。

    执行c.cache &^= 1 << i:表示将 c.cache 中的第 i 位清零,即将i位标记为分配状态;

    执行c.scav &^ = 1 << i:表示将 c.scav 中的第 i 位清零,即将i位标记为未清理状态;

    下面用一张图来表示经过alloc操作前后的结构和数据状态变化:

    image-20231225143324056

  3. 对于请求多个页面的情况,方法会调用另一个函数 allocN 来处理更复杂的分配逻辑。

    allocN的源码如下:

    // go 1.20.3 path: /src/runtime/mpagecache.go
    
    // allocN 方法从 pageCache 中分配指定数量(npages)的连续页面。
    func (c *pageCache) allocN(npages uintptr) (uintptr, uintptr) {
        // 在 c.cache 中找到足够大的连续空闲位的起始索引。
        i := findBitRange64(c.cache, uint(npages))
        // 如果找不到足够的连续空间(返回值 >= 64),则返回两个零值。
        if i >= 64 {
            return 0, 0
        }
    
        // 构造一个掩码,用于选中从索引 i 开始的 npages 个位。
        mask := ((uint64(1) << npages) - 1) << i
    
        // 计算被清理(scavenged)的页面数量。
        scav := sys.OnesCount64(c.scav & mask)
    
        // 更新 cache 和 scav 字段,清除已分配的页面位。
        c.cache &^= mask
        c.scav &^= mask
    
        // 计算分配的页面的基地址。
        // 返回基地址和被清理页面的总大小。
        return c.base + uintptr(i*pageSize), uintptr(scav) * pageSize
    }
    

    我们总结下该函数的基本流程:

    • 使用 findBitRange64 函数在 pageCachecache 字段中查找足够大的连续空闲位区域,这个区域需要有足够的空间来容纳请求的页面数 npages,如果找到的起始索引 i 大于或等于 64,则返回两个零值,表示分配失败;

      findBitRange64 函数作用是在一个 64 位无符号整数(uint64)中查找一段连续的、足够大的空闲位区域,该区域至少包含 n 个连续的 0。以下是对这个函数的逐行解释:

      // go 1.20.3 path: /src/runtime/mpallocbits.go
      
      // findBitRange64 在 64 位无符号整数 c 中查找至少包含 n 个连续 0 的区域
      func findBitRange64(c uint64, n uint) uint {
          p := n - 1  // 计算所需连续0的数量减 1
          k := uint(1)  // 初始化 k,用于控制右移的位数
      
          // 循环,直到找到足够的连续 0 或者遍历完整个数字
          for p > 0 {
              // 如果剩余所需的 0 的数量小于或等于 k,进行最后一次比较
              if p <= k {
                  c &= c >> (p & 63)  // 将 c 与其自身右移 p 位的结果进行按位与操作
                  break  // 跳出循环
              }
              // 否则,将 c 与其自身右移 k 位的结果进行按位与操作
              c &= c >> (k & 63)
              // 如果 c 变为 0,表示没有找到足够的连续 0
              if c == 0 {
                  return 64  // 返回 64,表示未找到
              }
              // 减少剩余所需的 0 的数量,将 k 倍增
              p -= k
              k *= 2
          }
          // 使用 sys.TrailingZeros64 计算 c 中最低位的连续 0 的数量,并返回该值
          return uint(sys.TrailingZeros64(c))
      }
      
    • 构造一个掩码 mask,用于在 cache 中标记要分配的页面。这个掩码对应于从索引 i 开始的 npages 个连续位;

      掩码 mask := ((uint64(1) << npages) - 1) << i的这个位运算非常巧妙,例如:

      npage 3i2,则mask则为 11100

      npage 5i3,则mask则为 11111000

      可以看出 1的位置始终跟着 i位置变化,mask值始终跟c.cache c.scav分配位置异或值为 1

    • 使用位与操作 (&) 将 mask 应用于 scav 字段,然后用 sys.OnesCount64 计算结果中的 1 的数量。这表示被清理(scavenged)的页面数量;

    • 使用位清除操作 (&^=) 更新 cachescav 字段,清除已经分配的页面位。这表示这些页面现在已被占用,不再可用于后续分配;

    • 计算并返回分配的页面地址和被清理页面的大小。

runtime.pageAlloc.allocToCache — 通过pageAlloc分配pageCache

如果申请的内存比较大或者线程的页缓存中内存不足,会通过runtime.pageAlloc.alloc从页堆分配一块页面缓存(pageCache),函数代码如下:

// go 1.20.3 path: /src/runtime/mpagecache.go

// allocToCache 从 pageAlloc 分配一个页面缓存
func (p *pageAlloc) allocToCache() pageCache {
    // 确保持有 mheap 锁。
    assertLockHeld(p.mheapLock)

    // 如果搜索地址超出了 pageAlloc 的结束地址,返回一个空的 pageCache
    if chunkIndex(p.searchAddr.addr()) >= p.end {
        return pageCache{}
    }

    // 初始化一个新的 pageCache
    c := pageCache{}
    // 计算当前搜索地址的 chunk 索引
    ci := chunkIndex(p.searchAddr.addr())
    var chunk *pallocData

    // 检查 summary 数据是否指示当前 chunk 有可用页面
    if p.summary[len(p.summary)-1][ci] != 0 {
        // 获取当前 chunk 的 pallocData。
        chunk = p.chunkOf(ci)

        // 在 chunk 中找到一个可用的页面。
        j, _ := chunk.find(1, chunkPageIndex(p.searchAddr.addr()))
        if j == ^uint(0) {
            throw("bad summary data") // 如果找不到,抛出异常
        }

        // 设置 pageCache 的基地址和缓存
        c = pageCache{
            base:  chunkBase(ci) + alignDown(uintptr(j), 64)*pageSize,
            cache: ^chunk.pages64(j),
            scav:  chunk.scavenged.block64(j),
        }
    } else {
        // 如果当前 chunk 没有可用页面,尝试在整个 pageAlloc 中找到一个可用页面
        addr, _ := p.find(1)
        if addr == 0 {
            p.searchAddr = maxSearchAddr()
            return pageCache{} // 如果找不到,返回一个空的 pageCache
        }
        ci := chunkIndex(addr)
        chunk = p.chunkOf(ci)

        // 设置 pageCache 的基地址和缓存
        c = pageCache{
            base:  alignDown(addr, 64*pageSize),
            cache: ^chunk.pages64(chunkPageIndex(addr)),
            scav:  chunk.scavenged.block64(chunkPageIndex(addr)),
        }
    }

    // 分配页面并更新 chunk 的状态
    cpi := chunkPageIndex(c.base)
    chunk.allocPages64(cpi, c.cache)
    chunk.scavenged.clearBlock64(cpi, c.cache & c.scav)

    // 更新 pageAlloc 的状态
    p.update(c.base, pageCachePages, false, true)
    p.searchAddr = offAddr{c.base + pageSize*(pageCachePages-1)}

    // 返回新分配的 pageCache
    return c
}

函数 allocToCache 的主要流程可以总结如下:

  1. 验证锁状态:确保在调用此函数时持有 mheapLock,这是为了线程安全。检查搜索地址的有效性:检查当前搜索地址是否已超出 pageAlloc 结构体管理的内存范围。如果是,返回一个空的 pageCache
  2. 初始化 pageCache 结构体:创建一个新的 pageCache 实例。
  3. 在当前 chunk 中查找可用页面
    • 根据当前搜索地址计算所在的内存块(chunk)的索引。
    • 如果在 summary 数据中找到指示当前 chunk 有可用页面的标记,则尝试在该 chunk 中找到一个可用的页面。
  4. 处理找到可用页面的情况
    • 如果找到可用页面,则设置 pageCache 的基地址、缓存和被清理页面的状态。
    • 更新 chunk 的状态,标记相应的页面已被分配。
  5. 处理没有找到可用页面的情况
    • 如果当前 chunk 没有可用页面,尝试在整个 pageAlloc 中找到一个可用页面。
    • 更新 pageCache 的基地址、缓存和被清理页面的状态。
  6. 更新 pageAlloc 的状态:标记已分配的页面,更新 searchAddr 以便下一次分配。返回配置好的 pageCache 实例:返回设置好的 pageCache 实例,用于后续的页面分配操作。

初始化

Go 语言中,内存的划分和初始化主要发生在程序启动时的运行时系统初始化过程中。这个过程涉及到多个函数和组件的协作。关键的函数和步骤包括:

runtime.mallocinit — 初始化内存分配系统

mallocinit 的主要职责是配置和初始化内存分配系统,确保在程序开始执行之前,内存分配环境已经准备妥当。

mallocinit 函数核心源码如下:

// go 1.20.3 path: /src/runtime/malloc.go

func mallocinit() {
  	// ......
  
    // 初始化全局内存分配器 mheap。
    mheap_.init()

    // 为第一个处理器分配并初始化 mcache,用于本地小对象的快速分配。
    mcache0 = allocmcache()

    // 初始化一系列锁,这些锁用于不同的内存管理相关操作。
    lockInit(&gcBitsArenas.lock, lockRankGcBitsArenas)
    lockInit(&profInsertLock, lockRankProfInsert)
    lockInit(&profBlockLock, lockRankProfBlock)
    lockInit(&profMemActiveLock, lockRankProfMemActive)
    for i := range profMemFutureLock {
        lockInit(&profMemFutureLock[i], lockRankProfMemFuture)
    }
    lockInit(&globalAlloc.mutex, lockRankGlobalAlloc)

    // 以下部分基于不同的架构和操作系统设置内存分配的初始地址。
    if goarch.PtrSize == 8 {
        // 对于 64 位系统,根据不同的平台和配置设置内存分配的起始地址。
        for i := 0x7f; i >= 0; i-- {
            var p uintptr
            // 根据不同的构建配置和操作系统,选择不同的内存地址范围。
            // 这是为了避免与其他应用程序或系统分配的内存冲突。
            switch {
            case raceenabled:
                // 当启用数据竞争检测时的地址计算。
                p = uintptr(i)<<32 | uintptrMask&(0x00c0<<32)
                if p >= uintptrMask&0x00e000000000 {
                    continue
                }
            // 对于特定架构和操作系统,设置特定的内存地址。
            case GOARCH == "arm64" && GOOS == "ios":
                p = uintptr(i)<<40 | uintptrMask&(0x0013<<28)
            case GOARCH == "arm64":
                p = uintptr(i)<<40 | uintptrMask&(0x0040<<32)
            case GOOS == "aix":
                if i == 0 {
                    continue
                }
                p = uintptr(i)<<40 | uintptrMask&(0xa0<<52)
            default:
                p = uintptr(i)<<40 | uintptrMask&(0x00c0<<32)
            }
            // 根据竞争检测状态和地址范围,选择合适的 hintList。
            hintList := &mheap_.arenaHints
            if (!raceenabled && i > 0x3f) || (raceenabled && i > 0x5f) {
                hintList = &mheap_.userArena.arenaHints
            }
            // 分配一个新的 arenaHint,并将其加入到 hintList。
            hint := (*arenaHint)(mheap_.arenaHintAlloc.alloc())
            hint.addr = p
            hint.next, *hintList = *hintList, hint
        }
    } else {
        // 对于 32 位系统,执行相应的内存地址设置。
        // ......
    }
}

在这个函数中,我们可以看到几个关键点:

  1. 内存分配器的初始化mheap_.init()allocmcache() 是初始化内存管理系统的关键步骤。它们分别初始化全局内存分配器和为第一个处理器分配内存缓存;
  2. 锁的初始化:多个 lockInit 调用初始化了内存管理中使用的各种锁,这对于确保在多线程环境中内存操作的安全性至关重要;
  3. 内存分配地址的设置:根据不同的架构和操作系统,函数设置了一系列不同的内存分配起始地址。这是为了避免与系统或其他程序的内存分配冲突;
  4. arenaHint 的配置:这部分代码基于计算出的地址创建并配置arenaHint,这对于优化内存分配效率非常重要。

runtime.mheap.init — 堆的初始化

mallocinit 调用之后,mheap.init 函数被触发,它进一步初始化 mheap 结构体,确保它在程序运行期间正确地分配和管理内存。

runtime.mheap.init源码如下:

// go 1.20.3 path: /src/runtime/mheap.go

func (h *mheap) init() {
    // 初始化 mheap 的两个锁,分别用于管理 heap 的基本操作和特殊场景。
    lockInit(&h.lock, lockRankMheap) // 用于基本的 heap 操作。
    lockInit(&h.speciallock, lockRankMheapSpecial) // 用于特殊场景,如最终处理器。

    // 初始化 span 分配器。mspan 对象代表内存中的一段连续空间。
    h.spanalloc.init(unsafe.Sizeof(mspan{}), recordspan, unsafe.Pointer(h), &memstats.mspan_sys)

    // 初始化 mcache 分配器。mcache 为每个处理器(P)提供本地缓存,优化内存分配。
    h.cachealloc.init(unsafe.Sizeof(mcache{}), nil, nil, &memstats.mcache_sys)

    // 初始化各种特殊目的的内存分配器。
    h.specialfinalizeralloc.init(unsafe.Sizeof(specialfinalizer{}), nil, nil, &memstats.other_sys)
    h.specialprofilealloc.init(unsafe.Sizeof(specialprofile{}), nil, nil, &memstats.other_sys)
    h.specialReachableAlloc.init(unsafe.Sizeof(specialReachable{}), nil, nil, &memstats.other_sys)
    h.arenaHintAlloc.init(unsafe.Sizeof(arenaHint{}), nil, nil, &memstats.other_sys)

    // 标记 span 分配器不应该返回已清零的内存。
    h.spanalloc.zero = false

    // 初始化中央缓存(central)。这里每个 central 管理一类大小的内存块。
    for i := range h.central {
        h.central[i].mcentral.init(spanClass(i))
    }

    // 初始化页表,管理内存页的分配和回收。
    h.pages.init(&h.lock, &memstats.gcMiscSys)
}

其主要流程如下:

  1. 锁的初始化:

    • 初始化 mheap 结构中的锁。这些锁用于在多线程环境中保护对堆的访问,防止并发问题。
  2. span 分配器的初始化、缓存分配器的初始化:

    • 初始化用于分配 mspan 对象的分配器。mspan 对象代表内存中的一段连续空间,是堆内存管理的基本单元。
    • 初始化用于 mcache 对象的分配器。mcache 是每个处理器(P)的本地内存缓存,用于加快小对象的分配。

    heap中,spanalloccacheallocspecialfinalizerallocspecialprofileallocarenaHintAlloc等都是fixalloc分配器类型,所以其初始化都是对其fixalloc分配器的初始化。在Go内存管理(上)的文章中已经对fixalloc分配器的初始化、分配、回收操作进行过分析,此处不重复了。

  3. 特殊分配器的初始化以及arena 相关设置:

    • 初始化一系列特殊用途的内存分配器,例如用于特殊内存块的分配器(如带有终结器的对象、性能分析数据等)。
    • 根据系统架构和运行时的需求,设置内存分配的初始位置和策略。
  4. 中央缓存的初始化:

    • 初始化 mcentral 结构,这是 mheap 的一部分,负责管理特定大小的内存块。每个大小类都有一个对应的 mcentral
  5. 页表的初始化:

    • 初始化页表结构,这是管理和跟踪分配的内存页的机制。

runtime.mcentral.init — mcentral的初始化

mcentral 的初始化非常简单,如下代码:

// go 1.20.3 path: /src/runtime/mcentral.go

func (c *mcentral) init(spc spanClass) {
    // 设置 mcentral 管理的 span 类别。
    c.spanclass = spc

    // 初始化 partial 列表的锁。partial 列表存储部分占用的 span。
    lockInit(&c.partial[0].spineLock, lockRankSpanSetSpine)
    lockInit(&c.partial[1].spineLock, lockRankSpanSetSpine)

    // 初始化 full 列表的锁。full 列表存储完全占用的 span。
    lockInit(&c.full[0].spineLock, lockRankSpanSetSpine)
    lockInit(&c.full[1].spineLock, lockRankSpanSetSpine)
}

通过这个初始化过程,mcentral 被配置为管理一类特定大小的内存块,并通过锁机制保证并发环境下内存分配和回收的安全性。 mcentral 在自己初始化之后不会立即分配 span,而是根据程序运行时的实际内存分配需求动态地进行。

具体来说,mcentral 分配 span 的过程通常遵循以下步骤:

  1. 内存分配请求:
    • 当程序执行过程中需要分配内存时(如通过 newmake 函数),Go 运行时会根据请求的大小确定对应的大小类,并找到相应的 mcentral
  2. 检查空闲 span:
    • mcentral 首先会检查其维护的空闲 span 列表。如果有可用的空闲 span,它将直接从这些 span 中分配内存。
  3. 请求新的 span:
    • 如果 mcentral 没有可用的空闲 span,它需要从全局内存分配器 mheap 请求一个新的 span。这通常是通过调用 mcentral.grow 函数完成的。
  4. mheap 提供新的 span:
    • mcentral.grow 内部,最终会调用 mheap.alloc 函数来从全局堆中分配一个新的 span
    • 新分配的 span 随后被切分成小块,并加入到 mcentral 的空闲列表中,以供后续的内存分配请求使用。
  5. 持续的内存管理:
    • 在程序的后续执行过程中,每当有新的内存分配请求,且 mcentral 需要更多的内存时,这个过程会重复进行。

mcentral.grow 函数以及mheap.alloc 函数后续内存分配会涉及,这边暂时不具体展开了。

runtime.pageAlloc.init — pageAlloc分配器初始化

pageAlloc 是一个专门用于管理和分配内存页(pages)的分配器。它是 Go 运行时内存管理系统的一个关键部分,负责对内存页级别的分配和回收进行管理。通过这个 runtime.pageAlloc.init 方法,pageAlloc 被正确初始化,准备好进行内存页的管理和分配。

源码如下:

// go 1.20.3 path: /src/runtime/mpagealloc.go

func (p *pageAlloc) init(mheapLock *mutex, sysStat *sysMemStat) {
    // 检查页级别日志大小是否超过最大允许值。
    if levelLogPages[0] > logMaxPackedValue {
        print("runtime: root level max pages = ", 1<<levelLogPages[0], "\n")
        print("runtime: summary max pages = ", maxPackedValue, "\n")
        throw("root level max pages doesn't fit in summary")
    }

    // 设置系统内存统计的引用。
    p.sysStat = sysStat

    // 初始化 inUse 位图,它标记了哪些页正在使用中。
    p.inUse.init(sysStat)

    // 执行系统级别的初始化,可能涉及到操作系统相关的内存设置。
    p.sysInit()

    // 设置搜索地址,这是分配内存时开始搜索的地址。
    p.searchAddr = maxSearchAddr()

    // 设置 mheap 锁的引用,这个锁用于同步对全局堆的访问。
    p.mheapLock = mheapLock
}

runtime.allocmcache — mcache初始化

mcache的初始化发生在两个地方:

  1. P0的初始化发生在mallocinit()中,其调用为:mcache0 = allocmcache()

  2. 其他P的初始化在func procresize(nprocs int32) *p中,procresize函数也在schedinit()中调用,代码如下:

    // go 1.20.3 path: /src/runtime/proc.go
    
    func procresize(nprocs int32) *p {
      // 省略代码 ......
    	for i := old; i < nprocs; i++ {
    		pp := allp[i]
    		if pp == nil {
    			pp = new(p)
    		}
    		pp.init(i)
    		atomicstorep(unsafe.Pointer(&allp[i]), unsafe.Pointer(pp))
    	}
      
    func (pp *p) init(id int32) {
      // 省略代码 ......
    	if pp.mcache == nil {
    		if id == 0 {
    			if mcache0 == nil {
    				throw("missing mcache?")
    			}
    			pp.mcache = mcache0
    		} else {
    			pp.mcache = allocmcache()
    		}
    	}
      // 省略代码 ......
    }
    

不管哪里初始化,其实都是调用 allocmcache() 方法,其源码如下:

// go 1.20.3 path: /src/runtime/mcache.go

func allocmcache() *mcache {
    var c *mcache
    systemstack(func() {
        // 锁定全局堆(mheap)的锁。
        lock(&mheap_.lock)

        // 从 mheap 的 cachealloc 分配器中分配一个新的 mcache。
        c = (*mcache)(mheap_.cachealloc.alloc())

        // 设置 mcache 的 flushGen 值为当前的 sweepgen,用于垃圾回收。
        c.flushGen.Store(mheap_.sweepgen)

        // 解锁全局堆的锁。
        unlock(&mheap_.lock)
    })

    // 初始化 mcache 中的 alloc 数组,该数组用于小对象分配。
    for i := range c.alloc {
        c.alloc[i] = &emptymspan
    }

    // 设置 mcache 的 nextSample,这是用于控制内存分配采样的。
    c.nextSample = nextSample()

    // 返回新分配的 mcache。
    return c
}

分析代码,其主要流程如下:

  1. 全局堆锁定:

    • 函数首先在 systemstack 上下文中执行,这是出于安全和效率的考虑。lock(&mheap_.lock) 锁定全局堆(mheap),确保分配操作的原子性和线程安全。
  2. 分配 mcache:

    • 通过 mheap_.cachealloc.alloc() 从全局堆的缓存分配器中分配一个新的 mcache 实例。
  3. 设置 flushGen:

    • c.flushGen.Store(mheap_.sweepgen) 设置 mcacheflushGen 字段,这与垃圾回收机制有关,用于确保 mcache 与当前的 sweep 代保持同步。
  4. 初始化 alloc 数组:

    • 通过遍历 c.alloc 并设置每个元素为 &emptymspan,初始化 mcache 中的内存分配数组。这个数组用于小对象的快速分配。
  5. 设置内存分配采样:

    • c.nextSample = nextSample() 设置内存分配采样的下一个阈值。这是一种性能监控机制,用于决定何时记录内存分配事件。
  6. 返回新分配的 mcache:

    • 最后,函数返回新分配和初始化的 mcache 实例。

mcentral一样,当一个 mcacheGo 语言运行时中初始化完毕后,它本身是空的,即它的各个大小类(span class)的缓存中并没有预先分配的 span。这意味着初始状态下的 mcache 不包含任何实际的内存块或 span。当对应大小的内存分配请求首次发生时,mcache 会从会向对应的 mcentral 请求一个新的 span。后续部分将在内存分配中具体分析。

至此,管理结构mheap67mcentral及每个Pmcache都初始化完毕,接下来进入重点–分配阶段。

内存分配

一般我们写代码需要分配内存空间的时候,都用下列几种方式:

  • new(T)
  • &T{}
  • make(xxxx)

但是无论是何种方式,何种写法,最终分配堆上内存都会通过调用 runtime.newobject函数分配内存,该函数会调用 runtime.mallocgc 分配指定大小的内存空间,这也是用户程序向堆上申请内存空间的必经函数:

// go 1.20.3 path: /src/runtime/malloc.go

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

去除一些GCrace以及debug等一些非重点代码,runtime.mallocgc主要源码如下:

// go 1.20.3 path: /src/runtime/malloc.go

func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
    // 检查当前垃圾回收阶段,如果是标记终止阶段,则抛出异常。
    if gcphase == _GCmarktermination {
        throw("mallocgc called with gcphase == _GCmarktermination")
    }

    // 对于请求分配0字节的内存,返回一个特殊的地址。
    if size == 0 {
        return unsafe.Pointer(&zerobase)
    }

    // 省略一些调试和追踪相关的代码...

    // 如果开启了内存对齐,调整请求的内存大小。
    userSize := size
    if asanenabled {
        size += computeRZlog(size)
    }

    // 省略一些调试和追踪相关的代码...

    // 计算 GC 辅助信用额度。
    assistG := deductAssistCredit(size)

    // 获取当前的 M(操作系统线程)并检查是否处于分配中的状态。
    mp := acquirem()
    if mp.mallocing != 0 {
        throw("malloc deadlock")
    }
    if mp.gsignal == getg() {
        throw("malloc during signal")
    }
    mp.mallocing = 1

    // 省略一些内部状态设置...

    //获取当前 p 对应的 mcache
    c := getMCache(mp)
    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
    delayedZeroing := false
    if size <= maxSmallSize {
			if noscan && size < maxTinySize {
				// 对于微对象的处理逻辑...
			} else {
				// 对于小对象的处理逻辑...
			}
    } else {
        // 对于大对象的处理逻辑...
    }

    // 设置对象类型的元数据,用于垃圾回收。
    if !noscan {
        var scanSize uintptr
        heapBitsSetType(uintptr(x), size, dataSize, typ)
        // 省略一些逻辑...
    }

    // 省略一些追踪和分析相关的代码...

    // 如果分配了需要延迟清零的内存,则在这里进行清零。
    if delayedZeroing {
        if !noscan {
            throw("delayed zeroing on data that may contain pointers")
        }
        memclrNoHeapPointersChunked(size, x) 
    }

    // 省略一些调试和追踪相关的代码...

    // 如果当前分配导致需要触发 GC,则启动 GC。
    if shouldhelpgc {
        if t := (gcTrigger{kind: gcTriggerHeap}); t.test() {
            gcStart(t)
        }
    }

    // 返回分配的内存地址。
    return x
}

const (
  _TinySize      = 16
  _TinySizeClass = int8(2)
  _MaxSmallSize   = 32768
	maxTinySize   = _TinySize
	tinySizeClass = _TinySizeClass
	maxSmallSize  = _MaxSmallSize
)

通过runtime.mallocgc的代码可以知道,runtime.mallocgc在分配内存的时候,会按照对象的大小以及是否为noscan型空间来选择不同的分配策略了分为3档来进行分配(**maxSmallSize32KBmaxTinySize**等于16):

  • 微对象: 小于16字节,而且是noscan类型的内存分配请求,会使用tiny allocator
  • 大对象:大于32KB的内存分配,包括noscanscannable类型,都会采用大块内存分配器;
  • 小对象:大于等于16B且小于等于32KBnoscan类型;以及不大于32KBscannable类型的分配请求,都会直接匹配预置的大小规格来分配;

我们会依次介绍运行时分配微对象、小对象和大对象的过程,梳理内存分配的核心执行流程。

大对象(Large)分配

分配对象的大小超过32KB视为大对象分配,大对象的分配流程与小对象和tiny对象的分配是不一样的,大对象的分配直接走heap进行分配。这其实也很好理解,因为预置的内存规格最大才32KB,所以会直接根据需要的页面数分配一个新的span

无论是大对象还是小对象的分配统一入口都是runtime.newobjectruntime.newobject内部直接调用了runtime.mallocgc函数,runtime.mallocgc函数根据分配对象的大小做不同的处理逻辑,下面的代码只保留了大对象分配的核心逻辑大对象的分配,来看下大对象的内存分配处理逻辑:

// go 1.20.3 path: /src/runtime/malloc.go

func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
   
    // 省略代码...
  
  	c := getMCache(mp)
  
  	// 省略代码...

    if size <= maxSmallSize {
			if noscan && size < maxTinySize {
				// 对于微对象的处理逻辑...
			} else {
				// 对于小对象的处理逻辑...
			}
    } else {
      	// 设置标志表示可能需要辅助进行垃圾收集
        shouldhelpgc = true
      	// 调用 allocLarge 从 mcache 分配一个大对象
      	// noscan 表示是否该对象包含指针,以此决定是否需要扫描
        span = c.allocLarge(size, noscan)
      	// 初始化 span 的一些内部字段,用于跟踪分配情况
        span.freeindex = 1  // 设置下一个可用的空闲索引为 1
        span.allocCount = 1 // 设置已分配对象计数为 1
      	// 将 size 更新为 span 实际管理的每个元素的大小
        size = span.elemsize
      	// 获取分配的内存块的基地址
        x = unsafe.Pointer(span.base())
      
      	// 如果需要将分配的内存清零,并且 span 标记了需要清零
        if needzero && span.needzero != 0 {
          if noscan {
            // 如果对象不包含指针,设置延迟清零标志
            delayedZeroing = true
          } else {
            // 如果对象包含指针,立即清零
            memclrNoHeapPointers(x, size)
          }
        }
    }
  
  	// 省略代码...

    return x
}

可以从代码看出大对象所分配的 mspan 是直接通过 mcache.largeAlloc 进行分配的,我们进一步看看mcache.largeAlloc函数。

image-20231221162238798

runtime.mcache.largeAlloc — 大对象分配

runtime.mcache.largeAlloc函数源码如下:

// go 1.20.3 path: /src/runtime/mcache.go

func (c *mcache) allocLarge(size uintptr, noscan bool) *mspan {
    // 检查 size 加上一个页面大小后是否会溢出。
    // 这是一种内存安全检查。
    if size+_PageSize < size {
        throw("out of memory")
    }

    // 计算需要多少页面来满足请求的大小。
    npages := size >> _PageShift
    if size&_PageMask != 0 {
        npages++
    }

    // 扣除为了进行清扫(sweeping)而积累的信用额度。
    deductSweepCredit(npages*_PageSize, npages)

    // 创建一个 span 类,表示是否扫描(noscan)。
    spc := makeSpanClass(0, noscan)

    // 从堆(mheap_)中分配所需页面数的 span。
    s := mheap_.alloc(npages, spc)
    if s == nil {
        throw("out of memory")
    }

    // 更新内存统计信息。
    stats := memstats.heapStats.acquire()
    atomic.Xadd64(&stats.largeAlloc, int64(npages*pageSize))
    atomic.Xadd64(&stats.largeAllocCount, 1)
    memstats.heapStats.release()

    // 更新 GC 控制器的总分配计数。
    gcController.totalAlloc.Add(int64(npages * pageSize))
    // 更新 GC 控制器状态。
    gcController.update(int64(s.npages*pageSize), 0)

    // 将新分配的 span 推入中央分配器的 fullSwept 列表。
    mheap_.central[spc].mcentral.fullSwept(mheap_.sweepgen).push(s)

    // 设置 span 的 limit 字段为基地址加上请求的大小。
    s.limit = s.base() + size

    // 初始化 span 的堆位图。
    s.initHeapBits(false)

    // 返回新分配的 span。
    return s
}

总结下该函数的主要流程如下:

  1. 检查内存溢出:首先检查请求的内存大小 size 加上一个页面大小 _PageSize 后是否会造成整数溢出。如果会溢出,则抛出内存不足的异常;
  2. 计算所需页面数:根据请求的内存大小 size,计算所需的页面数 npages。这涉及到将 size 右移页面位移 _PageShift,并根据页面掩码 _PageMask 确定是否需要额外的页面来满足请求,例如 size=32KB+1,则需要分配(32KB+1)/8KB=5page
  3. 扣除清扫信用:调用 deductSweepCredit 函数扣除为清扫(sweeping)积累的信用额度,这是垃圾收集机制的一部分。这个这边简单说明下,Go语言中规定申请一字节内存空间需要做多少扫描工作,这个值根据GC扫描的进度更新计算的,而每次执行辅助GC,最少要扫描64KB,协程每次执行辅助GC,多出来的部分会作为信用存储到当前G中,就像信用卡的额度一样,后续再执行mallocgc()时,只要信用额度用不完,就不用执行辅助GC了,这块内容在GC章节中细品;
  4. 创建 span 类:使用 makeSpanClass 函数创建一个 span 类(spc),表示是否需要扫描(由 noscan 参数决定),我们都知道,span68种规格,其中0号就是我们现在创建大对象所使用的;
  5. 分配内存 span:从堆(mheap_)中调用 alloc 方法分配所需页面数的 span(s)。如果内存不足,抛出内存不足的异常;
  6. 剩余的一些更新和初始化操作,这些操作包括:
    • 更新内存统计和GC 控制器:更新内存统计信息,包括大对象的分配总量和计数;向垃圾收集控制器(gcController)报告新分配的内存,更新总分配计数和控制器的状态;
    • 添加到中央分配器:将新分配的 span 添加到中央分配器(mheap_.central)的 fullSwept 列表中,以供后续使用;
    • 设置 span 限制:设置 spanlimit 字段,这是其管理的内存区域的上限;
    • 初始化堆位图:调用 s.initHeapBits 方法初始化 span 的堆位图,这与垃圾收集相关;
  7. 返回新分配的 span:返回新分配的 span 对象。

根据以上流程,我们可以得出大对象的分配流程图如下:

image-20231221163913824

下面我们将对runtime.mcache.largeAlloc函数中以及关联函数中出现过的重点函数进行分析。

runtime.mheap.alloc — 从堆中分配span

runtime.mheap.alloc方法分配一个新的mspan,真正分配是runtime.mheap.allocSpan做的,它本身除了的逻辑不多,主要是在runtime.mheap.allocSpan中进行,然后就是对不用的页面进行清扫回收,对分配到的内存根据是否需要清0做一些清理操作,代码如下:

// go 1.20.3 path: /src/runtime/mheap.go

// alloc 是 mheap 类型的一个方法,用于分配内存。
func (h *mheap) alloc(npages uintptr, spanclass spanClass) *mspan {
    var s *mspan  // 定义一个指向 mspan 类型的指针 s。

    // systemstack 是一个运行时函数,用于在系统栈上执行给定的函数。
    // 这通常用于执行可能涉及运行时操作的函数,以避免死锁或堆栈溢出。
    systemstack(func() {
        // 检查是否完成了内存清理(垃圾回收的一部分)。
        if !isSweepDone() {
            // 如果清理尚未完成,则先执行内存回收。
            h.reclaim(npages)
        }
        // 分配内存空间。allocSpan 是一个分配特定数量页面的函数,
        // spanAllocHeap 是一个枚举值,可能与分配的类型或优先级有关。
        s = h.allocSpan(npages, spanAllocHeap, spanclass)
    })

    return s  // 返回分配的内存空间的指针。
}

我们进一步看看主要工作进行的runtime.mheap.allocSpan的实现。

runtime.mheap.allocSpan — 从堆中分配span

runtime.mheap.allocSpan函数负责在内存堆 (mheap) 中分配内存页 (mspan)。代码中包含了复杂的逻辑来处理物理页面对齐、内存页的查找与分配、内存增长、以及内存统计和回收。 该函数代码码对于理解 Go 语言中内存分配和垃圾回收机制的内部工作原理是非常重要的。

由于其源码较为复杂并冗长,我们将流程归纳总结,按着先后和功能拆分着来分析:

  1. 初始化和预处理

    首先,runtime.mheap.allocSpan函数会进行初始化和预处理,主要的工作是:

    • 获取当前 goroutineg 对象;
    • 初始化一些本地变量,如基地址 base、回收页面数 scav 和内存增长量 growth
    • 检查是否需要根据物理页面大小进行对齐(对于栈空间分配特别重要)。

    这部分代码源码如下:

    // go 1.20.3 path: /src/runtime/mheap.go
    func (h *mheap) allocSpan(npages uintptr, typ spanAllocType, spanclass spanClass) (s *mspan) {
        gp := getg() // 获取当前 goroutine 的 g 对象。
        base, scav := uintptr(0), uintptr(0) // 初始化 base 和 scav 变量,用于存储内存页的基地址和回收的页面数。
        growth := uintptr(0) // 用于记录增长的内存大小。
    
        // 判断是否需要按物理页面对齐。
        needPhysPageAlign := physPageAlignedStacks && typ == spanAllocStack && pageSize < physPageSize
      
      	//省略后续逻辑代码 ......
    }
    
  2. 尝试从页面缓存中分配

    该步骤主要的工作是:

    • 检查当前的 P(处理器)是否有足够的页面缓存可供分配;
    • 如果页面缓存为空,锁定 heap 并分配页面到缓存;
    • 从缓存中分配所需的页面,并尝试创建一个新的 mspan(内存跨度)。

    该部分源码如下:

    // go 1.20.3 path: /src/runtime/mheap.go
    
    // allocSpan 是 mheap 类型的一个方法,用于分配指定数量的内存页。
    func (h *mheap) allocSpan(npages uintptr, typ spanAllocType, spanclass spanClass) (s *mspan) {
      
      	//省略前面流程代码,需要查看代码请往上翻看 ...... 
      
        pp := gp.m.p.ptr() // 获取当前 P 的指针。
      
        // 如果不需要物理页面对齐,且当前 P 存在,且请求的页面数小于页面缓存的四分之一。
        if !needPhysPageAlign && pp != nil && npages < pageCachePages/4 {
            c := &pp.pcache // 获取 P 的页面缓存。
    
            // 如果页面缓存为空,则锁定 heap 并分配页面到缓存,然后解锁。
            if c.empty() {
                lock(&h.lock)
                *c = h.pages.allocToCache()
                unlock(&h.lock)
            }
    
            // 从缓存中分配页面。
            base, scav = c.alloc(npages)
            if base != 0 {
                s = h.tryAllocMSpan() // 尝试分配 mspan。
                if s != nil {
                    goto HaveSpan // 如果成功分配则跳转到 HaveSpan。
                }
            }
        }
      
      //省略后续逻辑代码 ......
     
    }
    

    从上述代码可以看出,当满足不需要物理对齐且npages小于16个就会尝试从P的页面缓存(pp.pcache)中获取可用内存。

  3. 物理页面对齐处理

    该步骤主要流程是:

    • 如果需要物理页面对齐,计算额外需要的页面数: extraPages := physPageSize / pageSize
    • 调用heap.pages.find查找以获取足够的连续空间,如果没有获取到的情况下,尝试调用runtime.mheap.grow去扩容heap,然后再次heap.pages.find查找;
    • 确保基地址按物理页面对齐。

    该部分源码如下:

    // go 1.20.3 path: /src/runtime/mheap.go
    
    func (h *mheap) allocSpan(npages uintptr, typ spanAllocType, spanclass spanClass) (s *mspan) {
      
        //省略前面流程代码,需要查看代码请往上翻看 ...... 
      
        lock(&h.lock) // 锁定 heap。
    
        // 如果需要物理页面对齐。
        if needPhysPageAlign {
            // 计算额外需要的页面数。
            extraPages := physPageSize / pageSize
            // 在 heap 中查找足够的空闲页面。
            base, _ = h.pages.find(npages + extraPages)
            if base == 0 {
                var ok bool
                // 如果没有找到,则尝试增长 heap。
                growth, ok = h.grow(npages + extraPages)
                if !ok {
                    unlock(&h.lock)
                    return nil // 如果增长失败,返回 nil。
                }
                // 重新查找空闲页面。
                base, _ = h.pages.find(npages + extraPages)
                if base == 0 {
                    throw("grew heap, but no adequate free space found") // 如果仍然没有找到,抛出异常。
                }
            }
            // 对齐基地址。
            base = alignUp(base, physPageSize)
            // 分配页面范围。
            scav = h.pages.allocRange(base, npages)
        }
      
      //省略后续逻辑代码 ......
      
    }
    
  4. 直接从 heap 中分配

    如果到这步, base 仍然为 0,则在 heap 中分配页面,主要工作如下:

    • 调用heap.pages.alloc分配所需的页面;
    • 如有必要,尝试调用runtime.mheap.grow去增长 heap 以提供足够的空间,然后再次调用heap.pages.alloc分配页面;
    • 如果尚未创建 mspan,锁定 heap 并创建一个新的 mspan

    该步骤代码如下:

    // go 1.20.3 path: /src/runtime/mheap.go
    
    // allocSpan 是 mheap 类型的一个方法,用于分配指定数量的内存页。
    func (h *mheap) allocSpan(npages uintptr, typ spanAllocType, spanclass spanClass) (s *mspan) {
        
        //省略前面流程代码,需要查看代码请往上翻看 ...... 
      
        // 如果 base 仍然为 0,则在 heap 中分配页面。
        if base == 0 {
            base, scav = h.pages.alloc(npages)
            if base == 0 {
                var ok bool
                // 如果没有分配到,则尝试增长 heap。
                growth, ok = h.grow(npages)
                if !ok {
                    unlock(&h.lock)
                    return nil // 如果增长失败,返回 nil。
                }
                // 再次尝试分配页面。
                base, scav = h.pages.alloc(npages)
                if base == 0 {
                    throw("grew heap, but no adequate free space found") // 如果仍然没有分配到,抛出异常。
                }
            }
        }
        // 如果 s 仍然为 nil,则锁定状态下分配 mspan。
        if s == nil {
            s = h.allocMSpanLocked()
        }
        unlock(&h.lock) // 解锁 heap。
      
      //省略后续逻辑代码 ......
      
    }
    
  5. 初始化分配的 span、内存回收和统计、返回mspan

    到了这步,就是一些初始化mspan与关于内存回收和统计的操作了,主要有:

    • 使用分配的基地址、页面数等信息初始化 mspan
    • 根据当前的内存使用情况和回收目标,决定是否执行内存回收;
    • 更新各种内存统计数据;
    • 返回新创建的或获取的 mspan 指针。

    这部分代码如下:

    // go 1.20.3 path: /src/runtime/mheap.go
    
    func (h *mheap) allocSpan(npages uintptr, typ spanAllocType, spanclass spanClass) (s *mspan) {
     
      //省略前面流程代码,需要查看代码请往上翻看 ......
      
    HaveSpan:
        // 以下是一系列关于内存回收和统计的操作……
        // 省略部分代码以便注释更简洁。
    
        // 初始化分配的 span。
        h.initSpan(s, typ, spanclass, base, npages)
    
        nbytes := npages * pageSize // 计算分配的字节总数。
        // 如果有回收的页面,则标记为已使用,并更新相关统计信息。
        if scav != 0 {
            sysUsed(unsafe.Pointer(base), nbytes, scav)
            gcController.heapReleased.add(-int64(scav))
        }
    
        // 更新 heap 的空闲和使用统计。
        gcController.heapFree.add(-int64(nbytes - scav))
        if typ == spanAllocHeap {
            gcController.heapInUse.add(int64(nbytes))
        }
    
        // 更新内存统计信息。
        stats := memstats.heapStats.acquire()
        atomic.Xaddint64(&stats.committed, int64(scav))
        atomic.Xaddint64(&stats.released, -int64(scav))
        switch typ {
        case spanAllocHeap:
            atomic.Xaddint64(&stats.inHeap, int64(nbytes))
        case spanAllocStack:
            atomic.Xaddint64(&stats.inStacks, int64(nbytes))
        case spanAllocPtrScalarBits:
            atomic.Xaddint64(&stats.inPtrScalarBits, int64(nbytes))
        case spanAllocWorkBuf:
            atomic.Xaddint64(&stats.inWorkBufs, int64(nbytes))
        }
        memstats.heapStats.release()
    
        pageTraceAlloc(pp, now, base, npages) // 记录页面分配信息。
        return s // 返回分配的 mspan。
    }
    

至此,整个runtime.mheap.allocSpan 函数分析完成。在该函数中,出现过的runtime.mheap.pages.allocruntime.mheap.pages.allocToCacheruntime.mheap.pages.find等函数在前面章节已经分析过,再次不重复了,我们下面分析分析runtime.mheap.pages.grow函数,看看该函数是如何进行heap的增长操作的。

runtime.mheap.grow — 扩容堆空间

runtime.pageAlloc.alloc在分配失败时的处理,如果alloc分配失败,需要从操作系统新申请一片空间,然后继续走alloc进行分配,申请新空间处理逻辑在下面的grow方法中,我们来看看 grow 源码:

// go 1.20.3 path: /src/runtime/mheap.go

// grow 方法用于增加 mheap 的大小以容纳更多页面
func (h *mheap) grow(npage uintptr) (uintptr, bool) {
    // 确保持有 mheap 的锁,这是为了线程安全
    assertLockHeld(&h.lock)

    // 计算请求的页面大小,并按 pallocChunkPages 对齐
    ask := alignUp(npage, pallocChunkPages) * pageSize

    // 初始化 totalGrowth 为 0,用于记录增长的总大小
    totalGrowth := uintptr(0)
    // 计算预期增长后的末地址
    end := h.curArena.base + ask
    // 确保新的基地址按物理页面大小对齐
    nBase := alignUp(end, physPageSize)

    // 检查是否需要从系统申请更多内存
    if nBase > h.curArena.end || end < h.curArena.base {
        // 从系统申请内存
        av, asize := h.sysAlloc(ask, &h.arenaHints, true)
        // 如果申请失败,打印错误并返回
        if av == nil {
            inUse := gcController.heapFree.load() + gcController.heapReleased.load() + gcController.heapInUse.load()
            print("runtime: out of memory: cannot allocate ", ask, "-byte block (", inUse, " in use)\n")
            return 0, false
        }

        // 如果新申请的内存紧邻当前 arena 的末尾,直接扩展
        if uintptr(av) == h.curArena.end {
            h.curArena.end = uintptr(av) + asize
        } else {
            // 否则,映射新的内存区域,并更新统计和状态
            if size := h.curArena.end - h.curArena.base; size != 0 {
                sysMap(unsafe.Pointer(h.curArena.base), size, &gcController.heapReleased)
                stats := memstats.heapStats.acquire()
                atomic.Xaddint64(&stats.released, int64(size))
                memstats.heapStats.release()
                h.pages.grow(h.curArena.base, size)
                totalGrowth += size
            }
            h.curArena.base = uintptr(av)
            h.curArena.end = uintptr(av) + asize
        }

        // 更新 nBase 为新的对齐地址
        nBase = alignUp(h.curArena.base+ask, physPageSize)
    }

    // 设置新的基地址
    v := h.curArena.base
    h.curArena.base = nBase

    // 映射新的内存区域
    sysMap(unsafe.Pointer(v), nBase-v, &gcController.heapReleased)

    // 更新统计信息
    stats := memstats.heapStats.acquire()
    atomic.Xaddint64(&stats.released, int64(nBase-v))
    memstats.heapStats.release()

    // 增长 pages 并更新 totalGrowth
    h.pages.grow(v, nBase-v)
    totalGrowth += nBase - v

    // 返回增长的总大小和成功标志
    return totalGrowth, true
}

下面还是同样梳理下流程:

  1. 计算请求的内存大小并准备增长内存

    • 确保在执行 grow 方法时持有 mheap 的锁,以保证线程安全;
    • 根据请求的页面数 npage 计算出需要申请的内存大小 ask,并确保它按照 pallocChunkPages 的倍数对齐;
    • 初始化用于记录增长的总大小的变量 totalGrowth,计算增长后的末地址 end 和按物理页面大小对齐的新基地址 nBase
  2. 判断是否需要申请新内存以及系统内存申请

    • 如果新基地址 nBase 超过当前 arena 的结束地址或 end 小于当前 arena 的基地址,表示需要向系统申请新内存;
    • 调用runtime.mheap.sysAlloc从操作系统中申请更多的内存;
    • 如果申请失败(返回 nil),打印内存不足的错误信息,并返回 false 表示申请失败;

    为了缩减篇幅,我们简单的介绍下runtime.mheap.sysAlloc函数,其缩减版源码如下:

    // go 1.20.3 path: /src/runtime/malloc.go
    
    func (h *mheap) sysAlloc(n uintptr, hintList **arenaHint, register bool) (v unsafe.Pointer, size uintptr) {
        n = alignUp(n, heapArenaBytes)
    
        // 尝试从 arena 中分配内存。
        if hintList == &h.arenaHints {
            v = h.arena.alloc(n, heapArenaBytes, &gcController.heapReleased)
            if v != nil {
                size = n
                return v, size
            }
        }
    
        // 如果 arena 中分配失败,遍历 hintList 尝试分配。
        for *hintList != nil {
            hint := *hintList
            p := hint.addr
            if hint.down {
                p -= n
            }
    
            v = sysReserve(unsafe.Pointer(p), n)
            if p == uintptr(v) {
                hint.addr = p + n
                size = n
                return v, size
            }
    
            *hintList = hint.next
        }
    
        // 直接从系统中分配对齐的内存。
        v, size = sysReserveAligned(nil, n, heapArenaBytes)
        if v == nil {
            return nil, 0
        }
    
        // 更新 hintList。
        // 省略更新 hintList 的代码。
    
        return v, size
    }
    
    

    在这个简化的版本中,我们保留了以下核心步骤,其主要流程如下:

    1. 尝试从 arena 分配内存;
    2. 如果 arena 分配失败,遍历 hintList 尝试在提供的地址上预留内存;
    3. 如果遍历 hintList 仍然失败,直接从系统中分配对齐的内存;
    4. 更新 hintList
  3. 处理新申请的内存

    • 如果新申请的内存紧邻当前 arena 的末尾,直接扩展当前 arena
    • 否则,处理当前 arena 的映射,并将新申请的内存设置为当前 arena
  4. 映射新内存区域、更新统计信息

    • 使用 sysMap 函数映射新的内存区域;
    • 更新与内存相关的统计信息,如已释放的内存量。
  5. 增长内部页面管理

    调用 h.pages.grow 来增长内部页面管理结构,记录新的内存页面。

  6. 返回增长结果

    返回总增长的内存大小 totalGrowth 和成功标志 true

到这里,基本大对象的分配介绍完成,我们更新下流程图,方便更全面的了解:

image-20231227141136963

小对象(small)分配

小对象是指大小在[16B,32KB]范围或者小于16B但是包含指针的对象。小对象的分配也是走的mallocgc函数:

// go 1.20.3 path: /src/runtime/malloc.go

func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
   
    // 省略代码...
  
  	c := getMCache(mp)
  
  	// 省略代码...

    if size <= maxSmallSize {
			if noscan && size < maxTinySize {
				// 对于微对象的处理逻辑...
			} else {
        // 定义变量 sizeclass,用于存储内存大小类别。
        var sizeclass uint8

        // 根据请求的内存大小确定内存大小类别。
        if size <= smallSizeMax-8 {
            // 对于较小的内存请求,使用 size_to_class8 查找表来确定大小类别。
            sizeclass = size_to_class8[divRoundUp(size, smallSizeDiv)]
        } else {
            // 对于较大的内存请求,使用 size_to_class128 查找表来确定大小类别。
            sizeclass = size_to_class128[divRoundUp(size-smallSizeMax, largeSizeDiv)]
        }

        // 根据内存大小类别获取实际分配的内存大小。
        size = uintptr(class_to_size[sizeclass])

        // 创建一个 span 类别,这里使用 noscan 类别(表示不需要垃圾回收扫描)。
        spc := makeSpanClass(sizeclass, noscan)

        // 从当前 mcache 的 alloc 数组中获取对应大小类别的 span。
        span = c.alloc[spc]

        // 尝试快速从 span 中获取一个可用的内存块。
        v := nextFreeFast(span)
        if v == 0 {
            // 如果快速获取失败,使用 nextFree 方法来获取内存块,并检查是否需要协助垃圾回收。
            v, span, shouldhelpgc = c.nextFree(spc)
        }

        // 将内存地址转换为 unsafe.Pointer 类型。
        x = unsafe.Pointer(v)

        // 如果需要零初始化内存,并且 span 表示需要零初始化,则执行零初始化。
        if needzero && span.needzero != 0 {
            memclrNoHeapPointers(x, size)
				}
      }
    } else {
      	// 对于大对象的处理逻辑...
    }
  
  	// 省略代码...

    return x
}

从上面代码,概括起来概括起来小对象的分配分为三个步骤:

  1. 创建spanclass, 根据分配对象的大小,结合size_to_class8size_to_class128表,得到对象的sizeclass, 然后根据sizeclass以及对象是否包含指针计算得到spanclass
  2. 拿到计算得到的spanclassp.mcache保存的对应规格的span,然后从span对象中获取一个空闲的object;
  3. 如果步骤 2 获取失败,则会从mcentral获取一个新的span对象,加入到mcache中,然后在从刚申请的span中分配一个空的object

将流程转化为流程图:

image-20231228161457235

下面对小对象分配流程中出现的一些函数进行分析讲解:

runtime.nextFreeFast — 快速从mcache获取空闲对象

nextFreeFast快速从mspan中找到一个空闲的object,传入的mspan来自mcache, mcache中有一个*mspan数组alloc,缓存了各个规格(span class)的span对象,每个span管理的page已按预定的size切分好了。

其源码如下:

// go 1.20.3 path: /src/runtime/malloc.go

// nextFreeFast 从 mspan 快速分配一个内存块
func nextFreeFast(s *mspan) gclinkptr {
    // 使用 sys.TrailingZeros64 查找 allocCache 中第一个可用位的位置
    theBit := sys.TrailingZeros64(s.allocCache)
    
    // 检查是否找到了可用的位
    if theBit < 64 {
        // 计算出下一个可用内存块的索引
        result := s.freeindex + uintptr(theBit)

        // 确保 result 在元素总数范围内
        if result < s.nelems {
            // 计算新的 freeindex
            freeidx := result + 1

            // 如果新的 freeindex 是 64 的倍数且不是最后一个元素,返回 0
            if freeidx%64 == 0 && freeidx != s.nelems {
                return 0
            }

            // 更新 allocCache,移除已分配的位
            s.allocCache >>= uint(theBit + 1)

            // 更新 freeindex 和 allocCount。
            s.freeindex = freeidx
            s.allocCount++

            // 返回新分配内存块的地址
            return gclinkptr(result*s.elemsize + s.base())
        }
    }

    // 如果没有找到可用位或其他条件不满足,返回 0
    return 0
}

nextFreeFast 函数是一个用于管理内存分配的例程。它的作用是在 mspan 结构(代表一块内存区域)中查找下一个可用的(未分配的)对象,并更新相关的分配状态。以下是这个函数的流程:

  • 计算空闲对象的位索引

    • 使用 sys.TrailingZeros64(s.allocCache) 来找到 allocCache 中第一个为零的位的位置。allocCache 是一个 64 位的标记,其中的每一位表示 mspan 中对应位置的对象是否已被分配(1 表示已分配,0 表示未分配)。sys.TrailingZeros64 返回的是从最低位到第一个为零的位的距离(位索引)。
  • 检查是否存在空闲对象

    • 如果 theBit 小于 64,说明在 allocCache 中找到了空闲对象。
  • 计算并返回空闲对象的地址

    • 计算空闲对象的索引:result := s.freeindex + uintptr(theBit)。这里的 result 是空闲对象在 mspan 中的索引;检查 result 是否小于 mspan 中元素的总数 (s.nelems)。如果是,说明找到了有效的空闲对象。
  • 更新 mspan 的状态并返回分配的对象地址;如果没有找到空闲对象则返回0

runtime.mcache.nextFree — 获取新的span

nextFreeFast()mcache线程缓存中的span中没有寻找到空间, 则会调用nextFree()函数,其是一个更通用的内存分配函数。它在 nextFreeFast 无法满足分配请求时被调用,用于处理更复杂的分配场景。

其源码如下:

// go 1.20.3 path: /src/runtime/malloc.go

func (c *mcache) nextFree(spc spanClass) (v gclinkptr, s *mspan, shouldhelpgc bool) {
  //从mcache中获取当前 span 类型的缓存
	s = c.alloc[spc]
  //初始化 shouldhelpgc 为 false
	shouldhelpgc = false
  //调用 nextFreeIndex 方法来获取当前 span 中下一个空闲索引
	freeIndex := s.nextFreeIndex()
  
  //如果空闲索引等于 span 中元素的总数,则表示 span 已满
	if freeIndex == s.nelems {
    //如果在这种情况下 allocCount 不等于 nelems,则抛出异常,因为这意味着存在不一致
		if uintptr(s.allocCount) != s.nelems {
			println("runtime: s.allocCount=", s.allocCount, "s.nelems=", s.nelems)
			throw("s.allocCount != s.nelems && freeIndex == s.nelems")
		}
    //尝试使用 refill 方法来获取新的 span
		c.refill(spc)
    //设置 shouldhelpgc 为 true,表明可能需要进行垃圾回收
		shouldhelpgc = true
    
    //重新获取新 span 的下一个空闲索引
		s = c.alloc[spc]
		freeIndex = s.nextFreeIndex()
	}
  
	//如果新的空闲索引无效(即大于或等于元素总数),则抛出异常
	if freeIndex >= s.nelems {
		throw("freeIndex is not valid")
	}
  
	//计算并返回内存块的实际地址
	v = gclinkptr(freeIndex*s.elemsize + s.base())
  //allocCount自增
	s.allocCount++
  //如果 allocCount 超出了元素总数,抛出异常
	if uintptr(s.allocCount) > s.nelems {
		println("s.allocCount=", s.allocCount, "s.nelems=", s.nelems)
		throw("s.allocCount > s.nelems")
	}
  //函数返回计算出的内存块地址 v,当前 span s,以及是否需要帮助垃圾回收的标志 shouldhelpgc
	return
}

这个函数的目的是从 mcache 的当前 span 中找到下一个可用的内存块。当 mcache 的当前 span 没有足够的空间时,mcache.nextFree 会尝试从 mcentral 获取一个新的 span,或者在必要时从 mheap 分配。

虽然比 nextFreeFast 慢,但 mcache.nextFree 提供了更完备的内存分配策略,并且可以处理 mcache 缓存不命中的情况。

runtime.mcache.refill — 从mcentral中获取空闲对象

mcache中的某种类型的span已经没有可用空间时,则会调用runtime.mcache.refill函数从mcentral的列表获取一个新的所需大小规格的span并替换已经不存在可用对象的结构体。

源码如下:

// go 1.20.3 path: /src/runtime/malloc.go

func (c *mcache) refill(spc spanClass) {
    // 从当前 mcache 的 alloc 数组中获取指定 span 类型的当前 span
    s := c.alloc[spc]

    // 如果当前 span 的已分配对象计数不等于它所包含的元素总数,则抛出异常
    if uintptr(s.allocCount) != s.nelems {
        throw("refill of span with free space remaining")
    }

    // 如果当前 span 不是一个空的 span,则进行以下操作
    if s != &emptymspan {
        // 如果当前 span 的清扫代数不正确,则抛出异常
        if s.sweepgen != mheap_.sweepgen+3 {
            throw("bad sweepgen in refill")
        }

        // 将当前 span 从中心缓存移除
        mheap_.central[spc].mcentral.uncacheSpan(s)

        // 获取并更新内存统计信息
        stats := memstats.heapStats.acquire()
        slotsUsed := int64(s.allocCount) - int64(s.allocCountBeforeCache)
        atomic.Xadd64(&stats.smallAllocCount[spc.sizeclass()], slotsUsed)

        // 如果当前 span 类型是 tinySpanClass,更新 tinyAllocCount
        if spc == tinySpanClass {
            atomic.Xadd64(&stats.tinyAllocCount, int64(c.tinyAllocs))
            c.tinyAllocs = 0
        }

        // 释放内存统计信息
        memstats.heapStats.release()

        // 更新总分配的内存字节数
        bytesAllocated := slotsUsed * int64(s.elemsize)
        gcController.totalAlloc.Add(bytesAllocated)

        // 重置当前 span 的分配计数器
        s.allocCountBeforeCache = 0
    }

    // 从中心缓存中获取一个新的 span
    s = mheap_.central[spc].mcentral.cacheSpan()
    // 如果获取不到新的 span(内存不足),抛出异常
    if s == nil {
        throw("out of memory")
    }

    // 检查新获取的 span 是否还有空闲空间,如果没有,则抛出异常
    if uintptr(s.allocCount) == s.nelems {
        throw("span has no free space")
    }

    // 设置新 span 的清扫代数
    s.sweepgen = mheap_.sweepgen + 3

    // 设置新 span 的分配前计数器
    s.allocCountBeforeCache = s.allocCount

    // 更新垃圾回收控制器的统计数据
    usedBytes := uintptr(s.allocCount) * s.elemsize
    gcController.update(int64(s.npages*pageSize)-int64(usedBytes), int64(c.scanAlloc))
    c.scanAlloc = 0

    // 更新 mcache 的 alloc 数组,将新的 span 放入其中
    c.alloc[spc] = s
}

refill 函数的作用是在 mcache 的特定 span 类别耗尽时,从中心堆 (mheap_) 中获取一个新的 span 并更新相关统计信息。其主要流程为:

  1. 获取当前 Span: 从 mcachealloc 数组中获取当前请求大小类 (spanClass) 的 span;如果当前 span 的已分配计数 (allocCount) 与其元素总数 (nelems) 不匹配,则抛出异常;
  2. 处理非空 Span
    • 如果当前 span 不是空的,执行以下步骤:
      • 检查 span 的清扫代数 (sweepgen) 是否正确,若不正确则抛出异常。
      • 从中央缓存 (mcentral) 中移除当前 span
      • 更新内存统计信息。
      • 对于特定大小类(如 tinySpanClass),执行特定的统计更新。
      • 计算并更新总分配的字节数。
      • 重置 span 的分配前计数器 (allocCountBeforeCache)。
  3. 从 mcentral 获取新 Span
    • 调用 mheap_.central[spc].mcentral.cacheSpan() 从中央缓存中获取一个新的 span
    • 如果无法获取新的 span(例如,内存不足),抛出 “out of memory” 异常。
    • 检查新获取的 span 是否有可用空间,如果没有则抛出异常。
  4. 设置新 Span 的状态
    • 更新新 span 的清扫代数 (sweepgen)、新 span 的分配前计数器 (allocCountBeforeCache)、垃圾回收控制器的统计数据。
  5. 将新 Span 放入 mcache
    • 更新 mcachealloc 数组,将新获取的 span 放入其中。

runtime.mcentral.uncacheSpan — 从mcentral 中移除span

uncacheSpan 函数从 mcentral 的缓存中移除一个 span。它首先检查 span 是否有效(allocCount 不为 0),然后根据 span 的清扫状态将其分类为过时或当前。对于过时的 span,执行清扫操作;否则,根据 span 的剩余空间,将其添加到相应的清扫列表中。这个过程是内存管理的一部分,确保了有效的内存重用和垃圾回收。

源码如下:

// go 1.20.3 path: /src/runtime/mcentral.go

func (c *mcentral) uncacheSpan(s *mspan) {
    // 如果尝试移除一个已分配计数为 0 的 span,则抛出异常
    if s.allocCount == 0 {
        throw("uncaching span but s.allocCount == 0")
    }

    // 获取当前的全局清扫代数
    sg := mheap_.sweepgen

    // 检查 span 是否过时(即它是否已经被清扫)
    stale := s.sweepgen == sg+1

    // 如果 span 是过时的,将它的清扫代数设置为 sg-1,否则设置为当前代数 sg
    if stale {
        atomic.Store(&s.sweepgen, sg-1)
    } else {
        atomic.Store(&s.sweepgen, sg)
    }

    // 如果 span 是过时的,进行清扫操作
    if stale {
        ss := sweepLocked{s}
        ss.sweep(false)
    } else {
        // 如果 span 未满,将其添加到部分清扫列表中;如果已满,添加到完全清扫列表中
        if int(s.nelems)-int(s.allocCount) > 0 {
            c.partialSwept(sg).push(s)
        } else {
            c.fullSwept(sg).push(s)
        }
    }
}

runtime.mcentral.cacheSpan — 从mcentral中获取可用的span

runtime.mcentral.cacheSpan函数作用是试图从不同的span(内存块)集合中找到一个合适的span进行分配。这涉及检查部分清扫、完全未清扫的span,并在必要时增长内存池。

源码如下:

// go 1.20.3 path: /src/runtime/malloc.go

func (c *mcentral) cacheSpan() *mspan {
    // 计算span的字节数
    spanBytes := uintptr(class_to_allocnpages[c.spanclass.sizeclass()]) * _PageSize
    deductSweepCredit(spanBytes, 0) // 可能用于调整GC的内存清扫信用

    spanBudget := 100 // 用于控制循环的预算

    var s *mspan
    var sl sweepLocker

    sg := mheap_.sweepgen
    // 尝试从部分清扫的span中获取一个span
    if s = c.partialSwept(sg).pop(); s != nil {
        goto havespan
    }

    // 开始清扫锁定过程
    sl = sweep.active.begin()
    if sl.valid {
        // 尝试从部分未清扫的span中获取一个span
        for ; spanBudget >= 0; spanBudget-- {
            s = c.partialUnswept(sg).pop()
            if s == nil {
                break
            }
            // 如果获取成功,并且清扫成功,跳转到havespan
            if s, ok := sl.tryAcquire(s); ok {
                s.sweep(true)
                sweep.active.end(sl)
                goto havespan
            }
        }

        // 尝试从完全未清扫的span中获取一个span。
        for ; spanBudget >= 0; spanBudget-- {
            s = c.fullUnswept(sg).pop()
            if s == nil {
                break
            }
            // 如果获取成功,并且清扫成功,跳转到havespan。
            if s, ok := sl.tryAcquire(s); ok {
                s.sweep(true)
                freeIndex := s.nextFreeIndex()
                // 检查是否有可用空间。
                if freeIndex != s.nelems {
                    s.freeindex = freeIndex
                    sweep.active.end(sl)
                    goto havespan
                }
                // 如果没有可用空间,将其推回完全清扫的span中。
                c.fullSwept(sg).push(s.mspan)
            }
        }
        // 结束清扫过程。
        sweep.active.end(sl)
    }

    // 尝试增长span。
    s = c.grow()
    if s == nil {
        return nil
    }

havespan:
    // 检查span是否有可用对象。
    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")
    }
    // 计算空闲字节的基地址和位置。
    freeByteBase := s.freeindex &^ (64 - 1)
    whichByte := freeByteBase / 8
    s.refillAllocCache(whichByte)
    s.allocCache >>= s.freeindex % 64

    return s
}

该函数主要流程如下:

  1. 计算span的大小:
    • 通过 class_to_allocnpages[c.spanclass.sizeclass()] 查找span的大小,然后乘以页面大小(_PageSize)来计算spanBytes
  2. 扣除扫描信用:
    • deductSweepCredit(spanBytes, 0) 可能用于调整垃圾回收过程中的内存清扫信用。
  3. 初始化变量:
    • 设置一个 spanBudget,这是一个用于控制后续循环次数的预算。
    • 声明 s(指向 mspan 的指针)和 slsweepLocker类型)。
  4. 从部分清扫的span中尝试获取span:
    • 使用 c.partialSwept(sg).pop() 尝试获取一个已部分清扫的span
    • 如果成功获取,跳转到 havespan 标签。
  5. 开始清扫锁定过程:
    • 通过 sweep.active.begin() 获取一个 sweepLocker
    • 如果 sl 是有效的,执行后续的循环。
  6. 从部分未清扫的span中获取span:
    • 在预算内循环,使用 c.partialUnswept(sg).pop() 尝试获取。
    • 如果获取到span,并且能够成功清扫(sl.tryAcquire(s)),则跳转到 havespan 标签。
  7. 从完全未清扫的span中获取span:
    • 类似的循环,但这次是从 c.fullUnswept(sg) 获取span
    • 如果获取到span,并且能够成功清扫,并且还有可用空间,则更新 s.freeindex 并跳转到 havespan 标签。
    • 如果没有可用空间,将span推回完全清扫的列表中。
  8. 结束清扫过程:
    • 执行 sweep.active.end(sl) 来结束清扫过程。
  9. 尝试增长span:
    • 如果之前的步骤都未能获得span,则通过 c.grow() 尝试增长span
    • 如果仍然没有获得span,则返回 nil
  10. 处理获取到的span并返回获取到的span

runtime.mcentral.grow — 为 mcentral 结构分配一个新的 mspan

runtime.mcentral.cacheSpan函数中,如果在runtime.mcentral.partialruntime.mcentral.full中都无法获取到相应的可用的span,则会调用runtime.mcentral.grow来扩容 mcentral后再获取span

runtime.mcentral.grow源码如下:

// go 1.20.3 path: /src/runtime/mcentral.go

func (c *mcentral) grow() *mspan {
    // 计算根据当前大小类 (sizeclass) 需要多少页 (npages)。
    npages := uintptr(class_to_allocnpages[c.spanclass.sizeclass()])

    // 根据大小类获取每个对象的大小。
    size := uintptr(class_to_size[c.spanclass.sizeclass()])

    // 从全局堆 (mheap) 分配所需数量的页来创建一个新的 span。
    s := mheap_.alloc(npages, c.spanclass)
    if s == nil {
        // 如果分配失败,返回 nil。
        return nil
    }

    // 计算可以在 span 中放入多少个该大小的对象。
    n := s.divideByElemSize(npages << _PageShift)

    // 设置 span 的限制地址。这是 span 中可以使用的内存的上限。
    s.limit = s.base() + size*n

    // 初始化 span 的堆位图,用于垃圾回收。
    s.initHeapBits(false)

    // 返回新分配的 span。
    return s
}

通过 mcentral.grow 函数,Go 运行时能够动态地响应内存分配需求,为特定大小类别的对象分配适量的内存。这是确保内存分配既高效又灵活的关键机制之一。

至此,内存分配的小对象已经完成分析,更新下流程图,以便更直观的展示流程:

image-20240130171713924

微对象(tiny)分配

微对象指分配的对象小于16B,且不含指针。微对象采用tiny分配器进行分配。tiny allocatorGo 运行时中一个针对小对象优化的内存分配器,专门用于处理小对象(通常是小于或等于 16 字节)的分配。

tiny allocator它是通过每个 P 中的 mcache 实现的,其包含了与 tiny allocator 直接相关的字段(tiny , tinyoffset, tinyAllocs)。当有微对象需要分配时,tiny allocator 会检查当前 tiny 块是否有足够的空间容纳这个对象。如果有,对象就直接在当前 tiny 块中分配,紧接着之前分配的对象。当当前 tiny 块没有足够空间容纳新的微对象时,tiny allocator 会从当前 Pmcache 中请求一个新的 span,并从这个 span 中划分出一个新的 tiny 块用于后续的微对象分配。

在下面微对象分配的过程中,object对象已分配10B,还剩6B,接下来申请一个3B的内存空间,剩余的空间还够,继续从6B中分出去3B的空间,如下图所示:

image-20240131142330515

当前的分配情况如上图所示,接下来申请一个8B的内存空间,当前tiny分配器中只剩下3B空间,不够分配,则从mcache中取一个16object,从里面切出来8B分配出去,还剩8B保存在tiny分配器中,供后续分配使用。如下图:

image-20240131143308326

再来看看微对象的分配代码:

// go 1.20.3 path: /src/runtime/malloc.go

func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
   
    // 省略代码...
  
  	c := getMCache(mp)
 
  	// 省略代码...

    if size <= maxSmallSize {
        if noscan && size < maxTinySize {
            off := c.tinyoffset
            // 确保对象按照其大小进行适当的对齐。
            if size&7 == 0 {
                off = alignUp(off, 8)
            } else if goarch.PtrSize == 4 && size == 12 {
                off = alignUp(off, 8)
            } else if size&3 == 0 {
                off = alignUp(off, 4)
            } else if size&1 == 0 {
                off = alignUp(off, 2)
            }
            // 检查当前 tiny 块是否有足够的空间来分配新对象。
            if off+size <= maxTinySize && c.tiny != 0 {
                x = unsafe.Pointer(c.tiny + off)
                c.tinyoffset = off + size
                c.tinyAllocs++
                mp.mallocing = 0
                releasem(mp)
                return x
            }
            // 如果当前 tiny 块没有足够的空间,从 mcache 分配一个新的 tiny 块。
            span = c.alloc[tinySpanClass]
            // 从span中找到一个空的object,这个object的大小为16byte
            v := nextFreeFast(span)
            if v == 0 {
              	// 尝试从mcentral中分配
                v, span, shouldhelpgc = c.nextFree(tinySpanClass)
            }
            x = unsafe.Pointer(v)
            // 清零新分配的内存。
            (*[2]uint64)(x)[0] = 0
            (*[2]uint64)(x)[1] = 0
          
             // 更新分配器中的字段
             // 如果要分配的对象大小size比之前分配的要小即这次分配的object切分后剩下的空间比之前的要大,则保存这次剩下的空间
             // 实际效果比较这次分配剩下的空间和前次分配剩下空间,那个大保留那个。
            if !raceenabled && (size < c.tinyoffset || c.tiny == 0) {
                c.tiny = uintptr(x)
                c.tinyoffset = size
            }
            size = maxTinySize
        }
    } else {
      // 对于小对象的处理逻辑...
    } else {
      	// 对于大对象的处理逻辑...
    }
  	// 省略代码...
    return x
}

从代码可以看出,微对象的分配步骤基本跟小对象一致,无非在小对象分配流程前加了一步:检查当前 tiny 块是否有足够的空间来分配新对象,如果足够就在tiny块中分配。

其流程图如下:

image-20240131145822536

微对象的分配就不展开细说了,基本按着小对象走即可。

至此,内存分配就此讲完,等有时间了会再整理整理这个章节,现在内容有些混乱。

  • 14
    点赞
  • 28
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值