既然要面试就拿出真实力!-GO面经复习

既然要面试就拿出真实力!-GO面经复习

1、golang 中 make 和 new 的区别?

  1. make给切片、map、hannel分配内存,new给string、数组、int分配内存。
  2. make返回变量本身,new返回指针。
  3. make会初始化空间,new会将分配空间清零

2.for range 的时候它的地址会发生变化么?

在 for a,b := range c 遍历中, a 和 b 在内存中只会存在一份,即之后每次循环时遍历到的数据都是以值覆盖的方式赋给 a 和 b,a,b 的内存地址始终不变。由于有这个特性,for 循环里面如果开协程,不要直接把 a 或者 b 的地址传给协程。解决办法:在每次循环时,创建一个临时变量。

3.go defer,多个 defer 的顺序,defer 在什么时机会修改返回值

  1. 多个 defer 调用顺序是 LIFO(后入先出)

4.goroutine什么情况下会阻塞

  1. 原子、互斥量操作导致阻塞
  2. 网络请求和IO操作
  3. 文件IO操作
  4. sleep

5.select 的特性

  1. select 操作至少要有一个 case 语句,出现读写 nil 的 channel 该分支会忽略,在 nil 的 channel 上操作则会报错。

  2. select 仅支持管道,而且是单协程操作。

  3. 每个 case 语句仅能处理一个管道,要么读要么写。

  4. 多个 case 语句的执行顺序是随机的。

  5. 存在 default 语句,select 将不会阻塞,但是存在 default 会影响性能。

6.讲讲 Go 的 defer 底层数据结构和一些特性?**

答:每个 defer 语句都对应一个_defer 实例,多个实例使用指针连接起来形成一个单连表,保存在 gotoutine 数据结构中,每次插入_defer 实例,均插入到链表的头部,函数结束再一次从头部取出,从而形成后进先出的效果。

7.go出现panic的场景

  • 数组/切片越界
  • 空指针调用。比如访问一个 nil 结构体指针的成员
  • 过早关闭 HTTP 响应体
  • 除以 0
  • 向已经关闭的 channel 发送消息
  • 重复关闭 channel
  • 关闭未初始化的 channel
  • 未初始化 map。注意访问 map 不存在的 key 不会 panic,而是返回 map 类型对应的零值,但是不能直接赋值
  • 跨协程的 panic 处理
  • sync 计数为负数。
  • 类型断言不匹配。var a interface{} = 1; fmt.Println(a.(string)) 会 panic,建议用 s,ok := a.(string)

8.值拷贝与引用拷贝

map,slice,chan 是引用拷贝;引用拷贝 是 浅拷贝

其余的,都是 值拷贝;值拷贝 是 深拷贝

9.数组和切片的区别

  1. 数组是定长,访问和复制不能超过数组定义的长度,否则就会下标越界,切片长度和容量可以自动扩容

  2. 数组是值类型,切片是引用类型,每个切片都引用了一个底层数组,切片本身不能存储任何数据,都是这底层数组存储数据,所以修改切片的时候修改的是底层数组中的数据。切片一旦扩容,指向一个新的底层数组,内存地址也就随之改变

10.slice扩容特性

Go 的 slice 底层数据结构是由一个 array 指针指向底层数组,len 表示切片长度,cap 表示切片容量。slice 的主要实现是扩容。对于 append 向 slice 添加元素时,假如 slice 容量够用,则追加新元素进去,slice.len++,返回原来的 slice。当原容量不够,则 slice 先扩容,扩容之后 slice 得到新的 slice,将元素追加进新的 slice,slice.len++,返回新的 slice。对于切片的扩容规则:当切片比较小时(容量小于 1024),则采用较大的扩容倍速进行扩容(新的扩容会是原来的 2 倍),避免频繁扩容,从而减少内存分配的次数和数据拷贝的代价。当切片较大的时(原来的 slice 的容量大于或者等于 1024),采用较小的扩容倍速(新的扩容将扩大大于或者等于原来 1.25 倍),主要避免空间浪费,网上其实很多总结的是 1.25 倍,那是在不考虑内存对齐的情况下,实际上还要考虑内存对齐,扩容是大于或者等于 1.25 倍。

(关于刚才问的 slice 为什么传到函数内可能被修改,如果 slice 在函数内没有出现扩容,函数外和函数内 slice 变量指向是同一个数组,则函数内复制的 slice 变量值出现更改,函数外这个 slice 变量值也会被修改。如果 slice 在函数内出现扩容,则函数内变量的值会新生成一个数组(也就是新的 slice,而函数外的 slice 指向的还是原来的 slice,则函数内的修改不会影响函数外的 slice。)

11.map是否并发安全?

map的类型是map[key],key类型的ke必须是可比较的,通常情况,会选择内建的基本类型,比如整数、字符串做key的类型。如果要使用struct作为key,要保证struct对象在逻辑上是不可变的。在Go语言中,map[key]函数返回结果可以是一个值,也可以是两个值。map是无序的,如果我们想要保证遍历map时元素有序,可以使用辅助的数据结构,例如orderedmap。

**第一,**一定要先初始化,否则panic

**第二,**map类型是容易发生并发访问问题的。不注意就容易发生程序运行时并发读写导致的panic。 Go语言内建的map对象不是线程安全的,并发读写的时候运行时会有检查,遇到并发问题就会导致panic。

12.map是无序的

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

并不会立即释放,但会被垃圾回收器回收。

(可能会有不一样的答案,还需要进一步研究)

14.怎么处理对 map 进行并发访问?

  1. 使用内置sync.Map
  2. 使用读写锁实现并发安全map

15.map 的数据结构是什么?

golang 中 map 是一个 kv 对集合。底层使用 hash table,用链表来解决冲突 ,出现冲突时,不是每一个 key 都申请一个结构通过链表串起来,而是以 bmap 为最小粒度挂载,一个 bmap 可以放 8 个 kv。在哈希函数的选择上,会在程序启动时,检测 cpu 是否支持 aes,如果支持,则使用 aes hash,否则使用 memhash。每个 map 的底层结构是 hmap,是有若干个结构为 bmap 的 bucket 组成的数组。每个 bucket 底层都采用链表结构。

16.负载因子

负载因子用于衡量一个哈希表冲突情况,公式为:

负载因子 = 键数量/bucket数量

例如,对于一个bucket数量为4,包含4个键值对的哈希表来说,这个哈希表的负载因子为1.

哈希表需要将负载因子控制在合适的大小,超过其阀值需要进行rehash,也即键值对重新组织:

  • 哈希因子过小,说明空间利用率低
  • 哈希因子过大,说明冲突严重,存取效率低

每个哈希表的实现对负载因子容忍程度不同,比如Redis实现中负载因子大于1时就会触发rehash,而Go则在在负载因子达到6.5时才会触发rehash,因为Redis的每个bucket只能存1个键值对,而Go的bucket可能存8个键值对,所以Go可以容忍更高的负载因子。

17.map怎么实现扩容?

触发 map 扩容的条件

为了保证访问效率,当新元素将要添加进map时,都会检查是否需要扩容,扩容实际上是以空间换时间的手段。
触发扩容的条件有二个:

  1. 负载因子 > 6.5时,也即平均每个bucket存储的键值对达到6.5个。
  2. overflow数量 > 2^15时,也即overflow数量超过32768时。
增量扩容

当负载因子过大时,就新建一个bucket,新的bucket长度是原来的2倍,然后旧bucket数据搬迁到新的bucket。
考虑到如果map存储了数以亿计的key-value,一次性搬迁将会造成比较大的延时,Go采用逐步搬迁策略,即每次访问map时都会触发一次搬迁,每次搬迁2个键值对。

下图展示了包含一个bucket满载的map(为了描述方便,图中bucket省略了value区域):

img

当前map存储了7个键值对,只有1个bucket。此地负载因子为7。再次插入数据时将会触发扩容操作,扩容之后再将新插入键写入新的bucket。

当第8个键值对插入时,将会触发扩容,扩容后示意图如下:

img

hmap数据结构中oldbuckets成员指身原bucket,而buckets指向了新申请的bucket。新的键值对被插入新的bucket中。
后续对map的访问操作会触发迁移,将oldbuckets中的键值对逐步的搬迁过来。当oldbuckets中的键值对全部搬迁完毕后,删除oldbuckets。

搬迁完成后的示意图如下:

img

数据搬迁过程中原bucket中的键值对将存在于新bucket的前面,新插入的键值对将存在于新bucket的后面。
实际搬迁过程中比较复杂,将在后续源码分析中详细介绍。

等量扩容

所谓等量扩容,实际上并不是扩大容量,buckets数量不变,重新做一遍类似增量扩容的搬迁动作,把松散的键值对重新排列一次,以使bucket的使用率更高,进而保证更快的存取。
在极端场景下,比如不断地增删,而键值对正好集中在一小部分的bucket,这样会造成overflow的bucket数量增多,但负载因子又不高,从而无法执行增量搬迁的情况,如下图所示:

img

上图可见,overflow的bucket中大部分是空的,访问效率会很差。此时进行一次等量扩容,即buckets数量不变,经过重新组织后overflow的bucket数量会减少,即节省了空间又会提高访问效率。

18、查找过程

查找过程如下:

  1. 根据key值算出哈希值
  2. 取哈希值低位与hmap.B取模确定bucket位置
  3. 取哈希值高位在tophash数组中查询
  4. 如果tophash[i]中存储值也哈希值相等,则去找到该bucket中的key值进行比较
  5. 当前bucket没有找到,则继续从下个overflow的bucket中查找。
  6. 如果当前处于搬迁过程,则优先从oldbuckets查找

注:如果查找不到,也不会返回空值,而是返回相应类型的0值。

19、插入过程

新元素插入过程如下:

  1. 根据key值算出哈希值
  2. 取哈希值低位与hmap.B取模确定bucket位置
  3. 查找该key是否已经存在,如果存在则直接更新值
  4. 如果没找到将key,将key插入

20、slices能作为map类型的key吗?

当时被问的一脸懵逼,其实是这个问题的变种:golang 哪些类型可以作为map key?

答案是:**在golang规范中,可比较的类型都可以作为map key;**这个问题又延伸到在:golang规范中,哪些数据类型可以比较?

不能作为map key 的类型包括:

  • slices
  • maps
  • functions

21.Context创建

  • context.Backgroud()
  • context.TODO()

互为别名没有差别,不具备任何功能

22.Context派生

func WithCancel(parent Context) (ctx Context, cancel CancelFunc)
func WithDeadline(parent Context, deadline time.Time) (Context, CancelFunc)
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)
func WithValue(parent Context, key, val interface{}) Context

基于一个父Context可以随意衍生,其实这就是一个Context树,树的每个节点都可以有任意多个子节点,节点层级可以有任意多个,每个子节点都依赖于其父节点。

23.WithValue携带数据

Go语言中我们就可以使用Context来传递,通过使用WithValue来创建一个携带trace_id的context,然后不断透传下去

func TestContextWithValue(t *testing.T) {
	ctx := context.WithValue(context.Background(), "name", "派大星")
	fmt.Println(ctx.Value("name"))
}

24.超时控制

通常健壮的程序都是要设置超时时间的,避免因为服务端长时间响应消耗资源,所以一些web框架或rpc框架都会采用withTimeout或者withDeadline

来做超时控制,当一次请求到达我们设置的超时时间,就会及时取消,不在往下执行。withTimeout和withDeadline作用是一样的,就是传递的时间参数不同而已,他们都会通过传入的时间来自动取消Context,这里要注意的是他们都会返回一个cancelFunc方法,通过调用这个方法可以达到提前进行取消,不过在使用的过程还是建议在自动取消后也调用cancelFunc去停止定时减少不必要的资源浪费。

withTimeout、WithDeadline不同在于WithTimeout将持续时间作为参数输入而不是时间对象,这两个方法使用哪个都是一样的,看业务场景和个人习惯了,因为本质withTimout内部也是调用的WithDeadline。

func TestContextWithTimeout(t *testing.T) {
	ctx := context.Background()
	ctx, _ = context.WithTimeout(ctx, time.Second*3)
	select {
	case <-ctx.Done():
		fmt.Println("已超时,结束执行")
	}
}
func TestContextWithDeadLine(t *testing.T) {
	ctx := context.Background()
	deadTime := time.Now().Add(time.Second*3)
	ctx, _ = context.WithDeadline(ctx, deadTime)

	select {
	case <-ctx.Done():
		fmt.Println("到达deadLine,结束执行")
		return
	}
}

25.withCancel取消控制

日常业务开发中我们往往为了完成一个复杂的需求会开多个gouroutine去做一些事情,这就导致我们会在一次请求中开了多个goroutine确无法控制他们,这时我们就可以使用withCancel来衍生一个context传递到不同的goroutine中,当我想让这些goroutine停止运行,就可以调用cancel来进行取消。

func TestContextWithCancel(t *testing.T) {
	ctx := context.Background()
	ctx, cancel := context.WithCancel(ctx)
	cancel()
	select {
	case <-ctx.Done():
		fmt.Println("手动取消")
	}
}

26.channel底层原理

type hchan struct {
    //channel分为无缓冲和有缓冲两种。
    //对于有缓冲的channel存储数据,借助的是如下循环数组的结构
    qcount   uint           // 循环数组中的元素数量
    dataqsiz uint           // 循环数组的长度
    buf      unsafe.Pointer // 指向底层循环数组的指针
    elemsize uint16 //能够收发元素的大小
    
    
    closed   uint32   //channel是否关闭的标志
    elemtype *_type //channel中的元素类型
    
    //有缓冲channel内的缓冲数组会被作为一个“环型”来使用。
    //当下标超过数组容量后会回到第一个位置,所以需要有两个字段记录当前读和写的下标位置
    sendx    uint   // 下一次发送数据的下标位置
    recvx    uint   // 下一次读取数据的下标位置
    
    //当循环数组中没有数据时,收到了接收请求,那么接收数据的变量地址将会写入读等待队列
    //当循环数组中数据已满时,收到了发送请求,那么发送数据的变量地址将写入写等待队列
    recvq    waitq  // 读等待队列
    sendq    waitq  // 写等待队列
    
    
    lock mutex //互斥锁,保证读写channel时不存在并发竞争问题
} 	

img

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

首先,我们先复习一下Channel都有哪些特性?

  • 给一个 nil channel 发送数据,造成永远阻塞
  • 从一个 nil channel 接收数据,造成永远阻塞
  • 给一个已经关闭的 channel 发送数据,引起 panic
  • 从一个已经关闭的 channel 接收数据,如果缓冲区中为空,则返回一个零值
  • 无缓冲的channel是同步的,而有缓冲的channel是非同步的

以上5个特性是死东西,也可以通过口诀来记忆:“空读写阻塞,写关闭异常,读关闭空零”。

28.channel发送和接收流程

发送:

  1. 如果等待接收队列recvq不为空,说明缓冲区中没有数据或者没有缓冲区,此时直接从recvq取出G,并把数据写入,最后把该G唤醒,结束发送过程;
  2. 如果缓冲区中有空余位置,将数据写入缓冲区,结束发送过程;
  3. 如果缓冲区中没有空余位置,将待发送数据写入G,将当前G加入sendq,进入睡眠,等待被读goroutine唤醒;

接收:

  1. 如果等待发送队列sendq不为空,且没有缓冲区,直接从sendq中取出G,把G中数据读出,最后把G唤醒,结束读取过程;
  2. 如果等待发送队列sendq不为空,此时说明缓冲区已满,从缓冲区中首部读出数据,把G中数据写入缓冲区尾部,把G唤醒,结束读取过程;
  3. 如果缓冲区中有数据,则从缓冲区取出数据,结束读取过程;
  4. 将当前goroutine加入recvq,进入睡眠,等待被写goroutine唤醒;

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

线程是处理机调度的单位,进程是资源分配的单位

进程:是应用程序的启动实例,每个进程都有独立的内存空间,不同的进程通过进程间的通信方式来通信。

线程:从属于进程,每个进程至少包含一个线程,线程是 CPU 调度的基本单位,多个线程之间可以共享进程的资源并通过共享内存等线程间的通信方式来通信。

协程:为轻量级线程,与线程相比,协程不受操作系统的调度,协程的调度器由用户应用程序提供,协程调度器按照调度策略把协程调度到线程中运行

30.什么是GPM?

G:goroutine

P:上下文处理器

M:thread线程

在 GPM模型中:

  1. 有一个全局队列(Global Queue):存放等待运行的 G
  2. 每个P 的有一个本地队列:也是存放等待运行的 G,但数量有限,不超过 256 个。

GPM 的调度流程:

  1. go func()开始创建一个 goroutine。
  2. 新建的 goroutine 优先保存在 P 的本地队列中,如果 P 的本地队列已经满了,则会保存到全局队列中。
  3. M 会从 P 的队列中取一个可执行状态的 G 来执行,如果 P 的本地队列为空,就会从其他的 MP 组合偷取一个可执行的 G 来执行。
  4. 当 M 执行某一个 G 时,发生系统调用或者阻塞,M 阻塞,如果这个时候 G 在执行,runtime 会把这个线程 M 从 P 中摘除,然后创建一个新的操作系统线程M来服务于这个 P。
  5. 当 M 系统调用结束时,这个 G 会尝试获取一个空闲的 P 来执行,并放入到这个 P 的本地队列。
  6. 如果这个线程 M 变成休眠状态,加入到空闲线程中,然后整个 G 就会被放入到全局队列中。

GPM个数问题:

  1. G 的个数理论上是无限制的,但是受内存限制,
  2. P 的数量由启动时环境变量 $GOMAXPROCS 或者程序中 runtime.GOMAXPROCS() 决定。
  3. M 的数量由 Go 语言本身的限制决定,Go 程序启动时会设置 M 的最大数量为 10000 个,但是内核很难支持这么多的线程数,所以这个限制可以忽略。可以使用 runtime.SetMaxThreads() 设置 M 的最大数量。

31.为什么要有P?

带来什么改变

加了 P 之后会带来什么改变呢?我们再更显式的讲一下。

  • 每个 P 有自己的本地队列,大幅度的减轻了对全局队列的直接依赖,所带来的效果就是锁竞争的减少。而 GM 模型的性能开销大头就是锁竞争。
  • 每个 P 相对的平衡上,在 GMP 模型中也实现了 Work Stealing (工作量窃取机制)算法,如果 P 的本地队列为空,则会从全局队列或其他 P 的本地队列中窃取可运行的 G 来运行,减少空转,提高了资源利用率。

为什么要有 P

这时候就有小伙伴会疑惑了,如果是想实现本地队列、Work Stealing 算法,那为什么不直接在 M 上加呢,M 也照样可以实现类似的组件。为什么又再加多一个 P 组件?

结合 M(系统线程) 的定位来看,若这么做,有以下问题:

  • 一般来讲,M 的数量都会多于 P。像在 Go 中,M 的数量默认是 10000,P 的默认数量的 CPU 核数。另外由于 M 的属性,也就是如果存在系统阻塞调用,阻塞了 M,又不够用的情况下,M 会不断增加。
  • M 不断增加的话,如果本地队列挂载在 M 上,那就意味着本地队列也会随之增加。这显然是不合理的,因为本地队列的管理会变得复杂,且 Work Stealing 性能会大幅度下降。
  • M 被系统调用阻塞后,我们是期望把他既有未执行的任务分配给其他继续运行的,而不是一阻塞就导致全部停止。

因此使用 M 是不合理的,那么引入新的组件 P,把本地队列关联到 P 上,就能很好的解决这个问题。

32.调度器的设计策略

线程复用

  1. work stealing(工作量窃取)机制

    当本线程无可运行的G时,尝试从其他线程绑定的P偷取G,而不是销毁线程。

  2. hand off(移交)机制

    当本线程因为G进行系统调用阻塞时,线程释放绑定的P,把P转移给其他空闲的线程执行。

利用并行

  1. GOMAXPROCS设置P的数量,最多有GOMAXPROCS个线程分布在多个CPU上同时运行。
  2. GOMAXPROCS也限制了并发的程度,比如GOMAXPROCS = 核数/2,则最多利用了一半的CPU核进行并行。

抢占

  1. 在Go中,一个goroutine最多占用CPU 10ms,防止其他goroutine被饿死。

全局G队列

  1. 当M执行work stealing从其他P偷不到G时,它可以从全局G队列获取G。

33.调度时机

Go 语言的调度器结合了抢占式调度和协作式调度,以下是 Go 中这两种调度方式的具体实现和特性:

1 协作式调度

阻塞操作

  1. 当 Goroutine 执行阻塞操作(如通道操作、等待锁、系统调用等)时,它会主动放弃 CPU 控制权,允许调度器切换到其他 Goroutine。

显式调度

  1. Goroutine 显式请求 runtime.Gosched() 调用,调度器进行调度。
  2. 这个时候会从当前协程切换到 g0 协程,取消 G 与 M 之间的绑定关系,把 G 放入全局队列中。
2 抢占式调度(Preemptive Scheduling)

基于时间的抢占

  1. 从 Go 1.14 开始,调度器引入了基于时间的抢占机制。如果一个 Goroutine 运行时间超过 10 毫秒,或者在系统调用中超过了 20 微妙,调度器会在安全点(如函数调用、循环迭代、阻塞操作等)尝试暂停该 Goroutine。
  2. 这种抢占不依赖于 Goroutine 的显式放弃控制,而是由调度器主动触发。
  3. 安全点的选择旨在减少对 Goroutine 执行的干扰,同时确保调度的公平性和响应性。

基于信号的抢占:

  1. 当程序在执行过程中既无法主动挂起,也不能进行系统调用,且无法进行函数调用时,就可以使用信号来调度。
  2. 信号其实就是线程信号,在操作系统中有很多基于信号的底层通信方式(SIGPIPE / SIGURG / SIGHUP),而我们的线程可以注册对应信号的处理函数。
  3. 当线程接收到抢占信号时,会进入一个专门的信号处理器。这个处理器会检查是否处于安全点,如果是,则暂停当前 Goroutine 并进行上下文切换。

34.调度器的生命周期

img

35.M0和G0

1.m0
  • 定义:m0 是 Go 程序启动时创建的第一个 M。它是由 Go 运行时系统直接从操作系统线程创建的,不是从线程池中获取的。
  • 作用:m0 负责初始化和启动 Go 运行时环境,包括创建调度器、分配第一个 P(p0),并创建其他系统级别的资源。在程序的整个生命周期中,m0 会继续存在,即使它可能不执行任何 Go 代码。
  • 特点:m0 不同于其他 M,因为它不是从线程池中获取的。它可能没有绑定任何 P,除非程序中只有一个 P(即 GOMAXPROCS 设置为 1)。
2.g0
  • 定义:g0 是每个 M 的特殊 Goroutine,它不执行任何实际的 Go 代码。每个 M 在创建时都会分配一个 g0。
  • 作用:g0 主要用于执行调度器代码和进行系统调用。当 M 需要执行这些非用户代码时,会切换到 g0 的栈上运行。
  • 特点:g0 拥有自己的栈,这个栈用于存放调度器函数和系统调用的数据。这意味着当执行这些操作时,不会影响当前运行的用户 Goroutine 的栈。

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

三色标记法

三色只是为了叙述上方便抽象出来的一种说法,实际上对象并没有颜色之分。这里的三色,对应了垃圾回收过程中对象的三种状态:

  • 灰色:对象还在标记队列中等待
  • 黑色:对象已被标记,gcmarkBits对应的位为1(该对象不会在本次GC中被清理)
  • 白色:对象未被标记,gcmarkBits对应的位为0(该对象将会在本次GC中被清理)

例如,当前内存中有A~F一共6个对象,根对象a,b本身为栈上分配的局部变量,根对象a、b分别引用了对象A、B, 而B对象又引用了对象D,则GC开始前各对象的状态如下图所示:

null

初始状态下所有对象都是白色的。

接着开始扫描根对象a、b:

null

由于根对象引用了对象A、B,那么A、B变为灰色对象,接下来就开始分析灰色对象,分析A时,A没有引用其他对象很快就转入黑色,B引用了D,则B转入黑色的同时还需要将D转为灰色,进行接下来的分析。如下图所示:

null

上图中灰色对象只有D,由于D没有引用其他对象,所以D转入黑色。标记过程结束:

null

最终,黑色的对象会被保留下来,白色对象会被回收掉。

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

什么是STW

  • 对于垃圾回收来说,回收过程中也需要控制住内存的变化,否则回收过程中指针传递会引起内存引用关系变化,如果错误的回收了还在使用的内存,结果将是灾难性的。
  • Golang中的STW(Stop The World)就是停掉所有的goroutine,专心做垃圾回收,待垃圾回收结束后再恢复goroutine。
  • STW时间的长短直接影响了应用的执行,时间过长对于一些web应用来说是不可接受的,这也是广受诟病的原因之一

1)在开始新的一轮 GC 周期前,需要调用 gcWaitOnMark 方法上一轮 GC 的标记结束(含扫描终止、标记、或标记终止等)。

2)开始新的一轮 GC 周期,调用 gcStart 方法触发 GC 行为,开始扫描标记阶段。

3)需要调用 gcWaitOnMark 方法等待,直到当前 GC 周期的扫描、标记、标记终止完成。

4)需要调用 sweepone 方法,扫描未扫除的堆跨度,并持续扫除,保证清理完成。在等待扫除完毕前的阻塞时间,会调用 Gosched 让出。

5)在本轮 GC 已经基本完成后,会调用 mProf_PostSweep 方法。以此记录最后一次标记终止时的堆配置文件快照。

6)结束,释放 M。

38.GC 的触发时机?

  1. gcTriggerHeap:当所分配的堆大小达到阈值(由控制器计算的触发堆的大小)时,将会触发。

  2. gcTriggerTime:当距离上一个 GC 周期的时间超过一定时间时,将会触发。时间周期以runtime.forcegcperiod 变量为准,默认 2 分钟。

  3. gcTriggerCycle:如果没有开启 GC,则启动 GC。

  4. 手动触发的 runtime.GC 方法。

39.写屏障(Write Barrier)和辅助GC(Mutator Assist)

写屏障

  1. 前面说过STW目的是防止GC扫描时内存变化而停掉goroutine,而写屏障就是让goroutine与GC同时运行的手段。虽然写屏障不能完全消除STW,但是可以大大减少STW的时间。
  2. 写屏障类似一种开关,在GC的特定时机开启,开启后指针传递时会把指针标记,即本轮不回收,下次GC时再确定。
  3. GC过程中新分配的内存会被立即标记,用的并不是写屏障技术,也即GC过程中分配的内存不会在本轮GC中回收。

辅助GC

  1. 为了防止内存分配过快,在GC执行过程中,如果goroutine需要分配内存,那么这个goroutine会参与一部分GC的工作,即帮助GC做一部分工作,这个机制叫作Mutator Assist。

41.golang 的内存逃逸吗?什么情况下会发生内存逃逸?(必问)

  1. 本该分配到栈上的变量,跑到了堆上,这就导致了内存逃逸。
  2. 栈是高地址到低地址,栈上的变量,函数结束后变量会跟着回收掉,不会有额外性能的开销。
  3. 变量从栈逃逸到堆上,如果要回收掉,需要进行 gc,那么 gc 一定会带来额外的性能开销。编程语言不断优化 gc 算法,主要目的都是为了减少 gc 带来的额外性能开销,变量一旦逃逸会导致性能开销变大。

内存逃逸的情况如下:

1)方法内返回局部变量指针。

2)向 channel 发送指针数据。

3)在闭包中引用包外的值。

4)在 slice 或 map 中存储指针。

5)切片(扩容后)长度太大。

6)在 interface 类型上调用方法。

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

针对待分配对象的大小不同有不同的分配逻辑:

  • (0, 16B) 且不包含指针的对象: Tiny分配
  • (0, 16B) 包含指针的对象:正常分配
  • [16B, 32KB] : 正常分配
  • (32KB, -) : 大对象分配
    其中Tiny分配和大对象分配都属于内存管理的优化范畴,这里暂时仅关注一般的分配方法。

以申请size为n的内存为例,分配步骤如下:

  1. 获取当前线程的私有缓存mcache
  2. 根据size计算出适合的class的ID
  3. 从mcache的alloc[class]链表中查询可用的span
  4. 如果mcache没有可用的span则从mcentral申请一个新的span加入mcache中
  5. 如果mcentral中也没有可用的span则从mheap中申请一个新的span加入mcentral
  6. 从该span中获取到空闲对象地址并返回

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

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

准确地说,你并不需要知道。Golang 中的变量只要被引用就一直会存活,存储在堆上还是栈上由内部实现决定而和具体的语法没有关系。

知道变量的存储位置确实和效率编程有关系。如果可能,Golang 编译器会将函数的局部变量分配到函数栈帧(stack frame)上。然而,如果编译器不能确保变量在函数 return 之后不再被引用,编译器就会将变量分配到堆上。而且,如果一个局部变量非常大,那么它也应该被分配到堆上而不是栈上。

当前情况下,如果一个变量被取地址,那么它就有可能被分配到堆上,然而,还要对这些变量做逃逸分析,如果函数 return 之后,变量不再被引用,则将其分配到栈上。

44.介绍一下大对象小对象,为什么小对象多了会造成 gc 压力?

小于等于 32k 的对象就是小对象,其它都是大对象。一般小对象通过 mspan 分配内存;大对象则直接由 mheap 分配内存。通常小对象过多会导致 GC 三色法消耗过多的 CPU。优化思路是,减少对象分配。

小对象:如果申请小对象时,发现当前内存空间不存在空闲跨度时,将会需要调用 nextFree 方法获取新的可用的对象,可能会触发 GC 行为。

大对象:如果申请大于 32k 以上的大对象时,可能会触发 GC 行为。

以 golang 直接将其分配在堆上

准确地说,你并不需要知道。Golang 中的变量只要被引用就一直会存活,存储在堆上还是栈上由内部实现决定而和具体的语法没有关系。

知道变量的存储位置确实和效率编程有关系。如果可能,Golang 编译器会将函数的局部变量分配到函数栈帧(stack frame)上。然而,如果编译器不能确保变量在函数 return 之后不再被引用,编译器就会将变量分配到堆上。而且,如果一个局部变量非常大,那么它也应该被分配到堆上而不是栈上。

当前情况下,如果一个变量被取地址,那么它就有可能被分配到堆上,然而,还要对这些变量做逃逸分析,如果函数 return 之后,变量不再被引用,则将其分配到栈上。

44.介绍一下大对象小对象,为什么小对象多了会造成 gc 压力?

小于等于 32k 的对象就是小对象,其它都是大对象。一般小对象通过 mspan 分配内存;大对象则直接由 mheap 分配内存。通常小对象过多会导致 GC 三色法消耗过多的 CPU。优化思路是,减少对象分配。

小对象:如果申请小对象时,发现当前内存空间不存在空闲跨度时,将会需要调用 nextFree 方法获取新的可用的对象,可能会触发 GC 行为。

大对象:如果申请大于 32k 以上的大对象时,可能会触发 GC 行为。

锁和并发在另外一个帖子通过题目复习

  • 21
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值