空结构体的底层原理
基本类型的字节数
fmt.Println(unsafe.Sizeof(0)) // 8
fmt.Println(unsafe.Sizeof(uint(0))) // 8
a := 0
b := &a
fmt.Println(unsafe.Sizeof(b)) // 8
int
大小跟随系统字长- 指针的大小也是系统字长
空结构体
a := struct {
}{}
b := struct {
}{}
fmt.Println(unsafe.Sizeof(a)) // 0
fmt.Printf("a=%p \n", &a) //0x10e1438
fmt.Printf("b=%p \n", &b) //0x10e1438
空结构体指向的地址是zerobase,位置:runtime/malloc.go
// base address for all 0-byte allocations
var zerobase uintptr
空结构体的地址均相同(不被包含在其它结构体中时)
空结构体主要是为了节约内存,不占用空间
- 结合map,可以实现hashset(只要key,不要value)
- 结合channel,可以当纯信号
字符串,数组,切片的底层原理
字符串底层原理
fmt.Println(unsafe.Sizeof("hello RdrB1te")) //16
fmt.Println(unsafe.Sizeof("RdrB1te")) //16
上面两行字符串的长度都为16个字节,这是为什么?大胆猜测这一个字符串是不是包含了两个指针
string
的底层结构:
type stringStruct struct { str unsafe.Pointer len int
}
- 字符串的本质是个结构体
- Data指针指向底层Byte数组
Len
表示Byte数组的长度?字符个数?
由于上面的stringStruct
不允许外面的包使用,我们通过反射包类似的StringHeader
来查看Len
变量的大小
StringHeader的底层结构
type StringHeader struct { Data uintptr Len int
}
打印出字符串中Len
的值:
s := "武汉"
sh := (*reflect.StringHeader)(unsafe.Pointer(&s))
fmt.Println(sh.Len) // 6
字符编码问题
- 所有的字符均使用Unicode字符集
- 使用UTF-8编码
Unicode
- 一种统一的字符集
- 囊括了159种文字的144679个字符
- 14万个字符至少需要3个字节表示
- 英文字母均排在前128个
UTF-8
- Unicode的一种变长格式
- 128个US-ASCII字符只需要一个字节编码
- 西方常用字符需要两个字节
- 其他字符需要3个字节,极少需要4个字节
按照UTF-8编码,下面的字符串的Len
值应为10
s := "武汉haha"
sh := (*reflect.StringHeader)(unsafe.Pointer(&s))
fmt.Println(sh.Len) // 10
结论:Len表示Byte数组的长度(字节数)
字符串的访问
- 对字符串使用
len
方法得到的是字节数不是字符数 - 对字符串直接使用下标访问,得到的是字节
- 字符串被
range
遍历时,被解码成rune
类型的字符 - UTF-8编码解码算法位于runtime/utf8.go
字符串的切分
先转为rune数组,切片,再转为string
s := "武汉武汉"
s = string([]rune(s)[:2])
fmt.Println(s) // 武汉
切片底层原理
那slice
的底层结构是不是也是一个结构体?猜对了
slice
的底层结构(位于runtime/slice.go)
type slice struct { array unsafe.Pointer len int cap int
}
切片的本质是对数组的引用
切片的创建
根据数组或切片创建
arr := [4]int{1, 2, 3, 4}
sli1 := arr[0:4]
sli2 := sli1[0:1]
fmt.Println(sli1) // [1 2 3 4]
fmt.Println(sli2) // [1]
字面量:编译时插入创建数组的代码
sli1 := []int{1, 2, 3}
fmt.Println(sli1) // [1 2 3]
make:运行时创建
sli1 := make([]int, 2)
fmt.Println(sli1) // [0 0]
那切片的创建是在编译时完成的,还是运行时完成的呢?这该如何查看
使用go build -gcflags -S main.go
查看
找到字面量创建切片的行数进行查看,发现是在编译时先创建了一个数组,再基于数组创建了切片:
同理,使用make
创建切片的底层逻辑也可以通过上面的命令进行查看:
不同于字面量的创建过程,使用make
创建时直接调用了makeslice
方法,这个方法是个运行时的方法,直接传入参数,返回新建切片的指针:
func makeslice(et *_type, len, cap int) unsafe.Pointer {
......
}
再通过下面的示例回顾下切片创建的原理:
arr := [10]int{0,1,2,3,4,5,6,7,8,9}
slice := arr[1:4]
切片的访问
- 下标直接访问元素
range
遍历元素len(slice)
查看切片长度cap(slice)
查看数组容量
切片的追加
-
不扩容时,只调整
len
(编译器负责) -
扩容时,编译时转为调用
runtime.growslice()
-
如果期望容量大于当前容量的两倍就会使用期望容量
-
如果当前切片的长度小于1024,将容量翻倍
-
如果当前切片的长度大于1024,每次增加25%
-
切片扩容时,并发不安全,注意切片并发要加锁
上面扩容的逻辑可通过runtime.growslice()
方法进行查看:
newcap := old.cap
doublecap := newcap + newcap
if cap > doublecap { newcap = cap
} else { const threshold = 256 if old.cap < threshold { newcap = doublecap } else { // Check 0 < newcap to detect overflow // and prevent an infinite loop. for 0 < newcap && newcap < cap { // 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) / 4 } // Set newcap to the requested cap when // the newcap calculation overflowed. if newcap <= 0 { newcap = cap } }
}
map的底层原理
golang语言的map底层本质是用hashmap进行实现的
hashmap的基本方案
开放寻址法
底层实际是一个数组,数组元素是一个个的键值对。现在假设要插入一个新的键值对,key是字母b
,value是字母B
,先把key先hash,Hash后与下面的数组长度6求模为1,按理应放到下标为1的位置,但是被占了,就往后找,直到有空为止。
如果要读取key是字母b
的值也是一样的,先hash再取模,如果下标为1的位置不是字母b
,说明字母b
往后放了,就继续往后找,找到为止。
拉链法
前面的步骤一样,Hash后与下面的数组长度6求模为1,但是这里槽为1(下标为1)存放的并不是实际的键值对,而是一个指针,后面挂了一个相当于链表的东西,哈希碰撞或哈希值一样的情况下,会挂在1号槽的链表后面,假如1号槽的链表中已经有了一个键值对,则b:B
会追加到后面。这样就不像开放寻址法横向地往后找,而是向拉链一样,纵向地向下拉一个链表,挂在后面。
如果要读取key是字母b
的值也是一样的,hash求模后遍历链表,就能找到想要的数据。
Go map底层结构
runtime/map.go文件中,有个hmap的结构体,这就是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 // 存的键值对的数量flags uint8 B uint8 // lg2桶的大小noverflow uint16 // approximate number of overflow buckets; see incrnoverflow for details hash0 uint32 // hash的种子buckets unsafe.Pointer // array of 2^B Buckets. may be nil if count==0. oldbuckets unsafe.Pointer // previous bucket array of half the size, non-nil only when growing nevacuate uintptr // progress counter for evacuation (buckets less than this have been evacuated) extra *mapextra // optional fields
}
里面有个参数是buckets,意思是桶,这与上面讲到的拉链法中桶的概念名称一致,基本可以断定Go的map底层是用类似拉链法实现的。
那桶的数据结构是什么,继续往下看,buckets的指针指向的是一个由很多个bmap组成的数组,bmap由tophash、keys、elems、overflow四个参数组成,后三个参数是编译的时候才会生成(好支持不同的数据类型),tophash存了8个key的前一个字节的hash值,overflow是溢出指针,指向下一个bmap。
// A bucket for a Go map.
type bmap struct { // tophash generally contains the top byte of the hash value // for each key in this bucket. If tophash[0] < minTopHash, // tophash[0] is a bucket evacuation state instead. tophash [bucketCnt]uint8 // 存了8个key的前一个字节的hash值// Followed by bucketCnt keys and then bucketCnt elems. // NOTE: packing all the keys together and then all the elems together makes the // code a bit more complicated than alternating key/elem/key/elem/... but it allows // us to eliminate padding which would be needed for, e.g., map[int64]int8. // Followed by an overflow pointer.}
map的底层结构图示如下:
map的初始化
make的方式初始化
m := make(map[string]int, 9)
fmt.Println(m)
make初始化的过程一样可以用go build -gcflags -S main.go
查看:
进入makemap方法:
func makemap(t *maptype, hint int, h *hmap) *hmap { ......
}
make map初始化解析,首先根据map元素的数量计算出B,根据B的值创建桶,还有溢出桶;还会创建mapextra的结构体,这个结构体有个参数nextOverflow,它会指向下一个可用的溢出桶。
字面量方式的初始化
m := map[string]string{"a": "A", "b": "B"}
fmt.Println(m)
- 元素少于25个时,转化为简单赋值
- 元素多余25个时,转化为循环赋值
map的访问
如何读取a这个key的value:
1.先计算桶号:
2.确认了它在2号桶里面:
3.计算tophash,看在2号桶的哪个位置:
4.发现2号桶第一个位置的tophash就是0x5c,进一步匹配key值,获取最终的value;如果key的值没有匹配上,就会进一步往后匹配,如果都没找到,说明map中不存在这个key。
写入的过程,与访问的过程类似,这里不再赘述。
总结
- Go语言使用拉链实现了hashmap
- 每一个桶中存储键哈希的前8位
- 桶超出8个数据,就会存储到溢出桶中
Go map扩容原理
map为什么需要扩容
哈希碰撞的频率越高,会导致溢出桶越多,链表也越来越长,哈希操作的效率就会越来越低,性能严重下降:
Go map插入数据调用的mapassign方法有相关的扩容逻辑:
// Like mapaccess, but allocates a slot for the key if it is not present in the map.
func mapassign(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer { ......if !h.growing() && (overLoadFactor(h.count+1, h.B) || tooManyOverflowBuckets(h.noverflow, h.B)) { hashGrow(t, h) goto again // Growing the table invalidates everything, so try again ......
}
runtime.mapassign()可能会触发扩容的情况:
- 装载因子超过6.5(平均每个槽6.5个key)
- 使用了太多溢出桶(溢出桶超过了普通桶)
map扩容的类型
- 等量扩容:数据不多但是溢出桶太多了(之前数据很多,都被删了,需要整理)
- 翻倍扩容:数据太多了
map扩容步骤
map扩容:步骤1
可见hashGrow
这个方法:
- 创建一组新桶
- oldbuckets指向原有的桶数组
- buckets指向新的桶数组
- map标记为扩容状态
map扩容:步骤2
写入时,先找到原来的key的位置:
扩容后,假如变成了8个桶,这时B变成了3,就要看哈希的后三位,如果前面一位是0,说明要去2号桶,如果前面一位是1,说明要去6号桶,就将原来的旧桶里的数据往新桶进行了一分为2的迁移。如果新桶的数量没有变,那相当于就是对旧桶做了一个整理。
具体的扩容逻辑代码,可见runtime.map文件中的evacuate
这个方法。
- 将所有的数据从旧桶驱逐到新桶
- 采用渐进式驱逐
- 每次操作一个旧桶时,将旧桶数据驱逐到新桶
- 读取时不进行驱逐,只判断读取新桶还是旧桶
map扩容:步骤3
- 所有的旧桶驱逐完成后
- oldbuckets回收
总结
- 装载系数或者溢出桶的增加,会触发map扩容
- 扩容可能并不是增加桶数,而是整理
- map扩容采用渐进式,桶被操作时才会重新分配
Go map并发问题
当map同时进行读写操作时,会弹出fatal error: concurrent map read and map write
的报错,可用下面一段代码实验:
func main() { testM := make(map[int]int) go func() { for { _ = testM[0] } }() go func() { for { testM[1] = 2 } }() select {}
}
map为什么不支持并发读写
A正在旧桶读取数据时,B这时写入,要对这个旧桶进行驱逐:
- map的读写有并发问题
- A协程在桶中读数据时,B协程驱逐了这个桶
- A协程会读到错误的数据或者找不到数据
map并发问题解决方案
- 给map加锁(mutex)
- 使用sync.Map
sync.map的原理
sync.map的查询、修改、新增
sync.map的底层结构
可见sync.map结构体:
type Map struct { mu Mutex read atomic.Value dirty map[any]*entry misses int
}
相当于一套value有两套一模一样的key
sync.map正常读写的过程
读出a这个key的值"A":
sync.map追加的过程
先去read map找,发现没有,然后上锁,去下面的dirty map中(同时只能有一个协程去操作dirty map):
假如我要追加一个d:D
,追加后,这时read中的amended变为了true,意思是提醒使用者这时read map已经不完整了,有追加的新键值:
sync.map追加后读写的过程
假如我现在要读写刚才追加的d的值,首先先去上面找,没找到,由于amended变为了true,我开始往下面找,找到了,这时misses加1(上面没有命中,下面命中了的数量):
sync.map dirty提升
当misses的值等于下面dirty中key的数量时,几乎每次读都要走下面的,于是上面的就可以不要了:
上面的不要了,dirty往上移:
dirty取代了原来m的位置,上面的amended置为false,结构体中的misses置为0:
如果要追加,会重建dirty,指针指向一个新的dirty:
sync.map的删除
- 相比于查询、修改、新增,删除更麻烦
- 删除可以分为正常删除和追加后删除
- 提升后,被删key还需特殊处理
正常删除
没有追加的情况下,假如要删除d这个key,走上面的read map,将Pointter指针置为空,go的GC就会自动进行删除。
追加后删除
d是刚追加的情况下,要删除d这个key,首先还是走下面的dirty map,将Pointter指针置为空
后面遇到要提升dirty,提升上来后,下面要重建dirty map,是否要将d包含在其中,这是一个问题
sync.map采用的办法是,之前的d会指向expunged(被删除的),下面的dirty map重建的时候将不会在包含d
总结
- map在扩容时会有并发问题
- sync.Map使用了两个map,分离了扩容问题
- 不会引发扩容的操作(查、改)使用read map
- 可能引发扩容的操作(新增)使用dirty map
接口的底层原理
go隐式接口特点
- 只要实现了接口的全部方法,就是自动实现接口
- 可以在不修改代码的情况下抽象出新的接口
底层是如何表示接口的值
接口的简单用法:
type Phone interface { call()
}
type Xiaomi struct { Model string // 型号
} func (x Xiaomi) call() { }
func main() { var phone Phone = Xiaomi{} fmt.Println(phone)
}
上面的phone的类型是Xiaomi,但是为什么phone.Model无法打印出Model这个成员参数,所以phone不是一个简单的转成了Xiaomi的类型,而是一个Phone接口的值,那接口的值底层是一个什么样的表示呢,找到runtime/runtime2.go文件:
type iface struct { tab *itab data unsafe.Pointer // 指向装载的结构体
}
继续看下itab这个指针接口体:
type itab struct { inter *interfacetype _type *_type // 装载的结构体具体是一个什么类型hash uint32 // copy of _type.hash. Used for type switches. _ [4]byte fun [1]uintptr // variable sized. fun[0]==0 means _type does not implement inter. 这个类型实现了哪些方法
}
- 接口数据使用runtime.iface表示
- iface记录了数据的地址
- iface中也记录了接口类型信息和实现的方法(方便类型断言)
类型断言
- 类型断言是一个使用在接口值上的操作
- 可以将接口值转换为其它类型值(实现或者兼容接口)
- 可以配合switch进行类型判断
还是以上面的phone为例:
type Phone interface { call()
} type CommunicationTools interface { call()
} type Xiaomi struct { Model string // 型号
} func (x Xiaomi) call() { fmt.Println(x.Model)
}
func main() { var phone Phone = Xiaomi{} fmt.Println(phone) c := phone.(CommunicationTools) fmt.Println(c) switch phone.(type) { case CommunicationTools: fmt.Println("ok") } }
结构体和指针实现接口
用下面的示例代码来解释上面这个表格的意思:
type Phone interface { call()
} type Xiaomi struct { Model string // 型号
} type Huawei struct { Model string // 型号
}
// Xiaomi结构体实现了Phone接口,go在编译时会自动让Xiaomi结构体指针也实现Phone接口
func (x Xiaomi) call() { fmt.Println(x.Model)
}
// 只有Huawei结构体指针实现了Phone接口
func (x *Huawei) call() { fmt.Println(x.Model)
} func main() { var phone1 Phone = Xiaomi{} var phone2 Phone = &Xiaomi{} var phoneA Phone = Huawei{} // 报错了,因为Huawei结构体没有实现Phone接口 var phoneB Phone = &Huawei{} fmt.Println(phone1, phone2) fmt.Println(phoneA, phoneB)
}
用go build -gcflags -S main.go
查看go在编译时自动给Xiaomi结构体指针实现的Phone接口:
空接口的值及用途
空接口的值
- runtime.eface结构体
- 空接口底层不是普通接口
- 空接口值可以承载任何数据
空接口的用途
- 空接口的最大用途是作为任意类型的函数入参
- 函数调用时,会新生成一个空接口,再传参
总结
- Go的隐式接口更加方便系统的扩展和重构
- 结构体和指针都可以实现接口
- 空接口值可以承载任何类型的数据
nil,空结构体,空接口区分
nil
nil的变量定义位于builtin/builtin.go
:
// nil is a predeclared identifier representing the zero value for a
// pointer, channel, func, interface, map, or slice type.
var nil Type // Type must be a pointer, channel, func, interface, map, or slice type
a的零值和b的零值都为nil,但是它们两者之间并不能比较:
var a *int
var b map[int]bool
fmt.Println(a == nil) // true
fmt.Println(b == nil) // true fmt.Println(a == b) // error mismatched types *int and map[int]bool
- nil是空,并不一定是“空指针”
- nil是6种类型(pointer, channel, func, interface, map, or slice type)的“零值”
- 每种类型的nil是不同的,无法比较
空结构体
- 空结构体是Go中非常特殊的类型
- 空结构体的值不是nil
- 空结构体的指针也不是nil,但是都相同(zerobase)
空接口
var a interface{}
fmt.Println(a == nil) // true
var b *int
a = b
fmt.Println(b == nil) // true
fmt.Println(a == nil) // false a接口底层的eface里面有了类型信息
- 空接口不一定是“nil接口”
- 两个属性都nil才是nil接口
总结
- nil是多个类型的零值,或者空值
- 空结构体的指针和值都不是nil
- 空接口零值是nil,一旦有了类型信息就不是nil