Go 高手(09):字符串底层——string header、[]byte 转换、strings.Builder
更新时间:2026-09-01。本文是
languages/go/intermediate/高手层第 9 篇,接 map 底层。string 在 Go 里是不可变的字节序列。但它的底层结构很简单——一个指针 + 一个长度。理解 string header 和 []byte 的转换,能帮你写出更高效的字符串处理代码。
本文要回答的问题
- string 的底层结构是什么?为什么说 string 是不可变的?
string([]byte)和[]byte(string)的转换会分配内存吗?怎么零拷贝转换?strings.Builder为什么比+=拼接字符串快?- 字符串拼接有哪些方式?性能从高到低怎么排?
- 字符串的"不可变"有什么好处和代价?
一、string header:底层结构
type StringHeader struct {
Data uintptr // 指向底层字节数组的指针
Len int // 字节长度
}string header(16 字节)
┌──────────────────────┐
│ Data (ptr) │ → 指向底层字节数组
├──────────────────────┤
│ Len │ → 字节数,不是字符数
└──────────────────────┘s := "hello"
// Data → ['h', 'e', 'l', 'l', 'o']
// Len = 5注意:Len 是字节数,不是字符数。对于 UTF-8 编码的中文,一个字符可能占 3 个字节。
二、string 不可变:为什么?
s := "hello"
// s[0] = 'H' // ❌ 编译错误:cannot assign to s[0]string 不可变意味着:
- 不能修改字符串中的某个字符
- 多个 string 可以安全地共享同一个底层字节数组
- 子串
s[1:3]不会拷贝数据,直接共享底层数组
s := "hello world"
sub := s[6:] // "world",不拷贝,共享 Data 指针偏移好处:子串操作 O(1),不分配内存;字符串作为 map key 安全;字符串传递成本低(16 字节)。 代价:想修改字符串必须创建新字符串,有分配拷贝开销。
三、string ↔ []byte 的转换
s := "hello"
b := []byte(s) // string → []byte:分配新内存,拷贝数据
s2 := string(b) // []byte → string:分配新内存,拷贝数据这两个转换都会分配内存 + 拷贝数据,因为:
[]byte是可变的,不能共享 string 的不可变底层数组- 转换后,两边互不影响
性能优化:零拷贝转换(unsafe)
import "unsafe"
// string → []byte(零拷贝)
func StringToBytes(s string) []byte {
sh := (*reflect.StringHeader)(unsafe.Pointer(&s))
bh := reflect.SliceHeader{
Data: sh.Data,
Len: sh.Len,
Cap: sh.Len,
}
return *(*[]byte)(unsafe.Pointer(&bh))
}注意:零拷贝转换后的 []byte 不能修改,否则会破坏 string 的不可变性。只在只读场景下用。
四、字符串拼接的几种方式
// 1. += 操作符(最慢)
s := ""
for i := 0; i < 1000; i++ {
s += "a" // 每次创建新字符串,O(n²)
}
// 2. strings.Join(中等)
s := strings.Join(parts, "")
// 3. strings.Builder(最快,推荐)
var b strings.Builder
for i := 0; i < 1000; i++ {
b.WriteString("a")
}
s := b.String()性能对比(拼接 10000 次):
| 方式 | 耗时 | 分配次数 |
|---|---|---|
+= | 极慢 | 大量 |
strings.Join | 快 | 1 次 |
strings.Builder | 最快 | 最少 |
为什么 strings.Builder 快?
- 内部维护一个
[]byte缓冲区 - 缓冲区不够时自动扩容(和 slice 扩容一样)
- 最后
b.String()返回字符串,零拷贝(直接返回string(b.buf))
预分配更优:
var b strings.Builder
b.Grow(10000) // 预分配 10000 字节,避免扩容
for i := 0; i < 1000; i++ {
b.WriteString("something...")
}五、字符串的读写操作
// 遍历字符串(按字节)
s := "Hello, 世界"
for i := 0; i < len(s); i++ {
fmt.Printf("%x ", s[i]) // 字节
}
// 遍历字符串(按 rune,UTF-8 解码)
for i, r := range s {
fmt.Printf("%d: %c\n", i, r) // i 是字节索引,r 是 rune
}len(s) 返回的是字节数。for range 遍历字符串时,Go 自动做 UTF-8 解码,每次迭代返回一个 rune。
六、常见坑对照
| 坑 | 现象 | 对策 |
|---|---|---|
len(s) 对中文不准 | 中文字符串的 len 比预期大 | 用 utf8.RuneCountInString(s) |
频繁 += 拼接 | 性能极差,大量分配 | 用 strings.Builder |
| 零拷贝转换后修改 | 破坏 string 不可变性 | 只读场景用,要修改必须 copy |
把 string 转 []byte 后修改 | 不会影响原 string | 正常,因为转换时拷贝了 |
for i < len(s) 遍历中文 | 乱码 | 用 for range 自动解码 UTF-8 |
相关与延伸
下一篇:接口底层——iface、eface、动态派发;Go 字符串的 UTF-8 编码,和 C 字符串的
char*+\0终止对比,见 C 字符数组与字符串。
一句话总结
Go 字符串底层:string header 是 16 字节(Data + Len),Data 指向不可变的字节数组;子串操作 O(1) 不拷贝;string ↔ []byte 转换会分配内存拷贝数据,零拷贝转换用 unsafe 但只能读;strings.Builder 是字符串拼接最快的选择,内部用 []byte 缓冲区,最后零拷贝转 string;len(s) 是字节数,遍历中文用 for range。