数组
在Go语言中,数组是一种值类型,用来存储同一类型元素的序列。
Go语言数组的底层实现原理是:
- 数组中的元素会逐个分配内存,并按索引顺序排列。
- 数组的长度是固定的,在声明时就需要确定,之后不能改变。
- 数组是值类型,赋值和传参会复制整个数组。
- 数组在内存中是连续分配的,通过索引可以快速访问任一元素。
具体来看,Go语言中的数组底层是通过以下结构体实现的:
type array struct {
len int // 数组长度
cap int // 数组容量,通常与长度相同
data unsafe.Pointer // 指向数组第一个元素地址
}
- len表示数组的长度,也就是数组中元素的数量。
- cap表示数组的容量,对数组来说,容量和长度都是固定的,cap通常就等于len。
- data是一个unsafe.Pointer类型的指针,指向数组第一个元素的地址,数组中其他元素的地址按索引顺序依次排列。
这样设计的好处是:
- 可以快速获取数组长度信息
- 通过data指针可以快速访问任意索引元素
- 容量信息可以支持部分切片操作
综上,通过数组结构体的设计,Go语言可以高效地管理数组并支持对数组的常见操作。这也是Go语言数组性能高效的重要原因之一。
切片
在Go语言中,切片(Slice)是一种引用类型,用于表示一个拥有相同类型元素的可变长度的序列。
切片的底层实现原理是:
- 切片是一个结构体,包含指向底层数组的指针、容量和长度信息。
- 切片并不直接持有数据,它只是底层数组的一个引用。
- 长度表示切片中元素的数量,容量表示从切片的起始位置到底层数组结束的数量。
- 切片支持自动扩容,当追加时超过容量会引起扩容,以2倍容量续接新数组。
具体来看,切片的实现结构如下:
type slice struct {
array unsafe.Pointer // 指向底层数组地址
len int // 长度
cap int // 容量
}
切片通过指向底层数组来获得数据,起始位置是数组起始位置加上切片的起始索引。
这个设计使得切片避免了数组传值时的复制开销,多个切片可以共享底层数组的存储。
扩容时,会按照2倍容量创建新数组,复制元素后再设置指针指向新数组。
这种实现使得切片操作高效,而且可以灵活自动扩容,但也要注意切片之间可能会相互影响。
总之,切片的实现充分利用了Go语言的内存管理和数据共享的能力,使其成为了一个既高效又便利的类型。
切片扩容
切片扩容的主要规则是:
- 当切片长度达到容量时,再append时会触发扩容。
- 扩容后的新容量是之前的2倍。
- 如果新容量小于1024,会直接翻倍,否则增加25%。
- 扩容时会分配新的底层数组,并将元素拷贝过去。
Map
Go语言中的map底层使用哈希表(散列数组)与链表实现。
主要的数据结构有:
- buckets数组 - 这是一个散列表,数组的每个元素称为一个bucket,用来存储key-value对。
- bucket结构体 - 每个bucket保存一个key-value对,如果出现冲突,则在bucket后面挂一个链表来存储冲突的key-value。
- hmap结构体 - map的主结构,包含了buckets数组,元素数量、负载因子等信息。
- 链表 - 链表结点(key, value)用于解决哈希冲突。
具体来看,map的实现结构如下:
type hmap struct {
count int //元素的个数
flags uint8 //状态标志
B uint8 //可以最多容纳 6.5 * 2 ^ B 个元素,6.5为装载因子
noverflow uint16 //溢出的个数
hash0 uint32 //哈希种子
buckets unsafe.Pointer //指向一个桶数组
oldbuckets unsafe.Pointer //指向一个旧桶数组,用于扩容
nevacuate uintptr //搬迁进度,小于nevacuate的已经搬迁
overflow *[2]*[]*bmap //指向溢出桶的指针
}
插入时,先计算key的哈希,确定bucket的位置,如果没有冲突直接插入,如果冲突则添加到链表头部。
查询时计算key的哈希,找到对应的bucket,然后遍历链表,比对key找到对应的value。
Go语言map扩容时会重新分配buckets数组,并将元素重新哈希插入新的buckets。
这样通过哈希表和链表实现,平衡了查询、插入的效率,哈希表定长带来的冲突通过链表解决,是一种效率较优的设计。
Map扩容
装载因子
//count 就是 map 的元素个数,2^B 表示 bucket 数量
loadFactor := count / (2^B)
当满足下面任一条件时会触发自动扩容
- 装载因子超过阈值,源码中的阈值为6.5
- 当所有的bucket都装满时,装载因子=8,对于这种情况,元素太多而bucket太少,扩容时将B加1,直接将bucket扩容两倍,老的buckets挂在oldbucket上
- overflow的bucket数量过多
- 当B小于等于15时,overflow的bucket的数量大于2B,当B大于15时,overflow的bucket的数量大于215。这意味着虽然每个bucket中的数据并不多,但是存在着大量的overflow bucket,这很有可能是先插入删除了大量的数据,插入过程并没有触发条件1,注意overflow bucket中的所有元素删除了之后也不会释放overflow bucket ,这导致的问题是虽然数据量不大,但是查找时需要遍历的bucket(包括overflow bucket)多,也会降低效率
- 这种情况其实元素没那么多,但是overflow bucket很多,说明很多的bucket都没有装满,因此扩容时会开辟一个新的bucket空间,将老的bucket中的元素移动到新bucket是的同一个bucket中的key排列的更紧密。这就把原来在overflow bucket中的元素移动到了bucket中
扩容过程
- map扩容需要将原有的key value重新搬迁到新的内存地址,如果有大量的数据需要搬迁会严重影响性能。
- Go 采取了渐进式搬迁方式,原有的key并不会一次性搬迁完毕,每次最多只会搬迁2个bucket
- 核心代码中并没有真正进行搬迁,它只是分配好了新的buckets并把老的buckets挂到了oldbuckets字段上。只有在插入,修改,删除key的时候才会进行真正的搬迁。
结构体
在Go语言中,结构体(Struct)代表一种聚合数据类型,允许将不同类型的数据组合在一起。
结构体的底层实现原理是:
- 结构体的每个字段都是一个表示内存地址的指针。
- 结构体实例化时,会为每个字段在内存中分配对应类型和大小的空间。
- 结构体赋值和传参会复制整个实例,而不是指针。
- 结构体是值类型,所有字段都会内存连续排布。
具体来看,结构体的底层定义是:
type struct {
field1 *type1
field2 *type2
...
}
每个字段是一个指向对应字段数据空间的指针。
这样设计的好处:
- 可以直接通过指针操作内存,实现对结构体字段的访问
- 所有字段内存连续可以提高访问效率
- 作为值类型,赋值和传参行为简单可控
但同时要注意结构体较大时的复制成本。
综上,Go语言结构体的设计实现了一个高效且功能强大的数据聚合类型,既提供了面向对象的能力,也不失高效的内存访问特性。这是Go语言作为现代化系统语言的设计选择之一。
字符串
在Go语言中,字符串是一种基本的数据类型和引用类型的组合。字符串的底层实现原理可以概括为:
- 字符串是只读的字节 slice,底层使用 slice 结构表示,指向字符串数据的底层数组。
- 字符串都是UTF-8编码的,所以每个字符可能占用多个字节。
- 字符串都是不可变的,每次修改都会生成新的字符串,原字符串不变。
- 短字符串会进行内存优化,存储在栈上;长字符串会在堆上分配内存。
具体来看,字符串的底层结构定义如下:
type stringStruct struct {
str unsafe.Pointer
len int
}
str是一个指针,指向字符串 bytes 的数组。len 表示字符串的长度。
字符串的不可变性是通过将其设计为只读的 slice 实现的。
每次字符串修改,实际上都是生成一个新的字符串,然后将 str 指针指向新的内存,原字符串依然存在,只是失去了引用而被垃圾回收。
这种设计既保证了字符串的不可变性,也实现了对短字符串的优化。长字符串会转成动态分配,避免栈溢出。
总之,Go语言字符串的实现通过简单的 slice 结构,实现了高效的兼容不可变和可变的字符串操作,是其设计的亮点之一。
指针
在Go语言中,指针是一种存储内存地址的类型。它的底层实现原理是:
- 指针变量存储的是一个uintptr无符号整数。
- uintptr会存储目标值的内存地址。
- 通过对指针变量的解引用,可以访问或修改内存地址对应的值。
- nil指针是一个全0的值,指向内存地址0x0。
具体来看,指针的底层定义是:
type Pointer *T
type uintptr uint64
Pointer实际上就是uintptr的别名。uintptr是一个无符号整数,存储了一个64位的内存地址。
指针操作的底层实现:
- &var 取变量地址,返回其uintptr形式的内存地址
- *ptr 根据指针地址解引用,访问内存对应的值
- ptr++ 指针地址增1,用于指针遍历
- nil 指针,全0,内存地址0x0
综上,Go语言中指针的工作原理类似C语言,通过简单的整型存储允许高效地直接操作内存,是Go语言设计的重要组成部分。指针的优化使用也是Go语言编程中需要注意的一部分。
通道
在Go语言中,通道(channel)是Goroutine之间通信的管道。通道的底层实现原理如下:
- 通道是一种线程安全的消息队列,采用锁机制避免竞争。
- 发送和接收通过对队列的加锁、修改、解锁实现。
- 缓冲通道将消息存入队列;非缓冲通道需要同步发送接收。
- 关闭通道会传递某种状态来中断接收方的goroutine。
具体来看,通道的底层结构定义如下:
type hchan struct {
qcount uint // 队列中总元素数
dataqsiz uint // 环形队列的长度
buf unsafe.Pointer // 指向底层环形数组
elemsize uint16 // 每个元素的大小
closed uint32 // 是否已关闭
elemtype *_type // 元素类型
sendx uint // 发送位置
recvx uint // 接收位置
recvq waitq // 接收等待队列
sendq waitq // 发送等待队列
// ... 保护上述字段的锁和状态
}
发送接受机制:
- 发送将数据copy到环形数组buf中,缓冲区满则入sendq等待。
- 接收从buf中读数据,缓冲区空则入recvq等待。
- 关闭通道会向所有接收方发送一个关闭状态。
通过这种设计,通道实现了高效、安全的Goroutine间通信,是Go语言并发编程的基石。
接口
在Go语言中,接口(interface)是一种特殊的类型,它定义了一组方法签名,代表了某种能力或功能。接口的底层实现原理如下:
- 接口包含一个类型指针和方法集合。
- 接口类型指针指向具体实现接口的具体类型。
- 接口调用会动态调度到具体类型的方法。
- 接口调用需要一次间接查找。
具体来看,接口的底层结构定义如下:
type iface struct {
tab *itab
data unsafe.Pointer
}
type itab struct {
inter *interfacetype
_type *_type
hash uint32
_ [4]byte
fun [1]uintptr
}
- itab 包含接口类型信息和实现类型的方法集
- data 指向实现接口的具体类型实例
接口调用时查找步骤:
- 根据接口类型和实例类型查找它的itab
- 从itab获取具体方法的函数指针
- 通过数据指针和方法指针进行调用
这种设计将接口方法调用的动态分派优化为一次查找加一次间接调用。
综上,Go语言接口的实现机制提供了轻量高效的抽象能力,是其设计的亮点之一。
函数
在Go语言中,函数是一等公民,有非常灵活的使用方式。关于Go语言函数的底层实现,主要有以下几点:
- 函数实际上是一个类型为func的指针。
- 函数指针存储了函数的入口地址。
- 调用函数实际上是跳转到函数入口地址开始执行。
- 函数可以作为变量传递,支持高阶函数。
- 函数闭包是保存函数调用环境的结构体。
具体来看,函数的底层类型定义是:
type funcType struct {
dotdotdot
}
type funcVal struct {
fn uintptr
// 函数指针
value unsafe.Pointer
}
// 函数类型就是包含函数指针的struct
type Func func(args) returnType
对于闭包函数,会有一个额外的结构体保存调用环境:
type closure struct {
fn *funcval
freevars []unsafe.Pointer
}
综上,通过简单的指针和结构体设计,Go语言实现了函数的一等公民地位,并支持在此基础上方便地使用闭包和高阶函数。这是Go语言设计的亮点之一。