Go面试必备

Go 面试

基础

使用值为 nil 的 slice、map会发生啥

允许对值为 nil 的 slice 添加元素,但对值为 nil 的 map 添加元素,则会造成运行时 panic。

解析 JSON 数据时,默认将数值当做哪种类型

在 encode/decode JSON 数据时,Go 默认会将数值当做 float64 处理。

recover的执行时机

recover 必须在 defer 的func中运行。recover 捕获的是祖父级调用时的异常,直接调用时无效。

new和make的区别

new :是初始化一个指向类型的指针 (*T) 。new 函数是内建函数,函数定义:func new(Type) *Type。使用 new 函数来分配空间。传递给 new 函数的是一个类型,不是一个值。返回值是指向这个新分配的零值的指针。

make :是为 slice,map 或 chan 初始化并返回引用 (T)。make 函数是内建函数,函数定义:func make(Type, size IntegerType) Type;第一个参数是一个类型,第二个参数是长度;返回值是一个类型。

make(T, args) 函数的目的与 new(T) 不同。它仅仅用于创建 Slice, Map 和 Channel,并且返回类型是 T(不是T*)的一个初始化的(不是零值)的实例。

go语言中的引用类型包含哪些

数组切片、字典(map)、通道(channel)、接口(interface)。

go struct能不能比较

因为是强类型语言,所以不同类型的结构不能作比较,但是同一类型的实例值是可以比较的,实例不可以比较,因为是指针类型

Context

Map

Map 结构

map类型的变量本质上是一个hmap类型的指针,这些键值对实际上是通过哈希表来存储的。
哈希表首先要有一段内存用作存储键值对的桶(bucket),map的桶,每个桶里可以存储8个键值对,并且为了内存使用更加紧凑,8个键放一起,8个值放一起。
对应每个key只保存其哈希值的高8位(tophash)。而每个键值对的tophash、key和value的索引顺序一一对应。当桶存满了,扩容的代价还是比较高的,所以为了减少扩容次数,这里引入了溢出桶(overflow)指针。

Map 扩容

map的扩容有两种情况,如果超过负载因子(默认6.5)就触发翻倍扩容,然后采用渐进式扩容方法,每次读写map时检测到当前map处在扩容阶段(hmap.oldbuckets != nil),就执行一次迁移工作,把编号为hmap.nevacuate的旧桶迁移到新桶,每个旧桶的键值对都会分流到两个新桶中。

如果没有超过设置的负载因子上限,但是使用的溢出桶较多,也会触发扩容,不过这一次是等量扩容。

(1)如果常规桶数目不大于2^15,那么使用的溢出桶数目超过常规桶就算是多了;
(2)如果常规桶数目大于2^15,那么使用溢出桶数目一旦超过2^15就算多了。

为什么桶的负载因子没有超过上限值,却偏偏使用了很多溢出桶呢?自然是有很多键值对被删除的情况。

正是因为扩容过程中会发生键值对迁移,键值对的地址也会发生改变,所以才说map的元素是不可寻址的,如果要取一个value的地址则不能通过编译。

Slice

切片和数组的区别

数组是具有固定长度,且拥有零个或者多个,相同数据类型元素的序列。数组的长度是数组类型的一部分,所以[3]int 和 [4]int 是两种不同的数组类型。数组需要指定大小,不指定也会根据初始化的自动推算出大小,不可改变;数组是值传递。数组是内置类型,是一组同类型数据的集合,它是值类型,通过从0开始的下标索引访问元素值。在初始化后长度是固定的,无法修改其长度。当作为方法的参数传入时将复制一份数组而不是引用同一指针。

切片表示一个拥有相同类型元素的可变长度的序列。切片是一种轻量级的数据结构,它有三个属性:指针、长度和容量。切片不需要指定大小;切片是地址传递;切片可以通过数组来初始化,也可以通过内置函数make()初始化 。初始化时len=cap,在追加元素时如果容量cap不足时将按len的2倍扩容。

Slice的底层实现

切片对象非常小,是因为它是只有3个字段的数据结构:

  • 指向底层数组的指针
  • 切片的长度
  • 切片的容量

Slice的扩容机制,有什么注意点

Go 中切片扩容的策略是这样的:

首先判断,如果新申请容量大于 2 倍的旧容量,最终容量就是新申请的容量。否则判断,如果旧切片的长度小于 1024,则最终容量就是旧容量的两倍。

否则判断,如果旧切片长度大于等于 1024,则最终容量从旧容量开始循环增加原来的 1/4 , 直到最终容量大于等于新申请的容量。如果最终容量计算值溢出,则最终容量就是新申请容量。

情况一:原数组还有容量可以扩容(实际容量没有填充完),这种情况下,扩容以后的数组还是指向原来的数组,对一个切片的操作可能影响多个指针指向相同地址的Slice。

情况二:原来数组的容量已经达到了最大值,再想扩容, Go 默认会先开一片内存区域,把原来的值拷贝过来,然后再执行 append() 操作。这种情况丝毫不影响原数组。

要复制一个Slice,最好使用Copy函数。

Channel

Channel 的底层实现

channel分为“无缓冲”和“有缓冲”两种,对于有缓冲channel来讲,需要有相应的内存来存储数据,实际上就是一个数组。

hchan 需要记录数组的地址buf、容量datasiz、元素类型elemtype、元素的大小elemsize,以及已有元素个数qcount。

channel支持交替的读写(称send为写,recv为读)有缓冲channel内的缓冲数组会被作为一个“环型”来使用,当下标超过数组容量后会回到第一个位置,所以要分别记录读写下标的位置。

当读和写操作不能立即完成时,需要能够让当前协程在channel上等待,当条件满足时,要能够立即唤醒等待的协程,所以要有两个等待队列,分别针对读和写。

等待队列是runtime.sudog类型的双向链表,sudog中会记录是哪个协程在等待,等待哪一个channel等等。特别是sudog.elem这个成员,对于recvq中的sudog而言,它代表recv到数据以后要存储到哪里;对于sendq中的sudog而言,它代表要send的数据在哪里。

channel还可以关闭,因此还需要一个close字段。而且为了应对并发操作,channel还需要有一把锁lock。

channel 关闭后会怎样?

recvq里有协程等待

若channel关闭,hchan.closed=1;等待recv的协程接收到零值和false;协程离开channel的recvq回到run queue中。

缓冲区还有数据,但sendq 为空

channel关闭后,缓冲区里的数据还可以读,直到读完。但是不能再写,否则会panic。

sendq不为空

这些等待send的G都会离开sendq,恢复到_Grunnable状态,回到协程run queue中,协程恢复执行时会发生panic。

Goroutine

进程、线程、协程

进程:为了更合理的利用 CPU 资源,把内存划分为多块,不同程序使用各自的内存空间互不干扰,这里单独的程序就是一个进程,CPU 可以在多个进程之间切换执行,让 CPU 的利用率变高。为了实现 CPU 在多个进程之间切换,需要保存进程的上下文(如程序计数器、栈、内核数据结构等等),以便下次切换回来可以恢复执行

线程:线程是操作系统进行运算调度的最小单位。线程属于进程,一个进程中可以并发多个线程。进程有自己独立的内存空间,而线程(同进程中)是共享内存空间。

协程:用户态线程。其创建和切换都在用户代码中完成而无需进入操作系统内核,所以其开销要远远小于系统线程的创建和切换;

GMP模型

G:Goroutine,实际上我们每次调用 go func 就是生成了一个 G。

P:Processor,处理器,一般 P 的数量就是处理器的核数,可以通过 GOMAXPROCS 进行修改。

M:Machine,系统线程。

执行流程:

  1. 首先程序运行入口runtime.main会初始化M0和G0和P0。
  2. 然后执行main.main创建一个G,由M0运行。
  3. 当有其他G创建时,将G保存到当前P的本地队列,当本地队列元素放满时,放入全局队列。
  4. 当有其他空闲P时会唤醒M或者创建M。
  5. 然后M执行一个调度循环:调用G对象(当前P的本地队列 > 全局队列 > 其他P的队列)->执行->清理线程->继续寻找Goroutine。
  6. 当M阻塞时,会记录当前P,将M从P解除。把G运行在其他空闲的M或者创建新的M。
  7. 当M恢复时,会尝试获得上次的P,若没有空闲,尝试获取一个空闲的P。如果没有P空闲,M会休眠,G会放到全局队列。

GMP模型的限制

  • M 的限制:在 Go 语言中,M 的默认数量限制是 10000,如果超出则会报错
  • G 的限制:理论上会受内存的影响,假设一个 Goroutine 创建需要2~4k,4k * 1,000,000 = 4,000,000k ≈ 4G内存
  • P 的限制:P 的数量受环境变量 GOMAXPROCS 的直接影响。基本是受本机的核数影响

sync

sync.WaitGroup 的实现

waitGroup的数据结构主要包括一个有复合意义的state1

  • 在64位环境下:state1[0]是 waiter 数,state1[1]是 WaitGroup 的计数值,state1[2]是信号量。
  • 在32位环境下: state1[0]是信号量,state1[1]是 waiter 数,state1[2]是计数值。

当调用 Add 时,主要操作的state1字段中计数值部分+1
调用 Done 时,就是Add(-1)
wait 的实现思路:不断检查state值,如果其中的计数值为零,则说明所有的子goroutine已全部执行完毕,调用者不必等待,直接返回。如果计数值大于零,说明此时还有任务没有完成,那么调用者变成等待者,需要加入wait队列,并且阻塞自己。

sync.Mutex 的实现

Mutex包括state状态0未加锁,大于0已加锁,和sema信号量。

  • 正常模式:一个待加锁的G会尝试自旋几次尝试获得锁,若仍然不能获得锁,则通过信号量排队等待,所有的等待者会按照先入先出的顺序排队,但是当锁被释放时,第一个等待着被唤醒后不会直接拥有锁,而是需要和后来者也就是正在自旋的锁竞争,这种情况下后来者更有优势,一方面他们正在cpu上运行(当前正在cpu上占用时间片的goroutine比唤醒的goroutine抢占锁的概率高,是因为不需要上下文切换,因而抢占机会大一点),另一方面处于运行中的G可以有很多,而被唤醒的只有一个,所以被唤醒的G有很大概率拿不到锁,这种情况下,被唤醒的G会被重新插入队列的头部。而当一个G当前等待的时间超过1ms后,会由正常模式变为饥饿模式。
  • 饥饿模式:Mutex的所有权从执行unlock的G直接传递给等待队列头部的G,后来者不会自旋转,也不会尝试获得锁,直接进入队列尾部排队等待。当一个等待者获得锁时,当前等待者的等待时间小于1ms时或者它是最后一个等待者,会将饥饿模式变为正常模式

sync.Map 的实现

  • 空间换时间。通过冗余的两个数据结构(只读的 read 字段、可写的 dirty),来减少加 锁对性能的影响。对只读字段(read)的操作不需要加锁。
  • 优先从 read 字段读取、更新、删除,因为对 read 字段的读取不需要锁。
  • 动态调整。miss 次数多了之后,将 dirty 数据提升为 read,避免总是从 dirty 中加锁读 取。
  • double-checking。加锁之后先还要再检查 read 字段,确定真的不存在才操作 dirty 字 段。
  • 延迟删除。删除一个键值只是打标记,只有在提升 dirty 字段为 read 字段的时候才清理 删除的数据。

store:它是用来设置一个键值对,或者更新一个键值对的。如果运气好的话,更新的是已存在的未被删除的元素,直接更新read即可,不会用到锁。如果运气不好,需要更新(重用) 删除的对象、更新还未提升的 dirty 中的对象,或者新增加元素的时候就会使用到了锁。新加的元素需要放入到 dirty 中,如果 dirty 为 nil,那么需要从 read 字段中复制出来一个 dirty 对象。

Load:用来读取一个 key 对应的值。我们从 read 中读取到了这个 key 对应的值,那么就不需要加锁了。但是,如果请求的 key 不存在或者是新加的,就需要加锁从 dirty 中读取。其中,missLocked 增加 miss 的时候,如果 miss 数等于 dirty 长度,会将 dirty 提升为 read,并将 dirty 置空。

Delete:如果 read 中不存在,那么就需要从 dirty 中寻找这个项目。最终,如果项目存在就删除 (将它的值标记为 nil)。如果项目不为 nil 或者没有被标记为 expunged,那么还可以把 它的值返回。

为什么要double check?

先检查一遍,然后加锁再检查一遍,避免第一次检查后、加锁前,内容被修改。

GC

go gc 原理

从进程虚拟地址空间来看,程序要执行的指令在代码段,全局变量、静态数据等都会分配在数据段。而函数的局部变量、参数和返回值都可以在函数栈帧中找到。

但是,由于函数调用栈会在函数返回后销毁,那些在编译阶段不能确定大小的数据,以及生命周期超出当前函数的数据,都不适合分配在栈上。它们会被分配到堆上,在栈上使用其在堆上的地址。gc主要是清理堆上的数据。

那怎么区分哪些是有用数据?哪些是垃圾呢?

从虚拟地址空间来看,可以确定的是,程序中用得到的数据,一定是从栈、数据段这些地方追踪得到的数据。也就是说,以这些地方直接追踪到的变量作为根节点,可以追踪到的数据范围大于等于真正的有用数据。

(1)垃圾回收开始会把所有数据都标记为白色;
(2)然后把直接追踪到的root节点都标记为灰色,灰色代表基于当前节点展开的追踪还未完成;
(3)当基于某个节点的追踪任务完成后,便会把该节点标记为黑色,表示它是有用数据,而且无需基于它再次进行追踪了。
(4)当没有灰色节点时,就意味着标记工作可以结束了。此时有用数据都为黑色,垃圾都为白色,在清除阶段回收这些白色的垃圾即可。

垃圾回收的过程会产生stw,所以我们需要暂停的时间尽可能短,因此go使用主体并发增量式回收,指用户程序与垃圾回收交替执行,将垃圾回收工作分多次完成,也将暂停的时间分摊到多次,进而缩短每次暂停的时间。但是这也带来了额外的问题,交替执行的过程中,保不齐垃圾回收程序前脚刚把一个变量标记为垃圾,用户程序后脚又用到了它。

因此引入了写屏障,删除屏障。
写屏障:A已经被标记为黑色,若增加A到白色对象C的引用,可以通过写屏障直接把C标记为灰色;也可以把A回退到灰色状态
删除屏障:在删除灰色对象A到白色对象C的引用时,把C着为灰色

如何应对碎片化内存?

应对这一问题的办法主要是使用多个链表,不同链表管理不同大小的内存块,这样就可以快速找到符合条件的内存分块了。
因为mutator通常不会频繁申请大块内存,所以多链表管理的内存块规格主要面向中小分块,既可以满足大部分内存分配需求,又避免维护大块空闲链表而压迫到内存。

并发GC如何缓解内存分配压力?

为了避免GC执行过程中,内存分配压力过大,还实现了GC Assist机制,包括“辅助标记”和“辅助清扫”。
如果协程要分配内存,而GC标记工作尚未完成,它就要负担一部分标记工作,要申请的内存越大,对应要负担的标记任务就越多,这是一种借贷偿还机制。

如何控制GC的CPU使用率?

GC默认的CPU目标使用率为25%,在GC执行的初始化阶段,会根据当前CPU核数乘以CPU目标使用率来计算需要启动的mark worker数量。
(1)Dedicated模式的worker会执行标记任务直到被抢占;
(2)Fractional模式的worker除了被抢占外,还可以在达到目标使用率时主动让出。

例如,如果有四个核,425%=1,只需要启动一个Dedicated模式的worker。
如果有六个核,6
25%=1.5,rounding以后等于2,误差=2/1.5-1=1/3,误差超过0.3,所以Dedicated模式的worker要是有2个就超出目标使用率太多了,需要减去一个,再启用一个Fractional模式的worker来辅助完成额外的目标。

内存

内存结构

堆地址空间划分成一个一个的arena。在AMD64位的linux环境下,每个arena的大小是64M。
每个arena包含8192个page,每个page是8k。
go语言内存分配是按照一组预置的大小规格,把内存页划分成块,然后把不同规格的内存块放到对应的空闲链表中。程序申请内存时,分配器会先根据要申请的内存大小,找到最匹配的规格,然后从对应空闲链表中分配一个内存块。

go语言中给出67种预置的大小规格,最小为8位(B)最大为32K,所以arena中又被划分出span,每个span中包含一组连续的page,并且按照特定规格划分成了等大的内存块。

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

golang程序变量会携带有一组校验数据,用来证明它的整个生命周期是否在运行时完全可知。
如果变量通过了这些校验,它就可以在栈上分配。否则就说它逃逸了,必须在堆上分配。

能引起变量逃逸到堆上的典型情况:

  1. 在方法内把局部变量指针返回: 局部变量原本应该在栈中分配,在栈中回收。但是由于返回时被外部引用,因此其生命周期大于栈,则溢出。
  2. 发送指针或带有指针的值到 channel 中:在编译时,是没有办法知道哪个 goroutine 会在 channel 上接收数据,所以编译器没法知道变量什么时候才会被释放。
  3. 在一个切片上存储指针或带指针的值:一个典型的例子就是 []*string 。这会导致切片的内容逃逸。尽管其后面的数组可能是在栈上分配的,但其引用的值一定是在堆上。
  4. slice 的背后数组被重新分配了: append 时可能会超出其容量( cap )。 slice 初始化的地方在编译时是可以知道的,它最开始会在栈上分配。如果切片背后的存储要基于运行时的数据进行扩充,就会在堆上分配。
  5. 在 interface 类型上调用方法:在 interface 类型上调用方法都是动态调度的 —— 方法的真正实现只能在运行时知道。想像一个 io.Reader 类型的变量 r , 调用 r.Read(b) 会使得 r 的值和切片b 的背后存储都逃逸掉,所以会在堆上分配。

怎么分析内存逃逸?

go build -gcflags ‘-m’ main.go

”escapes to heap”,代表该行内存分配发生了逃逸现象。

-------- 本文结束 感谢阅读 --------

本文标题:Go面试必备

文章作者:Guyuqing

发布时间:2021年10月06日 - 11:50

最后更新:2021年10月12日 - 20:43

许可协议: 署名-非商业性使用-禁止演绎 4.0 国际 转载请保留原文链接及作者。

坚持技术分享,您的支持将鼓励我继续创作!
0%