Golang String
Little_YangYang

本笔记旨在记录Go语言中的底层内容学习过程,仅代表个人学习记录,阅读请注意鉴别版本及特性。

常量定义

1
2
tmpStringBufSize = 32

Golang String 结构体

1
2
3
4
type stringStruct struct {
str unsafe.Pointer // 指向一个字节序列的指针
len int // 字符串的长度(以字节为单位)
}

Golang的String结构体是由两个成员组成,str是一个unsafe.Pointer,主要指向的是一个字节序列的指针,该指针指向的是字符串序列内存地址的起始字节,该内存区域是只读的,也是Go语言中字符串不可变(immutable)特性的根本原因。

由于Golang使用的是非定长字节码来表示字符串的内容,非直接读取字节数目可以获取到字符串的长度,故此处追加了一个len成员来表示字节序列的长度,用以实现O(1)级别的字符串长度获取len(str),而不需要像C语言一样遍历整个字符串一直到\0为止

1
tmpBuf [tmpStringBufSize]byte

tmpBuf 是栈上临时缓冲区的具体类型,它是一个 32 字节的数组。

String类型的底层内存布局与stringStruct完全一致,运行时通过unsafe包来进行转换,函数如下:

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

该函数接受一个string类型的指针*string,然后通过unsafe.Pointer强制转换为*stringStruct类型的指针。其内存布局是等价的,故允许运行时直接操作字符串的内部指针和长度。

字符串拼接

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// concatstrings 实现了Go语言中的字符串拼接功能 x+y+z+...
// 该操作通过切片a传递参数
// 表示编译器认为拼接结果不会“逃逸”到堆上,如果总长度小于tmpStringBufSize,就会使用这个buf来存储结果。
func concatstrings(buf *tmpBuf, a []string) string {
idx := 0
l := 0
count := 0
for i, x := range a {
n := len(x)
if n == 0 {
continue
}
if l+n < l {
throw("string concatenation too long")
}
l += n
count++
idx = i
}
if count == 0 {
return ""
}

// 如果最终只有一个非空字符串,并且这个字符串本身不在栈上(或者结果允许在栈上),就直接返回这个字符串,避免任何内存分配和拷贝。
if count == 1 && (buf != nil || !stringDataOnStack(a[idx])) {
return a[idx]
}
s, b := rawstringtmp(buf, l)
for _, x := range a {
n := copy(b, x)
b = b[n:]
}
return s
}

执行逻辑

  1. 遍历一次 a,计算出所有非空字符串的总长度 l 和数量 count
  2. 优化1:如果最终只有一个非空字符串,并且这个字符串本身不在栈上(或者结果允许在栈上),就直接返回这个字符串,避免任何内存分配和拷贝。
  3. 调用 rawstringtmp 分配内存。这个函数会优先尝试使用传入的 buf。如果 l 太大,rawstringtmp 会在堆上分配内存。
  4. a 中的所有字符串内容依次拷贝到新分配的内存中。
  5. 返回新生成的字符串。

以下数个concatstring函数是对特殊数量的特化版本,分别用于拼接 2、3、4、5 个字符串。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func concatstring2(buf *tmpBuf, a0, a1 string) string {  
return concatstrings(buf, []string{a0, a1})
}

func concatstring3(buf *tmpBuf, a0, a1, a2 string) string {
return concatstrings(buf, []string{a0, a1, a2})
}

func concatstring4(buf *tmpBuf, a0, a1, a2, a3 string) string {
return concatstrings(buf, []string{a0, a1, a2, a3})
}

func concatstring5(buf *tmpBuf, a0, a1, a2, a3, a4 string) string {
return concatstrings(buf, []string{a0, a1, a2, a3, a4})
}

编译器会智能地选择调用这些函数,而不是通用的 concatstrings。这样做的好处是避免了创建一个 []string 切片以及相关的内存分配和开销,性能更好。如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//  cmd/compile/internal/walk
case typ.IsString():
if x.Esc() == ir.EscNone {
sz := int64(0)
for _, n1 := range x.List {
if n1.Op() == ir.OLITERAL {
sz += int64(len(ir.StringVal(n1)))
}
}

// Don't allocate the buffer if the result won't fit.
if sz < tmpstringbufsize {
// Create temporary buffer for result string on stack.
buf = stackBufAddr(tmpstringbufsize, types.Types[types.TUINT8])
}
}

args = []ir.Node{buf}
fnsmall, fnbig = "concatstring%d", "concatstrings"

concatbytes同理,但返回值是[]byte切片而不是string,后续需要使用字节切片时可以减少一次string到[]byte的转换。其同样拥有类似concatstrings的2,3,4,5特化版本。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
func concatbytes(buf *tmpBuf, a []string) []byte {  
l := 0
for _, x := range a {
n := len(x)
if l+n < l {
throw("string concatenation too long")
}
l += n
}
if l == 0 {
// This is to match the return type of the non-optimized concatenation.
return []byte{}
}

var b []byte
if buf != nil && l <= len(buf) {
*buf = tmpBuf{}
b = buf[:l]
} else {
b = rawbyteslice(l)
}
offset := 0
for _, x := range a {
copy(b[offset:], x)
offset += len(x)
}

return b
}

类型转换

下列函数实现了 string, []byte, []rune 之间的转换。

  • func slicebytetostring(buf *tmpBuf, ptr *byte, n int) string

string(byteSlice) 语法的底层实现。它将一个字节切片转换为一个字符串,会拷贝数据,分配一块新的内存,并将 ptr 指向的 n 个字节拷贝过去,然后用这块新内存生成一个字符串。这是保证字符串不可变性的关键。同样会尝试使用 buf 来进行栈上分配。另外,对于长度为 1 的字节切片,它有一个特殊的微优化,会从一个静态表中直接返回字符串,避免分配。

  • func stringtoslicebyte(buf *tmpBuf, s string) []byte

[]byte(s) 语法的底层实现。将字符串转换为字节切片。会拷贝数据,分配新的内存用于 []byte,并将字符串的内容拷贝过去。

  • func slicebytetostringtmp(ptr *byte, n int) string

这是一个不安全的、零拷贝的转换函数,不会拷贝数据。它也用于实现 string(byteSlice),但仅在编译器能确保安全性的特殊场景下使用(例如,map[string(k)],其中 k 是 []byte)。直接使用 ptr 指针和长度 n 来创建一个 string 头信息。这意味着返回的 string 和原始的 []byte 共享同一块底层内存。如果原始的 []byte 在之后被修改,那么这个 “不可变” 的 string 的内容也会随之改变,这违反了 Go 的基本原则。因此,它的使用被严格限制在编译器的内部优化中。

  • func slicerunetostring(buf *tmpBuf, a []rune) string
  • func stringtoslicerune(buf *[tmpStringBufSize]rune, s string) []rune

实现 string[]rune 之间的转换。因为 rune (本质是 int32) 和 byte 之间需要进行 UTF-8 编码和解码,所以这两个转换比 []byte 的转换要复杂。slicerunetostring:先遍历一次 []rune 计算出编码成 UTF-8 后需要的总字节数,分配内存,然后再遍历一次进行编码和拷贝,而stringtoslicerune:先遍历一次字符串计算出 rune 的数量,分配内存,然后再遍历一次进行解码并填充 []rune 切片。

底层内存操作和辅助函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
func rawstring(size int) (s string, b []byte) {  
p := mallocgc(uintptr(size), nil, false)
return unsafe.String((*byte)(p), size), unsafe.Slice((*byte)(p), size)
}

func rawbyteslice(size int) (b []byte) {
cap := roundupsize(uintptr(size), true)
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
}

func rawruneslice(size int) (b []rune) {
if uintptr(size) > maxAlloc/4 {
throw("out of memory")
}
mem := roundupsize(uintptr(size)*4, true)
p := mallocgc(mem, nil, false)
if mem != uintptr(size)*4 {
memclrNoHeapPointers(add(p, uintptr(size)*4), mem-uintptr(size)*4)
}

*(*slice)(unsafe.Pointer(&b)) = slice{p, size, int(mem / 4)}
return
}

rawstring函数在堆上分配一块指定大小的内存,并同时返回一个指向这块内存的 string 和一个 []byte。通常会通过返回的 []byte 来填充内容,然后丢弃掉b使用s

rawbyteslice函数直接在堆上分配指定容量的 []byte[]rune 切片。

rawruneslice分配一个非0值rune切片。