Golang 八股文
一、基础部分
1、值类型 和 引用类型
值类型:
- 包括基本数据类型,如
int
、float
、bool
、string
。 - 也包括复合数据类型,如数组和结构体(struct)。
- 变量直接存储值。
- 内存通常在栈上分配,栈在函数调用完毕后会被释放。
引用类型:
- 包括 切片(Slice)、映射(Map)、通道(Channel)和接口(Interface)。
- 变量存储的是指向实际数据的引用。
- 内存分配在堆上,生命周期由垃圾收集器管理。
这里提到了堆和栈,简单介绍下内存分配中的堆和栈:
栈
(操作系统):由操作系统自动分配释放 ,存放函数的参数值,局部变量的值等。其操作方式类似于数据结构中的栈。堆
(操作系统): 一般由程序员分配释放, 若程序员不释放,程序结束时可能由OS回收,分配方式倒是类似于链表。
1.1 值传递 和 引用传递
Go里面没有
引用传递
,Go语言是
值传递
- 值传递:指在调用函数时将实际参数复制一份传递到函数中,这样在函数中如果对参数进行修改,将不会影响到实际参数。
- 引用传递:指在调用函数时将实际参数的地址直接传递到函数中,那么在函数中对参数所进行的修改,将影响到实际参数。
在 Go 语言中,函数的参数传递只有值传递,而且传递的实参都是原始数据的一份拷贝。如果拷贝的内容是值类型的,那么在函数中就无法修改原始数据;如果拷贝的内容是指针(或者可以理解为引用类型
map
、
chan
等),那么就可以在函数中修改原始数据,这里是拷贝了指针,传递的是指针副本,也就是值传递,只是看起来像引用传递。
2、golang 中 make 和 new 的区别?
在Go语言中,
make
和
new
都是用于内存分配的内建函数,但它们在分配内存和初始化内存方面有所不同:
- 分配内存的区别: -
new
可以分配任意类型的内存,并返回一个指向该类型的指针。-make
专门用于分配slice
、map
和channel
这三种内建类型,并返回一个引用类型本身。 - 初始化的区别: -
new
分配内存后,对于值类型,分配的是零值填充的内存空间,可直接使用。而对于引用类型,虽然也会分配内存并返回一个指向该内存的指针,但这块内存代表的是定义的那个引用类型,它的零值是nil
,需要进一步初始化。例如new(int)之后,对应内存空间会有一个0;而new(map[string]int)
,对应内存空间是nil,而不是{"":0}
-make
分配内存后,内存会被初始化,即分配的是有初始值的内存空间。 - 返回类型的区别: -
new
返回的是指针类型。-make
返回的是与参数相同类型的值,而不是指针。 - 语法上的区别: -
new
的语法是func new(Type) *Type
。-make
的语法是func make(t Type, size ...IntegerType) Type
。
3、数组和切片的区别
- 类型: - 数组是值类型,这意味着当你将一个数组赋值给另一个数组时,实际上是创建了数组的一个副本。因此,数组在函数参数传递时可能会导致性能问题,因为需要复制整个数组。- 切片是引用类型,当你将一个切片赋值给另一个切片时,两个切片会引用同一个底层数组。这意味着对其中一个切片的修改也会影响到另一个切片。同理当切片作为函数参数传递修改时,会影响原数据。
- 长度和容量: - 数组的长度是固定的,它在声明时就被确定,并且数组的每个元素类型必须相同。数组的长度是其类型的一部分,因此
[3]int
和[4]int
是不同的类型。- 切片的长度是动态的,可以在运行时改变。切片还有一个容量属性,它表示切片底层数组中剩余的元素个数。切片的长度可以大于或等于其容量。 - 底层数据结构: - 数组是一组固定长度的元素序列,它的底层就是数组本身。- 切片的底层是一个数组,切片是对数组的抽象和封装,提供了更加灵活的操作方式。切片包含了指向底层数组的指针、长度和容量等属性。
4、for range ,元素地址会发生变化吗
在 for a,b := range c 遍历中, a 和 b 在内存中只会存在一份,即之后每次循环时遍历到的数据都是以值覆盖的方式赋给 a 和 b,并且a,b 的内存地址始终不变。由于有这个特性,for 循环里面如果开协程做并发,不要直接把 a 或者 b 的地址传给协程。
- 解决办法:在每次循环时,创建一个临时变量传给协程。
5、go defer,多个 defer 的顺序,defer 在什么时机会修改返回值?
作用:defer延迟函数,释放资源,收尾工作;如释放锁,关闭文件,关闭链接;捕获panic;
避坑指南:defer函数紧跟在资源打开后面,否则defer可能得不到执行,导致内存泄露。
多个 defer 调用顺序是 LIFO(后入先出),defer后的操作可以理解为压入栈中
defer,return,return value(函数返回值) 执行顺序:首先return,其次return value,最后defer。defer可以修改函数最终返回值,修改时机:有名返回值或者函数返回指针
参考:Go语言defer用法大总结
6、
uint
类型溢出问题
超过最大存储值,例如
uint8
最大是255
var a uint8 = 255
var b uint8 = 1
a + b
发生溢出, 结果为
0
7、 rune 类型
**
rune
类型是Go语言中用于表示
Unicode
字符的整数类型,它是
int32
的别名。**
用于表示一个 Unicode 码点(Unicode Code Point)。Unicode 码点是Unicode标准中为每个字符分配的唯一整数,它可以涵盖世界上几乎所有的字符和符号。
Go 语言中的
rune
主要是为了方便处理UTF-8编码的文本,特别是多字节字符,比如中文、日文、韩文等非ASCII字符。在UTF-8编码下,单个字符可能由1到4个字节组成,而一个
rune
能够容纳任何有效的Unicode码点,确保能够完整地表示所有这些字符。
举例:
s :="你好,世界!"
runes :=[]rune(s)// 对 runes 进行遍历或操作for i, r :=range runes {
fmt.Printf("索引: %d, 字符: %c, Unicode 编码: %U\n", i, r, r)}
输出:
索引: 0, 字符: 你, Unicode 编码: U+4F60
索引: 1, 字符: 好, Unicode 编码: U+597D
索引: 2, 字符: ,, Unicode 编码: U+FF0C
索引: 3, 字符: 世, Unicode 编码: U+4E16
索引: 4, 字符: 界, Unicode 编码: U+754C
索引: 5, 字符: !, Unicode 编码: U+FF01
7、Go语言中的
int
类型
go语言中的int的大小是和操作系统位数相关的,如果是32位操作系统,int类型的大小就是4字节。如果是64位操作系统,int类型的大小就是8个字节。
8、golang 中解析 tag 是怎么实现的?反射原理是什么?(中高级题目)
Tag 实现流程概要:
- 获取结构体类型的反射对象。
- 遍历结构体的所有字段。
- 对于每个字段,调用
方法并传入想要获取的 tag 键名,比如StructField.Tag.Get()
或"json"
。"xml"
- 返回并处理 tag 中的值。
反射的原理,基于接口来实现:
在Go语言中,所有的类型都实现了空接口
interface{}
,这使得它们都可以被转换为反射类型
Type
和反射值
Value
。反射过程涉及到几个步骤:
- 类型转换:将普通类型隐式转换为接口类型。这个转换过程是自动进行的,当一个变量被赋值给接口类型的值时,就会进行这个转换。
- 获取反射对象:通过转换得到的接口值,可以调用标准库
reflect
包中的函数来获取对应的反射类型Type
和反射值Value
。 - 操作反射对象:通过反射类型和反射值,可以获取到原始类型的各种信息,包括字段、方法、标签等,并且可以对这些信息进行读取和修改操作。
9、Golang 调用函数传入结构体时,应该传值还是指针?
分情况:
- 结构体的大小:如果结构体非常大,使用指针传递会更有效率,因为这样只会复制指针值(一般是8字节),而不是复制整个结构体。如果结构体小,值传递和指针传递的性能差异可能可以忽略不计。
- 是否需要修改原始结构体:如果你需要在函数中修改原始结构体,你应该使用指针传递。如果你使用值传递,函数会接收结构体的一个副本,你在函数中对结构体的修改不会影响到原始的结构体。
10、讲讲 Go 的 slice 底层数据结构和一些特性
Go语言中的slice是一个结构体,包含指针、长度和容量三个部分。它的特性包括:
- 指针:Slice包含一个指针,该指针指向底层数组,即实际存储数据的数组。
- **长度
len
**:Slice的长度表示当前Slice使用到的元素个数。 - **容量
cap
**:Slice的容量表示底层数组的大小,也就是Slice可以扩展的最大长度。 - 动态性:Slice是灵活的,其长度可以改变,不像数组那样固定。
- 传递效率:作为函数参数时,Slice传递的是引用,而不是像数组那样传递副本,这使得Slice在函数调用中更加高效。
在使用Slice时,需要注意以下几点:
- Slice可以通过内置的
make
、new
函数或直接对数组进行切片操作来创建。 - 修改Slice的元素会影响到底层数组,反之亦然。
- 当Slice的容量大于其长度时,可以通过append等操作来扩展Slice的长度,而不会改变底层数组的大小。
- 当Slice的长度达到容量时,再次进行append操作将会导致底层数组的重新分配和复制,这可能会影响性能。
11、讲讲 Go 的 select 底层数据结构和一些特性
Go语言中的
select
语句是一种多路复用的控制结构,它用于同时处理多个通道(channel)上的事件。其底层数据结构和特性如下:
- 数据结构:
select
的底层实现与操作系统中的I/O多路复用机制类似,如poll
和epoll
。在Go语言中,select
用于监听通道的发送和接收操作,当通道准备好进行相应的操作时,会触发select
中对应的case
分支执行。 - 特性: - 通道操作:
select
语句只能用于通道操作,而且是单协程操作,每个case
必须是单个通道发送或接收的操作。- 非抢占式:select
语句会监听所有指定的通道上的操作,一旦其中一个通道准备好,就会执行相应的代码块。这与switch
语句的顺序执行不同,select
的case
执行顺序是随机的。- 避免死锁:在使用select
时需要注意避免死锁的情况,例如至少要有一个通道准备好,否则select
会阻塞,直至有一个通道已准备好为止。如果存在default
分支,那么select
就不会阻塞,而是执行default
分支,但default
会略影响性能。- 超时控制:select
可以配合default
分支实现超时控制,如果在指定的时间内没有任何通道准备好,default
分支会被执行。- 无穿透执行:select
语句中没有类似switch
中的fallthrough
用法,即执行完一个case
后不会继续执行下一个case
。
12、讲讲 Go 的 defer 底层数据结构和一些特性
每个 defer 语句都对应一个
_defer
实例,多个实例使用指针连接起来形成一个单连表,保存在
goroutine
数据结构中,每次插入
_defer
实例,均插入到链表的头部,函数结束再一次从头部取出,从而形成后进先出的效果。
defer 的规则总结:
- 延迟函数的参数是 defer 语句出现的时候就已经确定了的。
- 延迟函数执行按照后进先出的顺序执行,即先出现的 defer 最后执行。
- 延迟函数可能操作主函数的返回值。
- 建议申请资源后立即使用 defer 关闭资源。
13、单引号,双引号,反引号的区别
单引号,表示
byte
类型或
rune
类型,对应
uint8
和
int32
类型,默认是
rune
类型。
byte
用来强调数据是
raw data
,而不是数字;而
rune
用来表示
Unicode
的
code point
。
funcmain(){var v rune='你'var v1 rune='k'var v2 byte='k'
fmt.Println(v, v1, v2)}
20320107107
双引号,里面可以是单个字符也可以是字符串,对应
string
类型,实际上是字符数组。可以用索引号访问某字节,也可以用
len()
函数来获取字符串所占的字节长度。双引号里的字符串可以转义,但是不能换行,可以利用
\n
来实现换行。
反引号中的字符串表示其原生的意思,里面的内容不会被转义,可以换行。
14、 Go 支持默认参数或可选参数吗?
不支持。但是可以利用结构体参数,或者
...
传入参数切片数组。
// 这个函数可以传入任意数量的整型参数funcsum(nums ...int){}
15、结构体打印时,
%v
和
%+v
的区别
%v
输出结构体各成员的值;
%+v
输出结构体各成员的名称和值;
%#v
输出结构体名称和结构体各成员的名称和值
16、Go 语言中如何表示枚举值
- 使用常量定义枚举值
- 使用自定义类型定义枚举
- 使用
iota
自增枚举值
17、空 struct{} 的用途
在Go语言中,空的
struct {}
类型通常被用作占位符或者信号。它不占用任何内存空间,也不包含任何字段,可以避免任何多余的内存分配。
- 实现集合类型:Go语言本身没有直接的集合类型(类似于Set),但我们可以使用map来替代,例如
type Set map[int]struct{}
。 - Map的key是不允许重复的,这和Set集合性质相符,再将struct{}作为Value,即可用Map实现Set。 - 实现空通道:在Go的并发编程中,我们经常会遇到通知型channel,它们不需要传递任何数据,只是用于协调Goroutine的运行。这种情况下,使用空结构体作为通道元素类型非常合适,因为它不会增加额外的内存开销。
- 实现方法接收者:有时我们需要使用结构体类型的变量作为方法接收者,但结构体本身不包含任何字段属性。这种情况下,使用空结构体作为接收者是比较合适的,因为它不会占用额外的内存空间。
二、Map相关
1、map 使用需要注意什么问题
- 初始化:在使用map之前,需要先进行初始化,否则会导致编译错误。初始化一个map可以使用字面量语法,如
m := make(map[string]int)
或m := map[string]int{}
。 - 并发安全:在高并发场景下,应当考虑使用
sync.Map
或者其他并发安全措施来保护map,因为原生的map类型不是并发安全的。 - 删除和添加操作:频繁的删除和添加元素会导致哈希表的频繁重建,这可能会影响程序的性能。因此,在设计数据结构时,应尽量避免在map中频繁地进行这些操作。
- 迭代效率:遍历大的map可能很慢,尤其是在map结构发生变化时。如果需要遍历map,应尽量在map结构稳定时进行,以提高迭代效率。
- 作为集合使用:由于Go语言中没有内置的集合类型,map经常被用作集合。当使用map作为集合时,通常不需要关心值,只需要键即可。
2、map并发安全
在Go语言中,内置的map类型并非并发安全。
- 读写冲突:当有多个
goroutine
同时对同一个map进行读写操作时,可能会发生冲突,导致程序崩溃或者数据不一致。 - 写操作的广义定义:在map的并发操作中,“写”不仅仅是指插入新的键值对,还包括更新或删除已有的键值对。
- 并发读安全:虽然map支持多个
goroutine
同时进行读取操作,但是在涉及到写操作时,就需要特别小心。 - 性能优化场景:官方文档提到,在某些特定的场景下,如键值对只被写入一次但多次读取,或者多个
goroutine
读写不同的键集合时,原生map的性能可能优于使用Mutex
或RWMutex
的情况。 - 解决方案: - 如果需要在并发环境中使用map,可以考虑使用sync包中的
sync.Map
,它是一个并发安全的map实现。- 可以使用读写锁(如sync.RWMutex
)来保护对map的访问。
3、map 循环是有序的还是无序的
无序的
- 哈希函数:map使用哈希函数来计算键的存储位置,这个过程是无序的。
- 内部结构:map的内部结构是一系列桶(bucket),每个桶是一个链表,链表中的元素是无序的。
- 随机化:Go语言的map在迭代时会生成一个随机数作为遍历的起始位置,这是为了防止哈希碰撞攻击,并且确保每次迭代的顺序都是不同的。
4、 map 中删除一个 key,它的内存会释放么?
不会立即释放。
在Go语言中,当你从map中删除一个键值对时,该操作并不会立即释放掉这个键值对所占用内存。这是因为map的底层实现是由若干个
bmap
(桶)构成的,桶只会扩容,不会缩容 ,map内存空间本身并不会立即归还给操作系统。
如果删除的元素是值类型,如int、float、bool、string以及数组和struct,这些类型的内存通常不会自动释放。如果删除的元素是引用类型,如切片、映射和通道等,虽然它们所指向的实际数据结构可能会被释放,但map中的键值对所占用的内存同样不会立即释放。
总的来说,Go语言的垃圾收集器会在合适的时机回收未使用的内存。这意味着,即使从map中删除了元素,内存也可能不会立即得到释放,而是等待下一次垃圾收集周期进行处理。
参考教程
5、nil map 和空 map 有何不同
nil map是指未初始化的map,而空map是指已初始化但不含任何键值对的map。具体区别表现在以下几个方面:
- 初始化状态:- nil map:是指map变量被声明但未被初始化,此时它的值是nil。- 空map:是指map已被初始化,但没有包含任何键值对,即它的长度为0。
- 内存分配:- nil map:由于未初始化,所以不会为map分配实际的内存空间。- 空map:虽然不包含任何元素,但是已经分配了哈希表所需的内存空间。
- 操作限制:- nil map:不能进行任何map的操作,如添加、删除或读取键值对,否则会引发panic。- 空map:可以进行正常的map操作,包括添加、删除和读取键值对
6、map 的数据结构是什么?是怎么实现扩容
详细讲解
**Go语言中的map使用哈希表(
hmap
)作为其数据结构,并且采用渐进式的方式进行扩容**。
Go语言的map是一种高效的键值对集合类型,它的底层实现是哈希表(
hmap
)。哈希表由多个桶(
buckets
)组成,每个桶用来存储具有相同哈希值的键值对。当map需要存储更多的元素时,就会触发扩容操作。这个过程涉及到以下关键步骤:
- 重新分配内存:在扩容时,Go会为哈希表分配一个新的、更大的内存区域。
- 渐进式搬迁:由于一次性搬迁大量的键值对会严重影响性能,Go map采用了渐进式搬迁的策略。这意味着在每次扩容时,只有部分数据会被迁移到新的内存地址。
- 控制搬迁数量:为了减少扩容对性能的影响,每次搬迁的键值对数量是有限的,通常最多只会搬迁2个桶。
- 保持数据访问:在扩容过程中,旧的桶(oldbuckets)仍然保持可用状态,以便在搬迁过程中可以继续访问和修改数据。
- 哈希冲突处理:为了解决哈希冲突的问题,Go map使用了拉链法,即在同一个桶内通过链表来存储具有相同哈希值的键值对。
7、golang 哪些类型不可以作为map key
任何可比较(comparable)和相等(equal)的类型都可以作为map的键。注意,空接口(interface{})可以作为map的键,但需要谨慎使用,因为它可以包含任何类型的值。
不能作为map key 的类型包括:
- slices
- maps
- functions
8、Map赋值和复制
当你把一个map赋值给另一个map时,实际上是复制了原map的引用,而不是复制了整个map的内容。这意味着新旧两个map共享相同的底层数据结构,修改其中一个map会影响到另一个。
ma :=make(map[string]any)
ma["one"]="hello"
mb := ma
mb["two"]="world"
fmt.Println(ma)// 输出:map[one:hello two:world]// 表示修改mb会影响ma
如果要复制一个完全独立的副本,需要遍历目标Map来创建。
三、context相关
1、context 结构是什么样的?context 使用场景和用途
context.Context
是
Golang
中用于处理并发编程的上下文控制,提供了截止日期、取消信号和请求相关值的传递机制。它的结构定义包括以下几个关键方法:
- Deadline: 此方法返回一个截止日期和一个布尔值,表示上下文是否设置了截止日期。
- Done: 这是一个通道,当上下文被取消或超时时,会向该通道发送一个信号。
- Err: 该方法返回上下文中发生的错误。
- Value: 这个函数用于存储和检索与上下文相关的键值对,是实现共享数据存储的地方,是协程安全的
其主要的应用 :
1:上下文控制,2:多个
goroutine
之间的数据交互等,3:超时控制:到某个时间点超时,过多久超时。
2、
context.Context
结构的核心特征
- Context 的创建: - 使用
context.Background()
创建一个顶级上下文,没有特定的取消信号和截止时间。- 使用context.TODO()
创建一个临时上下文,通常用于表明代码路径尚未完成,需要后续填充适当的上下文。- 使用context.WithCancel(parentCtx)
创建一个可以从父上下文中派生出来的新上下文,并带有可主动取消的功能。- 使用context.WithDeadline(parentCtx, deadline)
创建一个在指定截止时间到达时自动取消的上下文。- 使用context.WithTimeout(parentCtx, timeout)
创建一个在指定超时时间过后自动取消的上下文。- 使用context.WithValue(parentCtx, key, value)
创建一个携带键值对的上下文,用于传递请求级别信息。 - Context 的传递: - Context 应该作为参数传递给可能长时间运行或涉及到 Goroutine 启动的函数,以便在适当的时候能够取消操作或传播相关信息。
- 取消通知: - 通过调用
ctx.Done()
方法可以获得一个只读的 channel,当上下文被取消时,该 channel 会被关闭。- 通过检查<-ctx.Done()
可以得知上下文是否已被取消,这是一种非阻塞的方式检测取消信号。 - 截止时间: - 可以通过
ctx.Deadline()
查询上下文的截止时间,如果没有设定则返回time.Time{}
(零值)。 - Context 的取消: - 创建子上下文时获得的取消函数(如
cancelFunc
)可用于主动取消上下文及所有从该上下文衍生出的子上下文。
四、channel 相关
1、channel 是否线程安全?
- Channel 的线程安全性:channel 是线程安全的,这意味着多个 goroutine 可以同时对同一个 channel 进行读写操作,而不会产生数据竞争或冲突。这是因为 channel 在内部实现了必要的同步机制,如互斥锁,来确保对数据的访问是原子化的和同步的。
- Channel 的操作:在使用 channel 时,你可以利用
for range
循环来持续地从 channel 中读取数据,直到它被关闭。这种方式比手动使用锁来控制数据的访问要简单和高效得多。
2、go channel 的底层实现原理 (数据结构)
- 数据结构: Go 内部对 channel 的实现采用了名为
hchan
的结构体。hchan
结构体包含了如下几个重要的字段: -elemtype
: 存储 channel 中元素的数据类型信息。-buf
: 一个指向存储 channel 数据的数组的指针,用于实现缓冲 channel。-elemsize
: 元素的大小(以字节为单位)。-closed
: 标志位,指示 channel 是否已经被关闭。-recvx
和sendx
:分别代表接收和发送索引,用于记录下一个接收和发送的位置。-recvq
和sendq
:分别指向等待接收和发送的 goroutine 队列,当 channel 满或者空时,相应的 goroutine 会进入相应队列等待。-lock
:互斥锁,用于保证对 channel 内部数据结构的同步访问。 - 同步机制: - 无缓冲 channel:发送和接收操作都是同步的,也就是说,发送操作会阻塞,直到有接收者准备好;同样地,接收操作也会阻塞,直到有发送者发送数据。- 有缓冲 channel:在缓冲区未满的情况下,发送操作可以立即完成;同理,在缓冲区非空的情况下,接收操作也可以立即完成。当缓冲区已满或空时,channel 的同步机制会启用等待队列,将 goroutine 插入到相应队列中,直至满足发送或接收条件。
- 内存管理: - Go runtime 在创建 channel 时会分配一块内存来存储元素,并根据 channel 的缓冲大小调整这块内存的大小。- 在进行数据传输时,runtime 会确保 goroutine 间的同步,并通过 CAS(Compare and Swap)等原子操作来更新 channel 内部的状态,从而实现线程安全的数据交换。
- 调度策略: - Go 的运行时调度器密切关注 channel 上的活动,当一个 goroutine 因为 channel 操作被阻塞时,调度器会将该 goroutine 放入等待队列,并唤醒另一个可能已经准备好的 goroutine 继续执行。
3、nil channel、关闭的 channel、有数据的 channel,再进行读、写、关闭会怎么样
针对 Go 语言中的 channel,在不同状态下(nil、已关闭、有数据)进行读、写、关闭操作会有以下表现:
- nil channel:- 读取:尝试从 nil channel 读取数据会导致永远阻塞,除非通过另一个 goroutine 向其发送数据或关闭 channel。- 写入:向 nil channel 发送数据也会造成永远阻塞,必须先创建并初始化 channel 才能进行发送操作。- 关闭:试图关闭 nil channel 会直接导致 panic 错误。
- 已关闭的 channel:- 读取:从已关闭的 channel 读取数据,如果 channel 中还有剩余数据,那么会成功读取并返回数据;当 channel 中所有数据都被读取完毕时,再次读取会返回对应类型的零值,并且
ok
标志位为false
,表示 channel 已关闭且无数据可读。- 写入:向已关闭的 channel 发送数据会导致 panic 错误。- 关闭:对已关闭的 channel 再次调用 close 操作也是非法的,会导致 panic 错误。 - 有数据的 channel:- 读取:如果有数据,从 channel 中读取数据会成功,返回数据值,且
ok
标志位为true
。- 写入:如果 channel 是非缓冲的(无缓冲 channel),只有当有接收方正在读取数据时才能成功发送;如果是缓冲的 channel,只要缓冲未满就能成功发送数据。- 关闭:可以关闭有数据的 channel,关闭后不能再向其发送数据,但仍然可以从 channel 中读取剩余数据,直到数据被完全读取完。
总结:
- 无论是 nil channel 还是已关闭的 channel,都不能进行写入操作。
- 对 nil channel 进行读取和关闭操作会导致阻塞或 panic。
- 对已关闭的 channel 进行读取取决于是否有剩余数据,写入和关闭都会导致 panic。
- 有数据的 channel 可以正常进行读写操作,关闭后不再接受新的数据,但仍能读取旧数据,直到清空。
4、向 channel 发送数据和从 channel 读数据的流程
- 向channel发送数据: - 首先,检查channel中是否有空间存放数据。对于带缓冲的channel,如果缓冲区未满,则可以直接将数据存入缓冲区;如果缓冲区已满,则发送方会被阻塞,直到接收方从channel中读取数据并释放缓冲区空间。对于不带缓冲的channel,发送方会立即被阻塞,直到接收方准备好接收数据。- 当有空间可以存放数据时,发送方将数据存入channel,此时数据被视为已发送但尚未被接收。- 如果channel在发送数据后被关闭,那么发送操作会立即返回,不再阻塞。
- 从channel读取数据: - 首先,检查channel中是否有数据可供读取。对于带缓冲的channel,如果缓冲区不为空,则可以直接从中读取数据;如果缓冲区为空,则接收方会被阻塞,直到发送方将数据发送到channel中。对于不带缓冲的channel,接收方会立即被阻塞,直到发送方将数据发送到channel中。- 当有数据可供读取时,接收方从channel中取出数据,此时数据被视为已接收但尚未被处理。- 如果channel在读取数据后被关闭,那么接收操作会立即返回,不再阻塞。
五、GPM调度模型
1、什么是GPM调度模型?
Go 语言的 GPM 调度模型是 Go 运行时特有的并发调度模型,用于管理和调度 Goroutines(Go 语言的轻量级线程)。GPM 模型由三部分组成:Goroutine(G)、M(Machine)、和 P(Processor)。
实战参考
- G: 表示 Goroutine,每个 Goroutine 对应一个 G 结构体,G 存储 Goroutine 的运行堆栈、状态以及任务函数,可重用。G 并非执行体,每个 G 需要绑定到 P 才能被调度执行。
- P: Processor,表示逻辑处理器, 对 G 来说,P 相当于 CPU 核,G 只有绑定到 P(在 P 的 local runq 中)才能被调度。对 M 来说,P 提供了相关的执行环境(Context),如内存分配状态(mcache),任务队列(G)等,P 的数量决定了系统内最大可并行的 G 的数量(前提:物理 CPU 核数 >= P 的数量),P 的数量由用户设置的 GOMAXPROCS 决定,但是不论 GOMAXPROCS 设置为多大,P 的数量最大为 256。
- M: Machine,OS 线程抽象,代表着真正执行计算的资源,在绑定有效的 P 后,进入 schedule 循环;而 schedule 循环的机制大致是从 Global 队列、P 的 Local 队列以及 wait 队列中获取 G,切换到 G 的执行栈上并执行 G 的函数,调用 goexit 做清理工作并回到 M,如此反复。M 并不保留 G 状态,这是 G 可以跨 M 调度的基础,M 的数量是不定的,由 Go Runtime 调整,为了防止创建过多 OS 线程导致系统调度不过来,目前默认最大限制为 10000 个。
调度流程简述:
- 有一个全局队列和本地队列,其中本地队列的容量一般不超过256
- 当创建一个新的 Goroutine 时,它会被放入某个 P 的本地队列中。
- 若本地队列已满或者不存在,则会将 Goroutine 放入全局队列。
- M 通过与其关联的 P 获取待执行的 Goroutine,然后执行它。
- 若 M 闲置,会去全局队列或者从其他 P 抢夺 Goroutine 来执行(Work Stealing)。
- 当 Goroutine 因 I/O 操作阻塞时,对应的 M 会释放其与 P 的关联,并让出 CPU 给其他 Goroutine 执行,待阻塞解除后重新参与调度。
2、进程、线程、协程有什么区别
进程、线程、协程是三种不同的执行单元,它们在计算机程序执行中有各自的特点和用途:
- 进程(Process):- 进程是操作系统资源分配和调度的最小单位,每个进程都有自己独立的地址空间(内存、打开的文件、设备等资源)。- 进程之间是相互隔离的,通过 IPC(Inter-Process Communication,进程间通信)机制进行通信和数据交换。- 创建新的进程会分配新的资源,如内存空间,有一定的系统开销。
- 线程(Thread):- 线程是操作系统进行调度的最小单位,是进程中执行的实体,共享进程的资源,包括内存空间(除了栈空间)。- 线程之间的切换速度快于进程,因为它们不需要创建新的地址空间,只需保存和恢复线程上下文即可。- 多个线程可以在同一个进程中并发执行,提高了系统的并行计算能力,但也带来了线程安全问题,需要使用锁、信号量等机制来解决竞态条件和同步问题。
- 协程(Coroutine):- 协程是一种用户层面的轻量级线程,不像线程那样由操作系统调度,而是由程序自身调度,因此协程的创建、切换等操作比线程快得多,开销极小。- 协程存在于单一进程中,它们共享进程的资源,并且协程的切换是由用户程序控制的,通常在遇到 IO 操作或者
yield
时主动放弃执行权。- Go 语言中的 goroutine 就是一种协程实现,它由 Go 运行时管理,相较于传统的线程,goroutine 的调度更加高效,且易于理解和使用。- 协程间的通信和同步通常通过通道(channel)等机制来实现,更容易写出并发安全的代码。
总结来说,进程侧重于资源隔离,线程侧重于共享资源并发执行,协程则是在单个进程内的轻量级并发,具有更低的切换开销和更高的灵活性。
3、抢占式调度是如何抢占的
抢占式调度的过程通常包括以下几个步骤:
- 时间片分配:系统为每个任务分配一个固定的时间片(time quantum),即任务可以连续执行的最长时间。
- 上下文保存:当任务的时间片用完时,调度器会暂停该任务的执行,并保存其当前的执行状态(如寄存器值、程序计数器等)到任务的上下文中。
- 重新调度:调度器选择下一个要运行的任务。这个选择过程可能基于多种因素,如任务的优先级、等待时间、资源需求等。
- 上下文恢复:调度器加载新选中任务的上下文,恢复其执行状态,然后继续执行。
- 重复过程:这个过程周期性地重复,确保所有任务都有机会被执行。
4、Go语言调度模型G、M、P的数量多少合适?
- G(Goroutine): - Goroutine 的数量理论上没有严格的上限,但在实践中,大量的并发 Goroutine 会消耗内存(每个 Goroutine 都有自己的栈空间),并且过多的 Goroutine 可能导致上下文切换的开销增大,降低性能。- 一般建议控制 Goroutine 的数量不要过于庞大,尤其是活跃 Goroutine 数量。通过合理设计程序,避免不必要的并发,使用 Channel 和 WaitGroup 等同步机制来控制并发水平。
- P(Processor): - P 的数量代表了并发执行的 Goroutine 的最大数量,它的默认值由
GOMAXPROCS
环境变量或者runtime.GOMAXPROCS()
函数设置,如果不设置,默认为 CPU 核心数。- 一般来说,P 的数量设为与物理 CPU 核心数相匹配是比较合理的做法,这样可以充分利用多核优势。不过,在某些场景下,比如 CPU 密集型应用且核心数量较多时,可以尝试减小 P 的数量来观察效果,有时可能会因为减少上下文切换而提升性能。- 如果应用存在大量 I/O 密集型操作,适度增加 P 的数量(不超过 CPU 核心数)可能有利于提高系统的整体吞吐量,因为 I/O 阻塞时,M 可以释放并服务于其他 P。 - M(Machine/OS Thread): - M 的数量理论上可以大于 P,多余的部分会处于休眠状态,等待被唤醒并关联到一个 P 上执行 Goroutine。- Go 运行时会自动管理 M 的数量,确保有足够的 M 来运行所有关联到 P 的 Goroutine。- 在默认情况下,Go 调度器会根据 P 和 G 的数量以及系统资源动态调整 M 的数量,以达到较好的资源利用率。
总结来说,大部分情况下无需特别关注 M 的数量,只需合理设置 P 的数量以匹配系统资源和应用需求。而对于 G 的数量,应尽量控制在合理的范围内,以避免内存浪费和过度的上下文切换。通过观察和测量实际应用的性能,可以进一步微调 P 和 G 的数量来优化程序的并发处理能力。
5、GMP模型中,M 发生系统调用了, G 和 P 会怎么样
在Go语言的GMP调度模型中,当一个系统线程M(Machine)遇到需要执行系统调用时,会发生以下情况:
- M与P解绑:M在执行系统调用前会先尝试释放与之关联的处理器P(Processor)。这是因为系统调用很可能会导致M阻塞,为了不让P闲置,M会把P交给调度器,让其他可运行的M来使用P继续执行Goroutine(G),从而提高CPU利用率。
- G的调度:正在执行的Goroutine(G)会因为M的阻塞而暂停执行。这个G会被放回到P的本地队列或者全局队列中,等待下一个可用的M来获取并继续执行。如果G正在进行系统调用,当系统调用完成后,G需要重新获取P才能继续执行。
- P的重分配:释放的P可以被重新分配给其他空闲的M,或者如果有新的M创建,也可以分配给新M,使得这些M能够带着P去执行Goroutine队列中的任务。
- M的恢复:当M完成系统调用并解除阻塞后,它会尝试从调度器那里重新获取一个P,然后继续执行Goroutines。如果当前没有可用的P,M可能会进入休眠状态,直到有P可用时被唤醒。
这样的设计确保了即使某个M因为系统调用而阻塞,也不会影响到整个程序的并发执行能力,提高了系统的响应速度和吞吐量。
6、M 系统调用结束以后会怎么样
当M(Machine,代表操作系统线程)完成系统调用并结束阻塞状态后,它会尝试恢复执行Go的goroutine。具体步骤如下:
- 尝试获取P(Processor):M会首先尝试从调度器中获取一个空闲的P。P包含了执行环境和本地的任务队列,对于Goroutine的执行至关重要。
- 获取G并执行:- 如果M成功获取到了P,它会查看P的本地队列是否有待执行的Goroutine(G)。如果有,M会从P的本地队列中取出一个G并开始执行。- 如果P的本地队列为空,M可能会从全局队列中取出Goroutine来执行,或者如果全局队列也为空,M可能会尝试从其他P的本地队列中“偷取”Goroutine来避免空闲。
- 无P可获取:如果此时没有空闲的P可以分配给M,M会进入休眠状态或者被回收。M进入休眠意味着它不会占用CPU资源,而是等待条件满足时被唤醒,比如有新的任务到来或已有任务完成,从而可以重新获取P并开始执行Goroutine。
- 维持平衡:Go的运行时系统(runtime)会持续监控M、P、G的状态,以确保资源的有效利用和负载均衡。例如,sysmon监控线程会定期检查是否有阻塞过久的M,并可能采取行动促进其恢复工作。
六、锁相关
1、锁的基本概念
锁(通常是指
sync.Mutex
互斥锁 或
sync.RWMutex
读写互斥锁)在 Go 语言中主要用于保护共享资源(如变量、数据结构)免受并发访问时的数据竞争。当多个
goroutine
并发访问和修改相同的资源,并且这些修改操作不能原子性完成时,就需要使用锁来确保每次只有一个
goroutine
能够访问和修改资源。
2、golang有哪些类型的锁
在 Go 语言中,标准库
sync
包提供了以下几种类型的锁来实现同步控制:
- 互斥锁(Mutex):
sync.Mutex
是最基本的互斥锁类型,它在同一时刻只允许一个 Goroutine 访问受保护的资源。提供了Lock()
和Unlock()
方法,分别用于获取和释放锁。示例:var mu sync.Mutexmu.Lock()// 访问共享资源mu.Unlock()
- 读写互斥锁(RWMutex):
sync.RWMutex
是更为灵活的锁,它可以允许多个 Goroutine 同时读取数据,但写入数据时会独占锁,阻止其他 Goroutine 的读写。提供了RLock()
(读取锁)和RUnlock()
(释放读取锁),以及Lock()
(写入锁)和Unlock()
(释放写入锁)方法。示例:var rwmu sync.RWMutexrwmu.RLock()// 读取共享资源rwmu.RUnlock()rwmu.Lock()// 写入共享资源rwmu.Unlock()
- 互斥锁(Once):
sync.Once
用于确保某个操作(通常是一个初始化操作)只执行一次,即使在并发环境下也是如此。它通过内部的互斥锁机制实现,并提供了Do(f func())
方法。示例:var once sync.Oncevar data interface{}once.Do(func(){// 只执行一次的初始化操作 data =expensiveInitialization()})
- 条件变量(Cond):
sync.Cond
用于在满足特定条件时唤醒等待的 Goroutine,它基于互斥锁实现,提供了L
字段(一个互斥锁)以及Wait()
,Signal()
和Broadcast()
方法。示例:var cond sync.Condcond.L =&sync.Mutex{}cond.L.Lock()// 某条件不满足cond.Wait()// 在被 Signal() 或 Broadcast() 唤醒后,重新检查条件cond.L.Unlock()
- 原子操作(Atomic): 虽然不是传统意义上的锁,但
sync/atomic
包提供了原子操作,如原子增加、减少、交换和比较交换等,可用于实现无锁数据结构和细粒度的同步控制,这些操作在硬件层面上保证了并发安全。
请注意,Go 语言的并发设计鼓励使用 channels(通道)进行通信来代替共享内存,但以上锁机制在某些场景下仍然是必要的,例如实现传统的锁模式或者对现有 C/C++ 库进行封装时。
3、Go 如何实现原子操作?
原子操作就是不可中断的操作,外界是看不到原子操作的中间状态,要么看到原子操作已经完成,要么看到原子操作已经结束。在某个值的原子操作执行的过程中,CPU 绝对不会再去执行其他针对该值的操作,那么其他操作也是原子操作。
在 Go 语言中,原子操作通过
sync/atomic
包来实现。
sync/atomic
包提供了对整型值、指针以及其他一些类型进行原子操作的支持,这些操作在多线程或多 Goroutine 环境下是线程安全的,即在操作过程中不会被打断,保证了操作的完整性。
以下是一些
sync/atomic
包提供的原子操作函数示例:
- 整数类型的原子操作: -
SwapInt32/64
:交换(替换)一个 32/64 位整数变量的值,返回旧值。-CompareAndSwapInt32/64
:比较并交换,只有当当前值等于预期值时才将整数变量设置为新值,返回旧值。-AddInt32/64
:原子地将指定值加到整数变量上,并返回新的值。-LoadInt32/64
:原子地读取整数变量的值。-StoreInt32/64
:原子地将值存储到整数变量中。 - 指针类型的原子操作: -
SwapPointer
:交换指针变量的值,返回旧值。-CompareAndSwapPointer
:比较并交换指针变量的值。 - 其他类型: - 对于无符号整数类型(
uint32
、uint64
)和uintptr
类型也有类似的原子操作。-AtomicXXX
函数家族还提供了对bool
类型、Value
类型(用于封装任意类型,通过接口实现)的原子操作支持。
4、悲观锁、乐观锁是什么?
Mutex
是悲观锁还是乐观锁?
悲观锁和乐观锁是处理数据并发访问的两种不同策略:
- 悲观锁:假设数据经常发生冲突,因此在数据处理前先进行加锁。传统的关系型数据库通常使用这种类型的锁,如行锁、表锁等。它确保了操作的原子性、一致性、隔离性和持久性,但可能会降低并发性能,因为其他线程必须等待锁被释放才能访问数据。
- 乐观锁:假设数据通常不会发生冲突,因此不会在数据处理一开始就加锁,而是在数据提交更新时检查是否有冲突。如果有冲突,则重新尝试或返回错误信息。这种方式在冲突少的情况下能提高吞吐量,适用于读多写少的场景。
具体到Go语言中,
sync.Mutex
是一种典型的悲观锁实现,它在多个goroutine访问共享资源时强制加锁,确保同一时间只有一个goroutine能够访问该资源。而atomic包中的函数则提供了一种乐观锁的实现方式,它们通常在无冲突或冲突较少的情况下表现得更为高效。
5、Mutex 有几种模式?
- 正常模式(Normal Mode):这是 Mutex 的默认模式。在这个模式下,当一个 goroutine 试图获取锁时,会先自旋几次尝试通过原子操作获取锁。如果在自旋过程中未能获取到锁,该 goroutine 将进入等待队列,按照先入先出(FIFO)的顺序排队等待。然而,当锁被释放时,排在队列首位的等待者并不总是能立即获得锁,它还需要与后续进入的正在自旋的 goroutine 竞争锁的所有权。
- 饥饿模式(Starvation Mode):当一个 goroutine 等待获取锁的时间超过一定阈值(例如 1ms),它会将 Mutex 切换到饥饿模式。在这种模式下,锁的所有权会直接从解锁的 goroutine 传递给等待队列中排在最前面的 goroutine,而不是让新来的 goroutine 竞争获取,这样可以防止长时间等待的 goroutine 饥饿。
此外,Mutex 还具有适应能力,能够根据当前的使用情况自动切换模式。如果持有锁的 goroutine 的总等待时间小于一定的阈值(如 1ms),或者等待队列为空,Mutex 会被置于正常模式。这种设计旨在在保持高性能的同时,减少长时间等待的风险。
6、除了 mutex 以外还有那些方式安全读写共享变量
可以通过Channel和原子操作来安全地读写共享变量
- Channel:Goroutine之间可以通过Channel进行通信,无缓冲的Channel确保了发送和接收操作是同步的。
- 原子操作:Go语言中的原子操作可以用于对共享变量进行无锁(lock-free)的读写。例如可以用个数为 1 的信号量(semaphore)实现互斥
7、goroutine 的自旋占用资源如何解决
自旋锁是一种互斥锁的实现方式,当线程尝试获得一个锁时,如果发现这个锁已经被其他线程占用,它会不断地重复尝试获取锁,而不是放弃 CPU 的控制权。这个过程被称为自旋,它能够有效地减少线程切换的开销,提高锁的性能。 自旋锁同时避免了进程上下文的调度开销,因此对于短时间内的线程阻塞场景是有效的。
以下是一些解决方案:
- 减少自旋次数:可以通过调整 Mutex 的自旋次数来减少 goroutine 的自旋时间。例如,可以将自旋次数从默认的多次减少到一次或两次。
- 使用超时机制:可以在 Mutex 上设置超时时间,当超过该时间后,goroutine 将放弃自旋并进入阻塞状态等待锁释放。这可以避免 goroutine 长时间占用 CPU 资源。
- 使用其他同步原语:除了 Mutex 外,还可以使用其他的同步原语,如 Channel、WaitGroup 等来实现并发控制。这些原语可以更有效地利用系统资源,避免 goroutine 的自旋操作。
- 优化代码逻辑:通过优化代码逻辑,减少 goroutine 之间的竞争和冲突,从而降低自旋操作的频率和持续时间。
七、并发相关
1、怎么控制并发数
- 使用goroutine:Go语言的并发模型基于goroutine,每个goroutine都是一个独立的执行单元。通过创建多个goroutine来实现并发操作,可以有效地控制并发数。你可以根据需要创建适量的goroutine,以实现对并发数的控制。
- 使用channel:Channel是Go语言中用于在不同goroutine之间传递数据的一种机制。通过使用channel,可以实现对并发数的限制。你可以创建一个带缓冲区的channel,并限制其容量,从而控制同时运行的goroutine数量。当channel满时,新的goroutine会等待直到有可用的空位。
- 使用sync包中的WaitGroup:WaitGroup是Go语言提供的一个同步原语,用于等待一组goroutine的完成。通过使用WaitGroup,你可以控制并发数,确保所有goroutine都完成后再继续执行后续操作。
- 使用context包:Context包提供了一种优雅的方式来控制并发任务的取消和超时。通过使用context,你可以设置超时时间或手动取消任务,从而控制并发数。
- 使用第三方库:有一些第三方库提供了更高级的并发控制功能,如
golang.org/x/sync/semaphore
、golang.org/x/sync/errgroup
等。这些库提供了更灵活的并发控制选项,可以根据具体需求选择适合的库进行使用。
2、多个 goroutine 对同一个 map 写会 panic,异常是否可以用 defer 和 recover 捕获?
可以。Go语言,可以使用多值返回来返回错误。不要用异常代替错误,更不要用来控制流程。在极个别的情况下,才使用Go中引入的Exception处理:defer, panic, recover Go中,对异常处理的原则是:多用error包,少用panic
3、如何优雅的实现一个 goroutine 池
golang的协程池
ants库
八、GC相关
1、go gc 是怎么实现的?(必问)
一文弄懂 Golang GC、三色标记、混合写屏障机制
2、go 是 gc 算法是怎么实现的?(中高级)
Go语言GC实现原理及源码分析
3、gc中STW的时机是什么?各个阶段都要解决什么?
在 Go 中,GC 回收工作中需要停止所有的 goroutine,这个过程称为 STW(Stop-The-World)。Go 的 GC 会在以下几种情况下触发 STW:
- 手动调用:通过 runtime.GC() 函数手动触发。
- 内存分配:当程序运行时分配的内存超过一定阈值时,GC 会自动触发。
- 定时器:GC 也可以定期触发,以确保程序不会长时间运行而出现内存问题。
在 GC 的标记和清理两个阶段中,STW 的解决方式略有不同。
- 标记在标记阶段,Go 的 GC 使用三色标记算法进行回收。为了使标记阶段尽可能短且高效,Go 采用了并发标记和增量标记两种策略来减少 STW 的影响。- 并发标记:在标记阶段,并发地标记和清除对象。这允许同时执行垃圾回收和程序代码,从而最大限度地减少 STW 时间。- 增量标记:增量标记可以将标记阶段拆分成多个小步骤,垃圾回收器可以在每个小步骤之间恢复程序运行,这样可以更好地控制 STW 时间。
- 清理在清理阶段,Go 的 GC 采用了三色标记-清除算法进行垃圾回收。为了尽可能地减少 STW 时间和避免内存碎片问题,Go 引入了两个概念:根对象和写屏障。- 根对象:GC 将所有全局变量、栈、寄存器和程序计数器等标识为根对象,并将其作为垃圾回收的起点。这些对象是一定会被访问到的,因此可以保证它们不会被回收。- 写屏障:当程序向一个指针类型的变量赋值时,会触发一个写屏障。写屏障可以用于检测对象是否从白色变为黑色,如果是,则需要将该对象加入到待清理列表中。因此,在清理阶段期间,GC 只需要扫描根对象和待清理列表来确定哪些对象需要被释放即可,在这个过程中不需要遍历整个堆。这样可以显著减少 STW 时间和避免内存碎片问题。
4、GC 的触发时机
分为系统触发和主动触发。
1)gcTriggerHeap:当所分配的堆大小达到阈值(由控制器计算的触发堆的大小)时,将会触发。
2)gcTriggerTime:当距离上一个 GC 周期的时间超过一定时间时,将会触发。时间周期以runtime.forcegcperiod 变量为准,默认 2 分钟。
3)gcTriggerCycle:如果没有开启 GC,则启动 GC。
4)手动触发的 runtime.GC 方法。
5、说说
sync/singleflight
工具
sync/singleflight
是 Go 语言标准库中的一个并发控制工具,它提供了一种避免多个 Goroutine 同时执行相同的工作(例如远程调用或昂贵的计算)的功能,这被称为“单飞”模式。当有多个 Goroutine 同时请求相同的任务时,SingleFlight 会确保这些请求合并为一个,仅执行一次,并将结果广播给所有等待的 Goroutine。
九、内存相关
1、谈谈内存泄露,什么情况下内存会泄露?
go 中的内存泄漏一般都是 goroutine 泄漏,就是 goroutine 没有被关闭,或者没有添加超时控制,让 goroutine 一只处于阻塞状态,不能被 GC。
内存泄露有下面一些情况
Goroutine泄漏:如果Goroutine在执行时被阻塞而无法退出,就会导致Goroutine的内存泄露,一个Goroutine的最低栈大小为2KB,在高并发的场景下,对内存的消耗也是非常恐怖的。
互斥锁未释放或者造成死锁会造成内存泄漏
资源未关闭:- 如果程序打开了文件、数据库连接、网络连接或其他资源,但在使用完毕后没有及时调用对应的关闭方法(如
Close()
或Release()
),则这些资源所占用的内存在程序运行期间将无法被释放。循环引用:- 若两个或多个对象之间形成循环引用,即使它们已经不再被其他任何变量引用,但由于彼此之间的强引用关系,垃圾回收器无法识别它们为可回收对象,这也会导致内存泄漏。
不正确使用channel:- 从空channel读取数据,导致接收方 Goroutine 被阻塞并无法退出,间接引发内存泄漏。- 向已满的channel写入数据,导致发送方 Goroutine 被阻塞并无法退出,间接引发内存泄漏。
字符串的截取引发临时性的内存泄漏
func main() {
var str0 = "12345678901234567890"
str1 := str0[:10]
}
- 切片截取引起子切片内存泄漏
func main() {
var s0 = []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
s1 := s0[:3]
}
- 函数数组传参引发内存泄漏【如果我们在函数传参的时候用到了数组传参,且这个数组够大(我们假设数组大小为 100 万,64 位机上消耗的内存约为 800w 字节,即 8MB 内存),或者该函数短时间内被调用 N 次,那么可想而知,会消耗大量内存,对性能产生极大的影响,如果短时间内分配大量内存,而又来不及 GC,那么就会产生临时性的内存泄漏,对于高并发场景相当可怕。】
2、怎么定位排查内存泄漏问题?
- 使用 go tool pprof-
pprof
是 Go 自带的性能分析工具,可以帮助分析程序运行时的 CPU、内存和阻塞调用等情况。- 具体做法是开启 pprof 服务(可以在代码中嵌入或者通过 HTTP 方式开启),然后使用命令行工具抓取内存快照:go tool pprof http://localhost:port/debug/pprof/heap
获取 heap 快照后,可以使用 top、list、web 等命令分析内存分配详情,找出可能的内存泄漏点。 - 编写和运行单元测试- 编写针对可能存在问题的模块或功能的单元测试,结合内存分析工具观察测试过程中的内存使用情况,通过重复执行测试,观察内存是否持续增长,以此来定位潜在的内存泄漏。
- 监控 Goroutine 数量- 如果内存泄漏伴随着 Goroutine 数量的异常增长,可能是 Goroutine 泄漏或资源没有正确释放导致的。使用
runtime.NumGoroutine()
监控 Goroutine 数量,同时结合 pprof 查看 Goroutine 栈信息。
3、知道 golang 的内存逃逸吗?什么情况下会发生内存逃逸?(必问)
内存逃逸:本该分配到栈上的变量,跑到了堆上,这就导致了内存逃逸。
栈是高地址到低地址,栈上的变量,函数结束后变量会跟着回收掉,不会有额外性能的开销。变量从栈逃逸到堆上,如果要回收掉,需要进行 gc,那么 gc 一定会带来额外的性能开销。编程语言不断优化 gc 算法,主要目的都是为了减少 gc 带来的额外性能开销,变量一旦逃逸会导致性能开销变大。
内存逃逸在 Go 语言中通常发生在以下几种情况:
- 函数返回局部变量的指针: - 当函数内部创建了一个变量,并且函数返回的是指向这个局部变量的指针或引用时,由于返回的指针允许外部代码在函数结束后还能继续访问该变量,所以该变量必须在堆上分配,以保证其生命周期超过函数作用域。
- 外部引用: - 函数内部引用了外部作用域的变量,并且这个外部变量有可能在函数执行完毕后仍被函数内部生成的对象所引用,这时编译器无法确定何时可以释放外部变量,故外部变量及其被引用的部分可能会在堆上分配。
- 闭包引用外部变量: - 在闭包(匿名函数)中捕获了外部作用域的变量,如果闭包在其定义的函数返回后还能访问这些变量,那么这些变量的生命周期会延长至闭包被释放,因此它们可能会从栈上逃逸到堆上。
- 动态类型和接口转换: - 当一个变量需要存储在 interface{} 类型中时,编译器无法预知其具体类型和大小,可能会导致逃逸到堆上。
- 切片扩容: - 当切片(slice)在函数内部初始化后,由于 append 操作可能会导致其底层数组超出当前容量,编译器为了确保在函数返回后切片仍能正确工作,会将底层数组分配在堆上。
- 栈空间不足以容纳变量: - 如果局部变量过大,超过编译器预设的栈空间大小限制,那么即使该变量的生命周期并未超出函数作用域,也会分配在堆上。
- 发送指针或包含指针的值到 channel: - 当将一个局部变量的指针或者其他含有指针的复合类型值发送到 channel 中时,由于编译器无法得知何时 channel 的接收端会接收到数据,因此不能确定何时可以释放这些变量,因此可能导致内存逃逸。
3、请简述 Go 是如何分配内存的?
详解Go语言的内存模型及堆的分配管理
Go语言的内存分配机制:
- Go语言的内存管理主要涉及到堆内存的管理。Go语言抛弃了C/C++中的开发者管理内存的方式,实现了主动申请与主动释放管理,增加了逃逸分析和垃圾回收(GC),将开发者从内存管理中释放出来,让开发者有更多的精力去关注软件设计,而不是底层的内存问题¹。
- Go语言每次以
heapArena
为单位向虚拟内存申请内存空间,每次申请的内存单位为64MB。所有的heapArena
组成了mheap
(Go的堆内存)。Go语言是一个一个内存块(heapArena
)申请内存。 - Go的堆对象的分配内存采用线性分配或者链表分配的性能不高并且会出现内存碎片,Go语言中采用了分级分配的策略。将一个
heapArena
中划分成许多大小相等的小格子,空间大小相同的格子划分为一个等级⁴。
TCMalloc算法的思想:
- TCMalloc (Thread-Caching Malloc,线程缓存的malloc)是Google开发的内存分配算法库,最初作为Google性能工具库 perftools 的一部分,提供高效的多线程内存管理实现,用于替代操作系统的内存分配相关的函数(malloc、free,new,new []等),具有减少内存碎片、适用于多核、更好的并行性支持等特性⁷。
- TCMalloc将内存空间分成了不同尺寸大小的块,申请内存时会分配一个合适大小的内存块,这些空闲块通过链表维护。有些尺寸小的块是线程自主使用,不够用的时候才会从共有块中申请。
- TCMalloc特别对多线程做了优化,对于小对象的分配基本上是不存在锁竞争,而大对象使用了细粒度、高效的自旋锁(spinlock)。分配给线程的本地缓存,在长时间空闲的情况下会被回收,供其他线程使用,这样提高了在多线程情况下的内存利用率,不会浪费内存。
4、Channel 分配在栈上还是堆上?
在 Go 语言中,Channel(信道)本身是一个引用类型,它的实例始终分配在堆上。
5、Go 哪些对象分配在堆上,哪些对象分配在栈上?
- 栈上分配: - 局部变量(包括简单类型如整数、浮点数、布尔值等)在函数内部创建且其生命周期仅限于函数内部时,通常会被分配在栈上。- 小的、生命周期短的结构体或数组等,如果编译器通过逃逸分析确定它们不会被函数外部访问或其生命周期没有超出函数范围,也可能被分配在栈上。
- 堆上分配: - 所有通过
new
或make
函数创建的对象都会分配在堆上,因为它们的生命周期不受函数调用栈的限制。- 如果一个局部变量被指针引用并且这个指针可能会被函数外部持有,或者传递给其他goroutine,那么这个局部变量将会逃逸到堆上。- 当局部变量的大小超过了编译器设置的栈空间大小限制时,也会被分配在堆上。- 切片(slice)、通道(channel)、映射(map)等动态大小的数据结构,无论其生命周期如何,都在堆上分配。- 结构体或数组即使在函数内部创建,如果它们作为函数返回值的一部分,或者被捕获在闭包中,也会发生逃逸,分配在堆上。
准确地说,变量存储在堆上还是栈上,是由逃逸分析、作用域、生命周期以及变量的类型和大小等因素共同决定的,和具体的语法没有很大的关系。变量的生命周期和存储位置由以下几个因素决定:
- 作用域和生命周期: - 如果变量是函数内的局部变量,并且它的生命周期仅限于函数执行期间,那么它通常会被分配在栈上。一旦函数执行结束,栈上的变量就会被自动释放。- 如果变量的生命周期超出了当前函数的作用域(如通过返回指针、闭包引用等),则编译器可能会选择将其分配在堆上以确保在函数结束后的存活。
- 逃逸分析: - Go 编译器通过逃逸分析来确定变量是否需要分配在堆上。即使一个变量原本应该分配在栈上,但如果它满足逃逸条件(如返回指针、被全局引用等),编译器就会将其分配在堆上。
- 类型和大小: - 对于大型对象或动态大小的类型(如切片、映射、通道和某些结构体),它们总是存储在堆上,因为栈的大小有限,不适合存储这些大小不固定的对象。
6、介绍一下大对象小对象,为什么小对象多了会造成 gc 压力?
在 Go 语言中,内存分配通常会区分小对象和大对象:
- 小对象:指的是较小尺寸的内存分配请求。在 Go 语言中,小对象通常是指大小小于一定阈值(通常是 32KB 或由编译器设定)的对象,一般小对象通过 mspan 分配内存。
- 大对象:相比之下,大对象则是指大于上述阈值的内存分配请求,如大尺寸的数组或大型结构体等。大对象通常直接从全局堆(mheap)分配,并且分配和回收的开销相对较大。
小对象多了造成 GC(垃圾回收)压力的原因主要有以下几点:
- 内存碎片:小对象频繁分配和回收容易导致内存碎片,即使空闲内存总量足够,但由于分散成众多小块,难以分配给较大的内存请求,从而迫使 GC 更频繁地进行压缩和整理内存空间。
- 扫描成本增加:GC 在执行时需要遍历整个堆内存来标记活跃对象。小对象数量增多意味着有更多的内存区域需要扫描,这对 CPU 资源的需求更高,从而加大了 GC 期间的停顿时间(STW,Stop-The-World)。
- 元数据开销:每一个分配的小对象都需要额外的元数据来管理,包括但不限于对象头部信息、span(内存区块)管理信息等,这些都会增加堆内存的整体使用量。
- 同步开销:小对象分配频繁,可能导致线程本地缓存(mcache)与全局堆(mheap)之间的同步操作增多,尤其是在多线程环境下,同步锁的竞争可能导致性能下降。
- 分配/回收开销:频繁的小对象分配和回收操作涉及内存管理单元(如 mcache、mcentral 等)的查找、分配、合并等一系列操作,这些操作本身也会带来一定的计算开销。
十、其他问题
1、Go 多返回值怎么实现的?
- 函数签名定义: 在函数定义时,可以指定多个返回值类型
- 栈上的存储: 在函数调用时,Go 会为所有的返回值分配足够的空间,就像为参数分配空间一样。这些返回值空间会被压入调用者的栈帧上。
- 返回值传递: 当函数执行完毕并通过
return
语句返回时,会将返回值写入到之前预留的栈空间中。即便函数返回,这部分栈空间也不会立即被回收,因为它们现在包含了返回值。
2、讲讲 Go 中主协程如何等待其余协程退出?
- **使用
sync.WaitGroup
**:这是最常见且推荐的方式。sync.WaitGroup
有三个方法:Add
、Done
和Wait
。你可以在启动一个协程时调用Add
来设置要等待的协程数量,每个协程完成时调用Done
,最后在主协程中调用Wait
来阻塞等待所有协程完成。 - 使用通道(Channel):你可以创建一个通道,并在每个协程中发送一个信号或特定的值。主协程可以通过
range
遍历通道或者用len
函数检查通道的长度,以等待所有协程完成。 - **使用
sync.Once
**:虽然sync.Once
通常用于确保某个操作只执行一次,但你也可以将其用作同步机制。你可以在每个协程中调用Do
方法,而在主协程中调用Wait
方法来等待所有协程完成。 - 使用
context
包:如果你的程序使用了Go的context
包来管理多个协程,你可以使用context.Done()
通道来等待所有协程完成。当上下文被取消时,所有使用该上下文的协程都会收到信号并退出。 - **使用
time.Sleep
或time.After
**:这种方法不是最佳实践,因为它会导致不必要的延迟。你可以通过设置一个足够长的睡眠时间来等待所有协程完成,但这是不可靠的,尤其是在不确定协程何时完成的情况下。 - 使用第三方库:有些第三方库提供了更高级的同步原语,如
golang.org/x/sync/syncutil
包中的WaitGroup
等。这些库可以提供更灵活的同步选项,但通常sync.WaitGroup
已经足够使用。
总的来说,
sync.WaitGroup
是最简单且常用的方法,它能够有效地满足主协程等待其他协程退出的需求。
3、Go 语言中各种类型是如何比较是否相等?
在 Go 语言中,不同类型的数据比较是否相等有不同的规则:
- 基础类型:- 布尔型 (
bool
):可以直接使用==
进行比较。- 整型 (int
,uint
,byte
,rune
等)、浮点型 (float32
,float64
) 和复数 (complex64
,complex128
):可以用==
进行比较。- 字符串 (string
):同样使用==
进行比较。 - 指针类型:- 指针类型可以直接用
==
比较,比较的是指针本身的地址是否相同,而不是它们指向的数据是否相等。 - 数组和切片:- 数组 (
[N]T
) 可以用==
比较,前提是它们的类型相同且长度相等,且每个元素都可以比较。- 切片 ([]T
) 也可以用==
比较,但同样要求长度和元素都相等。如果需要比较切片内容的深拷贝相等性,可以使用reflect.DeepEqual
函数。 - 结构体(struct):- 结构体可以使用
==
进行比较,如果结构体中的所有字段都是可比较的(也就是说,它们要么是基本类型,要么也是可比较的结构体或数组等),那么整个结构体就可以通过==
来比较是否相等。 - 接口类型:- Go 语言中的接口值可以比较,但比较的前提是它们内部存储的具体类型必须是可比较的,即满足 Go 语言的比较规则(例如,基本类型、结构体类型等)。如果内部存储的是不可比较类型的值,则接口值之间也就无法进行比较。- 两个接口值(
interface{}
)如果它们的动态类型相同,并且动态值相等,那么它们才被认为是相等的。但是,由于接口可以包含任何类型的值,因此直接使用==
通常只能比较它们是否为同一个值,不能比较它们所封装的具体值是否相等。 - Map、Channel 和 Function 类型:- Map、Channel 和 Function 类型不可以直接用
==
比较,因为它们是引用类型,比较的是它们的地址,而不是它们的内容。 - 自定义类型:- 对于自定义类型,你需要提供自定义的相等性判断方法,如
Equal
函数,或者实现Equal
方法以满足某个接口要求。 - 深层次比较:- 若要进行复杂的深度比较,例如包含嵌套结构、切片、Map 等混合类型的结构体,可以使用
reflect.DeepEqual
函数,它会对两个任意类型的数据进行深度比较,查看它们的值是否相等。但是要注意,reflect.DeepEqual
不适合用于比较包含不可比较类型(如函数或包含 channel 的结构体)的数据。
4、Go 中 init 函数的特征?
- 自动调用:
init
函数是不需要显式调用,当包被加载(imported)时,该包中的所有init
函数会自动执行。在程序启动之初,先执行所有的init
函数,然后再执行main
函数。不管包被导入多少次,包内的 init 函数只会执行一次。 - 无参数和返回值:
init
函数没有参数,也不返回任何值,其声明格式为: - 并发安全: 虽然多个包的
init
函数执行顺序不确定,但同一包内的多个init
函数是顺序执行的,并且 Go 语言保证了并发安全,即不同init
函数间的执行互不影响。 - 跨包依赖: 不同包之间的
init
函数执行顺序遵循依赖关系,如果 A 包导入了 B 包,那么 B 包的init
函数会在 A 包的init
函数执行之前被执行。这允许开发者在包间构建一种依赖链式的初始化逻辑。
5、Go 中 uintptr 和 unsafe.Pointer 的区别?
在 Go 语言中,
uintptr
和
unsafe.Pointer
都与内存地址相关,但它们的用途和限制有所不同:
- uintptr: -
uintptr
是 Go 语言的内建类型,代表一个无符号整数类型,足够存储指针的值。-uintptr
通常用于进行指针算术(如偏移量计算),因为 Go 语言的标准指针类型不支持直接的数学运算。-uintptr
类型并不能直接解引用为原来的指针类型,也不能阻止垃圾回收器对指向的对象进行回收。换句话说,uintptr
仅仅是数字,不代表它指向的内存区域是有效的或可访问的。 - unsafe.Pointer: -
unsafe.Pointer
是来自unsafe
包的一个类型,它是 Go 语言中唯一的通用指针类型。-unsafe.Pointer
用于在不同类型的指针之间进行转换,这在高级内存操作、低级别编程或者与 C 语言接口交互时非常有用。- 虽然可以将任何类型的指针转换为unsafe.Pointer
,然后再转换回另一种类型的指针,但这违反了 Go 语言的安全抽象原则,因此只有在必要且知道风险的情况下才能使用。- 转换回具体类型指针后,unsafe.Pointer
指向的内存区域是可以被垃圾回收器追踪的,因此不会因为转换为unsafe.Pointer
而丢失对对象的引用。
6、golang共享内存(互斥锁)方法实现发送多个get请求
可以使用
sync.Mutex
或
sync.RWMutex
(读写互斥锁)来进行同步控制。
7、从数组中取一个相同大小的slice有成本吗?
在 Go 语言中,从数组中取一个相同大小的切片(slice)操作本身几乎是没有成本的。slice并不存储任何数据,它仅仅是对底层数组的一个引用,并提供对该连续片段的访问。当创建一个切片时,它实际上是创建了一个轻量级的结构体,这个结构体包含三个字段:指向底层数组的指针、长度(length)和容量(capacity)。创建切片时,并不会复制数组的元素,只是创建了一个视图(view)而已。
版权归原作者 Thoth Lee 所有, 如有侵权,请联系我们删除。