Go 数据结构 string 深度剖析

发布时间:2026/7/5 2:31:06

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(fmtunicode/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 会自动按runeUnicode 码点解码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.Sprintfbytes.Bufferstrings.Builderappendstring.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, ) // 内部提前计算总长度并预分配内存适合已知切片拼接 } }输出[vectubuntu-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只有Buffer7444 ns的一半。这就是最后一步零拷贝省下来的 CPU 开销。Append速度最快的原因2490 ns/op拿了第一这是因为内置的append有运行时runtime专门的汇编级别优化且切片扩容策略和底层容量对齐极度灵敏。但看内存7416 B/op能发现它最后强转 string 多拷贝了一次所以内存占用比 Builder 略大。一个值得注意的细节1000 次循环却只产生了 10~11 次内存分配这是因为[]byte的扩容是指数级增长的 —— 容量小于 1024 时每次翻倍超过后每次增加 25%。所以实际扩容次数远小于循环次数。总结拼接方式耗时 (ns/op)内存分配次数 (allocs/op)底层核心原理适用场景与局限BenchmarkAppend2490 ns11 次手动维护[]byte切片利用 runtime 内置的append进行快速扩容。最后string(buf)触发一次全量内存拷贝。偏底层字节处理。当后续还需要对字节切片进行微调、或是纯字节流操作时适用。BenchmarkStringsBuilder3072 ns10 次底层同样是[]byte。String()时利用unsafe.Pointer直接共享底层数组指针零拷贝返回。若提前知道总长度调用Grow(n)预分配可进一步减少扩容次数。绝大多数动态/循环拼接的首选。不知道最终长度需要不断往里塞字符串的通用高频场景。BenchmarkBytesBuffer7444 ns7 次经典的字节缓冲区。最后buf.String()会重新申请一块新内存把所有字节拷贝过去变成不可变 string。I/O 混合场景。多用于既要拼接字符串又要和io.Reader/Writer如网络、文件做交互的地方。BenchmarkStringsJoin8968 ns1 次 1. 遍历计算所有单项的精确总长度2. 一次性make足额空间3. 拷贝数据并零拷贝转为 string。已有切片数据、或可预知长度。数据原本就在[]string里或者能提前算好长度用它内存最干净只有 1 次分配。BenchmarkPlus288534 ns999 次每次都在堆上开辟新空间把老 string 和新短串拷贝过去。循环中会导致复杂度退化为O(N2)O(N^2)O(N2)。2-3 个已知串简单拼接。禁止在循环体内使用。单行a b c编译器会优化效率很高。BenchmarkSprintf375520 ns1999 次内部依赖reflect反射动态解析占位符参数会发生隐式转换并逃逸到堆上伴随大量高频分配。复杂的格式化输出 / 日志。性能极差纯粹为了代码可读性服务高频或循环拼接中绝对不能用。

相关新闻