当我们在谈silce时到底在谈些什么?

前言

在 Go 语言中,slice 是一个至关重要的基础数据结构,它不仅极大地简化了动态数组的操作,还巧妙地在性能与灵活性之间达成了平衡。无论是在简单的数组处理,还是构建复杂的数据结构,甚至在性能优化中,slice 都发挥着举足轻重的作用。

然而,slice 背后隐藏着许多深刻的设计哲学和复杂的实现细节,这些都值得我们深入探讨。从内存模型的设计,到如何处理容量的扩展,再到可能的性能瓶颈与常见的陷阱,slice 作为 Go 语言中的核心数据结构,其设计和实现都有值得反思和学习的地方。

今天,我们将一探其本质,分析 slice 在扩容时的细节,了解其设计背后的权衡,以及在实际开发中,如何更高效、精确地使用 slice,避免常见的坑。

本文整体结构可分为 3 篇,分别是基础篇、进阶篇和最佳实践篇。

基础篇:介绍 slice 含义和常用操作,使读者对 slice 有个大概的认识(非新手直接跳过)。

进阶篇: 结合 slice 源码和单步调试介绍核心扩容逻辑(核心)。

最佳实践篇:介绍 slice 使用场景和最佳实践以巩固基础和原理的理解(重要,理解了原理才有资格谈论最佳实践)。

1. 基础篇

数组是一个固定大小的连续内存块,操作起来既不灵活,也容易出错。这是因为数组是定长的,一旦长度定义好之后就不能再更改了,更深层的原因是数组的长度是其类型的一部分,这就限制了它的表达能力。比如 [3]int[4]int 就代表两个不同的类型(无法比较),而 slice 是 Go 对数组的一种抽象,提供了灵活的长度和容量控制,它可以在运行时动态扩容并且切片的类型和长度无关。

Go 语言中的切片结构的本质是对数组的封装,描述了一个数组片段。

1.1 Slice 的基本使用

slice 的基本操作可以分为初始化、追加、切分和拷贝,下面使用代码给出常见示例。

1.2 slice 初始化

slice 初始化有几种常见方式

  1. 使用 make 进行显示初始化
  2. 使用语法糖进行实例化
// 1 自定义底层数组的长度和容量
slice1 := make([]Type, SliceLen, SliceCap)
intSlice := make([]int, 0)           // 申请一个底层数组类型为 int、大小为 0 的切片
stringSlice := make([]string, 0, 5)  // 申请一个底层数组类型为 string、大小为 0 的切片、最大容量为 5

// 2 声明并初始化
intSlice := []int{}       
stringSlice := []string{}

// 3 初始化一个 m*n 大小的二维数组
arrs := make([][]int, m)
for row := range arrs {
  arrs[row] = make([]int, n)
}
// 以下方式也是有效的(刷算法的时候这种实例化快捷方式遇到会比较多)
arrs := [][]int{}
arrs = append(arrs, []int{1, 2, 3})

// 4 return
func XXXFunc(param1 Type, param2 Type) []int {
  return []int{1, 2, 3}
}

golang 的 slice 可以声明后直接进行追加操作,因为 append 调用时会检查底层数组容量是否足够存放新元素,如果不够用会触发扩容来分配所需的内存空间,但是不能对未经初始化的 slice 进行下标访问,否则越界报出 panic

panic: runtime error: index out of range [1] with length 1

goroutine 1 [running]:
main.main()
    /Users/smy/go/just-for-test-only-once/main.go:13 +0x44
exit status 2

1.3 追加

在需要将新元素追加到切片末尾时,go 官方鼓励使用内置的 append 函数进行操作,因为底层将追加的复杂性进行了封装,用户无需关心这些细节。如果切片有足够的容量容纳新元素,那么目标数组将被重新切分以容纳新元素。如果没有,则会分配一个新的底层数组,此时 append 会返回更新后的新切片,这也是为什么需要接收 append 返回结果的原因。

slice = append(slice, elem1, elem2) ✅
append(slice, elem1, elem2) ❌ 无法通过编译

func append(slice []Type, elems ...Type) []Type

append 内置函数的签名中可以发现,它是支持可变类型参数的,这意味着你可以直接追加一个新的切片。

package main

func main() {  
  s1 := []int{1, 2, 3, 4}
  s1 = append(s1, 5)     // [1, 2, 3, 4, 5]
  s1 = append(s1, 6, 7)  // [1, 2, 3, 4, 5, 6, 7]
  
  s2 := []int{8, 9, 10}
  s1 = append(s1, s2...) // [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
}

slice 源码中最核心的就是 append 调用时可能触发的扩容逻辑,和我们的直觉不同,并不是每次扩容都是按照 2 倍进行的,slice 源码中对这块做了优化,第二篇会详细介绍。

1.3 切分

slice 切分操作在很多场景下都比较有用,比如取数组的一部分元素使用、删除切片中任意位置的元素、模拟实现栈和队列等等。

下面提供一些简单的示例:

// demo 1
func main() {
  s1 := []int{1, 2, 3, 4, 5}
  s2 := s1[1:]         // s2 [2, 3, 4 ,5] 删除首元素
  s3 := s1[:len(s1)-1] // s3 [1, 2, 3, 4] 删除末尾元素
  s4 := append(s1[:2], s1[3:]...) // 删除第三个元素
}

// demo 2
type stack []int

func (s stack) Len() int {
  return len(s)
}

// 模拟栈 LIFO 特性
func (s *stack) pop() int {
  // todo: fixme
  if s.Len() == 0 {
    return -1
  }

  top := s[s.Len()-1]
  s = s[:s.Len()-1]
  return top
}

func (s *stack) push(newValue int) int {
  s = append(s, newValue)
}

1.4 拷贝

内置函数 copy 将元素从源切片复制到目标切片,Copy 将返回成功复制的字节数,它的值是原切片和目标切片中的较小值。

func main() {
  s1 := []int{1, 2, 3}
  s2 := make([]int, len(s1))
  copy(s2, s1)
  fmt.Println(s1) // [1 2 3]
  fmt.Println(s2) // [1 2 3]

  s2[0] = 11
  fmt.Println(s1) // [1 2 3]
  fmt.Println(s2) // [11 2 3]
}

作为特殊情况,它还可以直接将字符串对应的字节复制到字节切片。

func main() {
    str1 := "hello world"
    b1 := make([]byte, len(str1))    
    copy(b1, str1)
    fmt.Println(string(
    
    str2 := "你好世界"
    b2 := make([]byte, len(str2))
    copy(b2, str2)
    fmt.Println(string(b2))

    fmt.Printf("str1 length: %d, str2 length: %d\n", len(str1), len(str2))
}

// hello world
// 你好世界
// str1 length: 11, str2 length: 12

2 进阶篇

2.1 底层数据结构

一个 slice 本质上是一个小型的结构体,包含以下三个部分:

  • 指向底层数组的指针:slice 实际操作的是一个底层数组。
  • 长度:slice 当前可访问的元素数量。
  • 容量:从 slice 的起始位置到底层数组末尾的元素总数。

reflect.SliceHeader 是切片的运行时表示,它反映了切片在内存中的实际结构。

type SliceHeader struct {
    Data uintptr // 底层数组的指针
    Len  int     // 当前长度
    Cap  int     // 容量
} 

以下面的代码为例,看下整个过程中切片和对应底层数组发生了什么样的变化:

package main

func main() {
  s1 := make([]int, 0)
  fmt.Printf("底层数组的指针: %p\n", unsafe.Pointer((*reflect.SliceHeader)(unsafe.Pointer(&s1)).Data))
}

上面这段代码利用 unsafe 包和 reflect 包以及 unsafe.Pointer 通用指针和具体数据类型指针的转换打印出底层数组的指针,按照我们的直觉,我们使用 make 创建了一个大小为 0 的切片,所以它引用的底层数组应该也应该为 0,此时数组还没有初始化分配内存怎么就有地址了?

再看下面一段代码:

package main

func main() {
    s1 := make([]int, 0)
    PrintUnderlyingArray(s1)

    fmt.Println()
    var nullArr [0]int
    var nullStruct struct{}
    fmt.Printf("空数组的指针为: %p\n", &nullArr)
    fmt.Printf("空结构体的指针为: %p\n", &nullStruct)
}

func PrintUnderlyingArray(s1 []int) {
	fmt.Printf("底层数组的指针: %p\n", unsafe.Pointer((*reflect.SliceHeader)(unsafe.Pointer(&s1)).Data))
	fmt.Printf("底层数组的长度: %v\n", (*reflect.SliceHeader)(unsafe.Pointer(&s1)).Len)
	fmt.Printf("底层数组的容量: %v\n", (*reflect.SliceHeader)(unsafe.Pointer(&s1)).Cap)
}

程序运行后输出如下:

底层数组的指针: 0x102f54860
底层数组的长度: 0
底层数组的容量: 0
底层数组的指针: 0x14000104020
底层数组的长度: 1
底层数组的容量: 1

空数组的指针为: 0x102f54860
空结构体的指针为: 0x102f54860

从输出结果可知:Go 编译器会为空数组、空结构体分配相同的占位符地址,而这个占位符地址实际上表示的是 "空"。

继续切片变化的探究,请看如下代码:

package main

func main() {
    s1 := make([]int, 0)
    PrintUnderlyingArray(s1)

    s1 = append(s1, 1)
    PrintUnderlyingArray(s1)
}

输出如下:

底层数组的指针: 0x100e24860
底层数组的长度: 0
底层数组的容量: 0
底层数组的指针: 0x14000104020
底层数组的长度: 1
底层数组的容量: 1

可以看到底层数组已经发生了变化,它是一个大小为 1 的数组,而且唯一的元素值应该为 1,使用下面的程序进行验证:

package main

import (
    "fmt"
    "reflect"
    "unsafe"
)

func main() {
    s1 := make([]int, 0)
    PrintUnderlyingArray(s1)
    AccessUnderlyingArray(s1)

    s1 = append(s1, 1)
    PrintUnderlyingArray(s1)
    AccessUnderlyingArray(s1)
}

func PrintUnderlyingArray(s1 []int) {
    fmt.Printf("底层数组的指针: %p\n", unsafe.Pointer((*reflect.SliceHeader)(unsafe.Pointer(&s1)).Data))
    fmt.Printf("底层数组的长度: %v\n", (*reflect.SliceHeader)(unsafe.Pointer(&s1)).Len)
    fmt.Printf("底层数组的容量: %v\n", (*reflect.SliceHeader)(unsafe.Pointer(&s1)).Cap)
}

func AccessUnderlyingArray(s1 []int) {
    data := (*[1]int)(unsafe.Pointer(((*reflect.SliceHeader)(unsafe.Pointer(&s1)).Data)))
    fmt.Printf("底层数组: %v\n", data)
    fmt.Printf("底层数组首元素: %v\n", data[0])
}

程序输出如下:

底层数组的指针: 0x1002e8860
底层数组的长度: 0
底层数组的容量: 0
底层数组: &[0]
底层数组首元素: 0
底层数组的指针: 0x1400000e0d8
底层数组的长度: 1
底层数组的容量: 1
底层数组: &[1]
底层数组首元素: 1

ok,和我们预期的一样,现在继续往切片中追加元素:

func main() {
	s1 := make([]int, 0)
	PrintUnderlyingArray(s1)
	fmt.Println()

	s1 = append(s1, 1)
	PrintUnderlyingArray(s1)
	fmt.Println()

	s1 = append(s1, 2)
	PrintUnderlyingArray(s1)
	fmt.Println()

	s1 = append(s1, 3, 4)
	PrintUnderlyingArray(s1)
	fmt.Println()

	s1 = append(s1, 5)
	PrintUnderlyingArray(s1)
	fmt.Println()
}

func PrintUnderlyingArray(s1 []int) {
	fmt.Printf("底层数组的指针: %p\n", unsafe.Pointer((*reflect.SliceHeader)(unsafe.Pointer(&s1)).Data))
	fmt.Printf("底层数组的长度: %v\n", (*reflect.SliceHeader)(unsafe.Pointer(&s1)).Len)
	fmt.Printf("底层数组的容量: %v\n", (*reflect.SliceHeader)(unsafe.Pointer(&s1)).Cap)
}

输出如下:

底层数组的指针: 0x102b00860
底层数组的长度: 0
底层数组的容量: 0

底层数组的指针: 0x14000104020
底层数组的长度: 1
底层数组的容量: 1

底层数组的指针: 0x14000104030
底层数组的长度: 2
底层数组的容量: 2

底层数组的指针: 0x1400012c000
底层数组的长度: 4
底层数组的容量: 4

底层数组的指针: 0x1400011a040
底层数组的长度: 5
底层数组的容量: 8

使用 append 函数向 slice 追加元素,实际上是往底层数组相应的位置放置要追加的元素,而底层数组的长度是固定的,一旦底层数组元素无法容纳更多元素,那么就无法再继续放置新元素。这时 slice 触发扩容逻辑,将指向一个容量更大的数组,并将原来的内容整体迁移到新的内存位置,紧接着后面再放置新的元素,这就是基本的扩容介绍。

2.2 切片扩容推导

观察底层数组容量变化:0->1->2->4->8,一眼貌似就看出规律了:append 追加新的元素时,如果底层数组无法容纳更多元素,就会重新申请一个容量是之前底层数组两倍的新数组来存放旧元素和新元素。但事实真的是这样吗?再看下面这段程序:

package main

import "fmt"

func main() {
    s1 := make([]int, 0)
    oldCap := cap(s1)
    for i := 0; i < 1024; i++ {
        s1 = append(s1, i)
        newCap := cap(s1)
        if newCap != oldCap {
            fmt.Printf("index range [%d->%4d] oldcap = %-4d | after append %-4d  newcap = %-4d\n", 0, i-1, oldCap, i, newCap)
            oldCap = newCap
        }
    }
}

首先创建一个空的切片 s,然后在一个循环中不断地追加新的元素,同时记录新旧容量变化时刻的切片状态,以更好的观察追加和扩容行为导致的变化,从而更好的找出规律。

输出如下:

index range [0->  -1] oldcap = 0    | after append 0     newcap = 1   
index range [0->   0] oldcap = 1    | after append 1     newcap = 2   
index range [0->   1] oldcap = 2    | after append 2     newcap = 4   
index range [0->   3] oldcap = 4    | after append 4     newcap = 8   
index range [0->   7] oldcap = 8    | after append 8     newcap = 16  
index range [0->  15] oldcap = 16   | after append 16    newcap = 32  
index range [0->  31] oldcap = 32   | after append 32    newcap = 64  
index range [0->  63] oldcap = 64   | after append 64    newcap = 128 
index range [0-> 127] oldcap = 128  | after append 128   newcap = 256 
index range [0-> 255] oldcap = 256  | after append 256   newcap = 512 
index range [0-> 511] oldcap = 512  | after append 512   newcap = 848 
index range [0-> 847] oldcap = 848  | after append 848   newcap = 1280

你可能已经发现:在旧的切片容量在 512 之前都是按照上述总结出来的翻倍规律进行扩容的,但是旧切片长度到了 512 之后再进行追加,并没有直接申请一个大小为 1024 的底层数组,而是申请了一个大小为 848 的底层数组,为什么?这个 848 是怎么得到的?

源码面前没有秘密,要想弄清楚真正的扩容规律是怎样的必须从源码中寻找答案,而切片的扩容行为本质上是一个运行时特性,go 语言的编译器在扩容行为发生时跳转到对应的扩容函数执行。最简单的寻找扩容入口的方式就是对编译后的文件进行逆向工程,从汇编代码中寻找答案。

通过使用 go tool compile 工具并添加 -S 参数查看编译后程序对应的汇编代码:

go tool compile -S main.go

如果执行报"找不到 fmt 包的错误",可暂时注释掉 fmt.Printf 那行代码,可得到如下输出:

$ go tool compile -S main.go
main.main STEXT size=160 args=0x0 locals=0x58 funcid=0x0 align=0x0
        0x0000 00000 (/Users/smy/go/just-for-test-only-once/main.go:3)  TEXT    main.main(SB), ABIInternal, $96-0
        ...
        // 将切片类型信息([]int)加载到寄存器 R4 中 
        MOVD $type:int(SB), R4 
        ...
        // 调用 runtime.growslice 函数处理切片的扩容
        0x006c 00108 (/Users/smy/go/just-for-test-only-once/main.go:7)  CALL    runtime.growslice(SB)
        ...

汇编代码比较难读,抓住重点即可。

  • 汇编函数的第一行表示程序的入口点(即 main.main(SB)),意味着函数 main 正在被编译成汇编代码;
  • 初始化一个空切片
  • 循环地向切片中追加元素,每添加一次就检查切片的容量
  • 当切片的容量不够时,Go 会调用 runtime.growslice 函数进行扩容,growslice 会根据当前切片的容量和长度,计算出一个合适的容量,并将旧切片的元素复制到新的内存位置
  • 使用寄存器存储和比较切片的容量,确保每次扩容时都能够正确的更新容量

在 go 语言源码中(以 1.18 版本为例),切片的核心扩容函数位于 runtime 包下,具体的实现可以在 runtime/slice.go 文件中找到,该文件总共 300 多行代码,非常值得一读。

下面正式进入这个核心函数内部看看:

func growslice(et *_type, old slice, cap int) slice {
    // ...省略特判逻辑代码
    
    newcap := old.cap
    doublecap := newcap + newcap
    if cap > doublecap {
        newcap = cap
    } else {
        const threshold = 256
        if old.cap < threshold {
            newcap = doublecap
        } else {
            for 0 < newcap && newcap < cap {
                newcap += (newcap + 3 * threshold) / 4
            }
            
            if newcap <= 0 {
                newcap = cap
            }
        }
    }
    // ...
    capmem = roundupsize(capmem)
    newcap = int(capmem / et.size)
    // ...
}

扩容公式:当前容量 += 当前容量/4 + 192,直到当前容量超过所需容量时停止计算,这种扩容方式在源码中称为"平滑扩容",目的是尽可能少的分配额外内存空间,够用即可。

oldcap = 256 为例,新容量的计算如下:

// old slice = [0 1 ... 255]
// cap = 257
func growslice(et *_type, old slice, cap int) slice {
    // newcap = oldslice.cap = 256
    // doublecap = 512
    // cap < doublecap 走 else logic
    // old.cap = 256 不小于 threshold,继续走 else logic
    // newcap += newcap / 4 + 3/4 * 256
    // newcap = 256 + 256 = 512
}

因此当旧切片长度为 256 时,再往切片中添加新元素后,新的底层数组的容量为 512,这和输出结果匹配。

继续往下看,下一次扩容时机是旧切片长度为 512,又往切片中追加新元素 512,此时新容量的计算如下:

// old slice = [0 1 ... 511]
// cap = 513
func growslice(et *_type, old slice, cap int) slice {
    // newcap = oldslice.cap = 512
    // doublecap = 1024
    // cap < doublecap 走 else logic
    // old.cap = 512 不小于 threshold,继续走 else logic
    // newcap += newcap / 4 + 3/4 * 256
    // newcap = 512 + 512 /4 + 3/4 * 256 = 832
}

832 != 848,这是因为在 growslice 中还进行了额外的内存对齐处理:

func growslice(et *_type, old slice, cap int) slice {
    // 省略计算出 832 的这部分代码
    // newcap = 832

    ...
    capmem = roundupsize(uintptr(newcap) * goarch.PtrSize)
    newcap = int(capmem / goarch.PtrSize)
    ...
}

只看和扩容有关的核心逻辑,在拿到 832 之后,调用了 roundupsize 这个函数,在进入这个函数内部之前需要了解一些常量:

const _PageSize = 1 << _PageShift

const (
    _MaxSmallSize   = 32768
    smallSizeDiv    = 8
    smallSizeMax    = 1024
    largeSizeDiv    = 128
    _NumSizeClasses = 68
    _PageShift      = 13
)
  • _MaxSmallSize = 32768 (32KB),定义了被判定为小对象的最大尺寸,超过这个大小的对象都会被认定为大对象,会使用不同的分配策略;
  • smallSizeDiv:用来将小对象大小映射到对应的 size class,比如 8KB 是一类、16KB 又是一类,它们对应 size_to_class8 数组的不同索引;
  • smallSizeMax = 1024:使用小尺寸分配的临界值,小于此值的对象将使用 size_to_class8 数组,大于此值的对象将使用 size_to_class128 数组,后者存放了更大的内存对象;
  • largeSizeDiv:用来将大于 smallSizeMax 的对象大小映射到对应的 size class;
  • _NumSizeClasses:内存分配大小类别的总数(可在 src/runtime/sizeclasses.go 查看)
  • _PageShift:内存页大小的位移值,2^13 = 8192,表示一个内存页是 8KB,用于内存页大小计算和内存对齐。

上面的常量了解即可,提到它们主要是用于计算新容量,现在进入 roundupsize 内部:

// 在 32 位系统上,PtrSize 等于 4 字节,在 64 位系统上,PtrSize 等于 8 字节
roundupsize(uintptr(newcap) * goarch.PtrSize)
==> roundupsize(832 * 8) = roundupsize(6656)

func roundupsize(size uintptr) uintptr {
    if size < _MaxSmallSize {
        if size <= smallSizeMax-8 {
            return uintptr(class_to_size[size_to_class8[divRoundUp(size, smallSizeDiv)]])
        } else {
            return uintptr(class_to_size[size_to_class128[divRoundUp(size-smallSizeMax, largeSizeDiv)]])
        }
    }
    
    if size+_PageSize < size {
        return size
    }
    return alignUp(size, _PageSize)
}

// divRoundUp 返回 n / a 的向上取整值
func divRoundUp(n, a uintptr) uintptr {
    return (n + a - 1) / a
}

因为 size = 6656 < 32768,所以会进入外层 if 分支;
size > 1024-8,所以会执行内层的 else 分支,拆解这个计算:

  1. size-smallSizeMax = 6656 - 1024 = 5632
  2. divRoundUp(5680, largeSizeDiv) = 5632 / 128 = 44 (若存在小数则向上取整)
  3. 然后使用 44size_to_class128
var size_to_class128 = [(_MaxSmallSize-smallSizeMax)/largeSizeDiv + 1]uint8{32, 33, 34, 35, 36, 37, 37, 38, 38, 39, 39, 40, 40, 40, 41, 41, 41, 42, 43, 43, 44, 44, 44, 44, 44, 45, 45, 45, 45, 45, 45, 46, 46, 46, 46, 47, 47, 47, 47, 47, 47, 48, 48, 48, 49, 49, 50, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 52, 52, 52, 52, 52, 52, 52, 52, 52, 52, 53, 53, 54, 54, 54, 54, 55, 55, 55, 55, 55, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 57, 57, 57, 57, 57, 57, 57, 57, 57, 57, 58, 58, 58, 58, 58, 58, 59, 59, 59, 59, 59, 59, 59, 59, 59, 59, 59, 59, 59, 59, 59, 59, 60, 60, 60, 60, 60, 60, 60, 60, 60, 60, 60, 60, 60, 60, 60, 60, 61, 61, 61, 61, 61, 62, 62, 62, 62, 62, 62, 62, 62, 62, 62, 62, 63, 63, 63, 63, 63, 63, 63, 63, 63, 63, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 65, 65, 65, 65, 65, 65, 65, 65, 65, 65, 65, 65, 65, 65, 65, 65, 65, 65, 65, 65, 65, 66, 66, 66, 66, 66, 66, 66, 66, 66, 66, 66, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67}
  1. size_to_class128 索引 44 处对应的 size class 值为 49,接着拿 size class 查 class_to_size
var class_to_size = [_NumSizeClasses]uint16{0, 8, 16, 24, 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}
  1. size class = 49 对应的 size 大小为 6784

所以最终 roundupsize(6656) 返回 6784,这个函数的作用就是将请求的内存大小向上取整到最接近的 size class。

在本例中,原始请求 6656 个字节,被向上取整到 6784 个字节。从 sizeclasses.go 的表格中可以发现:class49 可以在一个 40970B 的 span 中分配 6 个对象,每个对象大小为 6784,这个对象就是符合请求 6656 的最合适的对象大小。

这种向上取整的机制一方面是为了内存对齐、减少内存碎片,另一方面也简化了内存分配器的实现、提高了分配和回收的效率。

为什么 size 在向上取整之前需要先减去一个 smallSizeMax?

size_to_class128 数组的设计是基于一个偏移的索引系统,go 的内存分配器将对象大小分为了两个区间,小对象区间是 [0, smallSizeMax),使用 size_to_class8 数组,中等对象区间 [smallSizeMax, 32768),使用 size_to_class128 数组,而这个数组的类型为 [(_MaxSmallSize-smallSizeMax)/largeSizeDiv + 1]uint8,从其长度的计算可以看出它只负责处理从 smallSizeMax 开始的大小,所以在使用这个数组计算 class 时需要先减去 smallSizeMax 得到相对偏移,然后除以 largeSizeDiv 获得数组索引。比如上面的例子,如果不减去这个值,那么最终得到的索引为 6656/128 = 52,拿到的对象大小为 12288,这远远超过了所需要的内存对象大小,而这种设计更加紧凑,也是一种内存优化手段。

  1. ok,现在已经拿到了 roundupsize(6656) 向上取整后的返回值 6784(最合适的内存对象大小),回到调用处继续往下看:
capmem = roundupsize(uintptr(newcap) * goarch.PtrSize)  // 6784
newcap = int(capmem / goarch.PtrSize)
...
memmove(p, old.array, lenmem)
return slice{p, old.len, newcap}

之前提到过 goarch.PtrSize 在 64 位机器上大小为 8,因此得到的 newcap = 6784 / 8 = 848

index range [0-> 255] oldcap = 256  | after append 256   newcap = 512 
index range [0-> 511] oldcap = 512  | after append 512   newcap = 848 

这就符合程序的输出结果了。

在拿到新的容量之后,Go 调用 memmove 把原来数组中的内容整体拷贝到新数组中,然后返回最新的切片对象。
到此为止,我们就算完整的推导了一次切片扩容过程。

最后总结一下本篇学到的知识。

在 Go 语言中,切片(slice)在大小达到 256 字节的阈值内,采用倍增扩容策略。当切片容量超过这个阈值后,扩容策略会转为平滑扩容:每次扩容时,会在当前容量基础上增加四分之一,再加上 192 字节,直到新容量满足所需的容量为止。计算得出新容量后,接着会进行向上取整,以找到最合适的内存对象大小,确保符合本次容量申请的要求,最终用于内存分配。

将这段话换一种形式描述: Go 切片的扩容策略采用了双阶段增长模式

  1. 倍增阶段 (≤ 256)
  • 当切片容量不超过 256 时,采用倍增策略
  • 新容量直接翻倍,提供充足的增长空间,避免频繁扩容
  1. 平滑增长阶段 (> 256)
  • 当切片容量超过 256 时,切换为更平滑的增长曲线
  • 新容量 = 当前容量 + (当前容量 ÷ 4) + 192
  • 这种增长方式可以避免过度分配,同时保持合理的扩容幅度
  1. 内存对齐
  • 计算得到新容量后,会向上取整到最接近的内存分配尺寸类别
  • 这种对齐机制确保了内存分配的效率,并减少内存碎片

这种双阶段扩容策略在小切片和大切片场景下都表现出良好的性能特征,既照顾了小对象的快速增长需求,又避免了大对象的内存浪费。

关于我
loading