go当中的空结构体
1.空结构体的占用内存空间大小是0字节
2.独立的空结构体变量指针地址都是统一的zerobase,
3.用途:节约内存
4.应用场景1:hashset map只要key不要val m1:=map[string]struct{}{} m[“a”] = struct{}{}
5.应用场景2:channel只用来传输信号时 a := make(chan struct{})
go字符串
1.go当中的字符串是runtime.stringStruct结构体,所以unsafe.sizeof(一个字符串)都是16字节。
type stringStruct struct {
str unsafe.Pointer //指针指向底层Byte数组
len int //表示 Byte数组长度,而不是字符个数
}
反射包里也有个相同成员的结构体,首字母大写,向外暴露
type StringHeader struct {
Data uintptr
Len int
}
2.对字符串直接使用len得到的是字节数,而不是字符数,对字符串直接下标访问也是字节
3.字符串被for range遍历时,被解码成rune类型的字符
4.UTF-8编码解码算法在runtime包下的utf8.go
5.字符串需要切分时,可以先转为rune数组,再转为切片,最后转成string
e.g s = string([]rune(s)[:3])
go切片
1.go的切片底层是runtime包的slice结构体,本质是对数组的引用,占24个字节
type slice struct {
array unsafe.Pointer //指针指向底层Byte数组
len int //切片的字节长度
cap int //切片的最大容量
}
2.切片的创建
- 可以根据数组创建 array[1:3], 这个时候,切片的指针指向数组的第二个元素,len为2,cap为数组的长度-1
- 字面量创建 s := []int{1, 2, 3}
- make创建 s := make([]int, 10)
3.切片的append
1.18以前的策略
-
不需要扩容时,只调整len
-
需要扩容时,调用runtime.growslice(),放弃原数组,新开一个原数组
如果原切片长度小于1024,将容量翻倍
如果期望容量大于原容量的两倍,那直接使用期望容量
如果原切片长度大于1024,每次增加25%
-
切片扩容的时候,并发不安全,注意切片并发要加锁。因为假如a扩容切片,b要读该切片,扩容的时候会放弃原数组,新开辟一个数组,而b还是读的原数组,这就会产生问题
1.18以后的策略
- 如果期望长度大于原容量两倍,直接使用期望容量
- 接下来的情况都建立在,期望容量小于等于原容量两倍的情况
- 如果原容量小于256,直接将原容量翻倍
- 否则,进入for循环,每次给原容量+= (原容量+3*256)/4
- 然后判断+完的值是否大于等于期望的新长度,如果大于等于就break返回
- 否则继续for循环
大家感兴趣可以阅读一下源码,很简单的逻辑。
官方的说法新的扩容机制可以更平滑的进行扩容。
// nextslicecap computes the next appropriate slice length.
func nextslicecap(newLen, oldCap int) int {
newcap := oldCap
doublecap := newcap + newcap
if newLen > doublecap {
return newLen
}
const threshold = 256
if oldCap < threshold {
return doublecap
}
for {
// Transition from growing 2x for small slices
// to growing 1.25x for large slices. This formula
// gives a smooth-ish transition between the two.
newcap += (newcap + 3*threshold) >> 2
// We need to check `newcap >= newLen` and whether `newcap` overflowed.
// newLen is guaranteed to be larger than zero, hence
// when newcap overflows then `uint(newcap) > uint(newLen)`.
// This allows to check for both with the same comparison.
if uint(newcap) >= uint(newLen) {
break
}
}
// Set newcap to the requested cap when
// the newcap calculation overflowed.
if newcap <= 0 {
return newLen
}
return newcap
}
go map
1.底层用的hashmap,hashmap有两种常用的方案。
-
开放寻址法
key进行hash函数,然后取模,去对应的slot槽存储,如果该下标已经被一对kv占用,则向下一位存储,以此类推,直到有空闲位置。读取的时候同样的原理。开放寻址法中对性能影响最大的是装载因子,它是数组中元素的数量与数组大小的比值。随着装载因子的增加,线性探测的平均用时就会逐渐增加,这会影响哈希表的读写性能。当装载率超过 70% 之后,哈希表的性能就会急剧下降,而一旦装载率达到 100%,整个哈希表就会完全失效
-
拉链法
key进行hash函数,然后取模,去对应的槽slot存储,不同的是,下标存储的都是指针,指向一个链表(Bucket桶),链表存储的一系列kv,选择好slot槽后,就开始遍历链表,如果有对应的key就更新value,否则在链表尾部追加新的kv。拉链法装载因子:=元素数量÷桶数量
index := hash("Key6") % array.len
通俗的解释一下两者的区别。可以理解为hashmap就是一个个房间,房间里放置的是你要存储的value,key就是一个个房间的密码锁。你要知道密码才能解开对应的房间门锁。密码怎么来呢,就是key进行一个hash函数计算再取模,得到密码。
开放寻址法就是得到密码后你去对应的房间开锁,如果有人,就去下一个房间直到找到一个空房间然后放置value
拉链法就是得到密码后你去对应的房间开锁,如果有人,就在房间里面加一张床,大家挤一挤
2.runtime.hamp结构体
// A header for a Go map.
type hmap struct {
// Note: the format of the hmap is also encoded in cmd/compile/internal/reflectdata/reflect.go.
// Make sure this stays in sync with the compiler's definition.
count int // 代表哈希表里的元素个数,使用len(map)返回的就是该字段值
flags uint8 //一个状态值,表示是否处于正在读写的状态
B uint8 // buckets的对数。例如B=6,那么buckets数组的长度为2^6=64,代表有64个桶
noverflow uint16 // 溢出桶的数量
hash0 uint32 // hash随机数种子,能为哈希函数的结果引入随机性,这个值在创建哈希表时确定,并在调用哈希函数时作为参数传入
buckets unsafe.Pointer // 指向buckets数组的指针,如果count=0,值为nil。数组每个元素都是bmap
oldbuckets unsafe.Pointer // 如果发生过扩容,该字段是指向旧桶数组的指针,该数组为新桶数组的一半,非扩容下为nil
nevacuate uintptr // 代表扩容的进度,小于此指针地址的buckets代表已经迁移完成
extra *mapextra // 存储一些额外的信息
}
buckets里面包含若干个bmap。
bmap 就是我们常说的 “桶”,一个桶里面会最多装 8 个 key,这些 key 之所以会落入同一个桶,是因为它们经过哈希计算后,哈希结果的低 B
位是相同的。
bmap包含四个字段,tophash:存储8个 key hash值的前8位,有助于快速遍历 keys:存储8个keys elems:存储8个val overflow:存储溢出桶指针,一个bmap只能放8个kv
type bmap struct {
tophash [8]uint8
keys [8]keytype // 对应的键类型
values [8]elemtype // 对应的值类型
overflow uintptr //overflow 存储的是指向溢出桶的指针,使用 uintptr 类型而非 *bmap 是为了避免被 GC 扫描到。
}
3.哈希碰撞
runtime.mapassign()可能会触发扩容情况:
- 装载因子超过6.5(平均每个槽6.5个key)
- 溢出桶超过普通桶的数量
扩容可能并不会增加桶数,而是整理
扩容:
- 创建一组新桶
- oldbuckets指向原有桶数组
- buckets指向新的桶数组
- map标记为扩容状态
- 将所有的数据从旧桶驱逐到新桶
- 采用渐进式驱逐
- 每操作的时候遇到一个旧桶时,将旧桶数据驱逐到新桶
- 所有的旧桶驱逐完成后
- oldbuckets回收
4.map的并发问题
-
map不支持并发的读写
-
A协程在桶中读数据,B协程驱逐了这个桶
-
A协程会读取到错误的数据或者找不到数据
解决方案:
- 给map加锁(mutex),但是牺牲了性能,因为map很可能是要经常读写的场景,加锁会极大地影响效率
- 使用sync.Map
接口
runtime2.iface和eface
iface表示拥有方法的接口类型
eface表示空接口类型
type iface struct {
tab *itab //储存接口本身的信息
data unsafe.Pointer //指向被赋值给接口类型变量的具体值
}
type itab struct {
inter *interfacetype //存储接口本身的信息(接口类型,方法集等)
_type *_type //具体的类型信息
hash uint32 // _type.hash的缓存,当需要将接口类型转换成具体的类型时,使用该字段判断转换的目标类型是否和具体类型_type一样
_ [4]byte
fun [1]uintptr // 具体类型实现了接口方法的调用地址
}
type eface struct {
_type *_type //表示具体类型的类型信息
data unsafe.Pointer //执行具体类型变量的值
}
1.接口的值使用runtime.iface结构体表示
2.iface记录了数据的地址
3.iface记录了接口类型信息和实现的方法
4.结构体和结构体指针都可以实现接口,如果用结构体实现一个方法的话,编译后会帮你也用该结构体的指针实现该方法。但是如果你用结构体指针实现一个方法的话,编译不会帮你用结构体实现该方法
5.空接口使用runtime.eface结构体表示,函数调用的时候,空接口可以传任意类型参数,编译的时候会生成一个eface再传入
nil
1.nil是空,但不一定是空指针。nil在go里是一个变量,类型是Type。它表示指针,管道,函数,接口,map,切片的零值。注意这其中不包括结构体,所以nil永远不可能等于一个结构体
2.空结构体是Go中非常特殊的类型,不仅空结构体的值不是nil,空结构体指针也不是nil,而是同一个地址zerobase
3.如果a是一个空接口,b是一个*int,这俩都是nil。如果把a=b,那么a不是nil,因为空接口是eafce结构体,有类型和数据组成,a=b之后,a这个就空接口就有了类型,就不是nil了。空接口只有类型和数据都是空,才为nil
内存对齐
1.由于cpu读取数据是按照64bit8字节长读取的,如果没有内存对齐,可能存在一个int32跨越了两个字长,需要读取两次的情况,内存的原子性和效率会受到影响。
2.go提供对齐系数函数,方便内存对齐。unsafe.Alignof()
**对齐系数的含义是变量的内存地址必须被对其系数整除。**如果对齐系数为4,表示变量内存地址必须是4的倍数
3.基本类型的对齐系数等于其内存占用字节大小
结构体内部对齐
1.指的是结构体内部成员的相对偏移量
2.每个成员的偏移量是自身大小与其对齐系数二者之间较小值的倍数
3.结构体长度填充是指通过增加结构体长度,对齐系统字长
4**.结构体的长度是最大成员长度和系统字长之间较小值的整数倍**
5.因为结构体成员定义顺序决定存放位置,且不可变。所以可以尝试通过调整成员顺序,节约空间
6.结构体整体的对齐系数是其成员的最大对齐系数。结构体对齐系数决定其地址
7.空结构体作为成员时,地址跟随前一个成员。如果出现在末尾时,需要补齐字长