string 实现原理
# string 数据结构
string是引用类型
string可以为空(长度为0),但不会是nil;
string对象不可以修改。
src/builtin/builtin.go
type stringStruct struct { str unsafe.Pointer len int }
1
2
3
4
# string 操作
# 1. 字符串的构建
字符串构建过程是先根据字符串构建stringStruct,再转换成string。转换的源码如下:
func gostringnocopy(str *byte) string { // 根据字符串地址构建string ss := stringStruct{str: unsafe.Pointer(str), len: findnull(str)} // 先构造stringStruct s := *(*string)(unsafe.Pointer(&ss)) // 再将stringStruct转换成string return s }
1
2
3
4
5
# 2. []byte转string
code
func GetStringBySlice(s []byte) string { return string(s) }
1
2
3需要注意的是这种转换需要一次内存拷贝
根据切片的长度申请内存空间,假设内存地址为p,切片长度为len(b);
构建string(string.str = p;string.len = len;)
拷贝数据(切片中数据拷贝到新申请的内存空间)
# 3. string转[]byte
code
func GetSliceByString(str string) []byte { return []byte(str) }
1
2
3需要注意的是这种转换需要一次内存拷贝
申请切片内存空间
将string拷贝到切片
# 字符串不允许修改
像C++语言中的string,其本身拥有内存空间,修改string是支持的。但Go的实现中,string不包含内存空间,只有一个内存的指针,这样做的好处是string变得非常轻量,可以很方便的进行传递而不用担心内存拷贝。
因为string通常指向字符串字面量,而字符串字面量存储位置是只读段,而不是堆或栈上,所以才有了string不可修改的约定。
# []byte转string一定会拷贝内存吗?
byte切片转换成string的场景很多,为了性能上的考虑,有时候只是临时需要字符串的场景下,byte切片转换成string时并不会拷贝内存,而是直接返回一个string,这个string的指针(string.str)指向切片的内存。
比如,编译器会识别如下临时场景:
使用m[string(b)]来查找map(map是string为key,临时把切片b转成string);
字符串拼接,如”<” + “string(b)” + “>”;
字符串比较:string(b) == “foo”
因为是临时把byte切片转换成string,也就避免了因byte切片同容改成而导致string引用失败的情况,所以此时可以不必拷贝内存新建一个string。
# string和[]byte取舍
string和[]byte都可以表示字符串,但因数据结构不同,其衍生出来的方法也不同,要根据实际应用场景来选择。
string 擅长的场景:
需要字符串比较的场景;
不需要nil字符串的场景;
[]byte擅长的场景:
修改字符串的场景,尤其是修改粒度为1个字节;
函数返回值,需要用nil表示含义的场景;
需要切片操作的场景;
- 虽然看起来string适用的场景不如[]byte多,但因为string直观,在实际应用中还是大量存在,在偏底层的实现中[]byte使用更多
# string 的高效拼接
速度 6>5>4>3>1>2
使用
+
// + func plusConcat(n int, str string) string { s := "" for i := 0; i < n; i++ { s += str } return s }
1
2
3
4
5
6
7
8使用
fmt.Sprintf
// Sprintf func sprintfConcat(n int, str string) string { s := "" for i := 0; i < n; i++ { s = fmt.Sprintf("%s%s", s, str) } return s }
1
2
3
4
5
6
7
8使用
strings.Builder
// strings.Builder func builderConcat(n int, str string) string { var builder strings.Builder for i := 0; i < n; i++ { builder.WriteString(str) } return builder.String() }
1
2
3
4
5
6
7
8使用
bytes.Buffer
// bytes.Buffer func bufferConcat(n int, s string) string { buf := new(bytes.Buffer) for i := 0; i < n; i++ { buf.WriteString(s) } return buf.String() }
1
2
3
4
5
6
7
8使用
[]byte
//[]byte func byteConcat(n int, str string) string { buf := make([]byte, 0) for i := 0; i < n; i++ { buf = append(buf, str...) } return string(buf) }
1
2
3
4
5
6
7
8带有容量的
[]byte
func preByteConcat(n int, str string) string { buf := make([]byte, 0, n*len(str)) for i := 0; i < n; i++ { buf = append(buf, str...) } return string(buf) }
1
2
3
4
5
6
7
测试用例
package main import "testing" func benchmark(b *testing.B, f func(int, string) string) { var str = randomString(10) for i := 0; i < b.N; i++ { f(10000, str) } } func BenchmarkPlusConcat(b *testing.B) { benchmark(b, plusConcat) } func BenchmarkSprintfConcat(b *testing.B) { benchmark(b, sprintfConcat) } func BenchmarkBuilderConcat(b *testing.B) { benchmark(b, builderConcat) } func BenchmarkBufferConcat(b *testing.B) { benchmark(b, bufferConcat) } func BenchmarkByteConcat(b *testing.B) { benchmark(b, byteConcat) } func BenchmarkPreByteConcat(b *testing.B) { benchmark(b, preByteConcat) }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17基准测试结果: 6>5>4>3>1>2
go test -bench="Concat$" -benchmem .
goos: darwin goarch: arm64 pkg: pic BenchmarkPlusConcat-8 43 28540977 ns/op 530996174 B/op 10009 allocs/op BenchmarkSprintfConcat-8 22 50766222 ns/op 833881447 B/op 37392 allocs/op BenchmarkBuilderConcat-8 17703 71768 ns/op 505841 B/op 24 allocs/op BenchmarkBufferConcat-8 19694 60595 ns/op 423537 B/op 13 allocs/op BenchmarkByteConcat-8 21356 56174 ns/op 612337 B/op 25 allocs/op BenchmarkPreByteConcat-8 36676 31380 ns/op 212992 B/op 2 allocs/op PASS ok pic 10.566s
1
2
3
4
5
6
7
8
9
10
11
12从基准测试的结果来看,使用
+
和fmt.Sprintf
的效率是最低的,和其余的方式相比,性能相差约 1000 倍,而且消耗了超过 1000 倍的内存。当然fmt.Sprintf
通常是用来格式化字符串的,一般不会用来拼接字符串。strings.Builder
、bytes.Buffer
和[]byte
的性能差距不大,而且消耗的内存也十分接近,性能最好且消耗内存最小的是preByteConcat
,这种方式预分配了内存,在字符串拼接的过程中,不需要进行字符串的拷贝,也不需要分配新的内存,因此性能最好,且内存消耗最小。
# 1. 推荐方式: strings.Builder
这是 Go 官方对
strings.Builder
的解释:A Builder is used to efficiently build a string using Write methods. It minimizes memory copying.
string.Builder
也提供了预分配内存的方式Grow
:func builderConcat(n int, str string) string { var builder strings.Builder builder.Grow(n * len(str)) for i := 0; i < n; i++ { builder.WriteString(str) } return builder.String() }
1
2
3
4
5
6
7
8使用了 Grow 优化后的版本的 benchmark 结果如下:
BenchmarkBuilderConcat-8 16855 0.07 ns/op 0.1 MB/op 1 allocs/op BenchmarkPreByteConcat-8 17379 0.07 ms/op 0.2 MB/op 2 allocs/op
1
2与预分配内存的
[]byte
相比,因为省去了[]byte
和字符串(string) 之间的转换,内存分配次数还减少了 1 次,内存消耗减半。
# 2. 原理
# a. string.Builder 和 +
字符串在 Go 语言中是不可变类型,占用内存大小是固定的,当使用
+
拼接 2 个字符串时,生成一个新的字符串,那么就需要开辟一段新的空间,新空间的大小是原来两个字符串的大小之和。拼接第三个字符串时,再开辟一段新空间,新空间大小是三个字符串大小之和,以此类推。假设一个字符串大小为 10 byte,拼接 1w 次,需要申请的内存大小为:10 + 2 * 10 + 3 * 10 + ... + 10000 * 10 byte = 500 MB
1而
strings.Builder
,bytes.Buffer
,包括切片[]byte
的内存是以倍数申请的。例如,初始大小为 0,当第一次写入大小为 10 byte 的字符串时,则会申请大小为 16 byte 的内存(恰好大于 10 byte 的 2 的指数),第二次写入 10 byte 时,内存不够,则申请 32 byte 的内存,第三次写入内存足够,则不申请新的,以此类推。在实际过程中,超过一定大小,比如 2048 byte 后,申请策略上会有些许调整。- 2048 以前按倍数申请,2048 之后,以 640 递增,最后一次递增 24576 到 122880。总共申请的内存大小约
0.52 MB
,约为上一种方式的千分之一。
- 2048 以前按倍数申请,2048 之后,以 640 递增,最后一次递增 24576 到 122880。总共申请的内存大小约
# b. strings.Builder 和 bytes.Buffer
strings.Builder
和bytes.Buffer
底层都是[]byte
数组,但strings.Builder
性能比bytes.Buffer
略快约 10% 。一个比较重要的区别在于,bytes.Buffer
转化为字符串时重新申请了一块空间,存放生成的字符串变量,而strings.Builder
直接将底层的[]byte
转换成了字符串类型返回了回来。- bytes.Buffer
// To build strings more efficiently, see the strings.Builder type. func (b *Buffer) String() string { if b == nil { // Special case, useful in debugging. return "<nil>" } return string(b.buf[b.off:]) }
1
2
3
4
5
6
7
8- strings.Builder
// String returns the accumulated string. func (b *Builder) String() string { return *(*string)(unsafe.Pointer(&b.buf)) }
1
2
3
4