1. Go 语言介绍
1.1 Go 语言优势
-
语法简单 - 开发效率高
-
集各种语言的优势 - 大量参考 c 和 python
-
c 语言:静态类型系统、指针操作等特点
-
Python 语言:简洁语法、垃圾回收等优势
-
-
执行性能高 - 直接编译成二进制,部署非常简单
-
想运行 Python 程序则需要预先安装好 Python
-
想运行 Java 程序则需要预先安装 jdk
-
而想运行 go 程序则不用事先安装任何软件,因为它本身就是 exe 文件可以直接执行
-
-
并发编程非常高效 - goroutine
-
编译速度快 - 比 C++、Java 编译快
Java 源代码需要通过 Java 编译器编译成字节码(byte code)。字节码是一种中间代码,它可以在 Java 虚拟机(JVM)上执行。然后,JVM 会将字节码解释成机器语言,最终在计算机上运行。这个过程也被称为 “即时编译”(Just-In-Time Compilation,JIT)。
1.2 Go 语言能做什么?
应用场景
-
Web 开发
- gin (ginex)、hertz、beego 等
-
容器虚拟化
- docker、k8s、istio
-
中间件
- etcd、tidb、infuluxdb、nsq 等
-
区块链
- 以太坊、fabric
-
微服务
- kitex、go-zero、dapr、rpcx、kratos、dubbo-go
使用情况
-
国内使用热度超过国外
-
大量国内公司使用
- 字节跳动、百度、腾讯、阿里、B 站、小米、滴滴、京东、360、七牛云、知乎、美团等
公司内部使用原因
-
性能比较好
-
部署简单,学习成本低
2. 变量与常量
2.1 定义与使用
如何定义变量?
-
变量必须先定义才能使用
-
go 语言是静态语言,要求变量的类型和赋值类型一致
- 因此不存在像 C++ 那样的隐式类型转换
-
变量名不能冲突
-
简洁变量定义不能用于全局变量
-
变量是有零值,也就是默认值
-
定义了变量一定要使用,否则会报错
定义须知
-
常量定义的时候指定的值不能修改,常量尽量大写
-
常量类型只可以定义 bool、数值(整数、浮点数和复数)和字符串
-
不曾使用的常量,没有强制使用的要求
-
显示指定类型的时候,必须确保常量左右值类型一致
-
同时定义多个常量时,需要注意未指明的常量将引用前面定义的值:
const (
x int = 16
y
s = "abc"
z
m
)
fmt.Println(x, y, s, z, m) //16 16 abc abc abc
2.2 iota 的使用细节
iota,特殊常量,可以认为是一个可以被编译器修改的常量。
const(
ERR1 = iota
ERR2
ERR25
ERR3
)
fmt.Println(ERR1, ERR2, ERR25, ERR3) //0 1 2 3
const(
ERR1 = iota + 1
ERR2
ERR25 = "ha"
ERR3
ERR4 = iota
)
fmt.Println(ERR1, ERR2, ERR25, ERR3, ERR4) //1 2 ha ha 4
注意:
- 如果中断了 iota,那么必须显示的恢复,后续会自动递增
- 自增类型默认是 int 类型
- iota 能简化 const 类型的定义
- 每次出现 const 的时候,iota 初始化为 0
2.3 匿名变量的定义与使用
//匿名变量
var _ int
func a() (int, bool){
return 0, false
}
_, ok := a()
if ok {
//打印
}
3. 数据类型
3.1 基本数据类型
3.1.1 bool 类型
布尔类型的值只可以是常量 true 或者 false,比如:var b bool = true。
3.1.2 数值类型
整数型
-
int8 有符号 8 位整型 (-128 到 127) 长度:8bit
-
int16 有符号 16 位整型 (-32768 到 32767)
-
int32 有符号 32 位整型 (-2147483648 到 2147483647)
-
int64 有符号 64 位整型 (-9223372036854775808 到9223372036854775807)
-
uint8 无符号 8 位整型 (0 到 255) 8 位都用于表示数值
-
uint16 无符号 16 位整型 (0 到 65535)
-
uint32 无符号 32 位整型 (0 到 4294967295)
-
uint64 无符号 64 位整型 (0 到 18446744073709551615)
浮点型
-
float32 32 位浮点型数
-
float64 64 位浮点型数
其它
-
byte 等于 uint8
-
rune 等于 int32
-
uint 32 或 64 位
注意:
byte 和 rune 主要是用于存储字符,byte 存储的范围更小,rune 存储的范围更大,可以包括中文。
3.1.3 string 类型
底层存储原理
var str string = "hello"
go 语言和 c 语言对于字符串结尾的判定条件有所不同,c 语言会认为在结尾处加上 \0 就代表这个字符串的结束。但是这就会限制内容中不能再出现 \0,如果出现就会发生不可预估的后果。
因此,在 go 语言中并没有采用这种方式,而是在起始地址后边,多存了一个长度。
type stringStruct struct {
str unsafe.Pointer
len int
}
注意:
在起始地址后存的长度并不是表示字符个数,而是表示字节个数。因为某些字符不会只占一个字节,比如汉字。
常用操作
//是否包含
name := "golang - good"
fmt.Println(strings.Contains(name, "go"))
//出现次数
fmt.Println(strings.Count(name, "o")
//分割字符串
fmt.Println(strings.Split(name, "-"))
//字符串是否包含前缀,是否包含后缀
fmt.Println(strings.HasPrefix(name, "gol"))
fmt.Println(strings.HasSuffix(name, "ood"))
//查找子串出现的位置
fmt.Println(strings.Index(name, "go"))
//子串替换
fmt.Println(strings.Replace(name, "go", "java", 1))
//大小写转换
fmt.Println(strings.ToLower("GO"))
fmt.Println(strings.ToUpper("java"))
//去掉字符串两头的特殊字符
fmt.Println(strings.Trim("#hello #go#", "#"))
格式化输出
- 缺省格式和类型
| 格式化后的效果 | 动词 | 描述 |
|---|---|---|
| [0 1] | %v | 缺省格式 |
| []int64{0, 1} | %#v | go 语法打印 |
| []int64 | %T | 类型打印 |
- 整形(缩进、进制类型、正负符号)
| 格式化后的效果 | 动词 | 描述 |
|---|---|---|
| 15 | %d | 十进制 |
| +15 | %+d | 必须显示正负符号 |
| __15 | %4d | Pad 空格 (宽度为 4,右对齐) |
| 15__ | %-4d | Pad 空格 (宽度为 4,左对齐) |
| 1111 | %b | 二进制 |
| 17 | %o | 八进制 |
| f | %x | 16 进制,小写 |
- 字符(有引号、Unicode)
Value:65(Unicode letter A)
| 格式化后的效果 | 动词 | 描述 |
|---|---|---|
| A | %c | 字符 |
| 'A' | %q | 有引号的字符 |
| U+0041 | %U | Unicode |
| U+0041 'A' | %#U | Unicode 有引号 |
- 浮点(缩进、精度、科学技术)
Value:123.456
| 格式化后的效果 | 动词 | 描述 |
|---|---|---|
| 1.234560e+02 | %e | 科学计数 |
| 123.456000 | %f | 十进制 |
- 字符串/字节 silce(引号、缩进、16 进制)
Value:"cafe"
| 格式化后的效果 | 动词 | 描述 |
|---|---|---|
| cafe | %s | 字符串原样输出 |
| __cafe | %6s | 宽度为 6,右对齐 |
只读特性
下标访问字符串:
str := "golang"
fmt.Println("%c\n", s1[2]) //可以执行
//s1[2] = 'e' //错误
注意:
go 语言中不能用下标直接去修改字符串内容,但是可以用来访问。
原因是 go 认为上面这样的字符串是不会被修改的,所以会把这样定义的字符串内容分配到只读内存段。
如果想修改字符串内容的话,可以转化成 slice,但是可能会同时修改底层字符串的内容。
如果想脱离这个只读的限制,并且想修改时不会影响到底层字符串的内容。那么可以转换成字节 slice,这样就会为 slice 变量重新分配一段内存,同时会也会拷贝原来字符串的内容。
byteStr := ([]byte)(str)
byteStr[2] = 'x'
fmt.Printf("%c\n", byteStr[2]) //x
举个具体点的例子:
func main() {
s := "go语言"
arr := []byte(s)
fmt.Println(arr) //[103 111 232 175 173 232 168 128]
fmt.Println(arr[2], s[2]) //232 232
arr[2] = 9
//s[2] = 9 //字符串不能修改
fmt.Println(len(s), len(arr)) //8 8
brr := []rune(s)
fmt.Println(brr) //[103 111 35821 35328]
fmt.Printf("%d %d %s %s\n", brr[2], s[2], string(brr[2]), string(s[2])) //35821 232 语 è
fmt.Println(utf8.RuneCountInString(s)) //4
}
高性能字符串拼接
username := "张三"
age := 24
address := "北京"
mobile := "18989898989"
性能要求不高,平时一般采用:
userMsg := username + strconv.Itoa(age) + address + mobile
userMsg := fmt.Sprinf("用户名:%s,年龄:%d,地址:%s,电话:%s", username, age, address, mobile)
性能要求高:
var builder string.Builder
builder.WriteString("用户名:")
builder.WriteString(username)
builder.WriteString(",年龄:")
builder.WriteString(strconv.Itoa(age))
builder.WriteString(",地址:")
builder.WriteString(address)
builder.WriteString(",电话:")
builder.WriteString(mobile)
re := builder.String()
fmt.Println(re)
注意:
- 当字符数量和长度较小的时候:
+拼接>strings.Builder>fmt.Sprintf- 当字符数量和长度较大的时候:
strings.Builder>+拼接>fmt.Sprintf
3.2 集合类型
3.2.1 数组
定义:
//一维数组
var name [count]int
//多维数组
var courseInfo [3][4]string
初始化:
/* 一维数组 */
//方法1
coures1 := [3]string{"go", "grpc", "gin"}
//方法2
courses2 := [3]string{2:"gin"}
//方法3
courses3 := [...]string{"go", "grpc", "gin"}
/* 多维数组 */
var courseInfo [3][4]string
//方法1
courseInfo[0] = [4]string{"go", "1h", "bobby", "go学习"}
//方法2
courseInfo[0][0] = "go"
courseInfo[0][1] = "1h"
courseInfo[0][2] = "bobby"
courseInfo[0][3] = "go学习"
遍历:
/* 一维数组 */
//方法1
for i := 0; i < len(courses); i++ {
fmt.Println(courses[i]}
}
//方法2
for _, value := range courses {
fmt.Println(value)
}
/* 多维数组 */
//方法1
for i := 0; i < len(courseInfo); i++ {
for j:=0; j < len(courseInfo[i]); j++ {
fmt.Print(courseInfo[i][j] + " ")
}
fmt.Println();
}
//方法2
for _, row := range courseInfo {
fmt.Println(row)
}
注意:
[2]string和[3]string是不同的类型[count]string是数组,[]string是切片
3.2.2 slice
定义与初始化
定义:
var courses []string
初始化:
//方法1 - 从数组直接创建 (也可以通过切片创建新的切片)
courses := [5]string{"go", "grpc", "gin", "mysql", "elasticsearch"}
courseSlice := courses[0:2] //[go, grpc]
//方法2 - 使用string{}
courses := []string{"go", "grpc", "gin", "mysql", "elasticsearch"}
//方法3 - make
courses := make([]string, 3)
courses[0] = "go"
//方法4 - append
var courses []string
courses = append(courses, "go")
注意:
切片数组是左闭右开区间
常用操作
增加元素:
var courses []string
//场景1 - 增加单个元素
courses = append(courses, "go")
//场景2 - 增加多个元素
courses = append(coureses, "go", "grpc")
//场景3 - 增加切片到尾部
courseSilce1 := []string{"go", "grpc"}
courseSilce2 := []string{"mysql", "es"}
courseSilce1 = append(courseSilce1, courseSilce2...)
删除元素:
courseSlice := []string{"go", "grpc", "mysql", "es", "gin"}
//删除mysql
myslice := append(courseSlice[:2], courseSlice[3:]...)
拷贝:
//浅拷贝 - 切片2改动会影响到切片1
courseSlice2 = courseSlice1[:]
//深拷贝 - 切片2改动不会影响到切片1
var courseSlice2 = make([]string, len(courseSlice1))
copy(courseSlice2, courseSlice1)
底层存储原理
//切片的实际存储类型
type slice struct {
array unsafe.Pointer //用来存储实际数据的数组指针,指向一块连续的内存
len int //切片中元素的数量
cap int //array数组的长度(实际容量)
}
下面我们分场景举两个例子:
- 整形
如果我们用 make 来初始化的话,则会分配实际的内存,并且会将元素全部初始化为 0,例如下面我们创建了 5 个大小的空间,但访问的只有前 2 个。
假设我们现在新增一个元素,并更改其中一个元素的值,可以发现容量没变但实际长度变成了 3,但需要注意的是后面两个元素仍不能访问,否则汇报数组越界的错误。
- 字符串
这次我们用 new 来初始化,与 make 不同,new 虽然会创建上面的三部分结构,但并不会分配底层数组空间,所以 data 为 nil。因此,如果我们试图更改数组的值比如 (*ps)[0] = "golang",则会报错。
但是,我们可以通过 append 的方式来分配底层数组。
分配位置
可以发现,我们上面举的例子中 slice 的 data 指针都指向 array 数组的第一个元素,但实际上 slice 可以指向 array 数组的任何一个位置。
注意:
array 数组一旦申明了容量大小就不可改变,因此我们可以通过 slice 来灵活的获得我们想要的数组变量。
来举个例子:
下面需要注意的是,s1 的容量计算范围是从开头到底层数组的结尾,因此其容量为 9。
注意:
切片是值传递,但是有引用传递的效果,是因为底层都指向同个地方。一旦增加导致扩容,会指向另一块区域。
我们这里拿 s2 切片数组进行模拟,一旦切片数组发生扩容,则会重新分配一块新的内存,此后对该切片数组的操作都不会再影响到原来的底层数组。
扩容规则
go1.18 之前:
// src/runtime/slice.go
func growslice(et *_type, old slice, cap int) slice {
// ...
newcap := old.cap
doublecap := newcap + newcap
if cap > doublecap {
newcap = cap
} else {
if old.cap < 1024 {
newcap = doublecap
} else {
// Check 0 < newcap to detect overflow
// and prevent an infinite loop.
for 0 < newcap && newcap < cap {
newcap += newcap / 4
}
// Set newcap to the requested cap when
// the newcap calculation overflowed.
if newcap <= 0 {
newcap = cap
}
}
}
// ...
return slice{p, old.len, newcap}
}
go1.18 之后:
// src/runtime/slice.go
func growslice(et *_type, old slice, cap int) slice {
// ...
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
}
}
}
// ...
return slice{p, old.len, newcap}
}
举个例子:
func main() {
ints := make([]int, 2)
fmt.Println(&ints[0], " len: ", len(ints), " cap: ", cap(ints))
ints = append(ints, 2, 3, 4)
fmt.Println(&ints[0], " len: ", len(ints), " cap: ", cap(ints))
}
output
>>> 0xc0000180a0 len: 2 cap: 2
>>> 0xc00000a360 len: 5 cap: 6
按照扩容规则来讲,上面这个例子 2 * 2 < 2 + 3,所以扩容后的容量理应是 5,但是最终的容量却为 6,与规则对不上。
这是因为扩容完之后的容量内存大小可能并不是最终的大小,新分配的内存大小并不是等于预估容量乘以数据类型的大小,而是需要按照一定的内存规格进行匹配。
在许多编程语言中,很多语言中申请分配内存并不是直接与操作系统交涉,而是和语言自身实现的内存管理模块。它会提前向操作系统申请一批内存,分成常用的规格管理起来。我们申请内存时,它会帮我们匹配到足够大且最接近的规格。
所以按上面这个例子来讲,最终分配的内存为 48 个字节,而在 64 位系统中 int 占 8 个字节,故最终的容量不是 40 / 8 = 5 个,而是 48 / 8 = 6 个。
3.2.3 map
定义与初始化
定义:
type class struct {
Content string
Name string
}
//定义一个map存储class结构体指针
var course map[string]*class
初始化:
//方法1
var courseMap = map[string]string {
"go":"go工程师",
"grpc":"grpc入门",
"gin":"gin深入理解"
}
//方法2
var courseMap = map[string]string{} //初始化为空
courseMap["mysql"] = "mysql原理"
//方法3
var courseMap = make(map[string]string, 3)
courseMap["mysql"] = "mysql原理"
//注意
var courseMap = map[string]string //没有初始化
//会报错!!!
courseMap["mysql"] = "mysql原理"
fmt.Println(courseMap)
注意:
如果只声明没有初始化,放值后打印会报错。
map 必须初始化才能使用,但是 slice 可以不初始化使用。
常用操作
遍历:
//方法1
for key, value := range courseMap {
fmt.Println(value)
}
//方法2
for key := range courseMap {
fmt.Println(key, courseMap[key])
}
注意:
map 是无序的,而且不保证每次打印都是相同的顺序。
判断是否存在:
//方法1
d, ok := courseMap["java"]
if !ok {
fmt.Println("not int")
}
//方法2
if _, ok := courseMap["java"]; !ok {
fmt.Println("not int")
}
删除元素:
delete(courseMap, "grpc")
注意:
如果删除不存在的元素,不会报错。
另外,map 不是线程安全的,需要用 sync.Map。
哈希表
说到键值对的存储,我们自然会想到哈希表,至于怎么将键值对存储到哈希桶中,主要有以下几个步骤:
-
通过哈希函数 Hash() 把 “键 k1” 处理一下,得到一个哈希值 hash
-
现在需要通过哈希值 hash 从所有桶中选择一个,桶编号范围为 [0, m-1]
-
选择方法有两种比较常用:
-
**取模法:**hash % m
-
**与运算:**hash & (m-1)
-
需要注意的是,与运算方法如果想确保运算结果落在区间 [0, m-1] 而不会出现空桶,就要限制桶的个数 m 必须是 2 的整数次幂。这样 m 的二进制表示一定只有一位为 1,并且 m-1 的二进制表示一定是低于这一位的所有位均为 1。
注意:
如果桶的数目不是 2 的整数次幂,就有可能出现有些桶绝对不会被选中的情况。
哈希冲突
知道如何选桶后还要考虑一个问题,如果出现选择的桶冲突了怎么办,下面我们同样来介绍两种常用的方法:
- 开放地址法
如果 {k2, v2} 选择的桶和 {k1, v1} 选择的一样即发生了冲突,则沿着 {k1, v1} 的桶往后找到第一个没有被占用的桶进行占用。
当要查找 {k2, v2} 时,会从 {k1, v1} 的桶开始比较,如果发现 key 值不相等,则会沿着该桶往后进行比较,直到找到 key = k2 的桶为止。
- 拉链法
还有一种方法不用沿着桶进行寻找,而是直接在冲突的桶后面链一个新桶存储这个键值对即可。
当要查找 {k2, v2} 时,同样会从 {k1, v1} 的桶开始比较,如果发现 key 值不相等,这次会沿着该桶后面的链表进行比较,直到找到 key = k2 的桶为止。
哈希扩容
上面的哈希冲突会影响到我们哈希表的读写效率,因此选择散列均匀的哈希函数可以减少哈希冲突的发生,另外适时的对哈希表进行扩容也是保障读写效率的有效手段。
通常我们会把存储键值对的数目与桶的数目的比值作为是否需要扩容的判断依据,这个比值被称为 “负载因子”。
当我们需要扩容时,就要分配更多的桶,它们就是 “新桶”,需要把 “旧桶” 里存储的键值对都迁移到新桶里。
如果哈希表存户的键值对较多,一次性迁移所有桶花费的时间就比较显著。所以通常会在哈希表扩容时,先分配足够多的新桶,然后用一个字段记录 “旧桶” 的位置 oldbuckets,再增加一个字段记录 “旧桶” 的迁移进度,例如记录下一个要迁移的 “旧桶” 编号 nevacuate。
在哈希表每次读写操作时,如果检测到当前处于扩容阶段,就完成一部分键值对的迁移任务,直到所有的 “旧桶” 迁移完成,“旧桶” 不再使用才算真正完成了一次哈希表的扩容。
像这样把键值对迁移的时间分摊到多次哈希表操作中的方式,就是 “渐进式扩容” 了,可以避免一次性扩容带来的性能瞬时抖动。
注意:
Go map 的扩容就采用的这种方式,原有的 key 并不会一次性搬迁完毕,每次最多只会搬迁 2 个 bucket。
底层存储原理
在 Go 语言中,map 类型的底层实现就是哈希表,map 类型的变量 a := map[string]string{} 本质上是一个指针 *hmap,指向 hmap 结构体。
type hmap struct {
count int //键值对数目
flags uint8 //状态标志(是否处于正在写入的状态等)
B uint8 //记录桶的数目是2的多少次幂,因为这里选择桶时用的是与运算的方法
noverflow uint16 //记录使用溢出桶的数量
hash0 uint32 //生成hash的随机数种子
buckets unsafe.Pointer //记录桶在哪里
oldbuckets unsafe.Pointer //用于在扩容阶段保存旧桶在哪儿
nevacuate uintptr //记录渐进式扩容阶段下一个要迁移的旧桶编号
extra *mapextra //存储溢出桶
}
map 的桶是用 bmap 结构进行存储,一个桶里可以放 8 个键值对,但是为了让内存排列更加紧凑,所以让 8 个 key 放一起,8 个 value 放一起。
在 8 个 key 的前面则是 8 个 tophash,每个 tophash 都是对应哈希值的高 8 位。
// A bucket for a Go map.
type bmap struct {
tophash [bucketCnt]uint8
// len为8的数组
// 用来快速定位key是否在这个bmap中
// 一个桶最多8个槽位,如果key所在的tophash值在tophash中,则代表该key在这个桶中
}
上面 bmap 结构是静态结构,在编译过程中 runtime.bmap 会拓展成以下结构体:
type bmap struct{
tophash [8]uint8
keys [8]keytype
// keytype 由编译器编译时候确定
values [8]elemtype
// elemtype 由编译器编译时候确定
overflow uintptr
// overflow指向下一个bmap,overflow是uintptr而不是*bmap类型,保证bmap完全不含指针,是为了减少gc,溢出桶存储到extra字段中
}
放在最后的是一个 bmap 型指针,指向一个溢出桶,溢出桶的内存布局与常规桶相同,是为了减少扩容次数而引入的。当一个桶存满了,还有可用的溢出桶时,就会在桶后面链一个溢出桶,继续往溢出桶里面存。
但实际上如果哈希表要分配的桶的数目大于 2^4,就认为使用到溢出桶的几率较大,就会预分配 2^(B-4) 个溢出桶备用,这些溢出桶与常规桶在内存中是连续的,只是前 2^B 个用作常规桶,后面的用作溢出桶。
举个例子,假设 B = 5,那么一共有 2^5 个桶,又因为 2^5 > 2^4,所以就会预分配 2^(5-4) = 2^1 个溢出桶。
也就是说,当 B = 5 时,前 2^5 个会被作为常规桶,而剩下的 2^1 个桶会当做溢出桶使用。
另外,hmap 结构体最后有一个 extra 字段,它会指向一个 mapextra 结构体,里面记录的都是溢出桶相关的信息。其中 nextoverflow 指向下一个空闲溢出桶,overflow 是一个 slice,记录目前已经被使用的溢出桶的地址,而 oldoverflow 则是用于。
type mapextra struct {
overflow *[]*bmap //包含已经使用的溢出桶
oldoverflow *[]*bma //包含扩容阶段旧桶使用的溢出桶
nextOverflow *bmap //指向下一个溢出桶
}
假如现在 2 号桶存满了,它就会在后面链一个溢出桶,而 nextoverflow 就会指向下一个空闲溢出桶,并且此时 noverflow 会记录使用溢出桶的数量即记录此时用了 1 个溢出桶。
我们来看一个例子,变量 a 本质上是一个 hmap 的指针,目前存储了一个键值对,只拥有一个桶,也没有预分配的溢出桶。
下面我们把这个桶展开来看一下,首先是 8 个 tophash,每个占一字节,因为 key 和 value 都是 string 类型,所以 64 位下每个 key 和 value 都占用 16 字节。
目前只存储了一个键值对,取 k1 哈希值的高 8 位 h1 存到 tophash 第一位,而 k1 和 v1 则按照字符串的存储规则进行存储。
如果我们把这个桶存满,接下来继续存储新的键值对时,这个哈希表是会创建溢出桶还是会发生扩容呢?这就要看 map 的扩容规则了。
扩容规则
- 翻倍扩容
当负载因子大于 6.5,即 map 元素个数 / 桶个数 > 6.5 时,就会触发翻倍扩容。
来看源码,其中:
-
bucketCnt = 8,一个桶可以装的最大元素个数
-
loadFactor = 6.5,负载因子,平均每个桶的元素个数
-
bucketShift(B):桶的个数
func overLoadFactor(count int, B uint8) bool {
return count > bucketCnt && uintptr(count) > loadFactor * bucketShift(B)
}
回到刚刚那个例子,当存储新的键值对时,我们就需要进行扩容。由于负载因子大于 6.5 了,所以会触发翻倍扩容,旧桶数量为 1,所以新桶数量为 1 * 2 = 2 个。
此时,buckets 就会指向刚分配出来的新桶,而 oldbuckets 则会指向旧桶,并且 nevacuate 为 0,标识接下来要迁移编号为 0 的旧桶,每个旧桶的键值对都会分流到新桶中。
例如,旧桶的数量为 4,那么翻倍扩容后新桶的数量就为 8。
如果一个哈希值选择 0 号旧桶,那么按照旧桶 m = 4 的情况来看,哈希值的二进制低两位一定为 0。
现在新桶 m = 8,那么 0 号旧桶可以迁移的新桶只有两种,取决于哈希值的第三位是 0 还是 1,如果为 0 则迁移到编号为 0 的新桶,反之为 1 则迁移到编号为 4 的新桶。
因为桶的数量一定是 2 的整数次幂,所以无论容量为多少,翻倍扩容后,每个旧桶都会按照这样的规律分流道两个新桶中。
- 等量扩容
如果负载因子没有超标,但是使用的溢出桶较多,也会触发扩容:
-
当常规桶数量不大于 2^15 时(B <= 15),此时如果溢出桶总数 >= 常规桶总数(noverflow >= 2^B),则认为溢出桶过多,就会触发等量扩容。
-
当常规桶数量大于 2^15 时(B > 15),此时直接与 2^15 比较,当溢出桶总数 >= 2^15 时(noverflow >= 2^15),即认为溢出桶太多了,也会触发等量扩容。
func tooManyOverflowBuckets(noverflow uint16, B uint8) bool {
// If the threshold is too low, we do extraneous work.
// If the threshold is too high, maps that grow and shrink can hold on to lots of unused memory.
// "too many" means (approximately) as many overflow buckets as regular buckets.
// See incrnoverflow for more details.
if B > 15 {
B = 15
}
// The compiler does not see here that B < 16; mask B to generate shorter shift code.
return noverflow >= uint16(1)<<(B&15)
}
所谓等量扩容,就是创建和旧桶数量一样多的新桶,然后把原来的键值对迁移到新桶中。但是既然是等量,那迁移这些还有什么意义呢?
那当然是有个特殊情况需要我们关注,当有很多键值对被删除的时候,就有可能出现已经使用了很多溢出桶,但是负载因子仍没有超过上限值的情况。
就像下面这个例子,编号为 0 的桶中有很多键值对已经被删除了,此时如果触发了等量扩容,则会分配等量的新桶。而旧桶的每一个桶则会迁移到对应的新桶中,迁移完后可以使每个键值对排列的更加紧凑,从而减少溢出桶的使用。
3.2.4 List
使用:
package main
import (
"container/list"
"fmt"
)
func main() {
var mylist list.List //mylist := list.New()
//尾部插入
mylist.PushBack("go")
mylist.PushBack("grpc")
mylist.PushBack("mysql")
//头部插入
mylist.PushFront("gin")
//遍历打印值,正序
for i := mylist.Front(); i != nil; i = i.Next() {
fmt.Println(i.Value)
}
}