golang面试题

285 阅读18分钟

1、Golang Channel的底层原理

juejin.cn/post/703765…

2、GMP模型

G:表示goroutine,每个goroutine 都有自己的栈空间,定时器,初始化的栈空间在2k 左右,空间会随着需求增长。
M:抽象化代表内核线程,记录内核线程栈信息,当goroutine 调度到线程时,使用该goroutine 自己的栈信息。
P:代表调度器,负责调度goroutine,维护一个本地goroutine队列,M从P上获得goroutine 并执行,同时还负责部分内存的管理。

其中在P,也就是逻辑处理器里面,包括了goroutine运行所需要的所有的资源,也就是说,每一个G,只有在绑定在P上面的时候,才可以被执行,而每一个P要去执行G,也必须绑定到M上才行。

image.png

每一个P上面都会有一个P的本地队列,这个本地队列就是用来存放goroutine的,每一个本地队列都会有一个存储的限制,最大的存储的goroutine为256个,如果在某一个P的本地队列里面存储的goroutine的个数超过了最大值,那么就会把这个队列里的一部分goroutine放到全局队列里面去(这点具体后面会讲到)。

而全局队列是一个带锁的队列,每一次P想要从全局队列里面获取一个goroutine,都必须抢占全局队列中的锁,来完成goroutine的偷取。

调度器在设计的过程中,主要遵循着四个策略

1、复用线程
2、利用并行
3、抢占
4、goroutine的全局队列

执行过程:

  1. 调用 go func()创建一个goroutine;

  2. 新创建的G优先保存在P的本地队列中,如果P的本地队列已经满了就会保存在全局的队列中;

  3. M需要在P的本地队列弹出一个可执行的G,如果P的本地队列为空,则先会去全局队列中获取G,如果全局队列也为空则去其他P中偷取G放到自己的P中

  4. G将相关参数传输给M,为M执行G做准备

  5. 当M执行某一个G时候如果发生了系统调用产生导致M会阻塞,如果当前P队列中有一些G,runtime会将线程M和P分离,然后再获取空闲的线程或创建一个新的内核级的线程来服务于这个P,阻塞调用完成后G被销毁将值返回;

  6. 销毁G,将执行结果返回

  7. 当M系统调用结束时候,这个M会尝试获取一个空闲的P执行,如果获取不到P,那么这个线程M变成休眠状态, 加入到空闲线程中。

GM与GMP区别

优化点有三个:
一是每个P有自己的本地队列,而不是所有的G操作都要经过全局的G队列,这样锁的竞争会少的多的多。而GM 模型的性能开销大头就是锁竞争。

二是P的本地队列平衡上,在 GMP 模型中也实现了Work Stealing算法,如果P的本地队列为空,则会从全局队列或其他 P 的本地队列中窃取可运行的G来运行(通常是偷一半),减少空转,提高了资源利用率。

三是hand off机制,当M0线程因为G1进行系统调用阻塞时,线程释放绑定的P,把P转移给其他空闲的线程M1执行,同样也是提高了资源利用率。

队列和线程的优化可以做在G层和M层,为什么要加一个P层呢?

因为M层是放在内核的,我们无权修改,内核级也是用户级线程发展成熟才加入内核中。所以在M无法修改的情况下,所有的修改只能放在用户层。将队列和M绑定,由于hand off机制M会一直扩增,因此队列也需要一直扩增,那么为了使Work Stealing 能够正常进行,队列管理将会变的复杂。因此设定了P层作为中间层,进行队列管理,控制GMP数量(最大个数为P的数量)。

juejin.cn/post/695660…

3、defer关键字

defer的两大特性

1、延迟调用: 在当前函数执行完成后调用执行。

2、后进先出: 多个defer函数时,执行顺序为后进先出。

defer与return的执行顺序

1、return语句本身并不是一条原子指令,它会先给返回值赋值,然后才是返回。

2、而在含defer表达式时,函数返回的过程是这样的:先给返回值赋值,然后调用defer表达式,最后再是返回结果。

defer的应用场景

场景一:资源释放

我们在代码中使用资源时如:打开一个文件,很容易因为忘记释放或者由于逻辑上的错误导致资源没有关闭。这时候使用defer可以避免这种资源泄漏。

应该牢记一个原则:在每个资源申请成功的后面都加上defer自动清理,不管该函数都多少个return,资源都会被正确的释放。

场景二:异常捕获

Golang中对于程序中的异常处理,没有try catch,但是有panic和recover。 当程序中抛出panic时,如果没有及时recover,会导致服务直接挂掉,造成很严重的后果,所以我们一般用recover来捕获异常。

4、map实现原理

4.1 map定义

map是一种key-value键值对的存储结构,其中key是不能重复的,其底层实现采用的是hash表。

4.2 map的数据结构

type hmap struct {
  count     int    // 元素的个数
  B         uint8  // buckets 数组的长度就是 2^B 个
  overflow uint16 // 溢出桶的数量
      
  buckets    unsafe.Pointer // 2^B个桶对应的数组指针
  oldbuckets unsafe.Pointer  // 发生扩容时,记录扩容前的buckets数组指针
  extra *mapextra //用于保存溢出桶的地址

image.png

在go的map实现中,它的底层结构体是hmap,hmap里维护着若干个bucket数组 (即桶数组)。

Bucket数组中每个元素都是bmap结构,也即每个bucket(桶)都是bmap结构,【ps:后文为了语义一致,和方便理解,就不再提bmap了,统一叫作桶】 每个桶中保存了8个kv对,如果8个满了,又来了一个key落在了这个桶里,会使用overflow连接下一个桶(溢出桶)。

4.3 map中数据操作

4.3.1 get数据

image.png

参考上图,k4的get流程可以归纳为如下几步:

1、计算k4的hash值。[由于当前主流机都是64位操作系统,所以计算结果有64个比特位]

2、通过最后的“B”位来确定在哪号桶,此时B为4,所以取k4对应哈希值的后4位,也就是0101,0101用十进制表示为5,所以在5号桶)。

3、根据k4对应的hash值前8位快速确定是在这个桶的哪个位置(额外说明一下,在bmap中存放了每个key对应的tophash,是key的哈希值前8位),一旦发现前8位一致,则会执行下一步。

4、对比key完整的hash是否匹配,如果匹配则获取对应value。

5、如果都没有找到,就去连接的下一个溢出桶中找

4.3.2 put数据

image.png

map的赋值流程可总结为如下几步:

1、通过key的hash值后“B”位确定是哪一个桶,图中示例为4号桶。

2、遍历当前桶,通过key的tophash和hash值,防止key重复,然后找到第一个可以插入的位置,即空位置处存储数据。

3、如果当前桶元素已满,会通过overflow链接创建一个新的桶,来存储数据。

关于hash冲突:当两个不同的key落在同一个桶中,就是发生了哈希冲突。冲突的解决手段是采用链表法:在 桶 中,从前往后找到第一个空位进行插入。如果8个kv满了,那么当前桶就会连接到下一个溢出桶(bmap)。

4.4 扩容

4.4.1 扩容的条件

首先我们了解下装载因子(loadFactor)的概念

loadFactor:=count / (2^B) 即 装载因子 = map中元素的个数 / map中当前桶的个数

通过计算公式我们可以得知,装载因子是指当前map中,每个桶中的平均元素个数。

扩容条件1装载因子> 6.5(源码中定义的)

这个也非常容易理解,正常情况下,如果没有溢出桶,那么一个桶中最多有8个元素,当平均每个桶中的数据超过了6.5个,那就意味着当前容量要不足了,发生扩容。

扩容条件2: 溢出桶的数量过多

当B < 15时,如果overflow的bucket数量超过2^B。

当B >= 15时,overflow的bucket数量超过2^15。

简单来讲,新加入key的hash值后B位都一样,使得个别桶一直在插入新数据,进而导致它的溢出桶链条越来越长。如此一来,当map在操作数据时,扫描速度就会变得很慢。及时的扩容,可以对这些元素进行重排,使元素在桶的位置更平均一些。

扩容时的细节

1、在我们的hmap结构中有一个oldbuckets吗,扩容刚发生时,会先将老数据存到这个里面。
2、每次对map进行删改操作时,会触发从oldbucket中迁移到bucket的操作【非一次性,分多次】
3、在扩容没有完全迁移完成之前,每次get或者put遍历数据时,都会先遍历oldbuckets,然后再遍历buckets。

4.4.2 扩容的方式

1、相同容量扩容

image.png

由于map中不断的put和delete key,桶中可能会出现很多断断续续的空位,这些空位会导致连接的bmap溢出桶很长,导致扫描时间变长。这种扩容实际上是一种整理,把后置位的数据整理到前面。这种情况下,元素会发生重排,但不会换桶。

2、2倍容量扩容

image.png

这种2倍扩容是由于当前桶数组确实不够用了,发生这种扩容时,元素会重排,可能会发生桶迁移

如图中所示,扩容前B=2,扩容后B=3,假设一元素key的hash值后三位为101,那么由上文的介绍可知,在扩容前,由hash值的后两位来决定几号桶,即 01 所以元素在1号桶。 在扩容发生后,由hash值得后三位来决定几号桶,即101所以元素会迁移到5号桶。

map的注意事项:不可对元素取址、线程不安全,工作中使用时候要用mutex锁

5、sync.Mutex(互斥锁)

在Go中对于并发程序进行公共资源的访问的限制最常用的就是互斥锁(sync.mutex)的方式

sync.mutex的常用方法有两个:

  • Mutex.lock()用来获取锁
  • Mutex.Unlock()用于释放锁

在 Lock 和 Unlock 方法之间的代码段称为资源的临界区,这一区间的代码是严格被锁保护的,是线程安全的,任何一个时间点最多只能有一个goroutine在执行。

5.1 sync.Mutex的数据结构

type Mutex struct {
	state int32
	sema  uint32
}

Sync.Mutex由两个字段构成,state用来表示当前互斥锁处于的状态,sema用于控制锁状态的信号量。

image.png

互斥锁state主要记录了如下四种状态:

waiter_num: 记录了当前等待抢这个锁的goroutine数量

starving: 当前锁是否处于饥饿状态 (后文会详解锁的饥饿状态) 0: 正常状态 1: 饥饿状态

woken: 当前锁是否有goroutine已被唤醒。 0:没有goroutine被唤醒; 1: 有goroutine正在加锁过程

locked: 当前锁是否被goroutine持有。 0: 未被持有 1: 已被持有

sema信号量的作用:

当持有锁的gorouine释放锁后,会释放sema信号量,这个信号量会唤醒之前抢锁阻塞的gorouine来获取锁。

5.2 锁的两种模式

互斥锁在设计上主要有两种模式: 正常模式和饥饿模式

之所以引入了饥饿模式,是为了保证goroutine获取互斥锁的公平性。所谓公平性,其实就是多个goroutine在获取锁时,goroutine获取锁的顺序,和请求锁的顺序一致,则为公平。

正常模式下,所有阻塞在等待队列中的goroutine会按顺序进行锁获取,当唤醒一个等待队列中的goroutine时,此goroutine并不会直接获取到锁,而是会和新请求锁的goroutine竞争。 通常新请求锁的goroutine更容易获取锁,这是因为新请求锁的goroutine正在占用cpu片执行,大概率可以直接执行到获取到锁的逻辑。

饥饿模式下, 新请求锁的goroutine不会进行锁获取,而是加入到队列尾部阻塞等待获取锁。

饥饿模式的触发条件:

  • 当一个goroutine等待锁的时间超过1ms时,互斥锁会切换到饥饿模式

饥饿模式的取消条件:

  • 当获取到锁的这个goroutine是等待锁队列中的最后一个goroutine,互斥锁会切换到正常模式
  • 当获取到锁的这个goroutine的等待时间在1ms之内,互斥锁会切换到正常模式

5.3 锁的注意事项

1、在一个goroutine中执行Lock()加锁成功后,不要再重复进行加锁,否则会panic。
2、在Lock()之前执行Unlock()释放锁会panic。
3、对于同一把锁,可以在一个goroutine中执行Lock加锁成功后,可以在另外一个gorouine中执行Unlock释放锁。

6、读写锁sync.RWMutex

6.1 读写锁的使用

读写互斥锁sync.RWMutex不限制对资源的并发读,但是读写,写写操作无法并行执行。

读写锁一共有四个函数:

  • RLock(): 申请读锁
  • RUnlock(): 解除读锁
  • Lock(): 申请写锁
  • Unlock(): 解除写锁

6.2 实现原理

6.2.1 sync.RWMutex的数据结构

Go中sync.RWMutext的结构体为

type RWMutex struct {
	w           Mutex  // 复用互斥锁
	writerSem   uint32 // 写锁监听读锁释放的信号量
	readerSem   uint32 // 读锁监听写锁释放的信号量
	readerCount int32  // 当前正在执行读操作的数量
	readerWait  int32  // 当写操作被阻塞时,需要等待读操作完成的个数
}

接下来,我们结合底层数据结构来分析读写锁是如何实现对资源并发读写的控制的。

1、读操作如何防止并发读写问题的?

  • RLock() : 申请读锁,每次执行此函数后,会对readerCount++,此时当有写操作执行Lock()时会判断readerCount>0,就会阻塞。
  • RUnLock() : 解除读锁,执行readerCount--,释放信号量唤醒等待写操作的goroutine。

2、写操作如何防止并发读写、并发写写问题?

  • Lock() : 申请写锁,获取互斥锁,此时会阻塞其他的写操作。并将readerCount 置为 -1,当有读操作进来,发现readerCount = -1, 即知道有写操作在进行,阻塞。
  • Unlock() : 解除写锁,会先通知所有阻塞的读操作goroutine,然后才会释放持有的互斥锁。

3、为什么写操作不会被饿死?

什么是写操作被饿死?

由于写操作要等待读操作结束后才可以获得锁,而写操作在等待期间可能还有新的读操作持续到来,如果写操作等待所有读操作结束,很可能会一直阻塞,这种现象称之为写操作被饿死。

通过RWMutex结构体中的readerWait属性可完美解决这个问题。

当写操作到来时,会把RWMutex.readerCount值拷贝到RWMutex.readerWait中,用于标记排在写操作前面的读者个数

前面的读操作结束后,除了会递减RWMutex.readerCount,还会递减RWMutex.readerWait值,当RWMutex.readerWait值变为0时唤醒写操作。

7、sync.map

sync.map 整体结构

image.png

sync.Map 的两个map,当从sync.Map类型中读取数据时,其会先查看read中是否包含所需的元素:

若有,则通过atomic原子操作读取数据并返回。 若无,则会判断read.readOnly中的amended属性,他会告诉程序dirty是否包含read.readOnly.m中没有的数据;因此若存在,也就是amended为true,将会进一步到dirty中查找数据。 sync.Map 的读操作性能如此之高的原因,就在于存在read这一巧妙的设计,其作为一个缓存层,提供了快路径(fast path)的查找。

同时其结合amended属性,配套解决了每次读取都涉及锁的问题,实现了读这一个使用场景的高性能。

blog.csdn.net/a348752377/…

8、数组和切片的区别

1)数组长度不同

数组初始化必须指定长度,并且长度就是固定的。
切片的长度是不固定的,可以追加元素,在追加时可能使切片的容量增大。

2)函数传参不同

数组是值类型,将一个数组赋值给另一个数组时,传递的是一份深拷贝,函数传参操作都会复制整个数组数据,会占用额外的内存,函数内对数组元素值的修改,不会修改原数组内容。

切片是引用类型,将一个切片赋值给另一个切片时,传递的是一份浅拷贝,函数传参操作不会拷贝整个切片,只会复制len和cap,底层共用同一个数组,不会占用额外的内存,函数内对数组元素值的修改,会修改原数组内容。

3)计算数组长度方式不同

数组需要遍历计算数组长度,时间复杂度为O(n)

切片底层包含len字段,可以通过len()计算切片长度,时间复杂度为O(1)

9、内存分配

Go语言内置运行时(就是runtime),抛弃了传统的内存分配方式,改为自主管理。这样可以自主地实现更好的内存使用模式,比如内存池、预分配等等。这样,不会每次内存分配都需要进行系统调用。

设计思想

内存分配算法采用Google的TCMalloc算法,每个线程都会自行维护一个独立的内存池,进行内存分配时优先从该内存池中分配,当内存池不足时才会向加锁向全局内存池申请,减少系统调用并且避免不同线程对全局内存池的锁竞争

把内存切分的非常的细小,分为多级管理,以降低锁的粒度

回收对象内存时,并没有将其真正释放掉,只是放回预先分配的大块内存中,以便复用。只有内存闲置过多的时候,才会尝试归还部分内存给操作系统,降低整体开销

Go的内存管理组件主要有:mspan、mcache、mcentral和mheap

10、Golang GC、三色标记、混合写屏障机制

juejin.cn/post/704073…

11、Go内存逃逸机制

概念

在一段程序中,每一个函数都会有自己的内存区域存放自己的局部变量、返回地址等,这些内存会由编译器在栈中进行分配,每一个函数都会分配一个栈桢,在函数运行结束后进行销毁,但是有些变量我们想在函数运行结束后仍然使用它,那么就需要把这个变量在堆上分配,这种从"栈"上逃逸到"堆"上的现象就成为内存逃逸 。

在栈上分配的地址,一般由系统申请和释放,不会有额外性能的开销,比如函数的入参、局部变量、返回值等。在堆上分配的内存,如果要回收掉,需要进行 GC,那么GC 一定会带来额外的性能开销。编程语言不断优化GC算法,主要目的都是为了减少 GC带来的额外性能开销,变量一旦逃逸会导致性能开销变大。

逃逸机制

编译器会根据变量是否被外部引用来决定是否逃逸:
如果函数外部没有引用,则优先放到栈中;
如果函数外部存在引用,则必定放到堆中;
如果栈上放不下,则必定放到堆上;

总结

栈上分配内存比在堆中分配内存效率更高
栈上分配的内存不需要GC处理,而堆需要
逃逸分析目的是决定内分配地址是栈还是堆
逃逸分析在编译阶段完成
因为无论变量的大小,只要是指针变量都会在堆上分配,所以对于小变量我们还是使用传值效率(而不是传指针)更高一点。