上週四小夥伴發了Go社區一個帖子下hej8875的回覆,以下:golang
package main import "fmt" func main() { s := []byte("") s1 := append(s, 'a') s2 := append(s, 'b') //fmt.Println(s1, "==========", s2) fmt.Println(string(s1), "==========", string(s2)) } // 出現個讓我理解不了的現象, 註釋時候輸出是 b ========== b // 取消註釋輸出是 [97] ========== [98] a ========== b
這個回覆比原貼有意思,也頗有迷惑性。做者測試了下,確實如此,因而和小夥伴們討論深究下。開始覺得應該挺簡單的,理解後,發現涉及挺多知識點,值得跟你們分享下過程。shell
先拋去註釋的這行代碼//fmt.Println(s1, "==========", s2)
,後面在講。 當輸出 b ========== b
時,已經不符合預期結果a和b了。咱們知道slice內部並不會存儲真實的值,而是對數組片斷的引用,其內部結構是:數組
type slice struct { data uintptr len int cap int }
其中data是指向數組元素的指針,len是指slice要引用數組中的元素數量。cap是指要引用數組中(從data指向開始計算)剩餘的元素數量,這個數量減去len,就是還能向這個slice(數組)添加多少元素,若是超出就會發生數據的複製。slice的示意圖:app
s := make([]byte, 5)// 下圖
s = s[2:4] //會從新生成新的slice,並賦值給s。與底層數組的引用也發生了改變
回到問題上,由此能夠推斷出:s := []byte("")
這行代碼中的s實際引用了一個 byte 的數組。frontend
其capacity 是32,length是 0:函數
s := []byte("") fmt.Println(cap(s), len(s)) //輸出: 32 0
關鍵點在於下面代碼s1 := append(s, 'a')
中的append,並無在原slice修改,固然也沒辦法修改,由於在Go中都是值傳遞的。當把s傳入append函數內時,已經複製出一份s1,而後在s1上追加 a
,s1長度是增長了1,但s長度仍然是0:工具
s := []byte("") fmt.Println(cap(s), len(s)) s1 := append(s, 'a') fmt.Println(cap(s1), len(s1)) // 輸出 // 32 0 // 32 1
因爲s,s1指向同一份數組,因此在s1上進行append a
操做時(底層數組[0]=a),也是s所指向數組的操做,但s自己不會有任何變化。這也是Go中append的寫法都是:post
s = append(s,'a')
append函數會返回s1,須要從新賦值給s。 若是不賦值的話,s自己記錄的數據就滯後了,再次對其append,就會從滯後的數據開始操做。雖然看起是append,實際上確是把上一次append的值給覆蓋了。性能
因此問題的答案是:後append的b,把上次append的a給覆蓋了,因此纔會輸出b b。測試
假設底層數組是arr
,如註釋:
s := []byte("") s1 := append(s, 'a') // 等同於 arr[0] = 'a' s2 := append(s, 'b') // 等同於 arr[0] = 'b' fmt.Println(string(s1), "==========", string(s2)) // 只是把同一份數組打印出來了
老溼,能不能再給力一點?能夠,咱們繼續,先來看個題:
s := []byte{} s1 := append(s, 'a') s2 := append(s, 'b') fmt.Println(string(s1), ",", string(s2)) fmt.Println(cap(s), len(s))
猜猜輸出什麼?
答案是:a , b 和 0 0,符合預期。
上面2.2章節例子中輸出的是:32,0。看來問題關鍵在這裏,二者差異在於一個是默認[]byte{}
,另外個是空字符串轉的[]byte("")
。其長度都是0,比較好理解,但爲何容量是32就不符合預期輸出了?
由於 capacity 是數組還能添加多少的容量,在能知足的狀況,不會從新分配。因此 capacity-length=32,是足夠appenda,b
的。咱們用make來驗證下:
// append 內會從新分配,輸出a,b s := make([]byte, 0, 0) // append 內不會從新分配,輸出b,b,由於容量爲1,足夠append s := make([]byte, 0, 1) s1 := append(s, 'a') s2 := append(s, 'b') fmt.Println(string(s1), ",", string(s2))
從新分配指的是:append 會檢查slice大小,若是容量不夠,會從新建立個更大的slice,並把原數組複製一份出來。在make([]byte,0,0)
這樣狀況下,s容量確定不夠用,因此s1,s2使用的都是各自從s複製出來的數組,結果也天然符合預期a,b了。
測試從新分配後的容量變大,打印s1:
s := make([]byte, 0, 0) s1 := append(s, 'a') fmt.Println(cap(s1), len(s1)) // 輸出 8,1。從新分配後擴大了
那爲何空字符串轉的slice的容量是32?而不是0或者8呢?
只好祭出殺手鐗了,翻源碼。Go官方提供的工具,能夠查到編譯後調用的彙編信息,否則在大片源碼中搜索也很累。
-gcflags
是傳遞參數給Go編譯器,-S -S
是打印彙編調用信息和數據,-S
只打印調用信息。
go run -gcflags '-S -S' main.go
下面是輸出:
0x0000 00000 () TEXT "".main(SB), $264-0 0x003e 00062 () MOVQ AX, (SP) 0x0042 00066 () XORPS X0, X0 0x0045 00069 () MOVUPS X0, 8(SP) 0x004a 00074 () PCDATA $0, $0 0x004a 00074 () CALL runtime.stringtoslicebyte(SB) 0x004f 00079 () MOVQ 32(SP), AX b , b
Go使用的是plan9彙編語法,雖然總體有些很差理解,但也能看出咱們須要的關鍵點:
CALL runtime.stringtoslicebyte(SB)
定位源碼到src\runtime\string.go
:
從stringtoslicebyte
函數中能夠看出容量32的源頭,見註釋:
const tmpStringBufSize = 32 type tmpBuf [tmpStringBufSize]byte func stringtoslicebyte(buf *tmpBuf, s string) []byte { var b []byte if buf != nil && len(s) <= len(buf) { *buf = tmpBuf{} // tmpBuf的默認容量是32 b = buf[:len(s)] // 建立個容量爲32,長度爲0的新slice,賦值給b。 } else { b = rawbyteslice(len(s)) } copy(b, s) // s是空字符串,複製過去也是長度0 return b }
那爲何不是走else中rawbyteslice
函數?
func rawbyteslice(size int) (b []byte) { cap := roundupsize(uintptr(size)) p := mallocgc(cap, nil, false) if cap != uintptr(size) { memclrNoHeapPointers(add(p, uintptr(size)), cap-uintptr(size)) } *(*slice)(unsafe.Pointer(&b)) = slice{p, size, int(cap)} return }
若是走else的話,容量就不是32了。假如走的話,也不影響得出的結論(覆蓋),能夠測試下:
s := []byte(strings.Repeat("c", 33)) s1 := append(s, 'a') s2 := append(s, 'b') fmt.Println(string(s1), ",", string(s2)) // cccccccccccccccccccccccccccccccccb , cccccccccccccccccccccccccccccccccb
老溼,能不能再給力一點?何時該走else?老溼你說了大半天,坑還沒填,爲啥加上註釋就符合預期輸出a,b
? 還有加上註釋爲啥連容量都變了?
s := []byte("") fmt.Println(cap(s), len(s)) s1 := append(s, 'a') s2 := append(s, 'b') fmt.Println(s1, ",", s2) fmt.Println(string(s1), ",", string(s2)) //輸出 // 0 0 // [97] ========== [98] // a , b
若是用逃逸分析來解釋的話,就比較好理解了,先看看什麼是逃逸分析。
若是一個函數或子程序內有局部對象,返回時返回該對象的指針,那這個指針可能在任何其餘地方會被引用,就能夠說該指針就成功「逃逸」了 。 而逃逸分析(escape analysis)就是分析這類指針範圍的方法,這樣作的好處是提升性能:
Go在編譯的時候進行逃逸分析,來決定一個對象放棧上仍是放堆上,不逃逸的對象放棧上,可能逃逸的放堆上 。
取消註釋狀況下:Go編譯程序進行逃逸分析時,檢測到fmt.Println
有引用到s,因此在決定堆上分配s下的數組。在進行string轉[]byte時,若是分配到棧上就會有個默認32的容量,分配堆上則沒有。
用下面命令執行,能夠獲得逃逸信息,這個命令只編譯程序不運行,上面用的go run -gcflags是傳遞參數到編譯器並運行程序。
go tool compile -m main.go
取消註釋fmt.Println(s1, ",", s2)
後 ([]byte)("")會逃逸到堆上:
main.go:23:13: s1 escapes to heap main.go:20:13: ([]byte)("") escapes to heap // 逃逸到堆上 main.go:23:18: "," escapes to heap main.go:23:18: s2 escapes to heap main.go:24:20: string(s1) escapes to heap main.go:24:20: string(s1) escapes to heap main.go:24:26: "," escapes to heap main.go:24:37: string(s2) escapes to heap main.go:24:37: string(s2) escapes to heap main.go:23:13: main ... argument does not escape main.go:24:13: main ... argument does not escape
加上註釋//fmt.Println(s1, ",", s2)
不會逃逸到堆上:
go tool compile -m main.go main.go:24:20: string(s1) escapes to heap main.go:24:20: string(s1) escapes to heap main.go:24:26: "," escapes to heap main.go:24:37: string(s2) escapes to heap main.go:24:37: string(s2) escapes to heap main.go:20:13: main ([]byte)("") does not escape //不逃逸 main.go:24:13: main ... argument does not escape
接着繼續定位調用stringtoslicebyte
的地方,在src\cmd\compile\internal\gc\walk.go
文件。 爲了便於理解,下面代碼進行了彙總:
const ( EscUnknown = iota EscNone // 結果或參數不逃逸堆上. ) case OSTRARRAYBYTE: a := nodnil() //默認數組爲空 if n.Esc == EscNone { // 在棧上爲slice建立臨時數組 t := types.NewArray(types.Types[TUINT8], tmpstringbufsize) a = nod(OADDR, temp(t), nil) } n = mkcall("stringtoslicebyte", n.Type, init, a, conv(n.Left, types.Types[TSTRING]))
不逃逸狀況下會分配個32字節的數組 t
。逃逸狀況下不分配,數組設置爲 nil,因此s的容量是0。接着從s上append a,b到s1,s2,其必然會發生複製,因此不會發生覆蓋前值,也符合預期結果a,b 。再看stringtoslicebyte
就很清晰了。
func stringtoslicebyte(buf *tmpBuf, s string) []byte { var b []byte if buf != nil && len(s) <= len(buf) { *buf = tmpBuf{} b = buf[:len(s)] } else { b = rawbyteslice(len(s)) } copy(b, s) return b }
不逃逸狀況下默認32。那逃逸狀況下分配策略是?
s := []byte("a") fmt.Println(cap(s)) s1 := append(s, 'a') s2 := append(s, 'b') fmt.Print(s1, s2)
若是是空字符串它的輸出:0。」a「字符串時輸出:8。
大小取決於src\runtime\size.go
中的roundupsize 函數和 class_to_size 變量。
這些增長大小的變化,是由 src\runtime\mksizeclasses.go
生成的。
老溼,能不能再給力一點? 老溼你講的全是錯誤的,我跑的結果和你是反的。對,你沒錯,做者也沒錯,畢竟咱們在用Go寫程序,若是Go底層發生變化了,確定結果不同。做者在調研過程當中,發現另外博客獲得的stringtoslicebyte
源碼是:
func stringtoslicebyte(s String) (b Slice) { b.array = runtime·mallocgc(s.len, 0, FlagNoScan|FlagNoZero); b.len = s.len; b.cap = s.len; runtime·memmove(b.array, s.str, s.len); }
上面版本的源碼,獲得的結果,也是符合預期的,由於不會默認分配32字節的數組。
繼續翻舊版代碼,到1.3.2版是這樣:
func stringtoslicebyte(s String) (b Slice) { uintptr cap; cap = runtime·roundupsize(s.len); b.array = runtime·mallocgc(cap, 0, FlagNoScan|FlagNoZero); b.len = s.len; b.cap = cap; runtime·memmove(b.array, s.str, s.len); if(cap != b.len) runtime·memclr(b.array+b.len, cap-b.len); }
1.6.4版:
func stringtoslicebyte(buf *tmpBuf, s string) []byte { var b []byte if buf != nil && len(s) <= len(buf) { b = buf[:len(s):len(s)] } else { b = rawbyteslice(len(s)) } copy(b, s) return b }
更古老的:
struct __go_open_array __go_string_to_byte_array (String str) { uintptr cap; unsigned char *data; struct __go_open_array ret; cap = runtime_roundupsize (str.len); data = (unsigned char *) runtime_mallocgc (cap, 0, FlagNoScan | FlagNoZero); __builtin_memcpy (data, str.str, str.len); if (cap != (uintptr) str.len) __builtin_memset (data + str.len, 0, cap - (uintptr) str.len); ret.__values = (void *) data; ret.__count = str.len; ret.__capacity = str.len; return ret; }
做者在1.6.4版本上測試,獲得的結果確實是反的,註釋了反而獲得預期結果 a, b。 本文中使用的是1.10.2
老溼,能不能再給力一點?🐶,再繼續一天時間都沒了。
總結下:
fmt.Println
引用了s,逃逸分析時發現須要逃逸而且是空字符串,因此分配了空數組。2次append都是操做各自從新分配後的新slice,因此輸出a,b。注意:
gc
是Go compiler
的意思,而不是Garbage Collection
,gcflags
中的gc
也是一樣意思。[]byte("string")
當成只讀的來用,否則就容易出現難排查的bug。原帖是:https://gocn.io/question/1852
https://go-review.googlesource.com/c/gofrontend/+/30827
http://golang-examples.tumblr.com/post/86403044869/conversion-between-byte-and-string-dont-share