引言

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

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

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

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

内容介绍

本文将介绍如下内容

字符串数据结构

字符串中的数据结构如下

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

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

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

我们把字符串分为两种

a:="hello"b:=a+"world"

编译期即可确定的字符串

a := "hello world"

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

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

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

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

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

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

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

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

我们得到了如下结果

0xc000050260
0xc000050270
0x595700
0x595700

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

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

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

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

go tool compile -m main.go

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

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

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

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

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

func concatstrings(buf *tmpBuf, a []string) string

分配到栈上还是堆上

concatstrings

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

a

concatstrings函数执行流程如下

acountlfunc rawstringtmp(buf *tmpBuf, l int) (s string, b []byte)buf!=nilfunc rawstring(size int) (s string, b []byte) rawstringmallocgcacopyabrawstringtmp

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

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

验证

Reception

结果如下

0xc000050260
0xc000050270
0xc00000a0e0
0xc00000a0f0

str

rawstring函数

rawstring
stringStructOf(*stringStruct)(unsafe.Pointer(sp))

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

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

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

[]byte

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

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

[]byte和string的更高效转换

[]bytestringstringtoslicebyteslicebytetostring

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

string->byte[]

byteArr
string[]byte[]byte

[]byte->string

[]bytestrings.Builder

结尾

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