01.String
1.字符编号对照表
ASCII 128个字符集
GB2312
......
不如我们通用字符集 Unicode
怎么存储这个内容呢?
eggo世界~
我们首先会想到使用编号
e 101 01100101
g 103 01100111
g 103 01100111
o 111 01101111
世 19990 01001110 00010110
界 30028 01110101 01001100
拿到他们的编号
直接组合无法划分~
所以照搬编号的方式无法解决
所以我们可能有第一个解决方案
我们把它们都定长 组成一个定长编码
这样不就解决这个问题了嘛?
但是这样非常非常损耗内存。而且字符集收录符号越多就越浪费
变长编码
编号 编码模板
[0,127] 0??????? 一字节
[128,2047] 110????? 10?????? 俩字节
[2048,65535] .....
所以我们可以进行划分了
UTF-8是go语言的默认编码
那么字符串变量是什么结构呢?
首先我们看看起始地址
如图c语言使用的是编号为0的字符,但是这也就限制了内容中不能再出现这个标识符,否则就很容易出问题
go语言在起始地址后面多存了一个长度长度不是字符个数而是字节个数。这样就定位了字符串变量。
GO语言认为字符串不可修改
所以在分配 s1:="HWS IS GOOD"的时候会将其地址分配到只读区域,保护程序可正常运行。
如果非要修改 我们可以s1="HWS IS BAD"
这样他就会重新指向新的内容并没修改原来的。
或者分配到slice..这个以后再说
02~slice
slice切片 变量需要存储在一段连续的内存中实际上就是数组
int data len cap
如果只是声明var ints []int那么他们的容量和长度均为0
如果使用make那么则 var ints[]int =make([]int,2,5)
已经存储的元素可以安全读写,但是超出这个范围的话就是越界访问 报错
如图,说明slice和数组之间的关系,左闭右开
如果此时再给s2追加元素呢?
那么原来的底层数组不可用,会开辟新的数组原来的元素拷贝过来添加新元素,容量翻倍。
slice扩容规则
如果扩容前容量翻倍还是小于所需最小的容量那么预估容量等于所需最小容量
否则 若原有的容量小于10224个字节翻倍
大于1024则先扩25%
预估容量X元素类型=所需容量
但是....有内存对齐。
go语言内存管理模块分为常用的内存块将其匹配到合适的位置
03 结构体和内存对齐
寄存器宽度什么的最大对齐边界取余
然后进行对齐,防止丢失数据
04map
哈希函数 #
实现哈希表的关键点在于哈希函数的选择,哈希函数的选择在很大程度上能够决定哈希表的读写性能。在理想情况下,哈希函数应该能够将不同键映射到不同的索引上,这要求哈希函数的输出范围大于输入范围,但是由于键的数量会远远大于映射的范围,所以在实际使用时,这个理想的效果是不可能实现的。
完美哈希函数
比较实际的方式是让哈希函数的结果能够尽可能的均匀分布,然后通过工程上的手段解决哈希碰撞的问题。哈希函数映射的结果一定要尽可能均匀,结果不均匀的哈希函数会带来更多的哈希冲突以及更差的读写性能。
不均匀哈希函数
如果使用结果分布较为均匀的哈希函数,那么哈希的增删改查的时间复杂度为 O(1);但是如果哈希函数的结果分布不均匀,那么所有操作的时间复杂度可能会达到 O(n),由此看来,使用好的哈希函数是至关重要的。
就像我们之前所提到的,在通常情况下,哈希函数输入的范围一定会远远大于输出的范围,所以在使用哈希表时一定会遇到冲突,哪怕我们使用了完美的哈希函数,当输入的键足够多也会产生冲突。然而多数的哈希函数都是不够完美的,所以仍然存在发生哈希碰撞的可能,这时就需要一些方法来解决哈希碰撞的问题,常见方法的就是开放寻址法和拉链法。
开放寻址法 #
开放寻址法2是一种在哈希表中解决哈希碰撞的方法,这种方法的核心思想是依次探测和比较数组中的元素以判断目标键值对是否存在于哈希表中,如果我们使用开放寻址法来实现哈希表,那么实现哈希表底层的数据结构就是数组,不过因为数组的长度有限,向哈希表写入 (author, draven) 这个键值对时会从如下的索引开始遍历:(说人话就是在当前位置往后找到下一个不为空的位置插入)
开放寻址法中对性能影响最大的是装载因子,它是数组中元素的数量与数组大小的比值。随着装载因子的增加,线性探测的平均用时就会逐渐增加,这会影响哈希表的读写性能。当装载率超过 70% 之后,哈希表的性能就会急剧下降,而一旦装载率达到 100%,整个哈希表就会完全失效,这时查找和插入任意元素的时间复杂度都是 O(n) 的,这时需要遍历数组中的全部元素,所以在实现哈希表时一定要关注装载因子的变化。(java中map的负载因子是0.75...)
拉链法 #
与开放地址法相比,拉链法是哈希表最常见的实现方法,大多数的编程语言都用拉链法实现哈希表,它的实现比较开放地址法稍微复杂一些,但是平均查找的长度也比较短,各个用于存储节点的内存都是动态申请的,可以节省比较多的存储空间。
实现拉链法一般会使用数组加上链表,不过一些编程语言会在拉链法的哈希中引入红黑树以优化性能,拉链法会使用链表数组作为哈希底层的数据结构,我们可以将它看成可以扩展的二维数组
拉链法是java中map的设计思路
go语言的数据结构
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
}
1 count 表示当前哈希表中的元素数量;
2 B 表示当前哈希表持有的 buckets 数量,但是因为哈希表中桶的数量都 2 的倍数,所以该字段会存储对数,也就是 len(buckets) == 2^B;
3 hash0 是哈希的种子,它能为哈希函数的结果引入随机性,这个值在创建哈希表时确定,并在调用哈希函数时作为参数传入;
4 oldbuckets 是哈希在扩容时用于保存之前 buckets 的字段,它的大小是当前 buckets 的一半;
如上图所示哈希表 runtime.hmap
的桶是 runtime.bmap
。每一个 runtime.bmap
都能存储 8 个键值对,当哈希表中存储的数据过多,单个桶已经装满时就会使用 extra.nextOverflow
中桶存储溢出的数据。
上述两种不同的桶在内存中是连续存储的,我们在这里将它们分别称为正常桶和溢出桶,上图中黄色的 runtime.bmap
就是正常桶,绿色的 runtime.bmap
是溢出桶,溢出桶是在 Go 语言还使用 C 语言实现时使用的设计3,由于它能够减少扩容的频率所以一直使用至今。
桶的结构体 runtime.bmap
在 Go 语言源代码中的定义只包含一个简单的 tophash
字段,tophash
存储了键的哈希的高 8 位,通过比较不同键的哈希的高 8 位可以减少访问键值对次数以提高性能:
随着哈希表存储的数据逐渐增多,我们会扩容哈希表或者使用额外的桶存储溢出的数据,不会让单个桶中的数据超过 8 个,不过溢出桶只是临时的解决方案,创建过多的溢出桶最终也会导致哈希的扩容。
初始化
hash := map[string]int{
"1": 2,
"3": 4,
"5": 6,
}
我们需要在初始化哈希时声明键值对的类型,这种使用字面量初始化的方式最终都会通过 cmd/compile/internal/gc.maplit
初始化,我们来分析一下该函数初始化哈希的过程:
func maplit(n *Node, m *Node, init *Nodes) {
a := nod(OMAKE, nil, nil)
a.Esc = n.Esc
a.List.Set2(typenod(n.Type), nodintconst(int64(n.List.Len())))
litas(m, a, init)
entries := n.List.Slice()
if len(entries) > 25 {
...
return
}
// Build list of var[c] = expr.
// Use temporaries so that mapassign1 can have addressable key, elem.
...
}
当哈希表中的元素数量少于或者等于 25 个时,编译器会将字面量初始化的结构体转换成以下的代码,将所有的键值对一次加入到哈希表中:
hash := make(map[string]int, 3)
hash["1"] = 2
hash["3"] = 4
hash["5"] = 6
这种初始化的方式与的数组和切片几乎完全相同,由此看来集合类型的初始化在 Go 语言中有着相同的处理逻辑。
一旦哈希表中元素的数量超过了 25 个,编译器会创建两个数组分别存储键和值,这些键值对会通过如下所示的 for 循环加入哈希:
hash := make(map[string]int, 26)
vstatk := []string{"1", "2", "3", ... , "26"}
vstatv := []int{1, 2, 3, ... , 26}
for i := 0; i < len(vstak); i++ {
hash[vstatk[i]] = vstatv[i]
}
运行时 #
当创建的哈希被分配到栈上并且其容量小于 BUCKETSIZE = 8
时,Go 语言在编译阶段会使用如下方式快速初始化哈希,这也是编译器对小容量的哈希做的优化:
var h *hmap
var hv hmap
var bv bmap
h := &hv
b := &bv
h.buckets = b
h.hash0 = fashtrand0()
除了上述特定的优化之外,无论 make
是从哪里来的,只要我们使用 make
创建哈希,Go 语言编译器都会在类型检查期间将它们转换成 runtime.makemap
,使用字面量初始化哈希也只是语言提供的辅助工具,最后调用的都是 runtime.makemap
:
func makemap(t *maptype, hint int, h *hmap) *hmap {
mem, overflow := math.MulUintptr(uintptr(hint), t.bucket.size)
if overflow || mem > maxAlloc {
hint = 0
}
if h == nil {
h = new(hmap)
}
h.hash0 = fastrand()
B := uint8(0)
for overLoadFactor(hint, B) {
B++
}
h.B = B
if h.B != 0 {
var nextOverflow *bmap
h.buckets, nextOverflow = makeBucketArray(t, h.B, nil)
if nextOverflow != nil {
h.extra = new(mapextra)
h.extra.nextOverflow = nextOverflow
}
}
return h
}
这个函数会按照下面的步骤执行:
- 计算哈希占用的内存是否溢出或者超出能分配的最大值;
- 调用
runtime.fastrand
获取一个随机的哈希种子; - 根据传入的
hint
计算出需要的最小需要的桶的数量; - 使用
runtime.makeBucketArray
创建用于保存桶的数组;
runtime.makeBucketArray
会根据传入的 B
计算出的需要创建的桶数量并在内存中分配一片连续的空间用于存储数据:
func makeBucketArray(t *maptype, b uint8, dirtyalloc unsafe.Pointer) (buckets unsafe.Pointer, nextOverflow *bmap) {
base := bucketShift(b)
nbuckets := base
if b >= 4 {
nbuckets += bucketShift(b - 4)
sz := t.bucket.size * nbuckets
up := roundupsize(sz)
if up != sz {
nbuckets = up / t.bucket.size
}
}
buckets = newarray(t.bucket, int(nbuckets))
if base != nbuckets {
nextOverflow = (*bmap)(add(buckets, base*uintptr(t.bucketsize)))
last := (*bmap)(add(buckets, (nbuckets-1)*uintptr(t.bucketsize)))
last.setoverflow(t, (*bmap)(buckets))
}
return buckets, nextOverflow
}
- 当桶的数量小于 <nobr aria-hidden="true" style="box-sizing: inherit; transition: none 0s ease 0s; border: 0px; padding: 0px; margin: 0px; max-width: none; max-height: none; min-width: 0px; min-height: 0px; vertical-align: 0px; line-height: normal; text-decoration: none; white-space: nowrap !important;">24</nobr><math xmlns="http://www.w3.org/1998/Math/MathML"><msup><mn>2</mn><mn>4</mn></msup></math> 时,由于数据较少、使用溢出桶的可能性较低,会省略创建的过程以减少额外开销;
- 当桶的数量多于 <nobr aria-hidden="true" style="box-sizing: inherit; transition: none 0s ease 0s; border: 0px; padding: 0px; margin: 0px; max-width: none; max-height: none; min-width: 0px; min-height: 0px; vertical-align: 0px; line-height: normal; text-decoration: none; white-space: nowrap !important;">24</nobr><math xmlns="http://www.w3.org/1998/Math/MathML"><msup><mn>2</mn><mn>4</mn></msup></math> 时,会额外创建 <nobr aria-hidden="true" style="box-sizing: inherit; transition: none 0s ease 0s; border: 0px; padding: 0px; margin: 0px; max-width: none; max-height: none; min-width: 0px; min-height: 0px; vertical-align: 0px; line-height: normal; text-decoration: none; white-space: nowrap !important;">2B−4</nobr><math xmlns="http://www.w3.org/1998/Math/MathML"><msup><mn>2</mn><mrow class="MJX-TeXAtom-ORD"><mi>B</mi><mo>−</mo><mn>4</mn></mrow></msup></math> 个溢出桶;
根据上述代码,我们能确定在正常情况下,正常桶和溢出桶在内存中的存储空间是连续的,只是被 runtime.hmap
中的不同字段引用,当溢出桶数量较多时会通过 runtime.newobject
创建新的溢出桶。
读写操作
哈希表作为一种数据结构,我们肯定要分析它的常见操作,首先就是读写操作的原理。哈希表的访问一般都是通过下标或者遍历进行的:
_ = hash[key]
for k, v := range hash {
// k, v
}
这两种方式虽然都能读取哈希表的数据,但是使用的函数和底层原理完全不同。前者需要知道哈希的键并且一次只能获取单个键对应的值,而后者可以遍历哈希中的全部键值对,访问数据时也不需要预先知道哈希的键。在这里我们会介绍前一种访问方式,第二种访问方式会在 range 一节中详细分析。
数据结构的写一般指的都是增加、删除和修改,增加和修改字段都使用索引和赋值语句,而删除字典中的数据需要使用关键字 delete:
hash[key] = value
hash[key] = newValue
delete(hash, key)
- 当接受一个参数时,会使用
runtime.mapaccess1
,该函数仅会返回一个指向目标值的指针; - 当接受两个参数时,会使用
runtime.mapaccess2
,除了返回目标值之外,它还会返回一个用于表示当前键对应的值是否存在的bool
值:
runtime.mapaccess1
会先通过哈希表设置的哈希函数、种子获取当前键对应的哈希,再通过 runtime.bucketMask
和 runtime.add
拿到该键值对所在的桶序号和哈希高位的 8 位数字。
在 bucketloop 循环中,哈希会依次遍历正常桶和溢出桶中的数据,它会先比较哈希的高 8 位和桶中存储的 tophash,后比较传入的和桶中的值以加速数据的读写。用于选择桶序号的是哈希的最低几位,而用于加速访问的是哈希的高 8 位,这种设计能够减少同一个桶中有大量相等 tophash 的概率影响性能。
访问哈希表中的数据
如上图所示,每一个桶都是一整片的内存空间,当发现桶中的 tophash
与传入键的 tophash
匹配之后,我们会通过指针和偏移量获取哈希中存储的键 keys[0]
并与 key
比较,如果两者相同就会获取目标值的指针 values[0]
并返回。
另一个同样用于访问哈希表中数据的 runtime.mapaccess2
只是在 runtime.mapaccess1
的基础上多返回了一个标识键值对是否存在的 bool
值:
哈希遍历溢出桶
如果当前桶已经满了,哈希会调用 runtime.hmap.newoverflow
创建新桶或者使用 runtime.hmap
预先在 noverflow
中创建好的桶来保存数据,新创建的桶不仅会被追加到已有桶的末尾,还会增加哈希表的 noverflow
计数器。
扩容 #
runtime.mapassign
函数会在以下两种情况发生时触发哈希的扩容:
- 装载因子已经超过 6.5;
- 哈希使用了太多溢出桶;
不过因为 Go 语言哈希的扩容不是一个原子的过程,所以 runtime.mapassign
还需要判断当前哈希是否已经处于扩容状态,避免二次扩容造成混乱。
根据触发的条件不同扩容的方式分成两种,如果这次扩容是溢出的桶太多导致的,那么这次扩容就是等量扩容 sameSizeGrow
,sameSizeGrow
是一种特殊情况下发生的扩容,当我们持续向哈希中插入数据并将它们全部删除时,如果哈希表中的数据量没有超过阈值,就会不断积累溢出桶造成缓慢的内存泄漏4。runtime: limit the number of map overflow buckets 引入了 sameSizeGrow
通过复用已有的哈希扩容机制解决该问题,一旦哈希中出现了过多的溢出桶,它会创建新桶保存数据,垃圾回收会清理老的溢出桶并释放内存5。
扩容的入口是 runtime.hashGrow
:
哈希在扩容的过程中会通过 runtime.makeBucketArray
创建一组新桶和预创建的溢出桶,随后将原有的桶数组设置到 oldbuckets
上并将新的空桶设置到 buckets
上,溢出桶也使用了相同的逻辑更新,下图展示了触发扩容后的哈希:
哈希表触发扩容
我们在 runtime.hashGrow
中还看不出来等量扩容和翻倍扩容的太多区别,等量扩容创建的新桶数量只是和旧桶一样,该函数中只是创建了新的桶,并没有对数据进行拷贝和转移。哈希表的数据迁移的过程在是 runtime.evacuate
中完成的,它会对传入桶中的元素进行再分配。
哈希表扩容目的
如果这是等量扩容,那么旧桶与新桶之间是一对一的关系,所以两个 runtime.evacDst
只会初始化一个。而当哈希表的容量翻倍时,每个旧桶的元素会都分流到新创建的两个桶中,这里仔细分析一下分流元素的逻辑:
删除 #
如果想要删除哈希中的元素,就需要使用 Go 语言中的 delete
关键字,这个关键字的唯一作用就是将某一个键对应的元素从哈希表中删除,无论是该键对应的值是否存在,这个内建的函数都不会返回任何的结果。