当前位置: 首页 > news >正文

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(反射)动态解析占位符,参数会发生隐式转换并逃逸到堆上,伴随大量高频分配。复杂的格式化输出 / 日志。性能极差,纯粹为了代码可读性服务,高频或循环拼接中绝对不能用。
http://www.cnnetsun.cn/news/3149692.html

相关文章:

  • Docker--Docker Swarm集群
  • Deepin Boot Maker实战指南:跨平台启动盘制作高效方案深度解析
  • 苏州本地AI流量破局!一网推GEO苏州本地服务中心年度收录破8万
  • QA Use:推荐一款AI 原生 E2E 测试平台,自然语言一键跑通用例!
  • 冰河木马 v8.4 手动清除实战:3步删除注册表项与恢复文件关联
  • NS-Emu-Tools 技术架构深度解析:现代模拟器管理的工程化实践
  • 深入浅出CAP理论:从原理到实战,用Go实现一个最终一致性的分布式键值存储
  • 《HarmonyOS技术精讲-Media Library Kit》之实战:构建简易相册应用
  • 网络安全与网络协议知识点汇总 + 选填题库
  • 微信登录 + 微信支付 业务逻辑分步详解
  • 自动扩缩容:3 种策略的适用场景
  • qt的元对象系统(具备反射能力)有哪些部件
  • 把 HLS 字幕玩出花:zwPlayer 如何让 M3U8 视频支持全文搜索、翻译与码率自适应
  • 记录arm64内核调试环境搭建qemu_arm64_linux_01
  • Rust AI 工具配置层级:命令参数、环境变量和配置文件别打架
  • 扒源码 | Cube Sandbox 的微虚机、容器镜像、可写层,是怎么串到一起的
  • 2026年储能船型开关生产商盘点:谁在领跑市场?
  • win11下Multipass修改默认MULTIPASS_STORAGE位置后,持续报错waiting for daemon的问题
  • 5分钟掌握ppInk:Windows屏幕标注终极指南,让远程协作效率翻倍
  • 【Java课程设计/毕业设计】农家乐客房排班运维管理系统的设计与实现 乡村民宿文旅服务智能化管理平台【附源码、数据库、万字文档】
  • 【Java毕业设计】基于前后端分离的民宿农家乐综合管理系统的设计与实现 农家乐客房住宿预约与订单管理系统(源码+文档+远程调试,全bao定制等)
  • 基于单片机人脸识别电子密码锁智能门禁指纹识别语音提醒防盗成品112(设计源文件+万字报告+讲解)(支持资料、图片参考_相关定制)_
  • 手中有机, 心中不慌 (5 只 二手 Android 手机)
  • 干货教程:APK反编译神器安卓修改大师,一步步教你如何美化和修改安卓应用
  • Java计算机毕设之美容会员储值充值积分管理系统的设计与实现 美业技师业绩提成统计管理系统(完整前后端代码+说明文档+LW,调试定制等)
  • LED灯珠颜色亮度工业自动化测量
  • 工业机器人送料机械手设计实战指南
  • 从电商项目课程设计,搞懂 JWT 鉴权和 Redis 缓存到底在解决什么问题
  • 面试官问:“模型一本正经胡说时,logprobs 抓得到吗?“
  • 你往 AI 里装的那些 skill,打开看过一眼吗?