Golang是怎样实现的: 彻底深入string

993 阅读11分钟

本身打算先写完sync包的, 但前几天在复习以前笔记的时候突然发现与字符串相关的寥寥无几. 同时作为一个Java选手, 很轻易的想到了几个问题

  • go字符串存储于内存的哪部分区域?
  • 我们初始化两个"hello world", 这两个"hello world"会放到同一块内存空间吗?
  • go字符串是动态的还是静态的, 修改他的时候是修改原字符串还是新构建一个字符串?

在网上搜索后发现目前网上对go语言字符串的介绍相关甚少, 因此我在仔细阅读源码后产出了这批文章.

ps: 本文虽由Java中问题引出, 但后续内容和Java无关, 码字不易, 对你有帮助的话麻烦帮忙点个赞^_^.

内容介绍

本文将介绍如下内容

image.png

字符串数据结构

字符串中的数据结构如下

type stringStruct struct {
   str unsafe.Pointer
   len int
}
  • str: 大部分情况下指向只读数据段中的一块内存区域, 少部分情况指向堆/栈, unsafe.Pointer类型, 大小8字节.
  • len: 这个字符串的长度, int类型, 在64bit机上大小8字节, 在32bit机上大小4字节.

字符串会分配到内存中的哪块区域

我们先看下这张图, 下面内容结合本图理解

字符串内存布局.jpg

我们把字符串分为两种

  • 编译期即可确定的字符串, 如a:="hello"
  • 运行时通过+拼接得到的字符串, 如b:=a+"world"

编译期即可确定的字符串

a := "hello world"

image.png 我们这里把字符串占用的内存分为两部分

  • stringStruct结构体所在的内存
  • unsafe.Pointer类型的str所在的内存

首先是stringStruct, 他是一个16字节大小的结构体, 因此他和一个普通结构体一样, 根据逃逸分析判断是否可以分配在栈上, 如果不行, 也会根据分级分配的方式分配到堆中.

而str则是指向了.rodata(只读数据段)中的存放的字符串字面量, 因此字符串字面量是在.rodata中

综上: string的数据结构stringStruct分配在堆/栈中, 而他对应的字符串字面量则是在只读数据段中

如果我们创建两个hello world字符串, 他们会放到同一内存区域吗?

根据上面的分析, 我们可以很容易的得到答案, 他们的数据结构stringStruct会分配在堆/栈的不同内存空间中, 而unsafe.Pointer则指向.rodata中的同一块内存区域

我们可以做出如下验证方式

//因为stringStruct是runtime包下一个不对外暴露的数据结构,
//所以我们新建一个结构相同的数据结构来接收string的内容
type Reception struct {
   p unsafe.Pointer
   len int
}

func main(){
   a := "hello world"
   b := "hello world"
   //用新建的Reception接收字符串内容, 本质上就是把a/b对应的二进制数据重新解析为Reception,
   //而Reception和stringStruct的结构相同, 所以不会出问题.
   rA := *(*Reception)(unsafe.Pointer(&a))
   rB := *(*Reception)(unsafe.Pointer(&b))
   //输出a,b的地址
   fmt.Println(&a)
   fmt.Println(&b)
   //输出stringStruct的str指向的地址
   fmt.Println(rA.p)
   fmt.Println(rB.p)
}

我们得到了如下结果

0xc000050260
0xc000050270
0x595700
0x595700

a,b两个stringStruct被分配到不同地址, 而他们的str则指向了同一地址.

运行时通过+拼接的字符串会放到那块内存中

字面量是否会在编译器合并

func main(){
   he := "hello"
   //编译期"li","hua"未能合并
   str1 := he+"li"+"hua"
   //编译期被合并为"nihao"
   str2 := "ni"+"hao"
   fmt.Println(str1)
}

网上有的文章说, 字符串字面量会在编译期进行合并, 但我在SDK1.18.9下测试的结果是只有右值为纯字面量时, 才会合并.

我们使用go tool compile -m main.go命令分析, 结果如下

main.go:8:13: inlining call to fmt.Println
//如果合并的话, 应该是he+"lihua"
main.go:7:17: he + "li" + "hua" escapes to heap
main.go:8:13: ... argument does not escape
main.go:8:13: str1 escapes to heap

大家可以自己用上述命令分析下自己SDK版本是否会合并.

不过重要的是, 我们知道右值为纯字面量拼接的字符串会在编译期合并, 等价于右值为纯字面量的字符串, 他的分配方式和编译期可确定的字符串一致.

接下来我们讨论右值表达式中存在变量的情况下是如何进行内存分配的

当我们用+连接多个字符串时, 会发生什么

我们先说结论, 运行时通过+连接多个字符串构成新串, 新串的stringStruct结构体和str指向的字面量都会被分配到堆/栈空间中.

在go语言编译期, 会把字符串的"+"替换为func concatstrings(buf *tmpBuf, a []string) string函数.

分配到栈上还是堆上

我们看下concatstrings的两个参数, 其中buf是一个栈空间的内存, go语言会通过所有要拼接的字符串总长度以及逃逸分析确定这个字符串会不会分配到栈上, 如果要分配到栈上, 则会传来buf参数.

栈上分配和堆上分配的流程几乎一致, 只不过在内存分配的时候会根据buf!=nil来判断该存放到哪块内存空间而已, 因此下文中我们统一按堆分配介绍.

而第二个参数a中存储有全部需要通过"+"连接的字符串

concatstrings函数执行流程如下

  1. 用for range循环来遍历整个a数组, 计算其中所有非空串的个数count和长度总和l
  2. 然后调用func rawstringtmp(buf *tmpBuf, l int) (s string, b []byte)函数来为这个字符串分配内存空间, 并返回字符串和其底层的[]byte数组. 对于该函数来说, 如果buf!=nil则使用buf的内存空间, 否则调用func rawstring(size int) (s string, b []byte) 函数, rawstring函数会调用mallocgc来在堆上分配内存空间, 并返回使用该内存空间的字符串及其底层切片.
  3. 此时我们已经拿到了一个字符串及其底层切片, 因为字符串不可变, 所以go通过修改其底层数组来为字符串赋值, 他会再次for range循环a数组, 然后通过copy函数来把a中的字符串拷贝到新串对应的底层数组b中, 从而达到修改新串的目的.
  4. 至此, 字符串s的内存分配和初始化已经全部完成, rawstringtmp函数返回

这样我们就得到了一个全部内存空间都分配在堆/栈中的字符串.

因此, 即使运行时多个通过+连接而成的新串有着相同的字面量, 他们的str也会指向不同的内存空间

image.png

验证

我们可以继续把字符串转换为Reception来看看他的str执行的地址

//因为stringStruct是runtime包下一个不对外暴露的数据结构,
//所以我们新建一个结构相同的数据结构来接收string的内容
type Reception struct {
   p unsafe.Pointer
   len int
}

func main(){
   h := "hello"
   a := h+" world"
   b := h+" world"
   //用新建的Reception接收字符串内容, 本质上就是把a/b对应的二进制数据重新解析为Reception,
   //而Reception和stringStruct的结构相同, 所以不会出问题.
   rA := *(*Reception)(unsafe.Pointer(&a))
   rB := *(*Reception)(unsafe.Pointer(&b))
   //输出a,b的地址
   fmt.Println(&a)
   fmt.Println(&b)
   //输出stringStruct的str指向的地址
   fmt.Println(rA.p)
   fmt.Println(rB.p)
}

结果如下

0xc000050260
0xc000050270
0xc00000a0e0
0xc00000a0f0

a和b字符串的str字段指向堆中不同的内存区域.

rawstring函数

rawstring真的是一个十分有趣的函数, 因此我决定对他进行详细的分析, 但他相对有点难度, 如果静下心来读懂, 定能让您有所收获. 我们直接上源码逐行分析

func rawstring(size int) (s string, b []byte) {
   //在堆中申请内存
   p := mallocgc(uintptr(size), nil, false)
   //把string转换为stringStruct数据结构
   stringStructOf(&s).str = p
   stringStructOf(&s).len = size
   //最重要的部分, 让b重新指向p空间
   *(*slice)(unsafe.Pointer(&b)) = slice{p, size, size}

   return
}

func stringStructOf(sp *string) *stringStruct {
   return (*stringStruct)(unsafe.Pointer(sp))
}

stringStructOf函数十分简单, 因为string和stringStruct的结构完全相同, 因此他直接通过把(*stringStruct)(unsafe.Pointer(sp))来把字符串指针sp转换为stringStruct指针, 然后通过stringStruct指针来获取stringStruct结构体.

我们可以这样理解下转换方式.

  • sp是一个string类型的指针, 他指向一块内存区域, 这块内存区域中全是二进制bit流, 但是我们会安装string的形式解释他, 即前8位被解释成一个指针, 后8位被解释成一个int类型.
  • 我们把sp转换为一个unsafe.Pointer, 此时将只保留起始地址和长度
  • 然后我们再把sp转换为stringStruct, 因此会按stringStruct的方式解释这段二进制bit流, 而因为stringStruct的结构和string一样, 所以也会把前8位解释成一个指针, 后8位解释成一个int类型, 不会出现差错.

接下来我们按同样的思路看下*(*slice)(unsafe.Pointer(&b)) = slice{p, size, size}

  • 首先获取到b的地址, 然后把他转换为一个*slice
  • 然后通过取地址运算符来获取slice对应的slice
  • 又因为slice本身就是指针类型, 所以我们让这个slice=slice{p,size,size}的时候只是改变了其指向, 也就等价于让b改变指向, 使其指向p这块内存空间, 也就是str指向的那块内存空间.

只会我们就可以通过b来修改这块内存空间, 从而间接修改字符串的ne

go中字符串是不可变的吗, 我们如何得到一个可变的字符串

go中字符串在语义中是不可变的, 并且咱们对字符串进行+操作时也是新开辟一块内存空间来存放修改后的字符串, 真的没有什么办法改变一个字符串中的数据吗?

回顾下我们之前分析的结论

  • 对于编译期确定的字符串, 他的str指针指向一个.rodata区的字面量, 不会被改变.
  • 而运行时确定的字符串, 他的str指针指向一个堆栈中的空间, 我们可以让一个[]byte指向其底层内存空间从而间接改变其内容

对于编译期确定的字符串, 尝试修改.rodata区中的字面量会panic

//尝试修改.rodata区中数据, painic
func main(){
   str := "hello world"
   byteArr := *(*[]byte)(unsafe.Pointer(&str))
   byteArr[0] = 'w'
   fmt.Println(str)
}

而对于运行时通过+拼接得到的新串, 修改堆栈中存放的字面量则可以成功

//输出wello world
func main(){
   str := "hello"
   //此时字符串str的unsafe.Pointer指针str会重新指向堆中内存
   str += "world"
   //让[]byte也指向堆中内存
   byteArr := *(*[]byte)(unsafe.Pointer(&str))
   //修改
   byteArr[0] = 'w'
   fmt.Println(str)
}

[]byte和string的更高效转换

一般情况下我们使用的强制类型的方式进行[]bytestring的互相转换都会被替换为stringtoslicebyteslicebytetostring函数, 这两个函数都会新申请一个内存空间, 然后将原本[]byte或string中的数据拷贝到新内存空间中, 涉及一次内存copy.

我们可以采用unsafe.Pointer当作一个中介来进行更高效的类型转换, 事实上, 这个方式咱们之前已多次使用.

string->byte[]

func main(){
   str := "hello"
   //注意下面这一行, 是核心
   byteArr := *(*[]byte)(unsafe.Pointer(&str))
   fmt.Println(byteArr)
}

个人强烈不推荐这种写法, 因为此时我们对byteArr的修改将导致超出预期的行为.

且因为stringStruct的数据结构中只有unsafe.Pointer和一个int型变量len, 而切片的数据结构slice则是有着unsafe.Pointer, int型变量len, 和int型变量cap, 所以我们通过上述方法把一个string强制转换为一个[]byte时, 这个[]byte的cap将是一个完全不可控的值(取决于这部分内存中的数据, 且访问这块内存本身就是非法的)

[]byte->string

func main(){
   //hello
   byteArr := []byte{104,101,108,108,111}
   str := *(*string)(unsafe.Pointer(&byteArr))
   fmt.Println(str)
}

相比起string->[]byte来说, []byte->string相对要安全很多, 我们只需要确保原始的[]byte不会被改变即可, 事实上, 这其实也是strings.Builder的实现原理之一

//string.Builder的String()函数本质上就是把string.Builder中维护的[]byte转换为string返回
func (b *Builder) String() string {
   return *(*string)(unsafe.Pointer(&b.buf))
}

结尾

我相信大家对字符串已经有了一个比较不错的认知了, 如果你之前是一名Java选手, 不要把字符串常量池等概念代入go中, 虽然Java和go中的字符串外在表现确实有些类似.

感谢您的阅读^_^.