内存块
内存分配的基本单位称为内存块。一个内存块是一段连续的内存区域。如前所述,在运行时,一个值的部分(value part)承载在单个内存块上。
单个内存块可能承载多个值的部分。内存块的大小必须不小于它所承载的任何一个值的部分的大小。
当一个内存块承载着某个值的部分时,我们可以说这个值的部分引用着该内存块。
内存分配操作会消耗一定的CPU资源来寻找合适的内存块。因此,创建的内存块越多(用于内存分配),消耗的CPU资源就越多。在编程中,我们应尽量避免不必要的内存分配,以获得更好的代码执行性能。
内存分配位置(Memory allocation places)
Go 运行时可能会在某个 goroutine 的栈(一种内存区域)或整个程序的堆(另一种内存区域)上寻找内存块,以承载某些值的部分。这个寻找过程称为(内存)分配。
栈和堆的内存管理方式差异很大。在大多数情况下,在栈上寻找内存块比在堆上要高效得多。
回收栈内存块的成本也远低于回收堆内存块。实际上,栈内存块无需单独回收。一个 goroutine 的栈实际上可以看作是一个单独的内存块,当该 goroutine 退出时,整个栈会被一并回收。
另一方面,当一个堆内存块上承载的所有值的部分都不再被使用时(换句话说,没有任何活跃的值的部分仍在引用该内存块),这个内存块会被视为垃圾,并在运行时的垃圾回收周期中最终被自动回收,这一过程可能会消耗一定的 CPU 资源(垃圾回收将在后续章节详细讨论)。通常,在堆上分配的内存块越多,垃圾回收的压力就越大。
由于堆内存分配的成本高得多,因此在 Go 代码的基准测试结果中,只有堆内存分配会被计入分配指标。但请注意,栈内存分配并非没有成本,只是其成本通常远低于堆内存分配。
Go 编译器的逃逸分析(escape analysis)模块能够检测出某些值的部分仅会被单个 goroutine 使用,并且在满足特定额外条件的情况下,会尝试让这些值的部分在运行时分配到栈上。关于栈内存分配和逃逸分析的更多细节,将在下一章中详细说明。
内存分配场景
通常情况下,以下每种操作都会至少触发一次内存分配。
-
- 声明变量
- 调用内置的`new`函数
- 调用内置的`make`函数
- 使用复合字面量修改切片和映射
- 将整数转换为字符串
- 使用`+`拼接字符串 - 在字符串与字节切片之间进行转换(反之亦然)
- 将字符串转换为符文(rune)切片
- 将值装箱到接口中(将非接口类型的值转换为接口类型)
- 向切片追加元素且切片容量不足时
- 向映射中添加新键值对且映射用于存储键值对的底层数组容量不足时
然而,官方标准的Go编译器会进行一些特殊的代码优化,因此在某些情况下,上述列出的部分操作并不会触发内存分配。这些优化将在本书后续章节中介绍。
因已分配的内存块大于实际需求而导致的内存浪费
不同内存块的大小可能各不相同,但并非随意确定的。在官方标准的 Go 运行时实现中,对于在堆上分配的内存块:
-
预定义了一些内存块大小等级(不超过 32768 字节)。在官方标准的 Go 编译器 1.24.x 版本中,最小的几个大小等级分别为 8、16、24、32、48、64、80 和 96 字节。
-
对于大于 32768 字节的内存块,每个块总是由多个内存页组成。官方标准的 Go 运行时(1.24 版本)所使用的内存页大小为 8192 字节。
所以,
- 若要为大小在[33, 48]字节范围内的值分配一块(堆)内存块,该内存块的大小通常(至少)为48字节。换言之,(当值的大小为33字节时)最多可能存在15字节的内存浪费。
- 若在堆上创建一个包含32769个元素的字节切片,承载该切片元素的内存块大小为40960字节(即32768 + 8192字节,对应5个内存页)。换言之,此时会产生8191字节的内存浪费。
换言之,内存块的实际大小往往大于需求大小。采用这样的策略是为了更简便、高效地管理内存,但有时可能会导致少量内存浪费(没错,这是一种权衡取舍)。
这些(情况)可通过以下程序得以证明:
go
package main
import "testing"
import "unsafe"
var t *[5]int64
var s []byte
func f(b *testing.B) {
for i := 0; i < b.N; i++ {
t = &[5]int64{}
}
}
func g(b *testing.B) {
for i := 0; i < b.N; i++ {
s = make([]byte, 32769)
}
}
func main() {
println(unsafe.Sizeof(*t)) // 40
rf := testing.Benchmark(f)
println(rf.AllocedBytesPerOp()) // 48
rg := testing.Benchmark(g)
println(rg.AllocedBytesPerOp()) // 40960
}
Another example:
go
package main
import "testing"
var s = []byte{32: 'b'} // len(s) == 33
var r string
func Concat(b *testing.B) {
for i := 0; i < b.N; i++ {
r = string(s) + string(s)
}
}
func main() {
br := testing.Benchmark(Concat)
println(br.AllocsPerOp()) // 3
println(br.AllocedBytesPerOp()) // 176
}
Concat函数内部共发生了3次内存分配。其中两次由字节切片到字符串的转换(string(s))引发,承载这两个结果字符串底层字节的内存块大小均为48字节(这是不小于33字节的最小大小等级)。第三次分配由字符串拼接导致,结果内存块的大小为80字节(不小于66字节的最小大小等级)。这三次分配总共占用了176字节(48+48+80)。最终,有14字节被浪费。在执行Concat函数的过程中,总共浪费了44字节(15+15+14)。
在上述示例中,`string(s)` 转换的结果仅在字符串拼接操作中临时使用。根据当前官方标准的 Go 编译器/运行时实现(1.24 版本),这些字符串的字节会在堆上分配(详情见下文章节)。拼接操作完成后,承载这些字符串字节的内存块会成为内存垃圾,并在之后的某个时刻被最终回收。
减少内存分配并节省内存
内存块分配得越少,消耗的CPU资源就越少,给垃圾回收带来的压力也越小。
如今内存(本身)并不昂贵,但云计算服务商提供的内存并非如此。因此,如果我们在云服务器上运行程序,Go程序节省的内存越多,就能节省越多成本。
以下是一些在编程中减少内存分配和节省内存的建议。
通过预先分配足够的内存来避免不必要的分配
我们经常使用内置的 `append` 函数向切片中添加元素。在语句 `r = append(s, elements...)` 中,如果切片 `s` 的剩余容量不足以容纳所有待添加的元素,Go 运行时就会分配一块新的内存块,以存放结果切片 `r` 的所有元素。
如果需要多次调用 `append` 函数来添加元素,最好确保基础切片有足够大的容量,以避免在整个添加过程中产生多次不必要的分配。
例如,在将多个切片合并为一个时,下面所示的 `MergeWithTwoLoops` 实现比 `MergeWithOneLoop` 实现更高效,因为前者的内存分配次数更少,值的复制操作也更少。
go
package allocations
import "testing"
func getData() [][]int {
return [][]int{
{1, 2},
{9, 10, 11},
{6, 2, 3, 7},
{11, 5, 7, 12, 16},
{8, 5, 6},
}
}
func MergeWithOneLoop(data ...[]int) []int {
var r []int
for _, s := range data {
r = append(r, s...)
}
return r
}
func MergeWithTwoLoops(data ...[]int) []int {
n := 0
for _, s := range data {
n += len(s)
}
r := make([]int, 0, n)
for _, s := range data {
r = append(r, s...)
}
return r
}
func Benchmark_MergeWithOneLoop(b *testing.B) {
data := getData()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = MergeWithOneLoop(data...)
}
}
func Benchmark_MergeWithTwoLoops(b *testing.B) {
data := getData()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = MergeWithTwoLoops(data...)
}
}
基准测试结果:
bash
Benchmark_MergeWithOneLoop-4 636.6 ns/op 352 B/op 4 allocs/op
Benchmark_MergeWithTwoLoops-4 268.4 ns/op 144 B/op 1 allocs/op
基准测试结果表明,内存分配对代码的执行性能影响很大。
我们来打印一些日志,看看在一次 `MergeWithOneLoop` 函数调用中,4 次内存分配分别发生在何时。
go
package main
import "fmt"
func getData() [][]int {
return [][]int{
{1, 2},
{9, 10, 11},
{6, 2, 3, 7},
{11, 5, 7, 12, 16},
{8, 5, 6},
}
}
const format = "Allocate from %v to %v (when append slice#%v).\n"
func MergeWithOneLoop(data [][]int) []int {
var oldCap int
var r []int
for i, s := range data {
r = append(r, s...)
if oldCap == cap(r) {
continue
}
fmt.Printf(format, oldCap, cap(r), i)
oldCap = cap(r)
}
return r
}
func main() {
MergeWithOneLoop(getData())
}
输出结果(适用于官方标准 Go 编译器 v1.24.n 版本的):
csharp
Allocate from 0 to 2 (when append slice#0).
Allocate from 2 to 6 (when append slice#1).
Allocate from 6 to 12 (when append slice#2).
Allocate from 12 to 24 (when append slice#3).
从输出结果可以看出,只有最后一次 `append` 调用没有触发内存分配。
实际上,`Merge_TwoLoops` 函数在理论上可以更快。在官方标准 Go 编译器 1.24 版本中,`Merge_TwoLoops` 函数中的 `make` 调用会将刚创建的所有元素清零,而这一操作其实并无必要。未来版本的编译器优化或许会省去这一清零步骤。
此外,上述 `Merge_TwoLoops` 函数的实现存在一个缺陷:它没有处理整数溢出的情况。以下是一个更完善的实现。
go
func Merge_TwoLoops(data ...[][]byte) []byte {
n := 0
for _, s := range data {
if k := n + len(s); k < n {
panic("slice length overflows")
} else {
n = k
}
}
r := make([]int, 0, n)
...
}
尽可能避免内存分配
内存分配越少越好,而完全不分配则是最佳状态。
以下是另一个示例,用于展示两种实现之间的性能差异:其中一种实现不产生任何内存分配,另一种则会产生一次内存分配。
go
package allocations
import "testing"
func buildOrginalData() []int {
s := make([]int, 1024)
for i := range s {
s[i] = i
}
return s
}
func check(v int) bool {
return v%2 == 0
}
func FilterOneAllocation(data []int) []int {
var r = make([]int, 0, len(data))
for _, v := range data {
if check(v) {
r = append(r, v)
}
}
return r
}
func FilterNoAllocations(data []int) []int {
var k = 0
for i, v := range data {
if check(v) {
data[i] = data[k]
data[k] = v
k++
}
}
return data[:k]
}
func Benchmark_FilterOneAllocation(b *testing.B) {
data := buildOrginalData()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = FilterOneAllocation(data)
}
}
func Benchmark_FilterNoAllocations(b *testing.B) {
data := buildOrginalData()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = FilterNoAllocations(data)
}
}
基准测试结果:
bash
Benchmark_FilterOneAllocation-4 3711 ns/op 8192 B/op 1 allocs/op
Benchmark_FilterNoAllocations-4 903.3 ns/op 0 B/op 0 allocs/op
从基准测试结果可以看出,`FilterNoAllocations` 实现的性能更优。(当然,如果不允许修改输入数据,那么我们就不得不选择会产生内存分配的实现方案。)
通过合并内存块来节省内存并减少分配次数
有时,我们可以分配一个大的内存块来承载多个值的部分,而非为每个值的部分分配一个小内存块。这样做能减少大量内存分配操作,从而降低CPU资源消耗,并在一定程度上缓解垃圾回收的压力。有时,这种方式还能减少内存浪费,但并非总是如此。
我们来看一个示例:
go
package allocations
import "testing"
const N = 100
type Book struct {
Title string
Author string
Pages int
}
//go:noinline
func CreateBooksOnOneLargeBlock(n int) []*Book {
books := make([]Book, n)
pbooks := make([]*Book, n)
for i := range pbooks {
pbooks[i] = &books[i]
}
return pbooks
}
//go:noinline
func CreateBooksOnManySmallBlocks(n int) []*Book {
books := make([]*Book, n)
for i := range books {
books[i] = new(Book)
}
return books
}
func Benchmark_CreateBooksOnOneLargeBlock(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = CreateBooksOnOneLargeBlock(N)
}
}
func Benchmark_CreateBooksOnManySmallBlocks(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = CreateBooksOnManySmallBlocks(N)
}
}
Run the benchmarks, we get:
bash
Benchmark_CreateOnOneLargeBlock-4 4372 ns/op 4992 B/op 2 allocs/op
Benchmark_CreateOnManySmallBlocks-4 18017 ns/op 5696 B/op 101 allocs/op
从结果可以看出,在一个大内存块上分配多个小值部分(的做法):
- 消耗的 CPU 时间少得多
- 占用的内存也略少一些.
第一个结论很容易理解。两次分配操作所花费的时间比101次分配操作少得多。
第二个结论实际上之前已经解释过了。如前所述,当一个小值(部分)的大小与官方标准Go运行时所支持的任何内存块类别都不完全匹配时,那么如果单独创建这个小值(部分),就会为其分配一个比所需略大的内存块。Book类型的大小是40字节(在64位架构上),而比40大的最小内存块大小类别是48字节。所以单独分配一个Book值会浪费8字节。
实际上,第二个结论只在特定条件下成立。具体来说,当N值在820到852这个范围内时,该结论就不成立。特别是当N等于820时,基准测试结果表明在一个大内存块上分配许多小值部分会多消耗3.5%的内存。
bash
Benchmark_CreateOnOneLargeBlock-4 30491 ns/op 47744 B/op 2 allocs/op
Benchmark_CreateOnManySmallBlocks-4 145204 ns/op 46144 B/op 821 allocs/op
为什么当N等于820时,`CreateBooksOnOneLargeBlock`函数会消耗更多内存呢?因为它需要分配一个最小大小为32800(820×40)的内存块,这比最大的小内存块类别32768还要大。所以该内存块需要5个内存页,总大小为40960(8192×5)。换句话说,浪费了8160(40960 - 32800)字节。
尽管有时会浪费更多内存,但一般来说,在一个大内存块上分配许多小值部分相较于在单独的内存块上分配每个小值部分还是相对更好的。当小值部分的生命周期几乎相同时尤其如此,在这种情况下,在一个大内存块上分配许多小值部分通常能有效避免内存碎片化。
使用值缓存池来避免一些内存分配操作
有时,我们需要时不时地频繁分配和丢弃特定类型的值。重用已分配的值以避免大量的分配操作是个不错的主意。
例如,即时战略游戏中有许多非玩家角色(NPC)。在一场游戏过程中,会不时生成和销毁大量的NPC。相关代码类似如下:
go
type NPC struct {
name [64]byte
nameLen uint16
blood uint16
properties uint32
x, y float64
}
func SpawnNPC(name string, x, y float64) *NPC {
var npc = newNPC()
npc.nameLen = uint16(copy(npc.name[:], name))
npc.x = x
npc.y = y
return npc
}
func newNPC() *NPC {
return &NPC{}
}
func releaseNPC(npc *NPC) {
}
由于Go语言支持自动垃圾回收(GC),`releaseNPC`函数可能无需做任何事情。然而,这样的实现会在游戏运行过程中导致大量的内存分配,给垃圾回收带来巨大压力,以至于很难保证良好的游戏帧率(每秒帧数,FPS)。
相反,我们可以使用一个缓存池来减少内存分配,如下代码所示。
scss
import "container/list"
var npcPool = struct {
sync.Mutex
*list.List
}{
List: list.New(),
}
func newNPC() *NPC {
npcPool.Lock()
defer npcPool.Unlock()
if npcPool.Len() == 0 {
return &NPC{}
}
return npcPool.Remove(npcPool.Front()).(*NPC)
}
func releaseNPC(npc *NPC) {
npcPool.Lock()
defer npcPool.Unlock()
*npc = NPC{} // zero the released NPC
npcPool.PushBack(npc)
}
通过使用这个池(也称为空闲列表),NPC值的分配将大幅减少,这对于在游戏过程中保持流畅的帧率(每秒帧数,FPS)非常有帮助。
如果缓存池仅在一个goroutine中使用,那么在实现过程中就无需进行并发同步。 我们还可以为池设置一个最大大小,以避免池占用过多内存。
标准的sync包提供了一个Pool类型来提供类似的功能,但存在一些设计差异:
- 如果在连续两个垃圾回收周期中发现`sync.Pool`中的空闲对象未被使用,它将被自动垃圾回收。这意味着`sync.Pool`的最大大小是动态的,取决于运行时的需求。
- `sync.Pool`中对象的类型和大小可以不同。但最佳实践是确保放入同一个`sync.Pool`中的对象类型相同且大小相同。
就我个人而言,我发现`sync.Pool`的设计在实际应用中很少能满足需求。所以在我的Go项目里,我经常使用自定义的值缓存池实现。