news 2026/7/5 2:30:48

Go 数据结构 string 深度剖析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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 认为字符串内容是不会被修改的,所以会把字符串分配到只读内存区域。这样设计有几个关键原因:

  1. 线程安全:不可变意味着任意多个 goroutine 并发读取同一字符串时无需加锁
  2. 哈希稳定:string 作为 map 的 key 时,其哈希值不会改变,保证了 map 的正确性
  3. 子串共享内存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

分析:

  1. +Sprintf直接崩掉
    • BenchmarkPlus999 allocs/op说明 1000 次循环里,几乎每拼接一次都在堆上申请一次内存
    • BenchmarkSprintf1999 allocs/op翻倍了,因为除了拼接,还要承担格式化参数逃逸到堆上的额外分配,耗时最长(375us)。
  2. StringsJoin内存控制无敌
    • 1 allocs/op证明了它的一次性预分配。无论拼接多少,只申请一次。
  3. StringsBuilder相比Buffer的优势
    • Builder耗时(3072 ns)只有Buffer(7444 ns)的一半。这就是最后一步零拷贝省下来的 CPU 开销。
  4. 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 次底层同样是[]byteString()时利用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(反射)动态解析占位符,参数会发生隐式转换并逃逸到堆上,伴随大量高频分配。复杂的格式化输出 / 日志。性能极差,纯粹为了代码可读性服务,高频或循环拼接中绝对不能用。
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/7/5 2:27:51

Docker--Docker Swarm集群

Docker Swarm 是docker原生集群管理系统&#xff0c;它将一个Docker主机池变成了一个虚拟主机&#xff0c;只需要使用简单的API就可以实现与Docker集群的通信。从Docker 1.12.0开始&#xff0c;Docker Swarm就内置于Docker引擎中了&#xff0c;不需要单独安装配置。节点架构swa…

作者头像 李华
网站建设 2026/7/5 2:26:37

Deepin Boot Maker实战指南:跨平台启动盘制作高效方案深度解析

Deepin Boot Maker实战指南&#xff1a;跨平台启动盘制作高效方案深度解析 【免费下载链接】deepin-boot-maker 项目地址: https://gitcode.com/gh_mirrors/de/deepin-boot-maker 项目定位与价值主张 Deepin Boot Maker作为一款开源启动盘制作工具&#xff0c;其核心价…

作者头像 李华
网站建设 2026/7/5 2:24:40

苏州本地AI流量破局!一网推GEO苏州本地服务中心年度收录破8万

当下AI搜索成为企业获客核心赛道,传统竞价推广成本高、流量泛化痛点突出,苏州本地制造、工贸企业亟需精准全域AI布局方案。近日,一网推GEO苏州本地服务中心对外披露苏州英瑞可真实运营数据,以量化成效印证本地化GEO优化技术实力,为苏州及周边城市商家提供可复制的长效获客范本。…

作者头像 李华
网站建设 2026/7/5 2:20:24

冰河木马 v8.4 手动清除实战:3步删除注册表项与恢复文件关联

冰河木马 v8.4 手动清除实战&#xff1a;3步删除注册表项与恢复文件关联当系统管理员发现某台办公电脑频繁出现异常网络连接和CPU占用飙升时&#xff0c;经验丰富的技术人员往往会立即联想到木马感染的可能性。冰河木马作为国内最早出现的远程控制程序之一&#xff0c;其v8.4版…

作者头像 李华
网站建设 2026/7/5 2:19:49

NS-Emu-Tools 技术架构深度解析:现代模拟器管理的工程化实践

NS-Emu-Tools 技术架构深度解析&#xff1a;现代模拟器管理的工程化实践 【免费下载链接】ns-emu-tools 一个用于安装/更新 NS 模拟器的工具 项目地址: https://gitcode.com/gh_mirrors/ns/ns-emu-tools 在 Nintendo Switch 模拟器生态快速演进的背景下&#xff0c;管理…

作者头像 李华