Golang 面经
一: 基本
1. make 和 new 的区别
make : 可以为 Slice,Map,channel 分配内存
new : 根据传入的类型分配一片内存空间并返回指向这片内存空间的指针,负责申请内存,但是不会初始化。虽然 new 函数也可以为切片、字典和通道分配内存,但没有意义,因为它分配以后的地址还是 nil
2. IO 多路复用
3. for range 的时候它的地址会发生变化吗
在for key, value := range c
遍历中, key 和 value 在内存中只会存在一份,即之后每次循环时遍历到的数据都是以值覆盖的方式赋给 key 和 value,key,value 的内存地址始终不变。
由于有这个特性,for 循环里面如果开协程,不要直接把 a 或者 b 的地址传给协程。解决办法:在每次循环时,创建一个临时变量。
// 可以自行验证
func main() {
s := make([]int, 0)
s = append(s, 1, 3, 4, 5, 6)
for key, value := range s {
fmt.Printf("%p %p \n", &key, &value)
}
}
// 解决方法
func main() {
s := make([]int, 0)
s = append(s, 1, 3, 4, 5, 6)
for key, value := range s {
//fmt.Printf("%p %p \n", &key, &value)
//key1 := key
//value1 := value
go func(a int, b int) {
fmt.Println(a, b)
}(key, value)
}
select {}
}
4. defer 执行顺序
作用: 延迟函数,释放资源,首尾工作,( 释放锁,关闭连接,关闭文件,捕获 panic )
多个 defer 调用顺序和栈一样,LIFO,先进后出
func b() (i int) {
defer func() {
i++
fmt.Println("defer2:", i)
}()
defer func() {
i++
fmt.Println("defer1:", i)
}()
return i
//或者直接写成
return
}
func main() {
fmt.Println("return:", b())
}
5. uint 类型移除
uint8 最大是 255
uint 255 + utint 1 = 0
uint 255 + utint 2 = 1
如果类型溢出了就会从 0 开始
6. 介绍一下 rune 类型
相当 int32
golang 中的字符串底层实现是通过 byte 数组的,中文字符在 unicode 下占2个字节,在 utf-8 编码下占3个字节,而 golang 默认编码正好是 utf-8
byte 等同于 int8,常用来处理 ascii 字符
rune 等同于 int32,常用来处理 unicode 或 utf-8 字符
7. golang 中解析 tag 是怎么实现的?反射原理是什么?
反射原理: 反射是指计算机程序在运行时(Run time)可以访问、检测和修改它本身状态或行为的一种能力或动态知道给定数据对象的类型和结构,并有机会修改它。
反射将接口变量转换成反射对象 Type 和 Value;
反射可以通过反射对象 Value 还原成原先的接口变量;
反射可以用来修改一个变量的值,前提是这个值可以被修改;
8. 调用函数传入结构体时候,应该传值还是传指针
看是否要对原结构体进行修改
Go 的函数参数传递都是值传递。所谓值传递:指在调用函数时将实际参数复制一份传递到函数中,这样在函数中如果对参数进行修改,将不会影响到实际参数。参数传递还有引用传递,所谓引用传递是指在调用函数时将实际参数的地址传递到函数中,那么在函数中对参数所进行的修改,将影响到实际参数
因为 Go 里面的 map,slice,chan 是引用类型。变量区分值类型和引用类型。所谓值类型:变量和变量的值存在同一个位置。所谓引用类型:变量和变量的值是不同的位置,变量的值存储的是对值的引用。但并不是 map,slice,chan 的所有的变量在函数内都能被修改,不同数据类型的底层存储结构和实现可能不太一样,情况也就不一样。
9. goroutine 什么情况下会阻塞
由于原子、互斥量或通道操作调用导致 Goroutine 阻塞,调度器将把当前阻塞的 Goroutine 切换出去,重新调度 LRQ 上的其他 Goroutine;
由于网络请求和 IO 操作导致 Goroutine 阻塞。
当调用一些系统方法的时候(如文件 I/O),如果系统方法调用的时候发生阻塞,这种情况下,网络轮询器(NetPoller)无法使用,而进行系统调用的 G1 将阻塞当前 M1。调度器引入 其它M 来服务 M1 的P。
如果在 Goroutine 去执行一个 sleep 操作,导致 M 被阻塞了。Go 程序后台有一个监控线程 sysmon,它监控那些长时间运行的 G 任务然后设置可以强占的标识符,别的 Goroutine 就可以抢先进来执行。
10. 讲讲 Go 的 select 底层数据结构和一些特性
答:go 的 select 为 golang 提供了多路 IO 复用机制,和其他 IO 复用一样,用于检测是否有读写事件是否 ready。linux 的系统 IO 模型有 select,poll,epoll,go 的 select 和 linux 系统 select 非常相似。
select 结构组成主要是由 case 语句和执行的函数组成 select 实现的多路复用是:每个线程或者进程都先到注册和接受的 channel(装置)注册,然后阻塞,然后只有一个线程在运输,当注册的线程和进程准备好数据后,装置会根据注册的信息得到相应的数据。
select 的特性:
select 操作至少要有一个 case 语句,出现读写 nil 的 channel 该分支会忽略,在 nil 的 channel 上操作则会报错。
select 仅支持管道,而且是单协程操作
每个 case 语句仅能处理一个管道,要么读要么写。
多个 case 语句的执行顺序是随机的。
存在 default 语句,select 将不会阻塞,但是存在 default 会影响性能。
11. Go defer 的底层数据结构
每个 defer 语句都对应一个 _defer 实例,多个实例使用指针连接起来形成一个单连表,保存在 gotoutine 数据结构中,每次插入 _defer 实例,均插入到链表的头部,函数结束再一次从头部取出,从而形成后进先出的效果。
12. 单引号,双引号,反引号的区别 ?
单引号,表示 byte 类型或 rune 类型,对应 uint8 和 int32 类型,默认是 rune 类型。byte 用来强调数据是 raw data,而不是数字;而 rune 用来表示 Unicode 的 code point。
双引号,才是字符串,实际上是字符数组。可以用索引号访问某字节,也可以用 len() 函数来获取字符串所占的字节长度。
反引号,表示字符串字面量,但不支持任何转义序列。字面量 raw literal string 的意思是,你定义时写的啥样,它就啥样,你有换行,它就换行。你写转义字符,它也就展示转义字符。
13. go 出现 panic 的场景
对已经关闭的 channel 进行关闭,向已经关闭的 channel 发送消息,对 nil channel 进行关闭
数组/切片越界
空指针异常
除以0
未初始化 map
协程里面也会出现 panic
类型断言不匹配
14. go 如何实现 set
使用 map 实现 set , value部分传入空结构体
type Empty struct{}
func main() {
_ = make(map[string]Empty)
}
15. go 如何实现多返回值
16. 值拷贝与引用拷贝,深拷贝与浅拷贝
map,slice,chan 是引用拷贝;引用拷贝 是 浅拷贝,其余值类型都是值拷贝;值拷贝是 深拷贝
深拷贝:(值拷贝)
拷贝的是数据本身,创造一个新的对象,并在内存中开辟一个新的内存地址,与原对象是完全独立的,不共享内存,修改新对象时不会影响原对象的值。释放内存时,也没有任何关联
func modify(a [3]int) {
a[0] = 4
fmt.Println("modify",a) // modify [4 2 3]
}
func main() {
a := [3]int{1, 2, 3}
modify(a)
fmt.Println("main",a) // main [1 2 3]
}
浅拷贝: ( 引用拷贝 )
拷贝的是数据地址,只复制指向的对象的指针,新旧对象的内存地址是一样的,修改一个另一个也会变。释放内存时,同时释放。
func modify(s []int) {
s[0] = 4
fmt.Println("modify",s) // modify [4 2 3]
}
func main() {
s := []int{1, 2, 3}
modify(s)
fmt.Println("main",s) // main [4 2 3]
}
17. Golang 中 slice,struct,map之间如何进行比较
我们知道基本数据类型之间可以用 = 来判断是否相等
使用reflect.DeepEqual()
来判断 slice,struct,map类型是否相等
基本数据类型也可以用
reflect.DeepEqual()
判断是否相等
reflect.DeepEqual()func main() {
strs := []string{"123", "2341", "432"}
strs2 := []string{"123", "2341", "432"}
if reflect.DeepEqual(strs, strs2) {
fmt.Println("strs = strs2")
}
// 其他类型同理
}
二: Slice
三: map
1. map 循环是有序的还是无序的?
无序的,map 因扩张⽽重新哈希时,各键值项存储位置都可能会发生改变,顺序自然也没法保证了,所以官方避免大家依赖顺序,直接打乱处理。就是 for range map 在开始处理循环逻辑的时候,就做了随机播种
2. map 中删除一个 key,它的内存会释放么?
在Go语言中,删除一个map
中的键不会直接释放相关内存。删除键只是将对应的键值对从map
中删除,并不会立即释放相关内存。Go语言的垃圾回收器(Garbage Collector)负责管理内存的分配和释放。当没有任何指向某个键的引用时,垃圾回收器会在适当的时候回收这部分内存。这意味着,虽然你删除了一个键,但内存并不会立即被释放,而是由垃圾回收器根据其算法在未来的某个时刻进行回收。
3. nil map 和空map
nil map: 指的是未进行初始化的map
空map:长度为 0 的map
func main() {
var test map[string]string
// 向nil map赋值会报错
test["sakura"] = "sa"
// 取值不会报错
fmt.Println(test["LF"])
}
四: 接口
五: context
六: channel
1. nil、关闭的 channel、有数据的 channel,再进行读、写、关闭会怎么样?(各类变种题型,重要)
Channel读写特性(15字口诀)
首先,我们先复习一下Channel都有哪些特性?
给一个 nil channel 发送数据,造成永远阻塞
从一个 nil channel 接收数据,造成永远阻塞
给一个已经关闭的 channel 发送数据,引起 panic
从一个已经关闭的 channel 接收数据,如果缓冲区中为空,则返回一个零值
无缓冲的channel是同步的,而有缓冲的channel是非同步的
以上5个特性是死东西,也可以通过口诀来记忆:“空读写阻塞,写关闭异常,读关闭空零”。
七: GMP
1. 进程,线程,协程
进程:是应用程序的启动实例,每个进程都有独立的内存空间,不同的进程通过进程间的通信方式来通信。
线程:从属于进程,每个进程至少包含一个线程,线程是 CPU 调度的基本单位,多个线程之间可以共享进程的资源并通过共享内存等线程间的通信方式来通信。
协程:为轻量级线程,与线程相比,协程不受操作系统的调度,协程的调度器由用户应用程序提供,协程调度器按照调度策略把协程调度到线程中运行
2. GMP
首先:G代表协程,M代表操作系统内核线程,P代表调度器,虚拟处理器
在 GMP 模型,有一个全局队列(Global Queue):存放等待运行的 G,还有一个 P 的本地队列:也是存放等待运行的 G,但数量有限,不超过 256 个。
GPM 的调度流程从 go func() 开始创建一个 goroutine,新建的 goroutine 优先保存在 P 的本地队列中,如果 P 的本地队列已经满了,则会保存到全局队列中。
M 会从 P 的队列中取一个可执行状态的 G 来执行,如果 P 的本地队列为空,就会从其他的 MP 组合偷取一个可执行的 G 来执行。
当 M 执行某一个 G 时候发生系统调用或者阻塞,M 阻塞,如果这个时候 G 在执行,runtime 会把这个线程 M 从 P 中摘除,然后创建一个新的操作系统线程来服务于这个 P,当 M 系统调用结束时,这个 G 会尝试获取一个空闲的 P 来执行,并放入到这个 P 的本地队列,如果这个线程 M 变成休眠状态,加入到空闲线程中,然后整个 G 就会被放入到全局队列中。
G , P , M 的个数问题:
G 的个数,只要内存够是无限制的
P 的数量由启动时环境变量GOMAXPROCS或者是由runtime的方法GOMAXPROCS()决定。这意味着在程序执行的任意时刻都只有GOMAXPROCS个goroutine在同时运行。
M 的数量:go 程序启动时,会设置 M 的最大数量,默认10000,但是内核很难支持这么多的线程数,所以这个限制可以忽略。
runtime/debug中的SetMaxThreads函数,设置M的最大数量
一个M阻塞了,会创建新的M。
一个 M 阻塞,P 就会去创建或者切换另一个 M,所以,即使 P 的默认数量是1,也有可能会创建很多个 M 出来。
hand off (移交)机制:当前线程的G进行阻塞调用时,例如睡眠,则当前线程就会释放P,然后把P转交给其它空闲的线程执行,如果没有闲置的线程,则创建新的线程
八: 锁
九: 并发
1. 主协程如何等待其余协程退出
使用 sync.WaitGroup 等待一组协程结束,
Add()
是添加计数,Done()
减去一个计数,Wait()
阻塞直到所有的任务完成Go 里面能通过有缓冲的 channel 实现其阻塞等待一组协程结束,这个不能保证一组 goroutine 按照顺序执行,可以并发执行协程。
Go 里面还能通过无缓冲的 channel 实现其阻塞等待一组协程结束,这个能保证一组 goroutine 按照顺序执行,但是不能并发执行。
2. 怎么控制并发
有缓存 channel
根据通道中没有数据时读取操作陷入阻塞和通道已满时继续写入操作陷入阻塞的特性,正好实现控制并发数量。
func main() {
count := 10 // 最大支持并发
sum := 100 // 任务总数
wg := sync.WaitGroup{} // 控制主协程等待所有子协程执行完之后再退出。
c := make(chan struct{}, count) // 控制任务并发的chan
defer close(c)
for i := 0; i < sum; i++ {
wg.Add(1)
c <- struct{}{} // 作用类似于waitgroup.Add(1)
go func(j int) {
defer wg.Done()
fmt.Println(j)
<-c // 执行完毕,释放资源
}(i)
}
wg.Wait()
}
三方库实现的协程池
3. 多个 goroutine 对同一个 map 写会 panic,异常是否可以用 defer 捕获?
可以捕获异常,但是只能捕获一次,Go语言,可以使用多值返回来返回错误。不要用异常代替错误,更不要用来控制流程。在极个别的情况下,才使用 Go 中引入的Exception 处理:defer, panic, recover Go 中,对异常处理的原则是:多用 error 包,少用 panic
十: GC
1. GC 是怎么实现的
GoV1.8 混合写屏障规则是:
1)GC 开始将栈上的对象全部扫描并标记为黑色(之后不再进行第二次重复扫描,无需 STW),
2)GC 期间,任何在栈上创建的新对象,均为黑色。
3)被删除的对象标记为灰色。
4)被添加的对象标记为灰色。
2、go 是 gc 算法是怎
2. GC 的触发时机
自动触发
gcTriggerHeap:当所分配的堆大小达到阈值(由控制器计算的触发堆的大小)时,将会触发。
gcTriggerTime:当距离上一个 GC 周期的时间超过一定时间时,将会触发。时间周期以runtime.forcegcperiod 变量为准,默认 2 分钟。
手动触发
runtime.GC()
十一: 内存
1. 谈谈内存泄露,什么情况下内存会泄露?怎么定位排查内存泄漏问题?
内存泄漏通常指程序在申请内存后,无法释放已不再使用的内存空间,导致系统资源持续消耗,久而久之可能导致程序性能下降甚至系统崩溃。
go 中的内存泄漏一般都是 goroutine 泄漏,就是 goroutine 没有被关闭,或者没有添加超时控制,让 goroutine 一只处于阻塞状态,不能被 GC。
内存泄露有下面一些情况
如果 goroutine 在执行时被阻塞而无法退出,就会导致 goroutine 的内存泄漏,一个 goroutine 的最低栈大小为 2KB,在高并发的场景下,对内存的消耗也是非常恐怖的。
互斥锁未释放或者造成死锁会造成内存泄漏
ime.Ticker 是每隔指定的时间就会向通道内写数据。作为循环触发器,必须调用 stop 方法才会停止,从而被 GC 掉,否则会一直占用内存空间。
字符串的截取引发临时性的内存泄漏
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,那么就会产生临时性的内存泄漏,对于高并发场景相当可怕。】
排查方式:
一般通过 pprof 是 Go 的性能分析工具,在程序运行过程中,可以记录程序的运行信息,可以是 CPU 使用情况、内存使用情况、goroutine 运行情况等,当需要性能调优或者定位 Bug 时候,这些记录的信息是相当重要。
2. golang 的内存逃逸吗?什么情况下会发生内存逃逸?
本该分配到栈上的变量,跑到了堆上,这就导致了内存逃逸。
栈是高地址到低地址,栈上的变量,函数结束后变量会跟着回收掉,不会有额外性能的开销。
变量从栈逃逸到堆上,如果要回收掉,需要进行 gc,那么 gc 一定会带来额外的性能开销。编程语言不断优化 gc 算法,主要目的都是为了减少 gc 带来的额外性能开销,变量一旦逃逸会导致性能开销变大。
内存逃逸的情况如下:
方法内返回局部变量指针。
向 channel 发送指针数据。
在闭包中引用包外的值。
在 slice 或 map 中存储指针。
切片(扩容后)长度太大。
在 interface 类型上调用方法。
评论区