string原理¶
string是什么¶
在我们编程过程中,字符串可以说是我们使用的最多的一个数据结构了,凡是涉及到文本处理的地方,我们都会用到字符串。在go语言中,字符串其实就是一串由UTF-8编码的字符序列。
接下来我们看一下官方库对string的一个描述。
| Go | |
|---|---|
text 的 string 变量,点击 string 跳转到其类型定义的地方,可从源代码看到对 string 的描述,源代码的位置在 src/builtin/builtin.go,描述如下:
| Go | |
|---|---|
- 字符串是所有
8bit字节的集合,但不一定是UTF-8编码的文本 - 字符串可以为
empty,但不能为nil,empty字符串就是一个没有任何字符的空串"" - 字符串不可以被修改,所以字符串类型的值是不可变的
所以字符串的本质是一串字符数组,每个字符在存储时都对应一个整数,也有可能对应多个整数,具体要看字符串的编码方式。可以看个例子:
| Go | |
|---|---|
运行结果:
可以看到,字符串的位置每个字符对应这个1个整数,这个整数就是字符的UTF-8编码值。
string数据结构¶
在Go语言中,string类型在底层是一个结构体,这个结构体在src/runtime/string.go文件中定义,如下:
stringStruct 包含两个字段,str类型为unsafe.Pointer,还有一个int类型的len字段。
str指向字符串的首地址len表示字符串的长度
定义一个 word 字符串,并打印其数据结构:
| Go | |
|---|---|
在本例中,len 的长度为5,表示 word 这个字符串占用的字节数,每个字节的值如图中所示。这里需要注意,len 字段存储的是实际的字节数,而不是字符数,所以对于非单字节编码的字符,其结果可能多于字符个数。
我们知道了在 runtime 里 string 的定义,但是我们平常写代码似乎并没有用到 stringStruct 结构,它是在什么地方被用到呢?
其实 stringStruct是字符串在运行时状态下的表现,当我们创建一个 string 的时候,可以理解为有两步:
- 根据给定的字符创建出
stringStruct结构 - 将
stringStruct结构转化为string类型
通过观察字符串的结构定义我们可以发现,其定义中并没有一个表示容量(Cap)的字段,所以意味着字符串类型并不能被扩容,字符换上的写操作包括拼接,追加等等都是通过拷贝来实现的。
string与[]byte的互相转换¶
前面我们说了,string是只读的,不可以被改变,但是我们在编码过程中,进行重新赋值也是很正常的,既然可以重新赋值,为什么说不能被修改呢,这不是互相矛盾吗?
这里要弄弄清楚一个概念,字符串修改并不等于重新赋值。我们在开发中所使用的,其实是对字符串的重新赋值,而不是修改。
示例:
| Go | |
|---|---|
运行结果:
| Go | |
|---|---|
string是不可修改的。这样一分析,那么可不可以将字符串转化为字节数组,然后通过下标修改字节数组,再转化回字符串呢,答案是可行的。
相互转化的语法如下例所示:
| Go | |
|---|---|
运行结果:
| Go | |
|---|---|
Hello变成了HAllo,好像达到了我们的目的。这里需要注意,虽然这种方式看似可行,修改了字符串Hello,但其实并不是我们所见的这样。最终得到的只是ss字符串的一个拷贝,源字符串并没有变化。
string与[]byte的转化原理¶
string与[]byte的转化其实会发生一次内存拷贝,并申请一块新的切片内存空间。
byte切片转化为string,大致过程分为:
- 新申请切片内存空间,构建内存地址为
addr,长度为len; - 将原切片中数据拷贝到新申请的
string中指针指向的内存空间; - 构建
string对象,指针地址为addr,len字段赋值为len。
string转化为byte数组同样简单:
- 新申请切片内存空间;
- 将
string中指针执行内存区域的内容拷贝到新切片; - 构建切片对象。
[]byte转化为string是否一定会发生内存拷贝¶
很多场景中会用到[]byte转化为string,但是并不是每一次转化,都会像上述过程一样,发生一次内存拷贝。在什么情况下不会发生拷贝呢?
转化为的字符串被用于临时场景,举几个例子:
- 字符串比较:
string(ss) == "Hello" - 字符串拼接:
"Hello" + string(ss) + "world" - 用作查找,比如
map的key,val := map[string(ss)]
这几种情况下,[]byte转化成的字符串并不会被后面程序用到,只是在当前场景下被临时用到,所以并不会拷贝内存,而是直接返回一个 string,这个 string 的指针 (string.str) 指向切片的内存。
字符串声明¶
Go语言中以字面量来声明字符串有两种方式,双引号和反引号:
使用双引号声明的字符串和其他语言中的字符串没有太多的区别,但是这种使用双引号的字符串只能用于单行字符串的初始化,当字符串里使用到一些特殊字符,比如双引号,换行符等等需要用\进行转义。但是,反引号声明的字符串没有这些限制,字符内容即为字符串里的原始内容,所以一般用反引号来声明的比较复杂的字符串,比如json串。
| Go | |
|---|---|
为什么这么设计¶
可能大家都会考虑到,为什么一个普通的字符串要设计这么复杂,还需要使用指针。暂时没找到官方文档的说明。
个人猜想,当遇到一个非常长的字符时,这样做使得string变得非常轻量,可以很方便的进行传递而不用担心内存拷贝。虽然在Go 中,不管是引用类型还是值类型参数传递都是值传递。但指针明显比值传递更节省内存。
Go 里常说的引用类型(slice、map、chan、func、interface)只是因为它们的值内部包含指针,这个指针指向实际数据。
当把一个 slice 传给函数时:
- 复制的是 这三字段:
unsafe.Pointer、len、cap - 指针字段的值会被原样复制过去
- 副本指针和原变量指针都指向同一块底层数据
字符串拼接¶
Go语言中字符串是不可改变的,所以我们在对字符串进行拼接的时候会有内存的拷贝,存在性能损耗。常见的你字符串拼接有以下几种方式:
+操作符fmt.Sprintfbytes.Bufferstrings.Builderappendstring.Join
性能测试¶
采用testing包下benchmark测试其性能
运行结果:
可以看到,采用sprintf拼接字符串性能是最差的,性能最好的方式是string.Builder和string.Join。
所以平时代码中,我们在拼接字符串的时候,最好采用后面几种方式,不要直接采用+或者sprintf,sprintf一般用于字符串的格式化而不用于拼接。
性能原理分析¶
| 方法 | 说明 |
|---|---|
| + | + 拼接 2 个字符串时,会生成一个新的字符串,开辟一段新的内存空间,新空间的大小是原来两个字符串的大小之和,所以没拼接一次买就要开辟一段空间,性能很差 |
| Sprintf | Sprintf 会从临时对象池中获取一个对象,然后格式化操作,最后转化为string,释放对象,实现很复杂,性能也很差 |
| strings.Bulider | 底层存储使用 []byte,转化为字符串时可复用,每次分配内存的时候,支持预分配内存并且自动扩容,所以总体来说,开辟内存的次数就少,性能最好 |
| bytes.Buffer | 底层存储使用 []byte,转化为字符串时不可复用,底层实现和strings.Builder差不多,性能比strings.Builder略差一点,区别是bytes.Buffer转化为字符串时重新申请了一块空间,存放生成的字符串变量,而 strings.Builder 直接将底层的 []byte 转换成了字符串类型返回了回来,性能仅次于strings.Builder |
| append | 直接使用[]byte扩容机制,可复用,支持预分配内存和自动扩容,性能只比+和Sprintf好,但是如果能提前分配好内存的话,性能将会仅次于strings.Bulider |
| string.Join | strings.join的性能约等于strings.builder,在已经字符串slice的时候可以使用,未知时不建议使用,构造切片也是会有性能损耗的 |
最终做一下总结:
性能对比:strings.builder ≈ strings.join > bytes.buffer > append > + > fmt.sprintf
- 如果进行少量的字符串拼接时,直接使用
+操作符是最方便也算是性能最高的,就无需使用strings.builder; - 如果进行大量的字符串拼接时,使用
strings.builder是最佳选择。

![string与[]byte的转化](../assets/string%25E5%258E%259F%25E7%2590%25862-CwEY6E6A.png)
![string与[]byte的转化](../assets/string%25E5%258E%259F%25E7%2590%25863-BJvBuWWM.png)