mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
2110 字
6 分钟
Go slice 与 string 底层实现:从 runtime 结构到性能陷阱
2023-08-15

slice 和 string 是 Go 中最常用的两种数据类型,但它们的底层实现远比表面复杂。slice 的三字段结构(指针+长度+容量)决定了它的扩容和共享行为;string 的不可变性背后是运行时的 copy-on-write 优化。理解这些底层机制,是避免写出有 bug 的代码的前提。


一、slice 的运行时结构#

Go slice 在运行时是一个包含三个字段的结构体:

// reflect.SliceHeader(与 runtime 内部表示等价)
type SliceHeader struct {
Data unsafe.Pointer // 指向底层数组的指针
Len int // 长度:当前元素个数
Cap int // 容量:底层数组从 Data 开始的元素个数
}
graph LR subgraph "slice 头(3 个字段)" PTR["Data: 0xc0000a0000"] LEN["Len: 5"] CAP["Cap: 8"] end subgraph "底层数组(连续内存)" A0["[0]: 1"] A1["[1]: 2"] A2["[2]: 3"] A3["[3]: 4"] A4["[4]: 5"] A5["[5]: _"] A6["[6]: _"] A7["[7]: _"] end PTR --> A0 style A0 fill:#4CAF50,color:#fff style A1 fill:#4CAF50,color:#fff style A2 fill:#4CAF50,color:#fff style A3 fill:#4CAF50,color:#fff style A4 fill:#4CAF50,color:#fff style A5 fill:#E0E0E0 style A6 fill:#E0E0E0 style A7 fill:#E0E0E0
  • Data:指向底层数组的起始地址
  • Lenlen(s) 返回的值,表示当前可访问的元素数
  • Capcap(s) 返回的值,表示不重新分配时最多能容纳的元素数

slice 头本身始终是 24 字节(64 位系统),无论指向多少元素:

var s []int64
fmt.Println(unsafe.Sizeof(s)) // 24(指针 8 + len 8 + cap 8)

二、slice 的创建#

三种创建方式#

// 1. 字面量
s1 := []int{1, 2, 3} // len=3, cap=3
// 2. make
s2 := make([]int, 5) // len=5, cap=5,元素零值
s3 := make([]int, 3, 10) // len=3, cap=10
// 3. 从数组切片
arr := [10]int{1, 2, 3, 4, 5}
s4 := arr[2:5] // len=3, cap=8(共享底层数组!)

从数组切片的底层#

arr := [5]int{10, 20, 30, 40, 50}
s := arr[1:3] // s = [20, 30]
// 底层等价于:
// s.Data = &arr[1] // 指向 arr 的第 1 个元素
// s.Len = 3 - 1 = 2 // 长度
// s.Cap = 5 - 1 = 4 // 容量 = 数组长度 - 起始索引
graph LR subgraph "数组 arr[5]" A0["[0]: 10"] A1["[1]: 20"] A2["[2]: 30"] A3["[3]: 40"] A4["[4]: 50"] end subgraph "slice s = arr[1:3]" S_PTR["Data → arr[1]"] S_LEN["Len: 2"] S_CAP["Cap: 4"] end S_PTR --> A1 style A1 fill:#4CAF50,color:#fff style A2 fill:#4CAF50,color:#fff style A3 fill:#FF9800,color:#fff style A4 fill:#FF9800,color:#fff

关键:slice 与原数组共享底层数组!修改 slice 会影响原数组。

makeslice:运行时创建#

make([]T, len, cap) 在编译期经过两步转换:类型检查阶段将 OMAKE 节点转为 OMAKESLICE,SSA 中间代码生成阶段根据是否逃逸和大小决定走编译期路径还是运行时路径。如果切片不逃逸且足够小,编译器直接在栈上创建数组再用 [:] 取切片;否则调用 runtime.makeslice

src/runtime/slice.go
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 {
// 二次检查:如果连 len 都分配不了,报 len 错误
mem, overflow := math.MulUintptr(et.size, uintptr(len))
if overflow || mem > maxAlloc || len < 0 {
panicmakeslicelen()
}
panicmakeslicecap()
}
return mallocgc(mem, et, true)
}

makeslice 只返回底层数组的指针,SliceHeader 的构建由调用方在编译期完成。这个设计是从 Go 1.13 开始的优化(commit 020a18c),减少了 ~0.2% 的包大小和 ~3.5% 的 panicIndex 调用。

字面量初始化的编译期展开#

[]int{1, 2, 3} 在编译期被 slicelit 函数展开成等价的代码:

// 编译期展开结果(伪代码)
var vstat [3]int
vstat[0] = 1
vstat[1] = 2
vstat[2] = 3
var vauto *[3]int = new([3]int)
*vauto = vstat
slice := vauto[:] // 最终走 OpSliceMake

先在静态存储区构建数组,再复制到堆上的新数组,最后用 [:] 取切片。第 5 步的 [:] 就是从数组切片创建的底层操作。

三、slice 扩容#

append 导致 len > cap 时,Go 运行时会分配新的底层数组并复制数据。

扩容策略#

// src/runtime/slice.go growslice(核心逻辑,Go 1.18+ 改为大 slice 用 1.25 倍公式)
func growslice(et *_type, old slice, cap int) slice {
newcap := old.cap
doublecap := newcap + newcap
if cap > doublecap {
// 期望容量超过两倍,直接用期望容量
newcap = cap
} else {
if old.len < 1024 {
// 小 slice:翻倍
newcap = doublecap
} else {
// 大 slice:约 1.25 倍增长
// Go 1.18+ 公式:newcap += (newcap + 3*256) / 4
for 0 < newcap && newcap < cap {
newcap += newcap / 4
}
if newcap <= 0 {
newcap = cap
}
}
}
// ... 内存对齐(见下文)
}

扩容规则总结#

旧容量新容量增长率
< 10242 × oldCap翻倍
≥ 1024~1.25 × oldCap线性增长

注意:Go 1.18 对扩容逻辑做了重构,大 slice 的增长公式改为 newcap += (newcap + 3*256) / 4(等价于约 1.25 倍),但核心思想不变:小 slice 翻倍,大 slice 约 1.25 倍。最终容量还会经过 roundupsize 对齐到 size class 边界,因此实际分配的容量可能比公式计算的更大。

内存对齐:roundupsize#

上面计算出的 newcap 只是”大致容量”,还要根据元素大小做内存对齐。roundupsize 将申请的内存向上取整到 size class 的边界,减少内存碎片:

// growslice 中的内存对齐逻辑
var overflow bool
var lenmem, newlenmem, capmem uintptr
switch {
case et.size == 1:
// 元素大小为 1 字节:直接对齐
lenmem = uintptr(old.len)
newlenmem = uintptr(cap)
capmem = roundupsize(uintptr(newcap))
overflow = uintptr(newcap) > maxAlloc
newcap = int(capmem)
case et.size == goarch.PtrSize:
// 元素大小等于指针大小(64 位系统为 8 字节)
lenmem = uintptr(old.len) * goarch.PtrSize
newlenmem = uintptr(cap) * goarch.PtrSize
capmem = roundupsize(uintptr(newcap) * goarch.PtrSize)
overflow = uintptr(newcap) > maxAlloc / goarch.PtrSize
newcap = int(capmem / goarch.PtrSize)
case isPowerOfTwo(et.size):
// 元素大小是 2 的幂次方
// ... 类似逻辑,用移位代替乘除
default:
// 通用情况:乘法 + 对齐 + 除法还原
lenmem = uintptr(old.len) * et.size
newlenmem = uintptr(cap) * et.size
capmem, _ = math.MulUintptr(et.size, uintptr(newcap))
capmem = roundupsize(capmem)
newcap = int(capmem / et.size)
}

roundupsize 内部查 class_to_size 数组,将申请字节数向上取整到最近的 size class。例如 []int64(元素 8 字节)从 cap=5 扩容,理论需要 10×8=80 字节,但 80 正好是一个 size class,所以实际 cap=10。如果理论需要 88 字节,取整到 96 字节,实际 cap=12。

分配与复制#

对齐完成后,根据元素类型选择分配方式:

var p unsafe.Pointer
if et.ptrdata == 0 {
// 无指针类型:不需要写屏障,更快
p = mallocgc(capmem, nil, false)
memclrNoHeapPointers(add(p, newlenmem), capmem-newlenmem)
} else {
// 含指针类型:需要写屏障
p = mallocgc(capmem, et, true)
if writeBarrier.enabled {
bulkBarrierPreWriteSrcOnly(uintptr(p), uintptr(old.array), lenmem)
}
}
memmove(p, old.array, lenmem) // 复制旧数据
return slice{p, old.len, newcap}

无指针类型(如 []int)分配时不需要 GC 写屏障,性能更好。含指针类型(如 []*int)需要写屏障协助 GC 追踪指针移动。

实际扩容验证#

func main() {
var s []int
var lastCap int
for i := 0; i < 2000; i++ {
s = append(s, i)
if cap(s) != lastCap {
if lastCap > 0 {
fmt.Printf("len=%4d cap=%4d%4d ratio=%.2f\n",
len(s), lastCap, cap(s),
float64(cap(s))/float64(lastCap))
}
lastCap = cap(s)
}
}
}
// 输出(节选):
// len= 2 cap= 1→ 2 ratio=2.00
// len= 3 cap= 2→ 4 ratio=2.00
// len= 5 cap= 4→ 8 ratio=2.00
// len= 9 cap= 8→ 16 ratio=2.00
// ...
// len=1025 cap= 1024→1280 ratio=1.25 ← 转折点
// len=1281 cap= 1280→1696 ratio=1.32
graph LR A["cap=0 → cap=1"] --> B["cap=1 → cap=2"] B --> C["cap=2 → cap=4"] C --> D["cap=4 → cap=8"] D --> E["cap=8 → cap=16"] E --> F["cap=16 → cap=32"] F --> G["..."] G --> H["cap=512 → cap=1024"] H --> I["cap=1024 → cap=1280<br/>(~1.25×)"] style A fill:#4CAF50,color:#fff style I fill:#FF9800,color:#fff

四、slice 拷贝:copy 的实现#

copy(a, b) 在编译期分两种情况处理。如果 copy 不是在运行时调用的,编译器直接展开:

// 编译期展开结果(伪代码)
n := len(a)
if n > len(b) {
n = len(b)
}
if a.ptr != b.ptr {
memmove(a.ptr, b.ptr, n*sizeof(elem(a)))
}

如果 copy 在运行时调用(如 go copy(a, b)),则走 runtime.slicecopy

src/runtime/slice.go
func slicecopy(to, fm slice, width uintptr) int {
if fm.len == 0 || to.len == 0 {
return 0
}
n := fm.len
if to.len < n {
n = to.len
}
if width == 0 {
return n // 空结构体,无需复制
}
size := uintptr(n) * width
if size == 1 {
// 单字节优化:直接赋值,不走 memmove
*(*byte)(to.array) = *(*byte)(fm.array)
} else {
memmove(to.array, fm.array, size)
}
return n
}

两个优化点:空结构体(width == 0)直接返回;单字节复制用直接赋值代替 memmove

五、slice 共享底层数组的陷阱#

这是 Go 中最常见的 bug 来源之一:

// 陷阱:多个 slice 共享底层数组
func sliceTrap() {
s := []int{1, 2, 3, 4, 5}
s1 := s[1:3] // s1 = [2, 3], cap=4
s2 := s[2:5] // s2 = [3, 4, 5], cap=3
// s1 和 s2 共享底层数组!
s1[1] = 100 // 修改 s1[1]
fmt.Println(s2[0]) // 输出 100!s2[0] 也被修改了
}
graph TD subgraph "底层数组" A0["[0]: 1"] A1["[1]: 2"] A2["[2]: 3 ← s1[1] 和 s2[0] 指向同一位置"] A3["[3]: 4"] A4["[4]: 5"] end subgraph "s1 = s[1:3]" S1["Data→A1, Len=2, Cap=4"] end subgraph "s2 = s[2:5]" S2["Data→A2, Len=3, Cap=3"] end S1 --> A1 S2 --> A2 style A2 fill:#F44336,color:#fff

解决方案:使用三索引切片#

// 三索引切片:限制容量,防止共享
s1 := s[1:3:3] // s1 = [2, 3], cap=2(不再是 4)
// 现在 append(s1, x) 会分配新数组,不会影响 s2

六、string 的运行时结构#

Go string 在运行时是一个两字段的结构体:

reflect.StringHeader
type StringHeader struct {
Data unsafe.Pointer // 指向字节数组的指针
Len int // 字节长度
}
graph LR subgraph "string 头(16 字节)" PTR["Data: 0xc0000a0010"] LEN["Len: 5"] end subgraph "底层数据(只读)" B0["'H'"] B1["'e'"] B2["'l'"] B3["'l'"] B4["'o'"] end PTR --> B0 style B0 fill:#2196F3,color:#fff style B1 fill:#2196F3,color:#fff style B2 fill:#2196F3,color:#fff style B3 fill:#2196F3,color:#fff style B4 fill:#2196F3,color:#fff

字符串不可变性#

Go 的 string 是不可变的(immutable),这由编译器和运行时共同保证:

  1. 编译器:不允许对 string 元素赋值(s[0] = 'x' 编译错误)
  2. 运行时:字符串数据存储在只读段(.rodata),写入会触发 segfault
  3. slice 截取s[1:3] 不会复制数据,只是创建新的 StringHeader

字符串共享#

s1 := "hello, world"
s2 := s1[7:] // s2 = "world"
// s1 和 s2 共享底层数据!
// s2.Data = s1.Data + 7
// s2.Len = s1.Len - 7 = 5

这种共享是安全的,因为字符串不可变,没有人能修改底层数据。

七、string 与 []byte 的转换#

标准转换(有拷贝)#

s := "hello"
b := []byte(s) // 分配新内存,拷贝数据
s2 := string(b) // 分配新内存,拷贝数据
// 零拷贝:string → []byte(不分配内存)
// Go 1.20+ 推荐使用 unsafe.Slice / unsafe.StringData
func str2bytes(s string) []byte {
return unsafe.Slice(unsafe.StringData(s), len(s))
}
// 零拷贝:[]byte → string(不分配内存)
func bytes2str(b []byte) string {
return unsafe.String(unsafe.SliceData(b), len(b))
}
Warning

零拷贝转换后,如果修改 []byte,对应的 string 也会改变,违反了字符串不可变性。只在确认不会修改时使用。旧代码中常见的 reflect.SliceHeader / reflect.StringHeader 写法在 Go 1.20+ 已被废弃,上述 unsafe.Slice / unsafe.String 是官方推荐的替代方案。

编译器优化#

Go 编译器对一些常见模式有优化:

// 优化 1:map 查找中的 []byte 转 string
m := map[string]int{}
b := []byte("key")
_ = m[string(b)] // 编译器优化:不分配内存
// 优化 2:字符串拼接
s := "hello" + " " + "world" // 编译器在编译时完成拼接
// 优化 3:range string
for i, r := range "hello" {
// 编译器优化:不转换为 []rune
_ = i; _ = r
}
// 优化 4:多字符串拼接 → 编译器使用 strings.Builder 或 runtime.concatstrings
s := s1 + s2 + s3 + s4 // 编译器优化为一次分配
// 等价于:
var b strings.Builder
b.Grow(len(s1) + len(s2) + len(s3) + len(s4))
b.WriteString(s1)
b.WriteString(s2)
b.WriteString(s3)
b.WriteString(s4)
s := b.String()

字符串驻留(String Interning)#

Go 编译器会对编译期可确定的字符串字面量做驻留(interning):相同内容的字符串字面量共享同一块底层存储,不重复分配。但运行时构造的字符串(如 string(bytes)fmt.Sprintf)不会自动驻留。

// 编译期驻留:s1 和 s2 指向同一块内存
s1 := "hello"
s2 := "hello"
// unsafe.StringData(s1) == unsafe.StringData(s2) // true
// 运行时构造:不驻留,各自分配
s3 := string([]byte("hello"))
s4 := string([]byte("hello"))
// unsafe.StringData(s3) != unsafe.StringData(s4) // true

如果程序中有大量重复的运行时字符串(如 HTTP header 名、枚举值),可以用 sync.Map 或第三方 interning 库手动驻留,减少内存占用。标准库的 net/http 内部就用了类似技巧来复用常见 header 名。

八、性能陷阱与最佳实践#

陷阱 1:大 slice 的 append 复制#

// 每次复制整个 slice
s := make([]int, 1000000)
s = append(s, 1) // 复制 1000000 个 int!
// 预分配足够容量
s := make([]int, 0, 1000001)
s = append(s, 1) // 不需要复制

陷阱 2:string 和 []byte 频繁转换#

// 每次转换都分配内存
func process(s string) []byte {
return []byte(s) // 分配 + 拷贝
}
// 使用 []byte 参数,避免转换
func processBytes(b []byte) []byte {
return b // 无拷贝
}

陷阱 3:子字符串持有大字符串的引用#

// s2 持有 s1 的整个底层数组引用,s1 无法被 GC
s1 := string(make([]byte, 1<<30)) // 1GB
s2 := s1[:10] // 只需要 10 字节,但 1GB 无法释放
// 手动复制
s2 := string([]byte(s1[:10])) // 只分配 10 字节

九、常见问题 FAQ#

Q1:slice 的 nil 和空 slice 有什么区别?#

var s1 []int // nil slice: Data=nil, Len=0, Cap=0
s2 := []int{} // 空 slice: Data!=nil, Len=0, Cap=0
s3 := make([]int, 0) // 空 slice: Data!=nil, Len=0, Cap=0
// 功能上等价:len、cap、range 都一样
// 区别:JSON 编码时 nil → null,空 slice → []

Q2:为什么 append 要返回新的 slice?#

因为 append 可能触发扩容,分配新的底层数组。此时返回的 slice 与原 slice 指向不同的底层数组。如果不返回新值,调用者会持有旧的(未扩容的)slice。

Q3:string 为什么设计为不可变?#

三个原因:(1) 安全性,并发读取字符串不需要加锁;(2) 共享,子字符串可以共享底层数据,节省内存;(3) map key,string 可以作为 map 的 key,可变性会破坏哈希一致性。

Q4:Go 的字符串是 UTF-8 的吗?#

Go 的 string 是字节序列,不保证是有效 UTF-8。但 Go 源码中的字符串字面量总是 UTF-8 编码。range string 按 UTF-8 解码 rune,但 len(string) 返回字节数而非字符数。

Q5:如何高效地构建大字符串?#

使用 strings.Builder

var b strings.Builder
b.Grow(estimatedSize) // 预分配
for _, s := range parts {
b.WriteString(s) // 不会每次分配
}
result := b.String() // 一次分配

小结#

slice 和 string 的底层结构决定了它们的使用边界。slice 的三字段结构(Data+Len+Cap)使得子切片与原切片共享底层数组,这是性能优化(避免复制),也是 bug 温床(修改一处影响另一处)。三索引切片 s[low:high:max] 是 Go 提供的显式隔离手段,限制 cap 从而让 append 分配新数组,但大多数开发者只在出 bug 后才知道这个语法。string 的两字段结构(Data+Len)加上不可变性,使得子字符串可以零成本共享底层数据,安全,因为没人能改它;但也带来了子字符串持有大字符串引用的内存泄漏问题,一个 s[:10] 可能让 1GB 的原始数据无法被 GC。扩容策略的选择(小 slice 翻倍、大 slice 约 1.25 倍)反映了一个朴素的权衡:小 slice 翻倍的内存浪费可以接受,大 slice 翻倍则可能直接 OOM。理解 slice 和 string 的关键在于理解共享底层数组这一设计带来的所有后果,当你写 s1 := s[1:3] 时,你应该本能地想到”s1 和 s 指向同一块内存”。

参考资料#

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

Go slice 与 string 底层实现:从 runtime 结构到性能陷阱
https://blog.souloss.cn/posts/golang/go-slice-string/
作者
Souloss
发布于
2023-08-15
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时