跳过正文
  1. 全部/
  2. 笔记/
  3. 面试题/

Go

目录

基础
#

1. 相比较于其他语言, Go 有什么优势或者特点?
#

Go 允许跨平台编译,编译出来的是二进制的可执行文件,直接部署在对应系统上即可运行。​

Go 在语言层次上天生支持高并发,通过 goroutine 和 channel 实现。 channel 的理论依据是 CSP 并发模型, 即所谓的通过通信来共享内存; Go 在 runtime 运行时里实现了属于自己的调度机制:GMP,降低了内核态和用户态的切换成本。​

Go 的语法简单,代码风格比较统一

2. go是面向对象的吗
#

  • 封装:Go语言里面字段首字母大小写来决定字段是否可以被外包访问
  • 继承:Go语言里面用组合结构体的方式 或 接口继承来实现
  • 多态:Go语言中通过接口来实现多态,不同的类型实现对应接口,然后调用接口变量的方法,结果取决于接口存储的对应类型的方法

3. golang 中 make 和 new 的区别?(基本必问)
#

  • make 只能用来分配及初始化类型为 slice、map、chan 的数据。new 可以分配任意类型的数据;
  • new 分配返回的是指针,即类型 *Type。make 返回数据类型本身,即 Type;
  • new 分配的空间被清零。make 分配空间后,会进行初始化;

4. 数组和切片的区别 (基本必问),切片怎么扩容
#

数组是值类型,长度固定​ 切片是引用类型,长度不固定,可以动态扩容 newcap = oldcap+(oldcap+3*256)/4

Pasted image 20260527215720.png

5. for i, v := range 切片,v地址会变化吗
#

地址不会发生变化,但是该地址的值是变化的,每遍历到一个元素,就把该元素值就会写到该地址​ PS:在最新版本Go 1.22中,v的地址会变化的,也就是不再共享变量了,知道这点的话,在面试一定要提出来,展示自己的技术面广

6. go defer,多个 defer 的顺序,defer 在什么时机会修改返回值?(defer 和 return)
#

5.1 defer 的执行顺序
#

defer的执行顺序类似于栈,是LIFO,先调用的defer语句后执行。

5.2 defer在什么时机会修改返回值
#

只有“命名返回值”会被 defer 修改。 因为 defer 操作的是同一个返回变量。

7. uint 类型溢出
#

类型有无符号占用存储空间表数范围备注
int32位系统4个字节64位系统8字节-2的31次方 ~ 2的31次方-1-2的63次方 ~ 2的63次方-1
uint32位系统4个字节64位系统8字节0 ~ 2的32次方-10 ~ 2的64次方-1
rune与int32一样-2的31次方 ~ 2的31次方-1等价于int32,表示Unicode码
byte与uint8等价0 ~ 255当要存储字符时选用byte

8. 介绍 rune 类型
#

rune是类型int32的别名


func main() {
	var str = "hello 你好" //思考下 len(str) 的长度是多少?//golang中string底层是通过byte数组实现的,直接求len 实际是在按字节长度计算 //所以一个汉字占3个字节算了3个长度​
	fmt.Println("len(str):", len(str)) // len(str): 12//通过rune类型处理unicode字符​
	fmt.Println("rune:", len([]rune(str))) //rune: 8​
}

9. Go中两个Nil可能不相等吗?
#

接口(interface) 是对非接口值(例如指针,struct等)的封装,内部实现包含 2 个字段,类型 T 和 值 V。一个接口等于 nil,当且仅当 T 和 V 处于 unset 状态(T=nil,V is unset)。​ 两个接口值比较时,会先比较 T,再比较 V。接口值与非接口值比较时,会先将非接口值尝试转换为接口值,再比较。​

func main() {
	var p *int = nil
	var i interface{} = p
	fmt.Println(i == p) // true​
	fmt.Println(p == nil) // true​
	fmt.Println(i == nil) // false​
}
  • 例子中,将一个nil非接口值p赋值给接口i,此时,i的内部字段为(T=*int, V=nil),i与p作比较时,将 p 转换为接口后再比较,因此 i == p,p 与 nil 比较,直接比较值,所以 p == nil。​
  • 但是当 i 与nil比较时,因为i为接口指,会将nil转换为接口(T=nil, V=nil),与i(T=*int, V=nil)不相等,因此 i != nil。因此 V 为 nil ,但 T 不为 nil 的接口不等于 nil。

10. golang 中解析 tag 是怎么实现的?反射原理是什么?
#

type Student struct { 
	Name string `key1:"value1" key2:"value2"` //名字​
	Age uint `key3:"value3"`//年龄​
}

Go 中解析的 tag 是通过反射实现的

反射是指计算机程序在运行时(Run time)可以访问、检测和修改它本身状态或行为的一种能力​

Go语言反射是通过接口来实现的,通过隐式转换,普通的类型被转换成interface类型,这个过程涉及到类型转换的过程,首先从Golang类型转为interface类型, 再从interface类型转换成反射类型, 再从反射类型得到想的类型和值的信息

11. go struct 能不能比较?
#

  • 对于不同类型的struct无法进行比较;而同一个struct的两个实例可比较也不可比较。​

  • 在Go中,Slice、map、func无法比较,当一个struct的成员是这三种类型中的任意一个,就无法进行比较。反之,struct是可以进行比较的

12. 结构体打印时,%v 和 %+v 的区别
#

fmt.Printf("a=%v \n", a) // a=&{1 张三} ​
fmt.Printf("a=%+v \n", a) // a=&{id:1 name:张三} ​
fmt.Printf("a=%#v \n", a) // a=&main.student{id:1, name:"张三"}

13. 空 struct{} 占用空间么?
#

空结构体 struct{} 实例不占据任何的内存空间。

14. go语言中空 struct{} 的用途?
#

  • 将 map 作为集合(Set)使用时,可以将值类型定义为空结构体,仅作为占位符使用即可。​

  • 使用在不发送数据的信道(channel)上,使用 channel 不需要发送任何的数据,只用来通知子协程(goroutine)执行任务,或只用来控制协程并发度。​

  • 用作接口的实现,结构体只包含方法,不包含任何的字段

15. go中"_“的作用
#

  1. import中的下滑线用于执行导入包下的所有init函数​
  2. 代码体中的下划线用于忽略返回值

16. Go 闭包
#

闭包实际上就是匿名函数 + 引用环境(捕获的变量)​ Go 中闭包总是以引用方式捕获变量

17. Go 多返回值怎么实现的?
#

函数调用时,调用者会在自己的栈帧中预先分配返回值的存储空间,然后将地址传递给被调用函数。被调用函数执行完后,直接将返回值写入这些位置。

18. Go 语言中不能比较的类型如何比较是否相等?
#

Go 中 slice、map、function 等类型不能直接比较,是因为其内部包含引用结构或不具备稳定的值语义。对于这类类型,需要根据数据语义进行逐元素或逐键值比较,例如 slice 逐元素比较、map 比较 key-value 集合。工程上可使用 reflect.DeepEqual 或 Go 1.21+ 的 slices.Equal,但在高性能场景应避免反射,而采用显式比较逻辑。

19. Go 中 init 函数的特征?
#

Go 中 init 函数是包级初始化函数,在 main 执行前由 runtime 自动调用,每个包可以有多个 init 函数,按依赖顺序执行。

应用初始化时初始化工作的顺序是,从被导入的最深层包开始进行初始化,层层递出最后到 main 包。

20. Go 中 uintptr 和 unsafe.Pointer 的区别?
#

unsafe.Pointer 是“通用指针类型”,参与 Go 内存模型;uintptr 只是一个“整数地址值”,不参与 GC,不安全。

uintptr 主要用于指针运算等底层操作

Context
#

1、context 结构是什么样的?
#

context其实是一个接口,提供了四种方法,而在官方go语言中对context接口提供了四种基本类型的实现

type Context interface {
	// Deadline方法的第一个返回值表示还有多久到期,第二个返回值代表是否被超时时间控制​
    Deadline() (deadline time.Time, ok bool)
	// Done() 返回一个 只读channel,当这个channel被关闭时,说明这个context被取消
    Done() <-chan struct{}
	// Err() 返回一个错误,表示channel被关闭的原因,例如是被取消,还是超时关闭​
    Err() error
	// value方法返回指定key对应的value,这是context携带的值
    Value(key any) any
}

有emptyCtx 、cancelCtx 、timerCtx、valueCtx四种实现

emptyCtx:emptyCtx 虽然实现了context接口,但是不具备任何功能,因为实现很简单,基本都是直接返回空值​

  • 我们一般调用context.Background()和context.TODO() 都是返回一个 *emptyCtx的动态类型(通过静态类型context.Context传递)​

cancelCtx:cancelCtx同时实现Context和canceler接口,通过取消函数cancelFunc实现退出通知。注意其退出通知机制不但通知自己,同时也通知其children节点。​

  • 我们一般调用context.WithCancel() 就会返回一个*cancelCtx 和cancelFunc ​

timerCtx: timerCtx是一个实现了Context接口的具体类型,其内部封装了cancelCtx类型实例,同时也有个deadline变量,用来实现定时退出通知​

  • 我们一般调用context.WithTimeout() 就会返回一个*timerCtx和cancelFunc,不仅可以定时通知,也可以调用cancelFunc进行通知​
  • 调用context.WithDeadline()也可以,WithTimeout是多少秒后进行通知,WithDeadline是在某个时间点通知,本质上,WithTimeout会转而WithDeadline​

valueCtx: valueCtx是一个实现了Context接口的具体类型,其内部封装了Context接口类型,同时也封装了一个k/v的存储变量,其是一个实现了数据传递​

  • 我们一般context.WithValue()来得到一个*valueCtx,valueCtx可以继承它的parent valueCtx中的{key, value}

2、context 使用场景和用途?(基本必问)
#

  1. context 主要用来在 goroutine 之间传递上下文信息,比如传递请求的trace_id,以便于追踪全局唯一请求​

  2. 另一个用处是可以用来做取消控制,通过取消信号和超时时间来控制子goroutine的退出,防止goroutine泄漏​

包括:取消信号、超时时间、截止时间、k-v 等。

Chennel
#

1. channel 是否线程安全?锁用在什么地方?
#

一般来说,我们对channel就只有读,写,关闭三种操作,这三种操作,channel底层数据结构都用同一把runtime.Mutex来进行保护

2. channel 的底层实现原理 (数据结构)
#

  • 对于包含缓冲的channel,go语言的channel底层是一个hchan的结构,里面包含一个指向循环数组的指针,这个循环数组就是用于存储数据的。当然还包含下次读取和下次发送的数据索引位置recvx和sendx​

  • 还包含两个goroutine等待队列,在一个goroutine对这个channel读写阻塞的时候会分情况放到这两个队列里,发送数据阻塞就放到sendq这个等待队列,接收数据阻塞就放到recvq这个等待队列​

  • 为了保证channel的线程安全,hchan结构还有一个互斥锁,用作数据读写时候加锁,当前close channel也会用到这个互斥锁

3. nil、关闭的 channel、有数据的 channel,再进行读、写、关闭会怎么样?(各类变种题型)
#

操作 \ 状态未初始化 (nil)关闭 (closed)正常 (normal)
关闭panicpanic正常关闭
当前goroutine永久性挂起,可能导致死锁panic阻塞挂起或者成功发送
当前goroutine永久性挂起,可能导致死锁读取缓冲区数据,读完后,每次读取都返回零值阻塞挂起或者成功接收

4. 对channel 进行读写数据的流程是怎样的
#

读操作

  • 成功读取: 如果channel中有数据,直接从channel里面读取,并且此时如果写等待队列里面有goroutine,还需要将队列头部goroutine数据放入到channel中,并唤醒这个goroutine​ 如果channel没有数据,就尝试从 写等待队列 头部goroutine读取数据,并做对应的唤醒操作

  • 挂起 channel里面没有数据 并且 写等待队列为空,则将当前goroutine 加入 读等待队列中,并挂起,等待唤醒

写操作

  • 成功写入: 如果channel 读等待队列不为空,则取 头部goroutine,将数据直接复制给这个头部goroutine,并将其唤醒,流程结束​ 否则就尝试将数据写入到channel 环形缓冲中

  • 挂起 通道里面无法存放数据 并且 读等待队列为空,则当前gorotine 加入写等待队列中,并挂起,等待唤醒

5. select的底层原理
#

select也被称为多路select,指的是一个goroutine 可以服务多个 channel的读或写操作,要清楚的知道 select分为两种,包含非阻塞型select(包含default分支的) 和 阻塞型select(不包含default分支的)

  • select的核心原理是,按照随机的顺序执行case,直到某个case完成操作,如果所有case的都没有完成操作,则看有没有default分支,如果有default分支,则直接走default,防止阻塞​

  • 如果没有的话,需要将当前goroutine 加入到所有case对应channel的等待队列中,并挂起当前goroutine,等待唤醒。​

  • 如果当前goroutine被某一个case 上的channel操作唤醒后,还需要将当前goroutine从所有case对应channel的等待队列中剔除

Map
#

1. map 使用注意的点,是否是并发安全的?
#

map 不是线程安全的。 ​

如果某个任务正在对map进行写操作,那么其他任务就不能对该 字典执行并发操作(读、写、删除),否则会导致进程崩溃。 ​

在查找、赋值、遍历、删除的过程中都会检测写标志,一旦发现写标志等于1,则直接 fatal 退出程序。赋值和删除函数在检测完写标志是0之后,先将写标志改成1,才会进行之后的操作。

2. map 循环是有序的还是无序的?
#

map的遍历是无序的,map每次遍历,都会从一个随机值序号的桶,在每个桶中,再从按照之前选定随机槽位开始遍历,所以是无序的。​

因为map 在扩容后,会发生 key 的搬迁,原来落在同一个 bucket 中的 key,搬迁后,有些 key 的位置就会发生改变。而遍历的过程,就是按顺序遍历 bucket,同时按顺序遍历 bucket 中的 key。搬迁后,key 的位置发生了重大的变化,这样,遍历 map 的结果就不可能按原来的顺序了。所以,go语言,强制每次遍历都随机开始。

3. map 如何顺序读取?
#

如果想顺序遍历map,先把key放到切片排序,再按照key的顺序遍历map

4. map 中删除一个 key,它的内存会释放么?
#

不会释放,删除一个key,可以认为是标记删除,只是修改key对应内存位置的值为空,并不会释放内存,只有在置空这个map的时候,整个map的空间才会被垃圾回后释放

5. 怎么处理对 map 进行并发访问?有没有其他方案? 区别是什么?
#

对map进行加读写锁或者是使用sync.map​

和原始map+RWLock的实现并发的方式相比,减少了加锁对性能的影响。 它做了一些优化:可以无锁访问read map,而且会优先操作read map,倘若只操作read map就可以满足要求,那就不用去加锁操作write map(dirty),所以在某些特定场景中它发生锁竞争的频率会远远小于map+RWLock的实现方式

6. nil map 和空 map 有何不同?
#

未初始化的map为nil map,​

  • 往值为nil的map添加值,会触发panic​
  • 读取值为nil的map,不会报错​
  • 删除值为nil的map,不会报错​

已经初始化,没有任何元素的map为空map,对空map增删改查不会报错

7. map 的数据结构是什么?是怎么实现扩容
#

Pasted image 20260529144657.png
  1. map的底层实现其实是一个hmap的结构,
  2. 其中包括一个buckets指针,指向一个bmap的数组,bmap数组每个元素是一个bmap结构,称之为桶
  3. 每个桶内存储着8个tophash和8个key-value的键值对
  4. 以及指向下一个溢出桶的指针

扩容

扩容时机:向 map 插入新 key 的时候 map元素个数 > 6.5(负载因子) * 桶个数,触发双倍扩容 如果溢出桶总数>=桶总数,则认为溢出桶过多,触发等量扩容

双倍扩容:新建一个buckets数组,新的buckets数量大小是原来的2倍,然后旧buckets数据搬迁到新的buckets。 等量扩容:并不扩大容量,buckets数量维持不变

扩容方式:​ 扩容过程并不是一次性进行的,而是采用的渐进式扩容

缩容

8. map 的 key 为什么得是可比较类型的?
#

本题主要考察go语言map中如何通过一个key计算得到它在桶中的位置​

第一步:根据key来计算出一个hash值(64位的,当然与机器位数挂钩)​ 第二步:然后根据hash值的低B位锁定桶号(找到对应的bucket)​ 第三步:接着在桶中找到对应的槽位(找到对应的一个cell)

但是这里会存在一个hash冲突的问题,进而比较key本身

sync.Map
#

1. sync.Map的底层原理
#

sync.Map采用 空间换取时间的取舍策略 以及 实时动态的数据流转策略,期望使用read map来尽量将读、更新、删除操作的流量用无锁化的操作挡下来,避免去加锁去访问拥有全量数据的dirty map​

sync.Map对于k-v对里面的v,还设计了两种删除状态,一种是为nil的软删除态,一种是为expunged的硬删除态​

  • nil态可以拦截删除操作在read map这一层​
  • expunged态可以正确标识dirty map中有没有对应的逻辑删除的key-entry

2. read map和dirty map之间的关联?
#

当因为miss read而访问dirty的次数等于dirty的长度时,需要将dirty map提升到read map,并置dirty为nil

当dirty map为nil,会在Store里面触发dirtyLocked流程,这个流程会遍历read map,将所有非删除状态的k-entry对写入到新dirty 里面去

3. 为什么要设计nil和expunged状态?
#

  • nil态是软删除态,可以让删除操作的流量在read map层挡住 - (防止加锁,去删除dirty map中的数据​)

  • expunged态是硬删除态,也是逻辑上k-v删除了,但是k-entey对只存在read map中,能正确标识出key-entry对是否存在于dirty map中

4. sync.Map 适用的场景?
#

sync.Map 是适用于读多、更新多、删多、写少的场景

5. 你认为sync.Map有啥不足吗?
#

  • sync.Map不适用于写多的场景,因为写操作足够多的话,sync.Map就相当于一把Mutex+Map​

  • 而且sync.Map中存在一个将read map数据流转到 dirty map的过程,这个过程是线性时间复杂度,当map中k-v数量较多的时候,容易导致程序性能抖动,比如想要访问sync.Map拿锁操作的goroutine 一直等待这个线性时间复杂度的过程完成

6. 补充知识——分段锁map是什么
#

GMP
#

Pasted image 20260529223114.png

1. 什么是 GMP?(必问)调度过程是什么样的?(对流程熟悉,要求更高,问的较少)
#

gmp模型是go语言中的协程调度模型

G:Goroutine, M:Machine,goroutine只有绑定到m上才能够正常运行 P:Processor,包含goroutine本地队列,队列长度为256,当有 goroutine 要创建时,会被添加到 P 上的 goroutine 本地队列上,如果 P 的本地队列已满,则会维护到全局队列里

gmp是go语言协程调度模型,g代表goroutine,m代表内核线程,p代表逻辑处理器,p中包含本地g队列,g通过p绑定到m才能真正运行

协程在刚创建的时候,会优先加到当前p的本地队列中,等待被调度 当这个p队列满了的时候,本地队列满了时,会将本地队列的一半 G 和新创建的 G 一起放入全局队列。

每个m都有一个特殊的协程g0负责调度工作,每一轮调度过程是这样的, M 优先执行其所绑定的 P 的本地运行队列中的 G,如果本地队列没有 G,则会从全局队列获取,为了提高效率和负载均衡,会从全局队列获取多个 G,而不是只取一个 同样,当全局队列没有时,会从其他 M 的 P 上偷取 G 来运行,偷取的个数通常是其他 P 运行队列的一半;如果还没有获取到g,则m就处于自旋状态。

2. GMP能不能去掉P层?会怎么样?
#

  • 每个 P 有自己的本地队列,大幅度的减轻了对全局队列的直接依赖,所带来的效果就是锁竞争的减少。而 GM 模型的性能开销大头就是锁竞争。​

  • 每个 P 相对的平衡上,在 GMP 模型中也实现了 Work Stealing 算法,如果 P 的本地队列为空,则会从全局队列或其他 P 的本地队列中窃取可运行的 G 来运行,减少空转,提高了资源利用率。

3. M 和 P 的数量问题?
#

P的数量:​

  • 由启动时环境变量$GOMAXPROCS或者是由runtime的方法GOMAXPROCS()决定​

M的数量:​

  • go语言本身的限制:go程序启动时,会设置M的最大数量,默认10000.但是内核很难支持这么多的线程数​
  • runtime/debug 中的 SetMaxThreads 函数,设置M的最大数量​
  • 一个M阻塞了,会创建新的 M

G的数量:​

  • 理论上没有限制,受限于内存,但是goroutine过多会影响程序性能

4. 进程、线程、协程有什么区别?
#

进程可以理解为一个动态的程序,进程是操作系统资源分配的基本单位 线程是操作系统调度的基本单位,进程独占一个虚拟内存空间,而进程里的线程共享一个进程虚拟内存空间。线程的粒度更小,一个进程可以有多个线程​

协程可以理解为用户态线程,跟线程的区别主要有三个方面​

  • 大小,协程大小为2k,可以动态扩容,而线程大小为2m,协程更轻量​

  • 线程切换需要用户态到内核态的切换,而协程的切换不用,只在用户态完成,协程切换消耗更小​

  • 线程的调度由操作系统完成,而协程的调度由运行时的调度器完成

5. 抢占式调度是如何抢占的?
#

Go1.14 之后是异步式抢占,基于信号。 sysmon 会检测到运行了 10ms 以上的 G(goroutine)。然后,sysmon 向运行 G 的 M发送信号(SIGURG)。Go 的信号处理程序会调用M上的一个叫作 gsignal 的 goroutine 来处理该信号,并使其检查该信号。gsignal 看到抢占信号,停止正在运行的 G。​

基于信号量的抢占可以防止类似于死循环这种没有发生函数调用的goroutine一直占用cpu导致程序阻塞,提高了程序的合理性

Sync
#

Mutex / RWMutex -> 控制“谁能访问”  
WaitGroup -> 控制“谁先结束”  
Once -> 控制“只执行一次”  
Cond -> 控制“条件成立才继续”  
Map -> 并发安全 map  
Pool -> 对象复用  
atomic -> 原子操作(更底层)

1. 除了 mutex 以外还有那些方式安全读写共享变量?
#

在go语言中有锁,信号量还有channel三种方式实现

  1. 用信号量实现互斥功能
const (
	Limit = 1 // 同時并行运行的goroutine上限,要实现互斥锁的功能,这里要设置为1​
	Weight = 1 // 每个goroutine获取信号量资源的权重​
)
func main() {
	s := semaphore.NewWeighted(Limit)
	var w sync.WaitGroup
	w.Add(10)
	for i := 0; i < 10; i++ {
		go func() {
				s.Acquire(context.Background(), Weight) // 获取信号量​
				doAdd()
				s.Release(Weight) // 释放信号量​
				w.Done()
			}()
		}
	w.Wait()
	fmt.Printf("num=%d\n", num)
}
  1. 用 channel 实现互斥功能
func main() {
	chanLock := make(chan struct{}, 1) // 实现互斥,channel的容量要设置为1​
	var wg sync.WaitGroup // WaitGroup 来保证子 goroutine 完成任务之前,主协程不会退出。​
	wg.Add(10) 
	
	for i := 0; i < 10; i++ {
		go func() {
			chanLock <- struct{}{}
			doAdd()
			<-chanLock
			wg.Done()
		}()
	}
	wg.Wait()
	fmt.Printf("num=%d\n", num)
}
  1. mutex 实现互斥
func main() {
	mu := sync.Mutex{}
	var wg sync.WaitGroup // WaitGroup 来保证子 goroutine 完成任务之前,主协程不会退出。​
	wg.Add(10)
	
	// lock.Lock() 互斥锁的实现方式​
	for i := 0; i < 10; i++ {
		go func() {
			mu.Lock()
			doAdd()
			mu.Unlock()
			wg.Done()
		}()
	}
	wg.Wait()
	fmt.Printf("num=%d\n", num)
}
  • 将共享变量的读写放到一个 goroutine 中,其它 goroutine 通过 channel 进行读写操作。​
  • 可以用个数为 1 的信号量(semaphore)实现互斥

2. Go 如何实现原子操作?
#

原子操作是一组不可中断的指令序列,由底层硬件支持,go语言的原子操作由sync/atomic包提供

	func AddT(addr *T, delta T)(new T)
	func StoreT(addr *T, val T)
	func LoadT(addr *T) (val T)
	func SwapT(addr *T, new T) (old T)
	func CompareAndSwapT(addr *T, old, new T) (swapped bool)

3. 原子操作和锁的区别
#

原子操作由底层硬件支持,而锁是基于原子操作+信号量完成的。若实现相同的功能,前者通常会更有效率​

原子操作是单个指令的互斥操作;互斥锁/读写锁是一种数据结构,可以完成临界区(多个指令)的互斥操作,扩大原子操作的范围​

原子操作是无锁操作,属于乐观锁 说起锁的时候,一般属于悲观锁

4. Mutex 是悲观锁还是乐观锁?悲观锁、乐观锁是什么?
#

乐观锁和悲观锁其实是两种锁思想,乐观锁假定别人不会修改数据,在操作数据的时候,查看一次数据,然后修改完真正生效的时候在查看一下数据有没有发生变化,如果发生变化,则认为数据被修改,有并发问题,放弃操作,否则执行操作。

悲观所就是时时刻刻认为有其他操作者修改数据,每次操作数据的时候,都尝试把数据锁住,操作期间其他人不能修改数据,直至锁被释放。

5. 互斥锁mutex底层是怎么实现的?
#

mutex底层是通过原子操作加信号量来实现的,

  • 通过atomic 包中的一些原子操作来实现锁的锁定
  • 通过信号量来实现协程的阻塞与唤醒

6. Mutex 有几种模式?
#

Normal 模式:新来的可能比排队的先拿到锁 Starvation 模式:某个 goroutine 等锁时间 > 1ms,

  • 严格 FIFO(先进先出)
  • 新来的 goroutine 不能插队
  • 锁会直接交给队列头部 goroutine

7. 在Mutex上自旋的goroutine 会占用太多资源吗
#

goroutine自旋是指当一个线程在获取锁的时候,如果锁已经被其他协程获取,那么该协程将循环等待,然后不断地判断是否能够被成功获取,直到获取到锁才会退出循环。

自旋条件:

  • 还没自旋超过 4 次​
  • 锁已被占用,并且锁不处于饥饿模式​
  • 多核处理器
  • GOMAXPROCS > 1
  • p 上本地 goroutine 队列为空

8. 读写锁底层是怎么实现的
#

一个写互斥锁 + 一个读计数器 + 一个等待队列”,通过原子计数和信号量实现“读并发、写独占 RLock

  • 先检查是否有 writer(或等待中的写锁)

  • 没有则直接把 reader count +1(原子操作)

  • 有 writer 就进入阻塞队列等待 Lock

  • 先抢互斥写锁(CAS)

  • 然后等待所有 reader 退出(reader count = 0)

  • 期间新 reader/ writer 都会被阻塞

  • RUnlock:reader count -1,减到 0 时唤醒 writer

  • Unlock:释放写锁,优先唤醒等待的 writer

9. Mutex 已经被一个 Goroutine 获取了, 其它等待中的 Goroutine 们只能一直等待。那么等这个锁释放后,等待中的 Goroutine 中哪一个会优先获取 Mutex 呢?
#

正常情况下:新请求的 Goroutine 进入自旋时是仍然拥有 CPU 的, 所以比等待信号量唤醒的 Goroutine 更容易获取锁(自旋四圈退出)

饥饿模式下:新加入的goroutine不参与抢锁,会加获取锁的goroutine队列末尾排队,所以是排在最前面的goroutine会优先获取锁

10. waitgroup 是怎样实现协程等待?
#

waitgroup 内部维护了一个计数器,当调用 wg.Add(1) 方法时,就会增加对应的数量;当调用 wg.Done() 时,计数器就会减一。直到计数器的数量减到 0 时,就会调用​

runtime_Semrelease 唤起之前因为 wg.Wait() 而阻塞住的 goroutine。

11. sync.Once 的原理,是怎样保证代码段只执行
#

内部维护了一个标识位,当它 == 0 时表示还没执行过函数,此时会加锁修改标识位,然后执行对应函数。后续再执行时发现标识位 != 0,则不会再执行后续动作了

并发
#

1. 怎么控制并发数?
#

sem := make(chan struct{}, 10) // 最大并发 10

for _, task := range tasks {
    sem <- struct{}{} // 获取令牌
    go func(t Task) {
        defer func() { <-sem }() // 释放令牌

        doWork(t)
    }(task)
}

2. 多个 goroutine 对同一个 map 写会 panic,异常是否可以用 defer 捕获?
#

Go 语言的错误分三种 error, panic 和 fatal error

map会检测是否存在并发写,如果检测到并发写会触发Fatal error ,Fatal error是属于系统出发的严重错误,无法被recover(),程序会直接退出

3. 如何优雅的实现一个 goroutine 池(百度、手写代码)
#

4. select 可以用于什么?
#

  • select可以让同一个goroutine监听多个channel的读写操作,实现单个goroutine的多路复用​

  • 配合default实现goroutine的非阻塞读写,当channel的数据没有准备好或者不能写入时,执行default,并不会阻塞

5. 主协程如何等其余协程完再操作?
#

可以用sync.WaitGroup或channel来实现协程等待

GC
#

1. go gc 是怎么实现的?(必问)
#

go语言的gc策略是采用三色标记法。 但是单纯的三色标记会带来STW,到执行率不高,所以在1.8版本之后,采用了三色标记法配合混合写屏障技术来实现gc。

Go gc经历了那几个版本?
#

1.3版本前:普通标记清除法,整个gc过程需要启动 STW,效率极低 ​

Pasted image 20260527230019.png
1.5版本:三色标记法,堆空间启动写屏障,栈空间不启动,全部扫描之后,需要重新扫描一次栈(需要 STW),效率普通​

1.8 版本:三色标记法,混合写屏障机制:栈空间不启动(根节点可达对象和新加入的对象全部标记成黑色),堆空间启用写屏障,整个扫描过程不要 STW,效率高

三色标记法过程是怎样的?有什么问题?
#

第一步:应用程序开始运行时,所有对象默认标记为白色​ 第二步:从根节点开始遍历,把遍历到的对象标记为灰色,放到灰色标记表中​ 第三步:遍历灰度集合,将灰色对象标记为黑色,并由灰色标记表移动到黑色标记表中;​ 将黑色对象引用的白色对象标记为灰色,放到灰色标记表中​ 第四步:重复第三步,直到灰色标记表为空

导致对象丢失:

  • 条件1: 一个白色对象被黑色对象引用 (白色被挂在黑色下)
  • 条件2: 灰色对象与它之间的可达关系的白色对象遭到破坏 (灰色同时丢了该白色)

强三色不变式:黑色对象不能引用白色对象 弱三色不变式:黑色引用 灰色引用了的白色对象

插入屏障 具体操作: 在A对象引用B对象的时候,B对象被标记为灰色。(将B挂在A下游,B必须被标记为灰色) 满足强三色不变式 删除屏障 具体操作: 被删除的对象,如果自身为灰色或者白色,那么被标记为灰色。 满足弱三色不变式

缺点: 插入写屏障:结束时需要STW来重新扫描栈,标记栈上引用的白色对象的存活; 删除写屏障:回收精度低,GC开始时STW扫描堆栈来记录初始快照,这个过程会保护开始时刻的所有存活对象。

混合写屏障

  1. GC开始将栈上的对象全部扫描并标记为黑色(之后不再进行第二次重复扫描,无需STW),
  2. GC期间,任何在栈上创建的新对象,均为黑色。
  3. 被删除的对象标记为灰色。
  4. 被添加的对象标记为灰色。

2. GC 中 stw 时机,各个阶段是如何解决的?
#

虽然有了混合写屏障技术,go语言的整个gc过程中还是有两次stw,因为写屏障需要开启和关闭,在整个标记过程开始之前需要stw,用于开启写屏障,为标记做准备,在标记终止阶段同样需要短暂的stw来暂定写屏障

3. GC 的触发时机?
#

gc可以在代码中通过调用runtime.GC手动触发​

也可以由系统被动触发,当超过两分钟没有gc或者是内存分配达到了一定的阈值的时候就会强制触发gc

4. GC扫描的根节点有哪些
#

  • 全局变量:程序在编译期就能确定的那些存在于程序整个生命周期的变量。​

  • 执行栈上的对象或指针:每个 goroutine 都包含自己的执行栈,这些执行栈上的对象包含栈上的变量及指向分配的堆内存区块的指针。​

  • 寄存器中的变量:寄存器的值可能表示一个指针,参与计算的这些指针可能指向某些赋值器分配的堆内存区块

内存相关:
#

1. 谈谈内存泄露,什么情况下内存会泄露?怎么定位排查内存泄漏问题?
#

go语言的内存泄漏一般是由于程序阻塞或者空转导致程序不能及时结束导致的,常见的情况就是goroutine的阻塞,或者空转,比如 goroutine 没有被关闭,或者没有添加超时控制,让 goroutine 一直处于阻塞状态,不能被 GC,还有一种就是一些资源句柄为释放,比如文件打开未关闭等等。

调用 runtime.NumGoroutine 方法来打印 执行代码前后Goroutine 的运行数量,进行前后比较,就能知道有没有泄露了。

2. 知道 golang 的内存逃逸吗?什么情况下会发生内存逃逸?
#

Go(Golang)的“内存逃逸(Escape Analysis)”是编译器的一项分析机制,用来决定一个变量应该分配在栈(stack)上还是堆(heap)上。将原本应该分配到栈上的对象分配到堆上的一个过程

  • 如果编译器能够确定变量只在当前函数生命周期内使用,那么分配到栈上。
  • 如果编译器无法保证变量不会在函数返回后继续被引用,那么变量会“逃逸”到堆上。
  1. 返回局部变量指针
  2. 局部变量被外部引用
  3. goroutine 使用局部变量 大量的内存逃逸会给gc带来压力

3. 请简述 Go 是如何分配内存的?
#

go语言对象的分配根据对象大小的不同申请策略也不同:​

  • 当要分配大于 32K 的对象时,从 mheap 分配。​

  • 当要分配的对象小于等于 32K 大于 16B 时,从 P 上的 mcache 分配,如果 mcache 没有内存,则从 mcentral 获取,如果 mcentral 也没有,则向 mheap 申请,如果 mheap 也没有,则从操作系统申请内存。​

  • 当要分配的对象小于等于 16B 时(微小对象),从 mcache 上的微型分配器上分配。

4. Channel 分配在栈上还是堆上?哪些对象分配在堆上,哪些对象分配在栈上?
#

channel分配在堆上,Channel 被设计用来实现协程间通信的组件,其作用域和生命周期不可能仅限于某个函数内部,所以 golang 直接将其分配在堆上

一般而言,大的对象直接分配在堆上,如果一个局部变量会被外部引用,生命周期不确定,也会分配到堆上。其他小对象会优先分配在栈上

5. 介绍一下大对象小对象,什么情况下会导致GC压力大?
#

  • go语言中小于等于 32k 的对象就是小对象,其中小于16B 的是微小对象,其它都是大对象。​

  • 当有大量小对象逃逸到堆上,或者有巨大的元素类型为指针的map和slice的情况下,GC压力会较大

Go代码性能优化:​
#

1. 你知道Go的哪些性能优化手段
#

Go 代码性能优化就两个:内存分配优化 和 并发优化,其余不值一提。​

Go内存分配优化:核心就是内存逃逸和对象池​

  1. 内存逃逸优化:属于那种听上去逼格很高,但是实际上效果非常有限的,将SQL优化一下,几十几百毫秒省出来了,但是Go内存分配优化来优化,可能也就优化了1毫秒。​
  2. 对象池:可以使用Go官方提供的sync.Pool

并发优化:主要思路就是有锁改无锁;写锁改读写锁;原子操作;​

gin框架相关
#

1. Gin框架的路由实现原理?
#

在 gin 框架中,使用的正是压缩前缀树的数据结构.

  • gin 的每种方法 (POST, GET …) 都有自己的一颗 路由树。​
  • 当 gin 收到客户端的请求时, 会去 路由树 里根据 URL 找到相关的 处理函数(handler)

2. gin框架的路由数据结构为什么使用压缩前缀树,而不用hashmap
#

path 匹配时不是完全精确匹配,比如末尾 ‘/’ 符号的增减、全匹配符号 ‘*’ 的处理等,map 无法胜任

Reply by Email