Go 数据结构 string 深度剖析
什么是 string
在src/builtin/builtin.go中这样定义:
// string is the set of all strings of 8-bit bytes, conventionally but not // necessarily representing UTF-8-encoded text. A string may be empty, but // not nil. Values of string type are immutable. type string string- 字符串是所有 8bit 字节的集合,但不一定是 UTF-8 编码的文本
- 字符串可以为空,但是不能为
nil - 字符串类型的值是不可变的
本质是一个字符数组,每个字符在存储时都对应一个整数,也可能对应多个整数
对于 C 语言的 string,每个字符串结尾必须加\0,表示这个字符串结束了
Go 不是这样设计,Go 使用一个 len (int类型)存这个字符串的总字节数
在src/runtime/string.go文件中,对 string 结构体进行了定义:
type stringStruct struct { str unsafe.Pointer len int }- str 指针指向字符串首地址
- len 表示字符串的长度
注意:
stringStruct是 runtime 内部使用的结构体,用户代码无法直接访问
len 表示的是这个字符串占用的字节数
一个常见误区是以为len返回的是字符个数,实际上它返回的是底层占用的字节数。对于中文字符(UTF-8 编码下每个中文字符占 3 个字节),差异非常明显:
packagemainimport("fmt""unicode/utf8")funcmain(){s:="你好"fmt.Println(len(s))// 6(字节数),不是 2fmt.Println(utf8.RuneCountInString(s))// 2(字符数)}要获取实际的字符数量,需要使用utf8.RuneCountInString。
看代码:
package main import "fmt" func main() { word := "Hello, World" for _, v := range word { fmt.Printf("%d\n", v) } }输出:
for range遍历字符串时,Go 会自动按rune(Unicode 码点)解码,v是解码后的 rune 值(int32),索引i是当前 rune 在字符串中的字节偏移量。相比之下,普通for i := 0; i < len(s); i++是逐字节遍历,遇到中文会得到乱码的单个字节。
以下是底层原理图:
值得注意的是,Go 认为字符串内容是不会被修改的,所以会把字符串分配到只读内存区域。这样设计有几个关键原因:
- 线程安全:不可变意味着任意多个 goroutine 并发读取同一字符串时无需加锁
- 哈希稳定:string 作为 map 的 key 时,其哈希值不会改变,保证了 map 的正确性
- 子串共享内存:
s[1:3]这种取子串的操作是 O(1) 的,新字符串直接复用原串的底层内存,无需拷贝
字符串变量可以指向同一块底层内存,共享底层内容,如下图所示:
正因为是共享底层内存的,如果允许通过 s1 修改内容,s2 也会随之变化,这样的风险无法预知,所以 Go 从根本上禁止了这种操作
如果非要修改,可以给变量赋新的值,让其指针指向新的内存空间:
以上是 string 的一些基本性质
string 和 []byte的转换
还有种方式,将字符串强转为切片,通过索引修改切片,再转换回字符串:
package main import "fmt" func main() { s := "Hello" strByte := []byte(s) strByte[0] = 'h' fmt.Println(string(strByte)) }输出:
hello需要注意的是:源字符串并没有发生变化,我们得到的只是 s 字符串的一个拷贝
转化原理
string 和 []byte 的转化会发生一次拷贝,申请一块新的切片空间
byte 切片转为 string 的过程:
- 新申请切片内存空间,构建内存地址为 addr, 长度为 len
- 构建 string 对象,指针地址为 addr, len 字段赋值为 len
- 将源切片中数据拷贝到新申请的 string 中指针指向的内存空间
string 转为 byte 切片的过程:
- 新申请切片内存空间
- 将 string 中指针指向内存区域的内容拷贝到新切片
字符串拼接
字符串拼接会有内存的拷贝,存在性能损耗,常见有以下方式:
- +操作符
- fmt.Sprintf
- bytes.Buffer
- strings.Builder
- append
- string.Join
使用代码测试一下:
package main import ( "bytes" "fmt" "strings" "testing" ) // 基础配置:拼接 1000 个短字符串 const ( loopCount = 1000 subStr = "go" ) // 1. + 操作符 func BenchmarkPlus(b *testing.B) { for i := 0; i < b.N; i++ { var s string for j := 0; j < loopCount; j++ { s += subStr // 每次都会产生新字符串,旧字符串变垃圾,高频触发内存拷贝 } } } // 2. fmt.Sprintf func BenchmarkSprintf(b *testing.B) { for i := 0; i < b.N; i++ { var s string for j := 0; j < loopCount; j++ { s = fmt.Sprintf("%s%s", s, subStr) // 内部涉及接口反射和动态分配,最慢 } } } // 3. bytes.Buffer func BenchmarkBytesBuffer(b *testing.B) { for i := 0; i < b.N; i++ { var buf bytes.Buffer for j := 0; j < loopCount; j++ { buf.WriteString(subStr) } _ = buf.String() // 最后一次性转换为 string } } // 4. strings.Builder func BenchmarkStringsBuilder(b *testing.B) { for i := 0; i < b.N; i++ { var builder strings.Builder for j := 0; j < loopCount; j++ { builder.WriteString(subStr) } _ = builder.String() // 底层通过 unsafe 转换,零拷贝指针,性能极高 } } // 5. append (切片转字符串) func BenchmarkAppend(b *testing.B) { for i := 0; i < b.N; i++ { var buf []byte for j := 0; j < loopCount; j++ { buf = append(buf, subStr...) } _ = string(buf) // 这一步依然会发生一次内存拷贝 } } // 6. strings.Join func BenchmarkStringsJoin(b *testing.B) { // 先准备好切片数据 slice := make([]string, loopCount) for i := 0; i < loopCount; i++ { slice[i] = subStr } b.ResetTimer() // 重置时间,扣除准备切片的耗时 for i := 0; i < b.N; i++ { _ = strings.Join(slice, "") // 内部提前计算总长度并预分配内存,适合已知切片拼接 } }输出:
[vect@ubuntu-dev ~/golang/priciple/02-string/demo3]$ gotest-bench=.-benchmemmain_test.go goos: linux goarch: amd64 cpu: Intel(R)Xeon(R)Gold6148CPU @2.40GHz BenchmarkPlus-23909288534ns/op1063873B/op999allocs/op BenchmarkSprintf-23244375520ns/op1080060B/op1999allocs/op BenchmarkBytesBuffer-21586117444ns/op6080B/op7allocs/op BenchmarkStringsBuilder-23398293072ns/op5368B/op10allocs/op BenchmarkAppend-25254472490ns/op7416B/op11allocs/op BenchmarkStringsJoin-21343028968ns/op2048B/op1allocs/op PASS ok command-line-arguments7.393s分析:
+和Sprintf直接崩掉:BenchmarkPlus的999 allocs/op说明 1000 次循环里,几乎每拼接一次都在堆上申请一次内存。BenchmarkSprintf的1999 allocs/op翻倍了,因为除了拼接,还要承担格式化参数逃逸到堆上的额外分配,耗时最长(375us)。
StringsJoin内存控制无敌:1 allocs/op证明了它的一次性预分配。无论拼接多少,只申请一次。
StringsBuilder相比Buffer的优势:Builder耗时(3072 ns)只有Buffer(7444 ns)的一半。这就是最后一步零拷贝省下来的 CPU 开销。
Append速度最快的原因:2490 ns/op拿了第一,这是因为内置的append有运行时(runtime)专门的汇编级别优化,且切片扩容策略和底层容量对齐极度灵敏。但看内存(7416 B/op)能发现,它最后强转 string 多拷贝了一次,所以内存占用比 Builder 略大。- 一个值得注意的细节:1000 次循环却只产生了 10~11 次内存分配,这是因为
[]byte的扩容是指数级增长的 —— 容量小于 1024 时每次翻倍,超过后每次增加 25%。所以实际扩容次数远小于循环次数。
总结:
| 拼接方式 | 耗时 (ns/op) | 内存分配次数 (allocs/op) | 底层核心原理 | 适用场景与局限 |
|---|---|---|---|---|
BenchmarkAppend | 2490 ns | 11 次 | 手动维护[]byte切片,利用 runtime 内置的append进行快速扩容。最后string(buf)触发一次全量内存拷贝。 | 偏底层字节处理。当后续还需要对字节切片进行微调、或是纯字节流操作时适用。 |
BenchmarkStringsBuilder | 3072 ns | 10 次 | 底层同样是[]byte。String()时利用unsafe.Pointer直接共享底层数组指针,零拷贝返回。若提前知道总长度,调用Grow(n)预分配可进一步减少扩容次数。 | 绝大多数动态/循环拼接的首选。不知道最终长度,需要不断往里塞字符串的通用高频场景。 |
BenchmarkBytesBuffer | 7444 ns | 7 次 | 经典的字节缓冲区。最后buf.String()会重新申请一块新内存,把所有字节拷贝过去变成不可变 string。 | I/O 混合场景。多用于既要拼接字符串,又要和io.Reader/Writer(如网络、文件)做交互的地方。 |
BenchmarkStringsJoin | 8968 ns | 1 次 👑 | 1. 遍历计算所有单项的精确总长度;2. 一次性make足额空间;3. 拷贝数据并零拷贝转为 string。 | 已有切片数据、或可预知长度。数据原本就在[]string里,或者能提前算好长度,用它内存最干净(只有 1 次分配)。 |
BenchmarkPlus | 288534 ns | 999 次 | 每次+=都在堆上开辟新空间,把老 string 和新短串拷贝过去。循环中会导致复杂度退化为O(N2)O(N^2)O(N2)。 | 2-3 个已知串简单拼接。禁止在循环体内使用。单行a + b + c编译器会优化,效率很高。 |
BenchmarkSprintf | 375520 ns | 1999 次 | 内部依赖reflect(反射)动态解析占位符,参数会发生隐式转换并逃逸到堆上,伴随大量高频分配。 | 复杂的格式化输出 / 日志。性能极差,纯粹为了代码可读性服务,高频或循环拼接中绝对不能用。 |
