深入string理解Golang是怎样实现的

目录
  • 引言
  • 内容介绍
  • 字符串数据结构
  • 字符串会分配到内存中的哪块区域
    • 编译期即可确定的字符串
    • 如果我们创建两个hello world字符串, 他们会放到同一内存区域吗?
  • 运行时通过+拼接的字符串会放到那块内存中
    • 字面量是否会在编译器合并
    • 当我们用+连接多个字符串时, 会发生什么
    • rawstring函数
  • go中字符串是不可变的吗, 我们如何得到一个可变的字符串
  • []byte和string的更高效转换
  • 结尾

引言

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

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

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

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

内容介绍

本文将介绍如下内容

字符串数据结构

字符串中的数据结构如下

type stringStruct struct {
   str unsafe.Pointer
   len int
}
  • 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中的同一块内存区域

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

//因为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函数执行流程如下

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

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

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

验证

我们可以继续把字符串转换为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中的字符串外在表现确实有些类似.

以上就是深入string理解Golang是怎样实现的的详细内容,更多关于Golang string实现的资料请关注我们其它相关文章!

(0)

相关推荐

  • GO语言基本类型String和Slice,Map操作详解

    目录 本文大纲 1.字符串String String常用操作:获取长度和遍历 字符串的strings包 字符串的strconv包: 2.切片Slice 3.集合Map 本文大纲 本文继续学习GO语言基础知识点. 1.字符串String String是Go语言的基本类型,在初始化后不能修改,Go字符串是一串固定长度的字符连接起来的字符序列,当然它也是一个字节的切片(Slice). import ("fmt") func main() { name := "Hello World

  • Golang底层原理解析String使用实例

    目录 引言 String底层 stringStruct结构 引言 本人因为种种原因(说来听听),放弃大学学的java,走上了golang这条路,本着干一行爱一行的情怀,做开发嘛,不能只会使用这门语言,所以打算开一个底层原理系列,深挖一下,狠狠的掌握一下这门语言 废话不多说,上货 String底层 既然研究底层,那就得全方面覆盖,必须先搞一下基础的东西,那必须直接基本数据类型走起啊, 字符串String的底层我看就很基础 string大家应该都不陌生,go中的string是所有8位字节字符串的集合

  • Go结构体SliceHeader及StringHeader作用详解

    目录 引言 SliceHeader 疑问 坑 StringHeader 0 拷贝转换 总结 引言 在 Go 语言中总是有一些看上去奇奇怪怪的东西,咋一眼一看感觉很熟悉,但又不理解其在 Go 代码中的实际意义,面试官却爱问... 今天要给大家介绍的是 SliceHeader 和 StringHeader 结构体,了解清楚他到底是什么,又有什么用,并且会在最后给大家介绍 0 拷贝转换的内容. 一起愉快地开始吸鱼之路. SliceHeader SliceHeader 如其名,Slice + Heade

  • Golang的strings.Split()踩坑记录

    目录 背景 场景 前置 排查 验证 打印底层信息 追源码 类似情况 总结 背景 工作中,当我们需要对字符串按照某个字符串切分成字符串数组数时,常用到strings.Split() 最近在使用过程中踩到了个坑,后对踩坑原因做了分析,并总结了使用string.Split可能踩到的坑.最后写本篇文章做复盘总结与分享 场景 当时是需要取某个结构体的某个属性,并将其按,切分 整体逻辑类似这样的 type Info struct{ Ids string // Ids: 123,456 } func test

  • 深入string理解Golang是怎样实现的

    目录 引言 内容介绍 字符串数据结构 字符串会分配到内存中的哪块区域 编译期即可确定的字符串 如果我们创建两个hello world字符串, 他们会放到同一内存区域吗? 运行时通过+拼接的字符串会放到那块内存中 字面量是否会在编译器合并 当我们用+连接多个字符串时, 会发生什么 rawstring函数 go中字符串是不可变的吗, 我们如何得到一个可变的字符串 []byte和string的更高效转换 结尾 引言 本身打算先写完sync包的, 但前几天在复习以前笔记的时候突然发现与字符串相关的寥寥无

  • 深入理解Golang的反射reflect示例

    目录 编程语言中反射的概念 interface 和 反射 Golang的反射reflect reflect的基本功能TypeOf和ValueOf 说明 从relfect.Value中获取接口interface的信息 已知原有类型[进行“强制转换”] 说明 未知原有类型[遍历探测其Filed] 说明 通过reflect.Value设置实际变量的值 说明 通过reflect.ValueOf来进行方法的调用 说明 Golang的反射reflect性能 小结 总结 参考链接 编程语言中反射的概念 在计算

  • 一文彻底理解Golang闭包实现原理

    目录 前言 函数一等公民 作用域 实现闭包 闭包扫描 闭包赋值 闭包函数调用 函数式编程 总结 前言 闭包对于一个长期写 Java 的开发者来说估计鲜有耳闻,我在写 Python 和 Go 之前也是没怎么了解,光这名字感觉就有点"神秘莫测",这篇文章的主要目的就是从编译器的角度来分析闭包,彻底搞懂闭包的实现原理. 函数一等公民 一门语言在实现闭包之前首先要具有的特性就是:First class function 函数是第一公民. 简单来说就是函数可以像一个普通的值一样在函数中传递,也能

  • 彻底理解golang中什么是nil

    nil是什么 相信写过Golang的程序员对下面一段代码是非常非常熟悉的了: if err != nil { // do something.... } 当出现不等于nil的时候,说明出现某些错误了,需要我们对这个错误进行一些处理,而如果等于nil说明运行正常.那什么是nil呢?查一下词典可以知道,nil的意思是无,或者是零值.零值,zero value,是不是有点熟悉?在Go语言中,如果你声明了一个变量但是没有对它进行赋值操作,那么这个变量就会有一个类型的默认零值. 这是每种类型对应的零值:

  • 深入理解Golang Channel 的底层结构

    目录 make chan 发送和接收 Goroutine Pause/Resume wait empty channel Golang 使用 Groutine 和 channels 实现了 CSP(Communicating Sequential Processes) 模型,channles在 goroutine 的通信和同步中承担着重要的角色. 在GopherCon 2017 中,Golang 专家 Kavya 深入介绍了 Go Channels 的内部机制,以及运行时调度器和内存管理系统是如

  • 深入理解Golang Channel 的底层结构

    目录 makechan 发送和接收 GoroutinePause/Resume waitemptychannel Golang 使用 Groutine 和 channels 实现了 CSP(Communicating Sequential Processes) 模型,channles在 goroutine 的通信和同步中承担着重要的角色. 在GopherCon 2017 中,Golang 专家 Kavya 深入介绍了 Go Channels 的内部机制,以及运行时调度器和内存管理系统是如何支持

  • 深入理解golang chan的使用

    目录 前言 见真身 结构体 发送数据 接收数据 上手 定义 发送与接收 前言 之前在看golang多线程通信的时候, 看到了go 的管道. 当时就觉得这玩意很神奇, 因为之前接触过的不管是php, java, Python, js, c等等, 都没有这玩意, 第一次见面, 难免勾起我的好奇心. 所以就想着看一看它具体是什么东西. 很明显, 管道是go实现在语言层面的功能, 所以我以为需要去翻他的源码了. 虽然最终没有翻到C的层次, 不过还是受益匪浅. 见真身 结构体 要想知道他是什么东西, 没什

  • 深入理解Golang channel的应用

    目录 前言 整体结构 创建 发送 接收 关闭 前言 channel是用于 goroutine 之间的同步.通信的数据结构 channel 的底层是通过 mutex 来控制并发的,但它为程序员提供了更高一层次的抽象,封装了更多的功能,这样并发编程变得更加容易和安全,得以让程序员把注意力留到业务上去,提升开发效率 channel的用途包括但不限于以下几点: 协程间通信,同步 定时任务:和timer结合 解耦生产方和消费方,实现阻塞队列 控制并发数 本文将介绍channel的底层原理,包括数据结构,c

  • 深入理解Golang make和new的区别及实现原理

    目录 前言 new的使用 底层实现 make的使用 底层实现 总结 前言 在Go语言中,有两个比较雷同的内置函数,分别是new和make方法,二者都可以用来分配内存,那他们有什么区别呢?对于初学者可能会觉得有点迷惑,尤其是在掌握不牢固的时候经常遇到panic,下面我们就从底层来分析一下二者的不同.感兴趣的小伙伴们可以参考借鉴,希望对大家能有所帮助. new的使用 new可以对类型进行内存创建和初始化,其返回值是所创建类型的指针引用,这是与make函数的区别之一.我们通过一个示例代码看下: fun

  • 深入理解golang的基本类型排序与slice排序

    前言 其实golang的排序思路和C和C++有些差别. C默认是对数组进行排序, C++是对一个序列进行排序, Go则更宽泛一些,待排序的可以是任何对象, 虽然很多情况下是一个slice(分片, 类似于数组),或是包含 slice 的一个对象. 排序(接口)的三个要素: 1.待排序元素个数 n : 2.第 i 和第 j 个元素的比较函数 cmp : 3.第 i 和 第 j 个元素的交换 swap : 乍一看条件 3 是多余的, c 和 c++ 都不提供 swap . c 的 qsort 的用法:

随机推荐