slice 和 string 是 Go 中最常用的两种数据类型,但它们的底层实现远比表面复杂。slice 的三字段结构(指针+长度+容量)决定了它的扩容和共享行为;string 的不可变性背后是运行时的 copy-on-write 优化。理解这些底层机制,是避免写出有 bug 的代码的前提。
本文要点
- slice 的运行时结构:
reflect.SliceHeader - slice 的创建、扩容与内存分配
- slice 共享底层数组的陷阱
- string 的运行时结构:
reflect.StringHeader - 字符串不可变性的实现机制
- string 与 []byte 的转换与优化
- 字符串拼接的编译器优化
- 性能陷阱与最佳实践
slice 的运行时结构
Go slice 在运行时是一个包含三个字段的结构体:
// reflect.SliceHeader(与 runtime 内部表示等价)type SliceHeader struct { Data unsafe.Pointer // 指向底层数组的指针 Len int // 长度:当前元素个数 Cap int // 容量:底层数组从 Data 开始的元素个数}- Data:指向底层数组的起始地址
- Len:
len(s)返回的值,表示当前可访问的元素数 - Cap:
cap(s)返回的值,表示不重新分配时最多能容纳的元素数
slice 占 24 字节
var s []int64fmt.Println(unsafe.Sizeof(s)) // 24(指针8 + len8 + cap8)无论 slice 指向多少元素,slice 头本身始终是 24 字节(64 位系统)。
slice 的创建
三种创建方式
// 1. 字面量s1 := []int{1, 2, 3} // len=3, cap=3
// 2. makes2 := 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 // 容量 = 数组长度 - 起始索引关键:slice 与原数组共享底层数组!修改 slice 会影响原数组。
slice 扩容
当 append 导致 len > cap 时,Go 运行时会分配新的底层数组并复制数据。
扩容策略
// src/runtime/slice.go (简化版)func growslice(old Slice, et *type, num int) Slice { oldCap := old.Cap newCap := oldCap + num
// 扩容规则 switch { case newCap < 2*oldCap: newCap = 2 * oldCap // 翻倍 case oldCap < 256: newCap = 2 * oldCap // 小 slice:翻倍 default: // 大 slice:1.25 倍增长(避免浪费太多内存) for newCap < oldCap + num { newCap += (newCap + 3*256) / 4 } }
// 内存对齐 var lenmem, capmem uintptr capmem = roundupsize(uintptr(newCap) * goarch.PtrSize) newCap = int(capmem / goarch.PtrSize)
// 分配新数组并复制 p := mallocgc(capmem, et, true) memmove(p, old.Data, lenmem)
return Slice{Data: p, Len: old.Len, Cap: newCap}}扩容规则总结
| 旧容量 | 新容量 | 增长率 |
|---|---|---|
| < 256 | 2 × oldCap | 翻倍 |
| ≥ 256 | ~1.25 × oldCap | 线性增长 |
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] 也被修改了}解决方案:使用三索引切片
// 三索引切片:限制容量,防止共享s1 := s[1:3:3] // s1 = [2, 3], cap=2(不再是4)// 现在 append(s1, x) 会分配新数组,不会影响 s2string 的运行时结构
Go string 在运行时是一个两字段的结构体:
type StringHeader struct { Data unsafe.Pointer // 指向字节数组的指针 Len int // 字节长度}字符串不可变性
Go 的 string 是不可变的(immutable),这由编译器和运行时共同保证:
- 编译器:不允许对 string 元素赋值(
s[0] = 'x'编译错误) - 运行时:字符串数据存储在只读段(.rodata),写入会触发 segfault
- 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) // 分配新内存,拷贝数据零拷贝转换(unsafe,慎用)
// 零拷贝:string → []byte(不分配内存)func str2bytes(s string) []byte { return *(*[]byte)(unsafe.Pointer(&reflect.SliceHeader{ Data: (*reflect.StringHeader)(unsafe.Pointer(&s)).Data, Len: len(s), Cap: len(s), }))}
// 零拷贝:[]byte → string(不分配内存)func bytes2str(b []byte) string { return *(*string)(unsafe.Pointer(&reflect.SliceHeader{ Data: (*reflect.StringHeader)(unsafe.Pointer(&b)).Data, Len: len(b), Cap: len(b), }))}警告:零拷贝转换后,如果修改 []byte,对应的 string 也会改变——违反了字符串不可变性!只在确认不会修改时使用。
编译器优化
Go 编译器对一些常见模式有优化:
// 优化 1:map 查找中的 []byte 转 stringm := map[string]int{}b := []byte("key")_ = m[string(b)] // 编译器优化:不分配内存
// 优化 2:字符串拼接s := "hello" + " " + "world" // 编译器在编译时完成拼接
// 优化 3:range stringfor i, r := range "hello" { // 编译器优化:不转换为 []rune _ = i; _ = r}字符串拼接的编译器优化
// 多个字符串拼接 → 编译器使用 strings.Builder 或 runtime.concatstringss := s1 + s2 + s3 + s4 // 编译器优化为一次分配
// 等价于:var b strings.Builderb.Grow(len(s1) + len(s2) + len(s3) + len(s4))b.WriteString(s1)b.WriteString(s2)b.WriteString(s3)b.WriteString(s4)s := b.String()性能陷阱与最佳实践
陷阱 1:大 slice 的 append 复制
// 每次复制整个 slices := 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 无法被 GCs1 := string(make([]byte, 1<<30)) // 1GBs2 := 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=0s2 := []int{} // 空 slice: Data!=nil, Len=0, Cap=0s3 := 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.Builderb.Grow(estimatedSize) // 预分配for _, s := range parts { b.WriteString(s) // 不会每次分配}result := b.String() // 一次分配小结
- slice 是三字段结构体(Data+Len+Cap),本身占 24 字节
- slice 扩容:小 slice 翻倍,大 slice 约 1.25 倍,还有内存对齐
- slice 共享底层数组是常见 bug 来源,三索引切片可限制容量
- string 是两字段结构体(Data+Len),本身占 16 字节,数据不可变
- string/[]byte 转换默认有拷贝,编译器对特定模式有优化
- 子字符串持有大字符串引用是内存泄漏的常见原因
参考资料
- Go Runtime Source: slice.go — slice 扩容实现
- Go Spec: Slice types — 语言规范
- Go Blog: Strings, bytes, runes and characters — 官方字符串详解
- Go Blog: Arrays, slices and strings — 官方 slice 入门
- internal/stringslice/slice.go — 编译器字符串优化
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






