Golang 切片底层结构

切片底层结构

1. 从数组到切片

Go 的数组是固定长度的连续内存块。实际工程中我们极少直接用数组,因为长度是类型的一部分------[3]int[5]int 是不同类型,无法互相赋值,也无法动态扩容。

切片解决的就是"长度可变"的问题。但切片并非动态数组那么简单,它的本质是一个指向底层数组的引用描述符------它自己不存数据,只存"数据在哪里、有多长、容量多大"。

2. SliceHeader 三元组

Go 运行时用 reflect.SliceHeader 描述切片的内部结构(Go 1.22 起源码中用 unsafe.Slice,但概念一致):

python 复制代码
┌──────────────────────────────────┐
│         slice 变量 (24 字节)        │
├──────────┬──────────┬─────────────┤
│   Data   │   Len    │    Cap      │
│ (指针)    │ (长度)   │  (容量)     │
│ 8 bytes  │ 8 bytes  │  8 bytes    │
└────┬─────┴──────────┴─────────────┘
     │
     ▼
┌───┬───┬───┬───┬───┬───┬───┬───┐
│ 0 │ 1 │ 2 │ 3 │ 4 │ 5 │ 6 │ 7 │   ← 底层数组
└───┴───┴───┴───┴───┴───┴───┴───┘
  ←── Len=3 ──→  ←──── Cap=8 ────→

三个字段的含义:

字段 类型 作用
Data *Array (unsafe.Pointer) 指向底层数组首个元素的指针
Len int 当前切片中有效元素的数量
Cap int 底层数组从 Data 指针位置开始,可供切片使用的总容量

关键认知:切片本身只占 24 字节(64 位系统),无论它引用了多少数据。传参时拷贝的是这 24 字节的描述符,底层数组不会被复制。

3. 切片 vs 数组:一字之差

go 复制代码
a := [5]int{10, 20, 30, 40, 50}  // 数组:长度是类型的一部分
s := []int{10, 20, 30, 40, 50}   // 切片:长度不参与类型

[5]int 是数组,在栈上分配(逃逸分析决定),传参时整块拷贝。[]int 是切片,传参时只拷贝 24 字节的 header。

4. 多切片共享底层数组

这是切片最核心的特性,也是最容易踩坑的地方:对底层数组做切片操作,产生的新切片和原切片共享同一块内存。

go 复制代码
s := []int{1, 2, 3, 4, 5}
a := s[1:3]   // a = [2, 3],共享底层数组
a[0] = 99    // 修改 a 的第一个元素
// s[1] 也变成了 99!

画个图:

ini 复制代码
s.Data ─────────────────┐
                        ▼
  ┌───┬───┬───┬───┬───┐
  │ 1 │ 99│ 3 │ 4 │ 5 │   底层数组
  └───┴───┴───┴───┴───┘
        ▲
a.Data ─┘  (a.Len=2, a.Cap=4)

s: Len=5, Cap=5
a: Len=2, Cap=4  (从索引1到末尾)

5. nil 切片 vs 空切片

这俩在 JSON 序列化时表现不同,但 len() 都返回 0。

声明方式 Data 指针 Len Cap s == nil
var s []int nil 0 0 true
s := []int{} 非 nil 0 0 false
s := make([]int, 0) 非 nil 0 0 false

nil 切片的 Data 指针是 nil,空切片的 Data 指针指向一个非 nil 的零长度内存块。在 encoding/json 中,nil 切片序列化为 null,空切片序列化为 []

6. 实战代码:用 unsafe 验证切片结构

下面的代码用 unsafe.Pointer 直接读取切片 header 的三个字段,并用指针算术验证多个切片确实指向同一块底层数组。

go 复制代码
package main

import (
	"fmt"
	"unsafe"
)

// sliceHeader 模拟 reflect.SliceHeader 的结构
type sliceHeader struct {
	Data uintptr // 底层数组指针
	Len  int    // 长度
	Cap  int    // 容量
}

// headerOf 通过 unsafe.Pointer 将切片转换为 sliceHeader
// 注意:这不会拷贝底层数组,只是把 24 字节的 header 重新解释
func headerOf(s []int) sliceHeader {
	return *(*sliceHeader)(unsafe.Pointer(&s))
}

func main() {
	// === 1. 验证 SliceHeader 三元组 ===
	s := make([]int, 3, 8)
	s[0], s[1], s[2] = 10, 20, 30
	h := headerOf(s)
	fmt.Println("=== 切片结构验证 ===")
	fmt.Printf("Data 指针: 0x%x\n", h.Data)
	fmt.Printf("Len: %d, Cap: %d\n", h.Len, h.Cap)
	fmt.Printf("切片变量大小: %d 字节\n", unsafe.Sizeof(s))

	// === 2. 验证多切片共享底层数组 ===
	fmt.Println("\n=== 共享底层数组验证 ===")
	original := []int{1, 2, 3, 4, 5, 6, 7, 8}
	sub1 := original[1:4] // [2, 3, 4]
	sub2 := original[3:6] // [4, 5, 6]

	h0 := headerOf(original)
	h1 := headerOf(sub1)
	h2 := headerOf(sub2)

	fmt.Printf("original: Data=0x%x Len=%d Cap=%d\n", h0.Data, h0.Len, h0.Cap)
	fmt.Printf("sub1:    Data=0x%x Len=%d Cap=%d\n", h1.Data, h1.Len, h1.Cap)
	fmt.Printf("sub2:    Data=0x%x Len=%d Cap=%d\n", h2.Data, h2.Len, h2.Cap)

	// 验证指针偏移:sub1.Data 应该比 original.Data 大 1 个 int(8 字节)
	elemSize := unsafe.Sizeof(original[0])
	fmt.Printf("\n元素大小: %d 字节\n", elemSize)
	fmt.Printf("sub1.Data - original.Data = %d 字节 (预期: %d)\n",
		h1.Data-h0.Data, 1*elemSize)
	fmt.Printf("sub2.Data - original.Data = %d 字节 (预期: %d)\n",
		h2.Data-h0.Data, 3*elemSize)

	// === 3. 验证修改 sub1 会影响 original ===
	fmt.Println("\n=== 共享修改验证 ===")
	sub1[0] = 99
	fmt.Printf("sub1[0] = 99 后,original[1] = %d (预期 99)\n", original[1])

	// === 4. nil 切片 vs 空切片 ===
	fmt.Println("\n=== nil vs 空切片 ===")
	var nilSlice []int
	emptySlice := []int{}
	madeSlice := make([]int, 0)

	hn := headerOf(nilSlice)
	he := headerOf(emptySlice)
	hm := headerOf(madeSlice)

	fmt.Printf("var s []int:       Data=0x%x Len=%d Cap=%d nil=%v\n", hn.Data, hn.Len, hn.Cap, nilSlice == nil)
	fmt.Printf("s := []int{}:      Data=0x%x Len=%d Cap=%d nil=%v\n", he.Data, he.Len, he.Cap, emptySlice == nil)
	fmt.Printf("make([]int, 0):   Data=0x%x Len=%d Cap=%d nil=%v\n", hm.Data, hm.Len, hm.Cap, madeSlice == nil)

	// === 5. 三段切片操作对 Cap 的影响 ===
	fmt.Println("\n=== 切片操作对 Cap 的影响 ===")
	full := make([]int, 10, 10)
	for i := range full {
		full[i] = i + 1
	}
	// 从索引 2 切到索引 5
	mid := full[2:5] // Len=3, Cap=8 (从索引2到末尾)
	// 用三段索引限制容量
	limited := full[2:5:5] // Len=3, Cap=3 (明确限制)

	hm1 := headerOf(mid)
	hl := headerOf(limited)
	fmt.Printf("full[2:5]:    Len=%d Cap=%d\n", hm1.Len, hm1.Cap)
	fmt.Printf("full[2:5:5]:  Len=%d Cap=%d (三段索引限制容量)\n", hl.Len, hl.Cap)
}

7. 运行结果预期

ini 复制代码
=== 切片结构验证 ===
Data 指针: 0x1400012e030
Len: 3, Cap: 8
切片变量大小: 24 字节

=== 共享底层数组验证 ===
original: Data=0x... Len=8 Cap=8
sub1:    Data=0x... Len=3 Cap=7
sub2:    Data=0x... Len=3 Cap=5
...
sub1.Data - original.Data = 8 字节 (预期: 8)
sub2.Data - original.Data = 24 字节 (预期: 24)

=== 共享修改验证 ===
sub1[0] = 99 后,original[1] = 99 (预期 99)

=== nil vs 空切片 ===
var s []int:       Data=0x0 Len=0 Cap=0 nil=true
s := []int{}:      Data=0x... Len=0 Cap=0 nil=false
make([]int, 0):   Data=0x... Len=0 Cap=0 nil=false

=== 切片操作对 Cap 的影响 ===
full[2:5]:    Len=3 Cap=8
full[2:5:5]:  Len=3 Cap=3 (三段索引限制容量)

8. 知识要点总结

  1. 切片是引用描述符:24 字节的 header(Data 指针 + Len + Cap),不存储实际数据。
  2. 共享底层数组:切片操作产生的新切片与原切片共享内存,修改会互相影响。
  3. Cap 的含义:从 Data 指针位置到底层数组末尾的可用空间。
  4. 三段索引 [low:high:max]:可以用第三个参数限制新切片的 Cap,防止意外扩容时覆盖后续数据。
  5. nil 切片 vs 空切片 :nil 切片的 Data 为 nil,空切片的 Data 非 nil。JSON 序列化时 nil→null,空→[]
  6. 传参不拷贝数据:切片传参只拷贝 24 字节的 header,底层数组始终共享。

9. 常见陷阱

陷阱 1:切片传参后修改影响原数据

go 复制代码
func modify(s []int) {
    s[0] = 999  // 会修改调用方的底层数组!
}

如果不想影响原数据,需要用 copy() 显式拷贝。

陷阱 2:append 后原切片可能"看不到"新元素

go 复制代码
s := make([]int, 3, 5)
s = append(s, 100)
// s 的 Len 变成 4,但调用方传进来的切片 Len 还是 3
// 如果 append 没触发扩容,修改的是共享的底层数组
// 如果触发扩容,Data 指针变了,原切片完全不受影响

陷阱 3:大切片做小切片导致内存泄漏

go 复制代码
big := make([]byte, 1000000)
small := big[10:20]  // small.Cap = 999990,底层数组不会被 GC
// big 的整个底层数组因为 small 还引用着,无法回收
// 解决:copy 出来
small = make([]byte, 10)
copy(small, big[10:20])

相关推荐
BingoGo28 分钟前
免费可商用 PHP 管理后台 CatchAdmin V5.4.0 发布,新增短信服务能力
后端·php
Csvn28 分钟前
🐍 Day 8:面向对象编程
后端·python
程序员天天困30 分钟前
向量检索不准怎么办:混合检索与 Rerank 重排序召回优化实战
后端·python·ai编程
吃饱了得干活31 分钟前
一篇讲清楚Spring Boot:自动装配、启动器、过滤器、拦截器、设计模式
java·spring boot·后端
CodeSheep42 分钟前
OpenJDK 全面禁止 AI 生成代码!
前端·后端·程序员
IT_陈寒1 小时前
为什么我的Vue组件总是莫名其妙重渲染?
前端·人工智能·后端
东风破_10 小时前
danci 2:创建的单词书到底存在哪里?从 Supabase 一路理解 ORM、Drizzle 和 RLS
数据库·后端·node.js
东风破_10 小时前
danci 项目(三):从 JSON 数据到 AI Coding,真实项目里的数据清洗、Prompt 和工程规范
前端·后端·node.js