数组
概述
Go 语言提供了数组类型的数据结构。
数组是具有相同唯一类型的一组已编号且长度固定的数据项序列,这种类型可以是任意的原始类型,例如整型、字符串或者自定义类型。
无论是在栈上还是静态存储区,数组在内存中都是一连串的内存空间,我们通过指向数组开头的指针、元素的数量以及元素类型占的空间大小表示数组。
对数组的访问和赋值需要同时依赖编译器和运行时,它的大多数操作在编译期间都会转换成直接读写内存,在中间代码生成期间,编译器还会插入运行时方法 runtime.panicIndex 调用防止发生越界错误。
声明数组
var variable_name [SIZE] variable_type
初始化数组
// 初始化数组中 {} 中的元素个数不能大于 [] 中的数字
var balance = [5]float32{1000.0, 2.0, 3.4, 7.0, 50.0}
// 如果数组长度不确定,可以使用 ... 代替数组的长度,编译器会根据元素个数自行推断数组的长度
balance := [...]float32{1000.0, 2.0, 3.4, 7.0, 50.0}
// 如果设置了数组的长度,我们还可以通过指定下标来初始化元素,未指定的值均为初始值
balance := [5]float32{1:2.0,3:7.0}
数组相关问题
初始化时的区别
在不考虑逃逸分析的情况下,如果数组中元素的个数小于或者等于 4 个,那么所有的变量会直接在栈上初始化,如果数组元素大于 4 个,变量就会在静态存储区初始化然后拷贝到栈上,这些转换后的代码才会继续进入中间代码生成和机器码生成两个阶段,最后生成可以执行的二进制文件。
// 小于或者等于 4 个
var arr [3]int
arr[0] = 1
arr[1] = 2
arr[2] = 3
// 大于 4 个
var arr [5]int
statictmp_0[0] = 1
statictmp_0[1] = 2
statictmp_0[2] = 3
statictmp_0[3] = 4
statictmp_0[4] = 5
逃逸分析
package main
import "fmt"
func newArray() *[4]int {
a := [4]int{1, 2, 3, 4}
return &a
}
func main() {
a := newArray()
fmt.Println(a)
}
go build -gcflags="-m -m" main.go
# command-line-arguments
./main.go:6:6: cannot inline newArray: marked go:noinline
./main.go:11:6: cannot inline main: function too complex: cost 139 exceeds budget 80
./main.go:13:13: inlining call to fmt.Println func(...interface {}) (int, error) { var fmt..autotmp_3 int; fmt..autotmp_3 = <N>; var fmt..autotmp_4 error; fmt..autotmp_4 = <N>; fmt..autotmp_3, fmt..autotmp_4 = fmt.Fprintln(io.Writer(os.Stdout), fmt.a...); return fmt..autotmp_3, fmt..autotmp_4 }
./main.go:7:2: a escapes to heap:
./main.go:7:2: flow: ~r0 = &a:
./main.go:7:2: from &a (address-of) at ./main.go:8:9
./main.go:7:2: from return &a (return) at ./main.go:8:2
./main.go:7:2: moved to heap: a
./main.go:13:13: []interface {} literal does not escape
<autogenerated>:1: .this does not escape
变量 a 发生 escape 的原因是,因为编译器发现在后续代码中存在对此局部变量的引用,因此将变量 a 移动到了 heap 中。
这个问题在 Go doc 的 FAQ 中说得比较清楚:在可能的情况下,编译器会在当前函数的栈中为局部变量分配内存。 一旦当编译器发现后续代码中存在对局部变量的引用,就会将局部变量从栈移动到堆,以此来避免 C/C++ 中常出现的所谓“悬空指针”的现象。另外当局部变量非常大的时候,编译器也会考虑在堆上创建局部变量。所以说,到底是在栈还是在堆上分配内存,并没有一个一成不变的规则,编译器会根据具体的情况做出最优选择。
slice
概述
Go 语言切片是对数组的抽象。
Go 数组的长度不可改变,在特定场景中这样的集合就不太适用,Go 中提供了一种灵活,功能强悍的内置类型切片(动态数组),与数组相比切片的长度是不固定的,可以追加元素,在追加时可能使切片的容量增大。
slice 数据结构
type SliceHeader struct {
Data uintptr // 引用数组指针地址
Len int // 切片的目前使用长度
Cap int // 切片的容量
}
Data 是一片连续的内存空间,这片内存空间可以用于存储切片中的全部元素,数组中的元素只是逻辑上的概念,底层存储其实都是连续的,所以我们可以将切片理解成一片连续的内存空间加上长度与容量的标识。
切片引入了一个抽象层,提供了对数组中部分连续片段的引用,而作为数组的引用,我们可以在运行区间可以修改它的长度和范围。当切片底层的数组长度不足时就会触发扩容,切片指向的数组可能会发生变化,不过在上层看来切片是没有变化的,上层只需要与切片打交道不需要关心数组的变化。
因为数组的内存固定且连续,多数操作都会直接读写内存的特定位置。但是切片是运行时才会确定内容的结构,所有操作还需要依赖 Go 语言的运行时。
定义切片
// 声明一个未指定大小的数组来定义切片
var identifier []type
初始化切片
// 直接初始化切片,[] 表示是切片类型,{1, 2, 3} 初始化值依次是 1, 2, 3,其 cap=len=3
s := []int{1, 2, 3}
// 初始化切片 s,是数组 arr 的引用
// 需要注意的是使用下标初始化切片不会拷贝原数组或者原切片中的数据,它只会创建一个指向原数组的切片结构体,所以修改新切片的数据也会修改原切片
s := arr[:]
s := arr[startIndex:endIndex]
s := arr[startIndex:]
s := arr[:endIndex]
// 通过内置函数 make() 初始化切片 s,[]int 标识为其元素类型为 int 的切片,其中 capacity 为可选参数
s := make([]int, len, cap)
slice 相关问题
len() 和 cap() 函数
切片是可索引的,并且可以由 len() 方法获取长度。切片提供了计算容量的方法 cap() 可以测量切片最长可以达到多少。
nil 切片和空切片指向的地址一样吗?
package main
import (
"fmt"
"reflect"
"unsafe"
)
func main() {
var s1 []int
s2 := make([]int, 0)
s4 := make([]int, 0)
fmt.Printf("s1 pointer:%+v, s2 pointer:%+v, s4 pointer:%+v, \n", *(*reflect.SliceHeader)(unsafe.Pointer(&s1)), *(*reflect.SliceHeader)(unsafe.Pointer(&s2)), *(*reflect.SliceHeader)(unsafe.Pointer(&s4)))
fmt.Printf("%v\n", (*(*reflect.SliceHeader)(unsafe.Pointer(&s1))).Data == (*(*reflect.SliceHeader)(unsafe.Pointer(&s2))).Data)
fmt.Printf("%v\n", (*(*reflect.SliceHeader)(unsafe.Pointer(&s2))).Data == (*(*reflect.SliceHeader)(unsafe.Pointer(&s4))).Data)
}
s1 pointer:{Data:0 Len:0 Cap:0}, s2 pointer:{Data:824634101440 Len:0 Cap:0}, s4 pointer:{Data:824634101440 Len:0 Cap:0},
false
true
nil 空切片引用数组指针地址为 0(无指向任何实际地址),空切片的引用数组指针地址是一个固定值。
字符串转成 byte 数组,会发生内存拷贝吗?
字符串转切片,会产生拷贝。严格来说,只要是发生类型强转都会发生内存拷贝。但是频繁的内存拷贝,听起来对性能不大友好。有没有什么办法可以在字符串转成切片的时候不用发生拷贝呢?
package main
import (
"fmt"
"reflect"
"unsafe"
)
func main() {
a := "aaa"
ssh := *(*reflect.StringHeader)(unsafe.Pointer(&a))
b := *(*[]byte)(unsafe.Pointer(&ssh))
fmt.Printf("%v", b)
}
[97 97 97]%
解释
StringHeader 是字符串在 go 的底层结构。
type StringHeader struct {
Data uintptr
Len int
}
SliceHeader 是切片在 go 的底层结构。
type SliceHeader struct {
Data uintptr
Len int
Cap int
}
那么如果想要在底层转换二者,只需要把 StringHeader 的地址强转成 SliceHeader 就行。那么 go 有个很强的包叫 unsafe。
unsafe.Pointer(&a)方法可以得到变量 a 的地址。(*reflect.StringHeader)(unsafe.Pointer(&a))可以把字符串 a 转成底层结构的形式。(*[]byte)(unsafe.Pointer(&ssh))可以把 ssh 底层结构体转成 byte 的切片的指针。- 再通过
*转为指针指向的实际内容。
向 Go 语言的切片追加元素
切片的追加操作 append 会根据源代码的编写分为两种实现逻辑:
- 在源代码文件中不赋值给原来的切片变量:
append(slice, 1, 2, 3)判断是否需要扩容(创建一个新数组,将原来的元素拷贝过去,由 runtime.growslice 完成),完成后写入新数据。 - 在源代码文件中赋值给原来的切片变量:
slice = append(slice, 1, 2, 3),与不覆盖原来变量情况相同,区别在于内部函数的变量操作,由于是覆盖原来的变量,而不是赋值给新的切片空间,Go 编译器会对此进行优化,不会产生数据拷贝。 当切片的容量不足时,会调用 runtime.growslice 函数为切片扩容,扩容是为切片分配新的内存空间并拷贝原切片中元素的过程。在分配内存空间之前需要先确定新的切片容量,运行时根据切片的当前容量选择不同的策略进行扩容: - 如果期望容量大于当前容量的两倍就会使用期望容量;
- 如果当前切片的长度小于 1024 就会将容量翻倍;
- 如果当前切片的长度大于 1024 就会每次增加 25% 的容量,直到新容量大于期望容量;
var arr []int64
arr = append(arr, 1, 2, 3, 4, 5)
执行上述代码时,会触发 runtime.growslice 函数扩容 arr 切片并传入期望的新容量 5,这时期望分配的内存大小为 40 字节;不过因为切片中的元素大小等于 sys.PtrSize,所以运行时会调用 runtime.roundupsize 向上取整内存的大小到 48 字节,所以新切片的容量为 48 / 8 = 6。
拷贝切片
相比于依次拷贝元素,runtime.memmove 能够提供更好的性能。需要注意的是在遇到大切片扩容或者复制时可能会发生大规模的内存拷贝,一定要减少类似操作避免影响程序的性能。
哈希表
概述
哈希是除了数组之外,最常见的数据结构。几乎所有的语言都会有数组和哈希表两种集合元素,有的语言将数组实现成列表,而有的语言将哈希称作字典或者映射。无论如何命名或者如何实现,数组和哈希是两种设计集合元素的思路,数组用于表示元素的序列,而哈希表示的是键值对之间映射关系。
哈希函数
实现哈希表的关键点在于哈希函数的选择,哈希函数的选择在很大程度上能够决定哈希表的读写性能。在理想情况下,哈希函数应该能够将不同键映射到不同的索引上,这要求哈希函数的输出范围大于输入范围,但是由于键的数量会远远大于映射的范围,所以在实际使用时,这个理想的效果是不可能实现的。
完美哈希函数
比较实际的方式是让哈希函数的结果能够尽可能的均匀分布,然后通过工程上的手段解决哈希碰撞的问题。哈希函数映射的结果一定要尽可能均匀,结果不均匀的哈希函数会带来更多的哈希冲突以及更差的读写性能。
不均匀哈希函数
如果使用结果分布较为均匀的哈希函数,那么哈希的增删改查的时间复杂度为 O(1);但是如果哈希函数的结果分布不均匀,那么所有操作的时间复杂度可能会达到 O(n),由此看来,使用好的哈希函数是至关重要的。
解决冲突
哪怕我们使用了完美的哈希函数,当输入的键足够多也会产生冲突。然而多数的哈希函数都是不够完美的,所以仍然存在发生哈希碰撞的可能,这时就需要一些方法来解决哈希碰撞的问题,常见方法的就是开放寻址法和拉链法。
需要注意的是,这里提到的哈希碰撞不是多个键对应的哈希完全相等,可能是多个哈希的部分相等,例如:两个键对应哈希的前四个字节相同。
开放寻址法
这种方法的核心思想是依次探测和比较数组中的元素以判断目标键值对是否存在于哈希表中,实现哈希表底层的数据结构是数组,不过因为数组的长度有限,向哈希表写入 (author, draven) 这个键值对时会从如下的索引开始遍历:
index := hash("author") % array.len
如上图所示,当 Key3 与已经存入哈希表中的两个键值对 Key1 和 Key2 发生冲突时,Key3 会被写入 Key2 后面的空闲位置。当我们再去读取 Key3 对应的值时就会先获取键的哈希并取模,这会先帮助我们找到 Key1,找到 Key1 后发现它与 Key 3 不相等,所以会继续查找后面的元素,直到内存为空或者找到目标元素。
开放寻址法中对性能影响最大的是装载因子,它是数组中元素的数量与数组大小的比值。随着装载因子的增加,线性探测的平均用时就会逐渐增加,这会影响哈希表的读写性能。当装载率超过 70% 之后,哈希表的性能就会急剧下降,而一旦装载率达到 100%,整个哈希表就会完全失效,这时查找和插入任意元素的时间复杂度都是 O(n) 的,这时需要遍历数组中的全部元素,所以在实现哈希表时一定要关注装载因子的变化。
拉链法
与开放寻址法相比,拉链法是哈希表最常见的实现方法,大多数的编程语言都用拉链法实现哈希表,它的实现比较开放寻址法稍微复杂一些,但是平均查找的长度也比较短,各个用于存储节点的内存都是动态申请的,可以节省比较多的存储空间。
实现拉链法一般会使用数组加上链表,不过一些编程语言会在拉链法的哈希中引入红黑树以优化性能,拉链法会使用链表数组作为哈希底层的数据结构,我们可以将它看成可以扩展的二维数组:
当需要将一个键值对 (Key6, Value6) 写入哈希表时,键值对中的键 Key6 都会先经过一个哈希函数,哈希函数返回的哈希会帮助我们选择一个桶,和开放地址法一样,选择桶的方式是直接对哈希返回的结果取模:
index := hash("Key6") % array.len
如果要在哈希表中获取某个键对应的值,会经历如下的过程:
在一个性能比较好的哈希表中,每一个桶中都应该有 0 ~ 1 个元素,有时会有 2 ~ 3 个,很少会超过这个数量。计算哈希、定位桶和遍历链表三个过程是哈希表读写操作的主要开销,使用拉链法实现的哈希也有装载因子这一概念:装载因子 := 元素数量 ÷ 桶数量。
与开放地址法一样,拉链法的装载因子越大,哈希的读写性能就越差。在一般情况下使用拉链法的哈希表装载因子都不会超过 1,当哈希表的装载因子较大时会触发哈希的扩容,创建更多的桶来存储哈希中的元素,保证性能不会出现严重的下降。如果有 1000 个桶的哈希表存储了 10000 个键值对,它的性能是保存 1000 个键值对的 1/10,但是仍然比在链表中直接读写好 1000 倍。
数据结构
type hmap struct {
count int
flags uint8
B uint8
noverflow uint16
hash0 uint32
buckets unsafe.Pointer
oldbuckets unsafe.Pointer
nevacuate uintptr
extra *mapextra
}
type mapextra struct {
overflow *[]*bmap
oldoverflow *[]*bmap
nextOverflow *bmap
}
- count 表示当前哈希表中的元素数量;
- B 表示当前哈希表持有的 buckets 数量,但是因为哈希表中桶的数量都 2 的倍数,所以该字段会存储对数,也就是 len(buckets) == 2^B;
- hash0 是哈希的种子,它能为哈希函数的结果引入随机性,这个值在创建哈希表时确定,并在调用哈希函数时作为参数传入;
- oldbuckets 是哈希在扩容时用于保存之前 buckets 的字段,它的大小是当前 buckets 的一半;
哈希表 runtime.hmap 的桶是 runtime.bmap。每一个 runtime.bmap 都能存储 8 个键值对,当哈希表中存储的数据过多(单个桶已经装满时),就会使用 extra.nextOverflow 中的桶来存储溢出的数据。
上述两种不同的桶,在内存中是连续存储的。一般将它们分别称为正常桶和溢出桶(黄色的 runtime.bmap 是正常桶,绿色的 runtime.bmap 是溢出桶)。
桶的结构体(runtime.bmap)在 Go 语言源代码中的定义只包含一个简单的 tophash 字段,tophash 存储了键的哈希的高 8 位,通过比较不同键的哈希的高 8 位可以减少访问键值对次数以提高性能。
type bmap struct {
tophash [bucketCnt]uint8
}
在运行期间,runtime.bmap 结构体其实不止包含 tophash 字段,因为哈希表中可能存储不同类型的键值对,而且 Go 语言也不支持泛型,所以键值对占据的内存空间大小只能在编译时进行推导。runtime.bmap 中的其他字段在运行时也都是通过计算内存地址的方式访问的,所以它的定义中就不包含这些字段,不过我们能根据编译期间的 cmd/compile/internal/gc.bmap 函数重建它的结构。
type bmap struct {
topbits [8]uint8
keys [8]keytype
values [8]valuetype
pad uintptr
overflow uintptr
}
随着哈希表存储的数据逐渐增多,会扩容哈希表或者使用额外的桶存储溢出的数据,不会让单个桶中的数据超过 8 个,不过溢出桶只是临时的解决方案,创建过多的溢出桶最终也会导致哈希的扩容。
初始化
定义 Map
// 声明变量,默认 map 是 nil
var map_variable map[key_data_type]value_data_type
// 使用 make 函数
map_variable := make(map[key_data_type]value_data_type)
// 使用字面量
hash := map[string]int{
"1": 2,
"3": 4,
"5": 6,
}
运行时
使用 make 创建哈希,Go 语言编译器都会在类型检查期间将它们转换成 runtime.makemap,使用字面量初始化哈希也只是语言提供的辅助工具,最后调用的都是 runtime.makemap。
这个函数会按照下面的步骤执行:
- 计算哈希占用的内存是否溢出或者超出能分配的最大值;
- 调用 runtime.fastrand 获取一个随机的哈希种子;
- 根据传入的 hint 计算出需要的最小需要的桶的数量;
- 使用 runtime.makeBucketArray 创建用于保存桶的数组; runtime.makeBucketArray 会根据传入的 B 计算出的需要创建的桶数量并在内存中分配一片连续的空间用于存储数据。\
- 当桶的数量小于 2^4 时,由于数据较少、使用溢出桶的可能性较低,会省略创建的过程以减少额外开销;
- 当桶的数量多于 2^4 时,会额外创建 2^(B−4) 个溢出桶; 正常桶和溢出桶在内存中的存储空间是连续的,只是被 runtime.hmap 中的不同字段引用,当溢出桶数量较多时会通过 runtime.newobject 创建新的溢出桶。
读写操作
访问
v := hash[key] // => v := *mapaccess1(maptype, hash, &key)
v, ok := hash[key] // => v, ok := mapaccess2(maptype, hash, &key)
runtime.mapaccess1 会先通过哈希表设置的哈希函数、种子获取当前键对应的哈希,再通过 runtime.bucketMask 和 runtime.add 拿到该键值对所在的桶序号和哈希高位的 8 位数字。
在 bucketloop 循环中,哈希会依次遍历正常桶和溢出桶中的数据,它会先比较哈希的高 8 位和桶中存储的 tophash,后比较传入的和桶中的值以加速数据的读写。用于选择桶序号的是哈希的最低几位,而用于加速访问的是哈希的高 8 位,这种设计能够减少同一个桶中有大量相等 tophash 的概率影响性能。
每一个桶都是一整片的内存空间,当发现桶中的 tophash 与传入键的 tophash 匹配之后,我们会通过指针和偏移量获取哈希中存储的键 keys[0] 并与 key 比较,如果两者相同就会获取目标值的指针 values[0] 并返回。
写入
当形如 hash[k] 的表达式出现在赋值符号左侧时,该表达式也会在编译期间转换成 runtime.mapassign 函数的调用,该函数与 runtime.mapaccess1 比较相似。
首先是函数会根据传入的键拿到对应的哈希和桶,然后通过遍历比较桶中存储的 tophash 和键的哈希,如果找到了相同结果就会返回目标位置的地址。其中 inserti 表示目标元素的在桶中的索引,insertk 和 val 分别表示键值对的地址,获得目标地址之后会通过算术计算寻址获得键值对 k 和 val。
如果当前键值对在哈希中不存在,哈希会为新键值对规划存储的内存地址,通过 runtime.typedmemmove 将键移动到对应的内存空间中并返回键对应值的地址 val。如果当前键值对在哈希中存在,那么就会直接返回目标区域的内存地址,哈希并不会在 runtime.mapassign 这个运行时函数中将值拷贝到桶中,该函数只会返回内存地址,真正的赋值操作是在编译期间插入。
如果当前桶已经满了,哈希会调用 runtime.hmap.newoverflow 创建新桶或者使用 runtime.hmap 预先在 noverflow 中创建好的桶来保存数据,新创建的桶不仅会被追加到已有桶的末尾,还会增加哈希表的 noverflow 计数器。
扩容
runtime.mapassign 函数会在以下两种情况发生时触发哈希的扩容:
- 装载因子已经超过 6.5;
- 哈希使用了太多溢出桶;
不过因为 Go 语言哈希的扩容不是一个原子的过程,所以 runtime.mapassign 还需要判断当前哈希是否已经处于扩容状态,避免二次扩容造成混乱。
根据触发的条件不同扩容的方式分成两种,如果这次扩容是溢出的桶太多导致的,那么这次扩容就是等量扩容 sameSizeGrow,sameSizeGrow 是一种特殊情况下发生的扩容,当我们持续向哈希中插入数据并将它们全部删除时,如果哈希表中的数据量没有超过阈值,就会不断积累溢出桶造成缓慢的内存泄漏。runtime: limit the number of map overflow buckets 引入了 sameSizeGrow 通过复用已有的哈希扩容机制解决该问题,一旦哈希中出现了过多的溢出桶,它会创建新桶保存数据,垃圾回收会清理老的溢出桶并释放内存。
扩容的入口是 runtime.hashGrow,哈希在扩容的过程中会通过 runtime.makeBucketArray 创建一组新桶和预创建的溢出桶,随后将原有的桶数组设置到 oldbuckets 上并将新的空桶设置到 buckets 上,溢出桶也使用了相同的逻辑更新。
在 runtime.hashGrow 中还看不出来等量扩容和翻倍扩容的太多区别,等量扩容创建的新桶数量只是和旧桶一样,该函数中只是创建了新的桶,并没有对数据进行拷贝和转移。哈希表的数据迁移的过程在是 runtime.evacuate 中完成的,它会对传入桶中的元素进行再分配。runtime.evacuate 会将一个旧桶中的数据分流到两个新桶,所以它会创建两个用于保存分配上下文的 runtime.evacDst 结构体,这两个结构体分别指向了一个新桶。
如果这是等量扩容,那么旧桶与新桶之间是一对一的关系,所以两个 runtime.evacDst 只会初始化一个。而当哈希表的容量翻倍时,每个旧桶的元素会都分流到新创建的两个桶中。
只使用哈希函数是不能定位到具体某一个桶的,哈希函数只会返回很长的哈希,例如:b72bfae3f3285244c4732ce457cca823bc189e0b。一般都会使用取模或者位操作来获取桶的编号,假如当前哈希中包含 4 个桶,那么它的桶掩码就是 0b11(3),使用位操作就会得到 3, 我们就会在 3 号桶中存储该数据。
如果新的哈希表有 8 个桶,在大多数情况下,原来经过桶掩码 0b11 结果为 3 的数据会因为桶掩码增加了一位变成 0b111 而分流到新的 3 号和 7 号桶,所有数据也都会被 runtime.typedmemmove 拷贝到目标桶中。
runtime.evacuate 最后会调用 runtime.advanceEvacuationMark 增加哈希的 nevacuate 计数器并在所有的旧桶都被分流后清空哈希的 oldbuckets 和 oldoverflow。
之前在分析哈希表访问函数 runtime.mapaccess1 时其实省略了扩容期间获取键值对的逻辑,当哈希表的 oldbuckets 存在时,会先定位到旧桶并在该桶没有被分流时从中获取键值对。
因为旧桶中的元素还没有被 runtime.evacuate 函数分流,其中还保存着我们需要使用的数据,所以旧桶会替代新创建的空桶提供数据。
当哈希表正在处于扩容状态时,每次向哈希表写入值时都会触发 runtime.growWork 增量拷贝哈希表中的内容。
当然除了写入操作之外,删除操作也会在哈希表扩容期间触发 runtime.growWork,触发的方式与这里的逻辑几乎完全相同,都是计算当前值所在的桶,然后拷贝桶中的元素。
总结一下哈希表扩容的设计和原理,哈希在存储元素过多时会触发扩容操作,每次都会将桶的数量翻倍,扩容过程不是原子的,而是通过 runtime.growWork 增量触发的,在扩容期间访问哈希表时会使用旧桶,向哈希表写入数据时会触发旧桶元素的分流。除了这种正常的扩容之外,为了解决大量写入、删除造成的内存泄漏问题,哈希引入了 sameSizeGrow 这一机制,在出现较多溢出桶时会整理哈希的内存减少空间的占用。
删除
Go 语言中的 delete 关键字,唯一作用就是将某一个键对应的元素从哈希表中删除,无论是该键对应的值是否存在,这个内建的函数都不会返回任何的结果。如果在删除期间遇到了哈希表的扩容,就会分流桶中的元素,分流结束之后会找到桶中的目标元素完成键值对的删除工作。
小结
Go 语言使用拉链法来解决哈希碰撞的问题实现了哈希表,它的访问、写入和删除等操作都在编译期间转换成了运行时的函数或者方法。哈希在每一个桶中存储键对应哈希的前 8 位,当对哈希进行操作时,这些 tophash 就成为可以帮助哈希快速遍历桶中元素的缓存。
哈希表的每个桶都只能存储 8 个键值对,一旦当前哈希的某个桶超出 8 个,新的键值对就会存储到哈希的溢出桶中。随着键值对数量的增加,溢出桶的数量和哈希的装载因子也会逐渐升高,超过一定范围就会触发扩容,扩容会将桶的数量翻倍,元素再分配的过程也是在调用写操作时增量进行的,不会造成性能的瞬时巨大抖动。
字符串
概述
虽然字符串被看做一个整体,但是它实际上是一片连续的内存空间,是一个只读的字节数组。
只读只意味着字符串会分配到只读的内存空间,但是 Go 语言只是不支持直接修改 string 类型变量的内存空间,仍然可以通过在 string 和 []byte 类型之间反复转换实现修改这一目的:
- 先将这段内存拷贝到堆或者栈上;
- 将变量的类型转换成 []byte 后并修改字节数据;
- 将修改后的字节数组转换回 string;
package main
import "fmt"
func main() {
str := "aa"
fmt.Println(&str)
b := []byte(str)
b[0] = 66
str = string(b) // str = "Ba"
fmt.Println(&str)
}
0xc00008e1e0
0xc00008e1e0
Go 中的字节就是 uint8,即修改一个整形数。然后 Go 中的字符串转换成的编码是 UTF-8 编码的字节数据。
结构体
type StringHeader struct {
Data uintptr
Len int
}
初始化
str1 := "this is a string"
str2 := `{"author": "draven", "tags": ["golang"]}`
拼接
Go 语言拼接字符串会使用 + 符号,编译器会将该符号对应的 OADD 节点转换成 OADDSTR 类型的节点,随后在 cmd/compile/internal/gc.walkexpr 中调用 cmd/compile/internal/gc.addstr 函数生成用于拼接字符串的代码。
cmd/compile/internal/gc.addstr 能帮助在编译期间选择合适的函数对字符串进行拼接,该函数会根据待拼接的字符串数量选择不同的逻辑:
- 如果小于或者等于 5 个,那么会调用 concatstring{2,3,4,5} 等一系列函数;
- 如果超过 5 个,那么会选择 runtime.concatstrings 传入一个数组切片;
其实无论使用 concatstring{2,3,4,5} 中的哪一个,最终都会调用 runtime.concatstrings,它会先对遍历传入的切片参数,再过滤空字符串并计算拼接后字符串的长度。
如果非空字符串的数量为 1 并且当前的字符串不在栈上,就可以直接返回该字符串,不需要做出额外操作。
但是在正常情况下,运行时会调用 copy 将输入的多个字符串拷贝到目标字符串所在的内存空间。新的字符串是一片新的内存空间,与原来的字符串也没有任何关联,一旦需要拼接的字符串非常大,拷贝带来的性能损失是无法忽略的。
转换
从字节数组到字符串的转换需要使用 runtime.slicebytetostring 函数,例如:string(bytes),该函数在函数体中会先处理两种比较常见的情况,也就是长度为 0 或者 1 的字节数组。
处理过后会根据传入的缓冲区大小决定是否需要为新字符串分配一片内存空间,runtime.stringStructOf 会将传入的字符串指针转换成 runtime.stringStruct 结构体指针,然后设置结构体持有的字符串指针 str 和长度 len,最后通过 runtime.memmove 将原 []byte 中的字节全部复制到新的内存空间中。
当想要将字符串转换成 []byte 类型时,需要使用 runtime.stringtoslicebyte 函数。
上述函数会根据是否传入缓冲区做出不同的处理:
- 当传入缓冲区时,它会使用传入的缓冲区存储 []byte;
- 当没有传入缓冲区时,运行时会调用 runtime.rawbyteslice 创建新的字节切片并将字符串中的内容拷贝过去;
字符串和 []byte 中的内容虽然一样,但是字符串的内容是只读的,我们不能通过下标或者其他形式改变其中的数据,而 []byte 中的内容是可以读写的。不过无论从哪种类型转换到另一种都需要拷贝数据,而内存拷贝的性能损耗会随着字符串和 []byte 长度的增长而增长。
相关问题
有意思的代码
package main
import (
"fmt"
"unsafe"
)
func main() {
s1 := "string"
s2 := s1
s3 := s1[3:]
fmt.Println(&s1, &s2, &s3)
printAddr := func(sp *string) {
unsafePtr := unsafe.Pointer(sp)
ptr := (*uintptr)(unsafePtr)
fmt.Println(*ptr, *(*string)(unsafe.Pointer(ptr)))
}
printAddr(&s1)
printAddr(&s2)
printAddr(&s3)
}
0xc00008e1e0 0xc00008e1f0 0xc00008e200
17611794 string
17611794 string
17611797 ing
三个赋值表达式都会调用 convTstring 赋值字符串的内容,所以底层的数组和地址也是不同的。