返回

Go八股之内存分配

Go 的内存分配机制

Go语言内置运行时(就是runtime),抛弃了传统的内存分配方式,改为自主管理。这样可以自主地实现更好的内存使用模式,比如内存池、预分配等等。这样,不会每次内存分配都需要进行系统调用。

设计思想

  • 内存分配算法采用TCMalloc算法,每个线程都会自行维护一个独立的内存池,进行内存分配时优先从该内存池中分配,当内存池不足时才会向加锁向全局内存池申请,减少系统调用并且避免不同线程对全局内存池的锁竞争
  • 把内存切分的非常的细小,分为多级管理,以降低锁的粒度
  • 回收对象内存时,并没有将其真正释放掉,只是放回预先分配的大块内存中,以便复用。只有内存闲置过多的时候,才会尝试归还部分内存给操作系统,降低整体开销

分配组件

Go 的内存管理组件主要有:mspanmcachemcentralmheap

mspan:内存管理单元

mspan是内存管理的基本单元,该结构体中包含next和 prev两个字段,它们分别指向了前一个和后一个mspan,每个mspan都管理npages个大小为8KB的页,一个span是由多个page组成的,这里的页不是操作系统中的内存页,它们是操作系统内存页的整数倍。

1
2
3
4
5
6
7
8
9
type mspan struct {
    next *mspan //后指针
    prev *mspan //前指针
    startAddr uintptr //管理页的起始地址,指向page
    npages uintptr //页数
    spanclass spanClass //规格,字节数
    ...
}
type spanclass uint8

mcache:线程缓存

mcache 管理线程在本地缓存的 mspan,每个 goroutine 绑定的P都有一个 mcache 字段

1
2
3
4
5
6
type mcache struct{
	alloc [numSpanClasses]*mspan
}

_NumSizeClasses=68
numSpanClassed=_NumSizeClassed<<1

mcacheSpan Classes 作为索引管理多个用于分配的 mspan,它包含所有规格的 mspan。它是_NumSizeClasses 的2倍,也就是 68*2=136,其中 *2 是将spanClass分成了有指针和没有指针两种,方便与垃圾回收。对于每种规格,有2个mspan,一个mspan不包含指针,另一个mspan则包含指针。对于无指针对象的mspan在进行垃圾回收的时候无需进一步扫描它是否引用了其他活跃的对象。

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

mcentral:中心缓存

mcentral管理全局的mspan供所有线程使用,全局mheap变量包含central字段,每个 mcentral 结构都维护在mheap结构内

1
2
3
4
5
6
type mcentral struct {
    spanclass spanClass // 指当前规格大小

    partial [2]spanSet // 有空闲object的mspan列表
    full    [2]spanSet // 没有空闲object的mspan列表
}

每个mcentral管理一种spanClass的mspan,并将有空闲空间和没有空闲空间的mspan分开管理。partial和 full的数据类型为spanSet,表示 mspans集,可以通过pop、push来获得mspans

1
2
3
4
5
6
7
8
type spanSet struct {
    spineLock mutex
    spine     unsafe.Pointer // 指向[]span的指针
    spineLen  uintptr        // Spine array length, accessed atomically
    spineCap  uintptr        // Spine array cap, accessed under lock

    index headTailIndex  // 前32位是头指针,后32位是尾指针
}

简单说下mcachemcentral获取和归还mspan的流程:

  • 获取; 加锁,从partial链表找到一个可用的mspan;并将其从partial链表删除;将取出的mspan加入到full链表;将mspan返回给工作线程,解锁。
  • 归还; 加锁,将mspanfull链表删除;将mspan加入到partial链表,解锁。

mheap:页堆

mheap管理Go的所有动态分配内存,可以认为是Go程序持有的整个堆空间,全局唯一

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
var mheap_ mheap
type mheap struct {
    lock      mutex    // 全局锁
    pages     pageAlloc // 页面分配的数据结构
    allspans []*mspan // 所有通过 mheap_ 申请的mspans
        // 堆
    arenas [1 << arenaL1Bits]*[1 << arenaL2Bits]*heapArena
    
        // 所有中心缓存mcentral
    central [numSpanClasses]struct {
        mcentral mcentral
        pad      [cpu.CacheLinePadSize - unsafe.Sizeof(mcentral{})%cpu.CacheLinePadSize]byte
    }
    ...
}

所有mcentral的集合则是存放于mheap中的。mheap里的arena 区域是堆内存的抽象,运行时会将 8KB 看做一页,这些内存页中存储了所有在堆上初始化的对象。运行时使用二维的 runtime.heapArena 数组管理所有的内存,每个 runtime.heapArena 都会管理 64MB 的内存。

当申请内存时,依次经过 mcachemcentral 都没有可用合适规格的大小内存,这时候会向 mheap 申请一块内存。然后按指定规格划分为一些列表,并将其添加到相同规格大小的 mcentral非空闲列表 后面

分配流程

image-20250115152613550

  • 首先通过计算使用的大小规格
  • 然后使用mcache中对应大小规格的块分配。
  • 如果mcentral中没有可用的块,则向mheap申请,并根据算法找到最合适的mspan
  • 如果申请到的mspan 超出申请大小,将会根据需求进行切分,以返回用户所需的页数。剩余的页构成一个新的 mspan 放回 mheap 的空闲列表。
  • 如果 mheap 中没有可用 span,则向操作系统申请一系列新的页(最小 1MB)

内存逃逸

内存逃逸指的是在函数内部分配的变量在函数结束后仍然被其他部分引用,导致其生命周期延长到函数外部。这种情况下,变量将不再局限于函数栈中,而是被分配到堆上。内存逃逸会导致额外的内存分配和垃圾回收的开销,影响程序的性能。

指针逃逸

在函数中创建了一个对象,返回了这个对象的指针。这种情况下,函数虽然退出了,但是因为指针的存在,对象的内存不能随着函数结束而回收,因此只能分配在堆上。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
package main

func add(x, y float64) *float64 {
    res := x + y
    return &res
}

func main() {
    add(1, 1.0)
}

这段代码中 res 变量发生了逃逸。使用 go build -gcflags=-m 指令可以看到:

1
2
3
4
5
# command-line-arguments
./test6.go:3:6: can inline add
./test6.go:8:6: can inline main
./test6.go:9:5: inlining call to add
./test6.go:4:2: moved to heap: res

栈空间不足

当栈空间足够时,不会发生逃逸,但是当变量过大时,已经完全超过栈空间的大小时,将会发生逃逸到堆上分配内存。局部变量s占用内存过大,编译器会将其分配到堆上

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package main

import (
	"math/rand"
)

func f1() {
	nums := make([]int, 8192)  // 64KB
	for i := 0; i < 8191; i++ {
		nums[i] = rand.Int()
	}
}

func f2() {
	nums := make([]int, 8193) // 64KB + 8B
	for i := 0; i < 8192; i++ {
		nums[i] = rand.Int()
	}
}

func main() {
	f1()
	f2()
}

上述代码中,f1 函数发生未内存逃逸,f2 函数发生内存逃逸。(博主环境为 Windows 10,Go 1.23.0)

1
2
3
# command-line-arguments
./test7.go:8:14: make([]int, 8192) does not escape
./test7.go:15:14: make([]int, 8193) escapes to heap

变量大小不确定

编译期间无法确定slice的长度,这种情况为了保证内存的安全,编译器也会触发逃逸,在堆上进行分配内存。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
package main

import "math/rand"

func f(n int) {
	nums := make([]int, n)
	for i := 0; i < n; i++ {
		nums[i] = rand.Int()
	}
}

func main() {
	f(1)
}
1
2
3
# command-line-arguments
./test8.go:12:6: can inline main
./test8.go:6:14: make([]int, n) escapes to heap

interface{} 动态类型逃逸

1
2
3
4
5
6
7
package main

import "fmt"

func main() {
	fmt.Println("hello world")
}

hello world 字符串作为实参传递给 fmt.Println(),但是因为 fmt.Println() 的参数类型定义为 interface{} 因此也会发生内存逃逸。

1
2
3
4
5
# command-line-arguments
./test9.go:5:6: can inline main
./test9.go:7:13: inlining call to fmt.Println
./test9.go:7:13: ... argument does not escape
./test9.go:7:14: "hello world" escapes to heap

闭包引用对象

一个函数和对其周围状态的引用捆绑在一起,这样的组合就是闭包。也就是说,闭包让你可以在一个内层函数中访问到其外层函数的作用域。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
package main

func test() func() int {
	var i int = 1
	return func() int {
		i++
		return i
	}
}

func main() {
	test()
}

test() 返回值是一个闭包函数,该闭包函数访问外部变量 nn 会一直存在直到 test() 函数被销毁。

1
2
3
4
5
6
7
8
# command-line-arguments
./test10.go:3:6: can inline test
./test10.go:5:9: can inline test.func1
./test10.go:11:6: can inline main
./test10.go:12:6: inlining call to test
./test10.go:4:6: moved to heap: i
./test10.go:5:9: func literal escapes to heap
./test10.go:12:6: func literal does not escape

小结

  1. 栈上分配内存比在堆中分配内存效率更高
  2. 栈上分配的内存不需要 GC 处理,而堆需要
  3. 逃逸分析目的是决定内分配地址是栈还是堆
  4. 逃逸分析在编译阶段完成

因为无论变量的大小,只要是指针变量都会在堆上分配,所以对于小变量我们还是使用传值效率更高一点。

Go 内存对齐机制

内存对齐

为了能让CPU可以更快的存取到各个字段,Go编译器会帮你把struct结构体做数据的对齐。所谓的数据对齐,是指内存地址是所存储数据大小(按字节为单位)的整数倍,以便CPU可以一次将该数据从内存中读取出来。 编译器通过在结构体的各个字段之间填充一些空白已达到对齐的目的。

对齐系数

不同硬件平台占用的大小和对齐值都可能是不一样的,32位系统对齐系数是4,64位系统对齐系数是8。

不同类型的对齐系数也可能不一样,使用Go 语言中的unsafe.Alignof函数可以返回相应类型的对齐系数。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
package main

import (
	"fmt"
	"unsafe"
)

func main() {
	fmt.Printf("bool alignof is %d\n", unsafe.Alignof(bool(true))) // bool alignof is 1
	fmt.Printf("string alignof is %d\n", unsafe.Alignof(string("a"))) // string alignof is 8
	fmt.Printf("int8 alignof is %d\n", unsafe.Alignof(int8(0))) // int8 alignof is 1
	fmt.Printf("int16 alignof is %d\n", unsafe.Alignof(int16(0))) // int16 alignof is 2
	fmt.Printf("int32 alignof is %d\n", unsafe.Alignof(int32(0))) // int32 alignof is 4
	fmt.Printf("int64 alignof is %d\n", unsafe.Alignof(int64(0))) // int64 alignof is 8
	fmt.Printf("float32 alignof is %f\n", unsafe.Alignof(float32(0))) // float32 alignof is 4
	fmt.Printf("float alignof is %d\n", unsafe.Alignof(float64(0))) // float alignof is 8
}

对齐原则

  • 结构体变量中成员的偏移量必须是成员变量大小和成员对齐系数两者最小值的整数倍
  • 整个结构体的地址必须是最大字节和编译器默认对齐系数两者最小值的整数倍(结构体的内存占用是1/4/8/16 byte…)
  • struct{}放在结构体中间不进行对齐,放在结构体最后一个字段则要根据最大字节和编译器默认对齐系数两者最小值来进行字段对齐

优势

  • 提高可移植性,有些CPU可以访问任意地址上的任意数据,而有些CPU只能在特定地址访问数据,因此不同硬件平台具有差异性,这样的代码就不具有移植性,如果在编译时,将分配的内存进行对齐,这就具有平台可以移植性了

  • 提高内存的访问效率,32位CPU下一次可以从内存中读取32位(4个字节)的数据,64位CPU下一次可以从内存中读取64位(8个字节)的数据,这个长度也称为CPU的字长。CPU一次可以读取1个字长的数据到内存中,如果所需要读取的数据正好跨了1个字长,那就得花两个CPU周期的时间去读取了。因此在内存中存放数据时进行对齐,可以提高内存访问效率。

劣势

  • 存在内存空间的浪费

make 和 new 的异同

makenew
make 仅用来分配及初始化类型为 slice、map、chan 的数据。new 可分配任意类型的数据,根据传入的类型申请一块内存,返回指向这块内存的指针,即类型 *Type。
make函数返回的是slice、map、chan类型本身new函数返回一个指向该类型内存地址的指针
Licensed under CC BY-NC-SA 4.0
载入天数...载入时分秒...
使用 Hugo 构建
主题 StackJimmy 设计