Go 底层原理丨slice 从第一性原理到实现细节 永久链接

Go 底层原理 · 第 10

Go 底层原理丨slice 从第一性原理到实现细节

本文从第一性原理出发,深入探讨 slice 的实现细节,包括 slice 的底层结构、实现原理、使用场景等。

本篇将从第一性原理出发,全面解析 Go 的 slice 设计哲学和底层实现。

特此声明,本篇是笔者与 Google Gemini 3Pro 共创所作,非常庆幸在当今 AI 时代下获取知识已是如此便利,且也为学习者从第一性原理理解所学知识大大降低了门槛。不过本篇的篇章安排和叙述逻辑,均由笔者把控和审阅,欢迎放心阅读。

1. 本质

在计算机内存中,最基础的数据结构是连续内存块(数组)。但数组有一个致命缺陷:

  • 固定大小:编译时确定,无法动态增长
  • 值语义:传递时整体拷贝,效率低下
  • 缺乏元信息:只有指针,不知道长度和容量

Slice 本质上是对数组的引用封装,它解决了上述三个问题:

// runtime/slice.go

type slice struct {
	array unsafe.Pointer  // 指向底层数组的指针
	len   int             // 当前长度(已使用元素数量)
	cap   int             // 容量(底层数组的总大小)
}
  • array:指向真正的数据存储位置(底层数组)
  • len:定义了 slice 的可见范围(0 到 len-1 可访问)
  • cap:定义了 slice 的扩展能力(len 到 cap-1 可通过 reslice 访问)

所以说 Slice 不是容器,而是视图(View)+ 元数据(Metadata)的组合。

2. 创建

2.1 makeslice

func main() {
	s := make([]int, 3)
	fmt.Println(s)
}

通过下述命令,可以查看 Plan9 汇编代码,你会发现 make 一个 slice 底层调用的就是 makeslice 函数:

go build -gcflags -S main.go

输出如下:

LEAQ    type.int(SB), AX
MOVL    $3, BX
MOVQ    BX, CX
PCDATA  $1, $0
CALL    runtime.makeslice(SB) 		#直接调用 makeslice 方法

makeslice 的实现如下:

func makeslice(et *_type, len, cap int) unsafe.Pointer {
	mem, overflow := math.MulUintptr(et.Size_, uintptr(cap))
	if overflow || mem > maxAlloc || len < 0 || len > cap {
		mem, overflow := math.MulUintptr(et.Size_, uintptr(len))
		if overflow || mem > maxAlloc || len < 0 {
			panicmakeslicelen()
		}
		panicmakeslicecap()
	}

	return mallocgc(mem, et, true)
}
  1. 安全检查优先

    • 检查整数溢出(overflow
    • 检查内存限制(mem > maxAlloc
    • 检查参数合法性(len < 0 || len > cap
  2. 按容量分配内存mem = et.Size_ × cap

    • 分配的是 cap 而非 len 的内存
    • 这为后续增长预留了空间,避免频繁重新分配
  3. 调用 mallocgc:Go 的核心内存分配器

    • 与 GC 集成,支持垃圾回收
    • 第三个参数 true 表示需要初始化为零值

3. 扩容

3.1 扩容触发时机

append 导致 len > cap 时,触发 growslice 函数。groupslice 可以概括为 6 步:

  1. 参数验证:检查新长度是否合法,零大小类型直接返回特殊值。

  2. 计算新容量:调用 nextslicecap:容量小于 256 时翻倍,大于等于 256 时约 1.25 倍增长。

  3. 计算内存大小:根据元素大小选择优化路径:1 字节无需乘法,指针大小用位移,2 的幂用位移,其他用乘法。

  4. 内存对齐:用 roundupsize 向上取整到分配器的标准大小,充分利用空间,同时检查溢出和内存上限。

  5. 分配新内存:非指针类型告诉 GC 不扫描;指针类型需要 GC 扫描和写屏障。只清零 [newLen:newCap) 区间。

  6. 复制数据:用 memmove 复制旧元素到新内存,返回新 slice(新指针、新长度、新容量)。

3.2 容量增长算法

func nextslicecap(newLen, oldCap int) int {
	newcap := oldCap

  // 如果新长度超过两倍旧容量,直接使用新长度
	doublecap := newcap + newcap
	if newLen > doublecap {
		return newLen
	}

  // 小于 256,双倍增长
	const threshold = 256
	if oldCap < threshold {
		return doublecap
	}

  // 大于 256
	for {
    // 逐步从 2 倍扩容缩小到 1.25 倍扩容
		newcap += (newcap + 3*threshold) >> 2
		if uint(newcap) >= uint(newLen) {
			break
		}
	}

	if newcap <= 0 {
		return newLen
	}
	return newcap
}
  1. 特殊情况:如果新长度超过两倍旧容量,直接使用新长度

    • 避免多次扩容
  2. 小切片(cap < 256):双倍增长

    • 快速增长,减少小数据量的多次分配
    • 时间复杂度:单次 append 的摊销成本为 O(1)
  3. 大切片(cap ≥ 256):从 2 倍逐步降到 1.25 倍增长

    • newcap += (newcap + 3×256) / 4
    • 平衡了增长速度和内存浪费
    • 避免大切片浪费过多内存

3.3 内存对齐优化

	switch {
	case et.Size_ == 1:
		lenmem = uintptr(oldLen)
		newlenmem = uintptr(newLen)
		capmem = roundupsize(uintptr(newcap), noscan)
		overflow = uintptr(newcap) > maxAlloc
		newcap = int(capmem)
	case et.Size_ == goarch.PtrSize:
		lenmem = uintptr(oldLen) * goarch.PtrSize
		newlenmem = uintptr(newLen) * goarch.PtrSize
		capmem = roundupsize(uintptr(newcap)*goarch.PtrSize, noscan)
		overflow = uintptr(newcap) > maxAlloc/goarch.PtrSize
		newcap = int(capmem / goarch.PtrSize)
	case isPowerOfTwo(et.Size_):
		var shift uintptr
		if goarch.PtrSize == 8 {
			// Mask shift for better code generation.
			shift = uintptr(sys.TrailingZeros64(uint64(et.Size_))) & 63
		} else {
			shift = uintptr(sys.TrailingZeros32(uint32(et.Size_))) & 31
		}
		lenmem = uintptr(oldLen) << shift
		newlenmem = uintptr(newLen) << shift
		capmem = roundupsize(uintptr(newcap)<<shift, noscan)
		overflow = uintptr(newcap) > (maxAlloc >> shift)
		newcap = int(capmem >> shift)
		capmem = uintptr(newcap) << shift
	default:
		lenmem = uintptr(oldLen) * et.Size_
		newlenmem = uintptr(newLen) * et.Size_
		capmem, overflow = math.MulUintptr(et.Size_, uintptr(newcap))
		capmem = roundupsize(capmem, noscan)
		newcap = int(capmem / et.Size_)
		capmem = uintptr(newcap) * et.Size_
	}
  1. 特化常见情况

    • Size == 1(byte/uint8):直接计算,无需乘法
    • Size == PtrSize(指针大小):编译器可优化为位移
    • Size 为 2 的幂:用位移替代乘除法
  2. roundupsize 函数

    • 将内存大小向上舍入到分配器的 size class
    • 利用内存分配器的固定大小类别,避免内存碎片
    • 结果:实际分配的容量可能大于计算的容量

3.4 数据复制与 GC 协作

	var p unsafe.Pointer
	if !et.Pointers() {
		p = mallocgc(capmem, nil, false)
		// The append() that calls growslice is going to overwrite from oldLen to newLen.
		// Only clear the part that will not be overwritten.
		// The reflect_growslice() that calls growslice will manually clear
		// the region not cleared here.
		memclrNoHeapPointers(add(p, newlenmem), capmem-newlenmem)
	} else {
		// Note: can't use rawmem (which avoids zeroing of memory), because then GC can scan uninitialized memory.
		p = mallocgc(capmem, et, true)
		if lenmem > 0 && writeBarrier.enabled {
			// Only shade the pointers in oldPtr since we know the destination slice p
			// only contains nil pointers because it has been cleared during alloc.
			//
			// It's safe to pass a type to this function as an optimization because
			// from and to only ever refer to memory representing whole values of
			// type et. See the comment on bulkBarrierPreWrite.
			bulkBarrierPreWriteSrcOnly(uintptr(p), uintptr(oldPtr), lenmem-et.Size_+et.PtrBytes, et)
		}
	}
	memmove(p, oldPtr, lenmem)

	return slice{p, newLen, newcap}

GC 协作的设计

  1. 区分指针和非指针类型

    • 非指针类型:mallocgc(..., nil, false) - 不需要 GC 扫描
    • 指针类型:mallocgc(..., et, true) - 需要 GC 扫描和 write barrier
  2. Write Barrier

    • 在并发 GC 时,保证指针写入的可见性
    • 使用 bulkBarrierPreWriteSrcOnly 批量处理,提高效率
  3. 内存清零策略

    • 只清除 [newLen, newCap) 区间,节省时间
    • [oldLen, newLen) 由 append 覆盖,无需清零

4. 共享机制

4.1 Reslicing 原理

s := []int{0, 1, 2, 3, 4, 5}
s1 := s[1:4]  // len=3, cap=5, 共享同一底层数组
s2 := s[2:5]  // len=3, cap=4, 也共享

底层实现

  • 三个 slice 的 array 指针指向同一块内存的不同偏移
  • s1.array = s.array + 1×sizeof(int)
  • s2.array = s.array + 2×sizeof(int)

设计权衡

  • 优势:零拷贝,高效的子序列操作
  • 风险:修改一个 slice 会影响其他共享底层数组的 slice
  • 哲学:Go 选择效率优先,由程序员管理共享语义

4.2 特殊情况:元素大小为 0

	if et.Size_ == 0 {
		// append should not create a slice with nil pointer but non-zero len.
		// We assume that append doesn't need to preserve oldPtr in this case.
		return slice{unsafe.Pointer(&zerobase), newLen, newLen}
	}

对于 struct{}[0]int 等零大小类型:

  • 所有实例共享同一个地址 &zerobase
  • 不分配任何实际内存
  • len 和 cap 的语义依然保持

5. 实践

5.1 预分配的重要性

// 差:多次扩容
var s []int
for i := 0; i < 10000; i++ {
    s = append(s, i)
}

// 好:一次分配
s := make([]int, 0, 10000)
for i := 0; i < 10000; i++ {
    s = append(s, i)
}

5.2 注意共享陷阱

func process(s []int) {
    s = append(s, 999)  // 可能扩容,不影响原 slice
    s[0] = 100          // 可能影响原 slice(如果未扩容)
}

5.3 大 Slice 的子切片内存泄漏

// 问题:持有整个底层数组
func leak() []byte {
    data := make([]byte, 1<<20)  // 1MB
    return data[:100]             // 只用 100 字节,但持有 1MB
}

// 解决:拷贝所需数据
func noLeak() []byte {
    data := make([]byte, 1<<20)
    result := make([]byte, 100)
    copy(result, data[:100])
    return result
}
下一篇Go 底层原理丨深度剖析 Gin 框架核心机制:从 HTTP 请求生命周期到高性能设计哲学

来自开放网络的回应

引用、喜欢、转发和站外回复会通过 Webmention 回到这里。

接收与展示代码已经就绪,注册正式域名后即可开始收集回应。

评论

评论由 GitHub Discussions 保存和管理。