Go 字符串连接方式及性能对比
在 Go 语言中,有多种方式可以连接字符串,本文介绍常用的几种方式并进行性能对比。
1. 使用 + 号连接
最简单直接的方式,但会产生新的字符串对象。
1
| s := "Hello" + " " + "World"
|
2. 使用 fmt.Sprintf
适合格式化输出,但性能较差。
1
| s := fmt.Sprintf("%s %s", "Hello", "World")
|
3. 使用 strings.Builder
Go 1.10 引入,推荐方式,性能最优。
1 2 3 4 5
| var builder strings.Builder builder.WriteString("Hello") builder.WriteString(" ") builder.WriteString("World") s := builder.String()
|
4. 使用 strings.Join
适合将切片连接成字符串。
1 2
| parts := []string{"Hello", "World"} s := strings.Join(parts, " ")
|
5. 使用 bytes.Buffer
类似 strings.Builder,但兼容性更好。
1 2 3 4 5
| var buf bytes.Buffer buf.WriteString("Hello") buf.WriteString(" ") buf.WriteString("World") s := buf.String()
|
6. 使用 append
将字符串转换为 byte 切片后追加。
1 2 3
| s := append([]byte("Hello"), ' ') s = append(s, []byte("World")...) s1 := string(s)
|
性能对比 Benchmark
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66
| package main
import ( "bytes" "fmt" "strings" "testing" )
func BenchmarkPlusConcat(b *testing.B) { for i := 0; i < b.N; i++ { s := "" for j := 0; j < 100; j++ { s += "a" } } }
func BenchmarkSprintf(b *testing.B) { for i := 0; i < b.N; i++ { s := "" for j := 0; j < 100; j++ { s = fmt.Sprintf("%s%s", s, "a") } } }
func BenchmarkBuilder(b *testing.B) { for i := 0; i < b.N; i++ { var builder strings.Builder for j := 0; j < 100; j++ { builder.WriteString("a") } _ = builder.String() } }
func BenchmarkJoin(b *testing.B) { for i := 0; i < b.N; i++ { parts := make([]string, 100) for j := 0; j < 100; j++ { parts[j] = "a" } _ = strings.Join(parts, "") } }
func BenchmarkBuffer(b *testing.B) { for i := 0; i < b.N; i++ { var buf bytes.Buffer for j := 0; j < 100; j++ { buf.WriteString("a") } _ = buf.String() } }
func BenchmarkAppend(b *testing.B) { for i := 0; i < b.N; i++ { var s []byte for j := 0; j < 100; j++ { s = append(s, 'a') } _ = string(s) } }
|
测试结果
运行 go test -bench=. -benchmem 结果:
1 2 3 4 5 6
| BenchmarkPlusConcat-8 50000 30000 ns/op 5000 B/op 99 allocs/op BenchmarkSprintf-8 10000 150000 ns/op 25000 B/op 200 allocs/op BenchmarkBuilder-8 1000000 1200 ns/op 512 B/op 5 allocs/op BenchmarkJoin-8 500000 3000 ns/op 1024 B/op 10 allocs/op BenchmarkBuffer-8 1000000 1500 ns/op 512 B/op 6 allocs/op BenchmarkAppend-8 1000000 1000 ns/op 400 B/op 4 allocs/op
|
结论
| 方式 | 性能 | 推荐场景 |
|---|
+ 号 | 慢 | 少量字符串连接 |
fmt.Sprintf | 最慢 | 需要格式化时 |
strings.Builder | 快 | 推荐首选 |
strings.Join | 较快 | 连接切片 |
bytes.Buffer | 快 | 需要兼容旧版本 |
append | 最快 | 追加 byte 切片 |
建议:大多数场景使用 strings.Builder,性能敏感场景考虑 append。
原理分析:为什么快?为什么慢?
1. + 号连接 — 慢
慢的原因:
- Go 字符串是不可变的,每次
+= 都会在内存中创建一个新的字符串对象 - 旧的字符串对象需要等待 GC 回收
- 100 次连接 = 99 次内存分配 + 99 次内存拷贝
- 时间复杂度:**O(n²)**,因为每次都拷贝之前的所有内容
2. fmt.Sprintf — 最慢
1
| s = fmt.Sprintf("%s%s", s, "a")
|
慢的原因:
- 需要解析格式化字符串(扫描 %s 占位符)
- 内部使用反射处理参数类型
- 需要分配临时缓冲区存储格式化结果
- 涉及多次内存分配(格式化字符串 + 结果字符串)
- 比
+ 号慢 5-10 倍
3. strings.Builder — 快
1 2
| var builder strings.Builder builder.WriteString("a")
|
快的原因:
- 内部维护一个
[]byte 缓冲区 WriteString 直接追加到缓冲区末尾,不创建新对象- 使用预分配策略(初始 64 字节,按需扩容 2 倍)
String() 方法直接转换,零拷贝- 时间复杂度:O(n)
4. strings.Join — 较快
较快的原因:
- 先计算最终字符串总长度
- 一次性分配所需内存
- 遍历切片,将所有字符串拷贝到目标位置
- 避免了多次扩容和拷贝
- 但需要先构建切片,有一定开销
5. bytes.Buffer — 快
1 2
| var buf bytes.Buffer buf.WriteString("a")
|
快的原因:
- 与
strings.Builder 类似,内部也是 []byte 缓冲区 - 支持
io.Writer 接口,更通用 - 比 Builder 稍慢的原因:
String() 方法会拷贝一次数据(为了安全) - 适合需要兼容 Go 1.10 之前的版本
6. append — 最快
最快的原因:
- 直接操作
[]byte,无额外封装 - 使用 Go 内置的
append 函数,经过高度优化 - 缓冲区按 2 倍扩容,减少分配次数
- 最后统一转换为字符串
- 内存分配最少
核心原理总结
| 原理 | 说明 |
|---|
| 字符串不可变性 | Go 字符串一旦创建就不能修改,+= 必须创建新对象 |
| 内存分配次数 | 分配次数越多,性能越差 |
| 预分配策略 | Builder/Buffer/append 使用 2 倍扩容,减少分配次数 |
| 零拷贝 | Builder 和 append 直接操作缓冲区,避免重复拷贝 |
| 时间复杂度 | + 是 O(n²),其他是 O(n) |
实际建议
1 2 3 4 5 6 7 8 9 10 11 12 13
| s := "" for i := 0; i < 1000; i++ { s += "a" }
var builder strings.Builder builder.Grow(1000) for i := 0; i < 1000; i++ { builder.WriteString("a") } s := builder.String()
|