深入string理解Golang是怎樣實現(xiàn)的
引言
本身打算先寫完sync包的, 但前幾天在復習以前筆記的時候突然發(fā)現(xiàn)與字符串相關(guān)的寥寥無幾. 同時作為一個Java選手, 很輕易的想到了幾個問題
- go字符串存儲于內(nèi)存的哪部分區(qū)域?
- 我們初始化兩個"hello world", 這兩個"hello world"會放到同一塊內(nèi)存空間嗎?
- go字符串是動態(tài)的還是靜態(tài)的, 修改他的時候是修改原字符串還是新構(gòu)建一個字符串?
在網(wǎng)上搜索后發(fā)現(xiàn)目前網(wǎng)上對go語言字符串的介紹相關(guān)甚少, 因此我在仔細閱讀源碼后產(chǎn)出了這批文章.
ps: 本文雖由Java中問題引出, 但后續(xù)內(nèi)容和Java無關(guān), 碼字不易, 對你有幫助的話麻煩幫忙點個贊^_^.
內(nèi)容介紹
本文將介紹如下內(nèi)容
字符串數(shù)據(jù)結(jié)構(gòu)
字符串中的數(shù)據(jù)結(jié)構(gòu)如下
type stringStruct struct { str unsafe.Pointer len int }
- str: 大部分情況下指向只讀數(shù)據(jù)段中的一塊內(nèi)存區(qū)域, 少部分情況指向堆/棧, unsafe.Pointer類型, 大小8字節(jié).
- len: 這個字符串的長度, int類型, 在64bit機上大小8字節(jié), 在32bit機上大小4字節(jié).
字符串會分配到內(nèi)存中的哪塊區(qū)域
我們先看下這張圖, 下面內(nèi)容結(jié)合本圖理解
我們把字符串分為兩種
- 編譯期即可確定的字符串, 如
a:="hello"
- 運行時通過+拼接得到的字符串, 如
b:=a+"world"
編譯期即可確定的字符串
如a := "hello world"
我們這里把字符串占用的內(nèi)存分為兩部分
- stringStruct結(jié)構(gòu)體所在的內(nèi)存
- unsafe.Pointer類型的str所在的內(nèi)存
首先是stringStruct, 他是一個16字節(jié)大小的結(jié)構(gòu)體, 因此他和一個普通結(jié)構(gòu)體一樣, 根據(jù)逃逸分析判斷是否可以分配在棧上, 如果不行, 也會根據(jù)分級分配的方式分配到堆中.
而str則是指向了.rodata(只讀數(shù)據(jù)段)中的存放的字符串字面量, 因此字符串字面量是在.rodata中
綜上: string的數(shù)據(jù)結(jié)構(gòu)stringStruct分配在堆/棧中, 而他對應的字符串字面量則是在只讀數(shù)據(jù)段中
如果我們創(chuàng)建兩個hello world字符串, 他們會放到同一內(nèi)存區(qū)域嗎?
根據(jù)上面的分析, 我們可以很容易的得到答案, 他們的數(shù)據(jù)結(jié)構(gòu)stringStruct會分配在堆/棧的不同內(nèi)存空間中, 而unsafe.Pointer則指向.rodata中的同一塊內(nèi)存區(qū)域
我們可以做出如下驗證方式
//因為stringStruct是runtime包下一個不對外暴露的數(shù)據(jù)結(jié)構(gòu), //所以我們新建一個結(jié)構(gòu)相同的數(shù)據(jù)結(jié)構(gòu)來接收string的內(nèi)容 type Reception struct { p unsafe.Pointer len int } func main(){ a := "hello world" b := "hello world" //用新建的Reception接收字符串內(nèi)容, 本質(zhì)上就是把a/b對應的二進制數(shù)據(jù)重新解析為Reception, //而Reception和stringStruct的結(jié)構(gòu)相同, 所以不會出問題. 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) }
我們得到了如下結(jié)果
0xc000050260
0xc000050270
0x595700
0x595700
a,b兩個stringStruct被分配到不同地址, 而他們的str則指向了同一地址.
運行時通過+拼接的字符串會放到那塊內(nèi)存中
字面量是否會在編譯器合并
func main(){ he := "hello" //編譯期"li","hua"未能合并 str1 := he+"li"+"hua" //編譯期被合并為"nihao" str2 := "ni"+"hao" fmt.Println(str1) }
網(wǎng)上有的文章說, 字符串字面量會在編譯期進行合并, 但我在SDK1.18.9下測試的結(jié)果是只有右值為純字面量時, 才會合并.
我們使用go tool compile -m main.go
命令分析, 結(jié)果如下
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版本是否會合并.
不過重要的是, 我們知道右值為純字面量拼接的字符串會在編譯期合并, 等價于右值為純字面量的字符串, 他的分配方式和編譯期可確定的字符串一致.
接下來我們討論右值表達式中存在變量的情況下是如何進行內(nèi)存分配的
當我們用+連接多個字符串時, 會發(fā)生什么
我們先說結(jié)論, 運行時通過+連接多個字符串構(gòu)成新串, 新串的stringStruct結(jié)構(gòu)體和str指向的字面量都會被分配到堆/??臻g中.
在go語言編譯期, 會把字符串的"+"替換為func concatstrings(buf *tmpBuf, a []string) string
函數(shù).
分配到棧上還是堆上
我們看下concatstrings
的兩個參數(shù), 其中buf是一個棧空間的內(nèi)存, go語言會通過所有要拼接的字符串總長度以及逃逸分析確定這個字符串會不會分配到棧上, 如果要分配到棧上, 則會傳來buf參數(shù).
棧上分配和堆上分配的流程幾乎一致, 只不過在內(nèi)存分配的時候會根據(jù)buf!=nil來判斷該存放到哪塊內(nèi)存空間而已, 因此下文中我們統(tǒng)一按堆分配介紹.
而第二個參數(shù)a
中存儲有全部需要通過"+"連接的字符串
concatstrings函數(shù)執(zhí)行流程如下
- 用for range循環(huán)來遍歷整個
a
數(shù)組, 計算其中所有非空串的個數(shù)count
和長度總和l
- 然后調(diào)用
func rawstringtmp(buf *tmpBuf, l int) (s string, b []byte)
函數(shù)來為這個字符串分配內(nèi)存空間, 并返回字符串和其底層的[]byte數(shù)組. 對于該函數(shù)來說, 如果buf!=nil
則使用buf的內(nèi)存空間, 否則調(diào)用func rawstring(size int) (s string, b []byte)
函數(shù),rawstring
函數(shù)會調(diào)用mallocgc
來在堆上分配內(nèi)存空間, 并返回使用該內(nèi)存空間的字符串及其底層切片. - 此時我們已經(jīng)拿到了一個字符串及其底層切片, 因為字符串不可變, 所以go通過修改其底層數(shù)組來為字符串賦值, 他會再次for range循環(huán)
a
數(shù)組, 然后通過copy
函數(shù)來把a
中的字符串拷貝到新串對應的底層數(shù)組b
中, 從而達到修改新串的目的. - 至此, 字符串s的內(nèi)存分配和初始化已經(jīng)全部完成,
rawstringtmp
函數(shù)返回
這樣我們就得到了一個全部內(nèi)存空間都分配在堆/棧中的字符串.
因此, 即使運行時多個通過+連接而成的新串有著相同的字面量, 他們的str也會指向不同的內(nèi)存空間
驗證
我們可以繼續(xù)把字符串轉(zhuǎn)換為Reception
來看看他的str執(zhí)行的地址
//因為stringStruct是runtime包下一個不對外暴露的數(shù)據(jù)結(jié)構(gòu), //所以我們新建一個結(jié)構(gòu)相同的數(shù)據(jù)結(jié)構(gòu)來接收string的內(nèi)容 type Reception struct { p unsafe.Pointer len int } func main(){ h := "hello" a := h+" world" b := h+" world" //用新建的Reception接收字符串內(nèi)容, 本質(zhì)上就是把a/b對應的二進制數(shù)據(jù)重新解析為Reception, //而Reception和stringStruct的結(jié)構(gòu)相同, 所以不會出問題. 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) }
結(jié)果如下
0xc000050260
0xc000050270
0xc00000a0e0
0xc00000a0f0
a和b字符串的str
字段指向堆中不同的內(nèi)存區(qū)域.
rawstring函數(shù)
rawstring
真的是一個十分有趣的函數(shù), 因此我決定對他進行詳細的分析, 但他相對有點難度, 如果靜下心來讀懂, 定能讓您有所收獲. 我們直接上源碼逐行分析
func rawstring(size int) (s string, b []byte) { //在堆中申請內(nèi)存 p := mallocgc(uintptr(size), nil, false) //把string轉(zhuǎn)換為stringStruct數(shù)據(jù)結(jié)構(gòu) 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
函數(shù)十分簡單, 因為string和stringStruct的結(jié)構(gòu)完全相同, 因此他直接通過把(*stringStruct)(unsafe.Pointer(sp))
來把字符串指針sp轉(zhuǎn)換為stringStruct指針, 然后通過stringStruct指針來獲取stringStruct結(jié)構(gòu)體.
我們可以這樣理解下轉(zhuǎn)換方式.
- sp是一個string類型的指針, 他指向一塊內(nèi)存區(qū)域, 這塊內(nèi)存區(qū)域中全是二進制bit流, 但是我們會安裝string的形式解釋他, 即前8位被解釋成一個指針, 后8位被解釋成一個int類型.
- 我們把sp轉(zhuǎn)換為一個unsafe.Pointer, 此時將只保留起始地址和長度
- 然后我們再把sp轉(zhuǎn)換為stringStruct, 因此會按stringStruct的方式解釋這段二進制bit流, 而因為stringStruct的結(jié)構(gòu)和string一樣, 所以也會把前8位解釋成一個指針, 后8位解釋成一個int類型, 不會出現(xiàn)差錯.
接下來我們按同樣的思路看下*(*slice)(unsafe.Pointer(&b)) = slice{p, size, size}
- 首先獲取到b的地址, 然后把他轉(zhuǎn)換為一個*slice
- 然后通過取地址運算符來獲取slice對應的slice
- 又因為slice本身就是指針類型, 所以我們讓這個slice=slice{p,size,size}的時候只是改變了其指向, 也就等價于讓b改變指向, 使其指向p這塊內(nèi)存空間, 也就是str指向的那塊內(nèi)存空間.
只會我們就可以通過b來修改這塊內(nèi)存空間, 從而間接修改字符串的ne
go中字符串是不可變的嗎, 我們?nèi)绾蔚玫揭粋€可變的字符串
go中字符串在語義中是不可變的, 并且咱們對字符串進行+操作時也是新開辟一塊內(nèi)存空間來存放修改后的字符串, 真的沒有什么辦法改變一個字符串中的數(shù)據(jù)嗎?
回顧下我們之前分析的結(jié)論
- 對于編譯期確定的字符串, 他的str指針指向一個.rodata區(qū)的字面量, 不會被改變.
- 而運行時確定的字符串, 他的str指針指向一個堆棧中的空間, 我們可以讓一個
[]byte
指向其底層內(nèi)存空間從而間接改變其內(nèi)容
對于編譯期確定的字符串, 嘗試修改.rodata區(qū)中的字面量會panic
//嘗試修改.rodata區(qū)中數(shù)據(jù), 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會重新指向堆中內(nèi)存 str += "world" //讓[]byte也指向堆中內(nèi)存 byteArr := *(*[]byte)(unsafe.Pointer(&str)) //修改 byteArr[0] = 'w' fmt.Println(str) }
[]byte和string的更高效轉(zhuǎn)換
一般情況下我們使用的強制類型的方式進行[]byte
和string
的互相轉(zhuǎn)換都會被替換為stringtoslicebyte
和slicebytetostring
函數(shù), 這兩個函數(shù)都會新申請一個內(nèi)存空間, 然后將原本[]byte或string中的數(shù)據(jù)拷貝到新內(nèi)存空間中, 涉及一次內(nèi)存copy.
我們可以采用unsafe.Pointer當作一個中介來進行更高效的類型轉(zhuǎn)換, 事實上, 這個方式咱們之前已多次使用.
string->byte[]
func main(){ str := "hello" //注意下面這一行, 是核心 byteArr := *(*[]byte)(unsafe.Pointer(&str)) fmt.Println(byteArr) }
個人強烈不推薦這種寫法, 因為此時我們對byteArr
的修改將導致超出預期的行為.
且因為stringStruct的數(shù)據(jù)結(jié)構(gòu)中只有unsafe.Pointer和一個int型變量len, 而切片的數(shù)據(jù)結(jié)構(gòu)slice則是有著unsafe.Pointer, int型變量len, 和int型變量cap, 所以我們通過上述方法把一個string
強制轉(zhuǎn)換為一個[]byte
時, 這個[]byte
的cap將是一個完全不可控的值(取決于這部分內(nèi)存中的數(shù)據(jù), 且訪問這塊內(nèi)存本身就是非法的)
[]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
的實現(xiàn)原理之一
//string.Builder的String()函數(shù)本質(zhì)上就是把string.Builder中維護的[]byte轉(zhuǎn)換為string返回 func (b *Builder) String() string { return *(*string)(unsafe.Pointer(&b.buf)) }
結(jié)尾
我相信大家對字符串已經(jīng)有了一個比較不錯的認知了, 如果你之前是一名Java選手, 不要把字符串常量池等概念代入go中, 雖然Java和go中的字符串外在表現(xiàn)確實有些類似.
以上就是深入string理解Golang是怎樣實現(xiàn)的的詳細內(nèi)容,更多關(guān)于Golang string實現(xiàn)的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
如何控制Go編碼JSON數(shù)據(jù)時的行為(問題及解決方案)
今天來聊一下我在Go中對數(shù)據(jù)進行 JSON 編碼時遇到次數(shù)最多的三個問題以及解決方法,本文通過實例代碼給大家介紹的非常詳細,具有一定的參考借鑒價值,需要的朋友參考下吧2020-02-02

Go語言pointer及switch?fallthrough實戰(zhàn)詳解