【Go面试】Go slice深拷贝和浅拷贝_哔哩哔哩_bilibili
简单言之就是是否会影响调用的结构体,方法接收者是指针会影响
一般来说,局部变量会在函数返回后被销毁,因此被返回的引用就成为了"无所指""的引用,程序会进入未知状态。
但这在Go中是安全的,Go编译器将会对每个局部变量进行逃逸分析。如果发现局部变量的作用域超出该函数,则不会将内存分配在栈上,而是分配在堆上,因为他们不在栈区,即使释放函数,其内容也不会受影响。
形参和实际参数内存地址不一样,证明是指传递﹔参数是值类型,所以函数内对形参的修改,不会修改原内容数据
特点:多个defer顺序类似栈。
pannic之后的defer不会执行,之前的会。
定义:
defer能够让我们推迟执行某些函数调用,推迟到当前函数返回前才实际执行。defer与panic和recover结合,形成了Go语言风格的异常与捕获机制。
使用场景∶
defer语习经常被用于处理成对的操作,如文件句柄关闭、连接关闭、释放锁
优点:
方便开发者使用
缺点:
有性能损耗
实现原理:
Go14中编译器会将deler函数直接插入到函致的尾部,无需链表和栈上参数拷贝,性能大幅提升。把deler函数在当前函数内展开并直接调用,这种方式被称为open codeddefer
首先纠正下make和new是内置函数,不是关键字
变量初始化,一般包括2步,变量声明+变量内存分配,var关键字就是用来声明变量的,new和make函数主要是用来分配内存的
var声明值类型的变量时,系统会默认为他分配内存空间,并赋该类型的零值
比如布尔、数字、字符串,j结构体
如果指针类型或者引用类型的变量,系统不会为它分配内存,默认就是nil。此时如果你想直接使用,那么系统会抛异常,必须进行内存分配后,才能使用。
new和 make两个内置函数,主要用来分配内存空间,有了内存,变量就能使用了,主要有以下2点区别:
使用场景区别:
make 只能用来分配及初始化类型为slice、map、chan 的数据。new可以分配任意类型的数据,并且置零。
返回值区别;
make函数原型如下,返回的是slice、map、chan类型本身
切片是基于数组实现的,它的底层是数组,可以理解为对底层数组的抽象。
传参区别
数组
是值类型,将一个数组赋值给另一个数组时,传递的是一份深拷贝,函数传参操作都会复制整个数组数据,会占用额外的内存,函致内对数组元素值的修改,不会修改原数组内容。
切片
是引用类型,将一个切片赋值给另一个切片时,传递的是一份浅拷贝,函数传参操作不会拷贝整个切片,只会复制len和cap,底层共用同一个数组,不会占用额外的内存,函数内对数组元素值的修改,会修改原数组内容。直到发生扩容转移内存之后,就不会影响。
区别:前后是否共享一片内存空间
深拷贝:copy或者append函数
旧内存被gc获取释放
先看下线程安全的定义:
多个线程访问同一个对象时,调用这个对象的行为都可以获得正确的结果,那么这个对象就是线程安全的。若有多个线程同时执行写操作,一般都需要考虑线程同步,否则的话就可能影响线程安全。
Go中的map是一个指针,占用8个字节,指向hmap结构体源码包中 src/runtime/map.go定义了hmap的数据结构:
hmap包含若干个结构为bmap的数组,每个bmap底层都采用链表结构,bmap通常叫其bucket(桶)
想要有序遍历,要先对key进行排序,然后根据key遍历
保证线程安全的方式:
Go语言中读取 map有两种语法︰
带comma和不带comma。当要查询的key不在 map里,带comma 的用法会返回一个bool型变量提示key 是否在 map中; 而不带comma的语句则会返回一个value类型的零值。如果value 是int型就会返回0,如果value 是string 类型,就会返回空字符串。
【Go面试】Go map冲突的解决方式?_哔哩哔哩_bilibili
负载因子(load factor),用于衡量当前哈希表中空间占用率的核心指标,也就是每个bucket桶存储的平均元素个数。
就是官方统计决定的
Go官方发现:装载因子越大,填入的元素越多,空间利用率就越高,但发生哈希冲突的几率就变大。反之,装载因子越小,填入的元素越少,冲突发生的几率减小,但空间浪费也会变得更多,而且还会提高扩容操作的次数
根据这份测试结果和讨论,Go官方取了一个相对适中的值,把Go中的map的负载因子硬编码为6.5,这就是6.5的选择缘由。这意味着在Go语言中,当map存储的元素个数大于或等于6.5*桶个数时,就会触发扩容行为。
条件1:超过负载
条件2:溢出桶太多
当桶总数<2^15时,如果溢出桶总数>=桶总数,则认为溢出桶过多。
当桶总数>=2^15时,直接与2^15比较,当溢出桶总数>=2^15时,即认为溢出桶太多了。
双倍扩容︰针对条件1,新建一个buckets数组,新的buckets大小是原来的2倍,然后旧buckets数据搬迁到新的buckets。该方法我们称之为双倍扩容(类似slice)
等量扩容∶针对条件2,并不扩大容量,buckets数量维持不变,重新做一遍类似双倍扩容的搬迁动作,把松散的键值对重新排列一次,使得同一个 bucket中的key排列地更紧密,节省空间,提高bucket利用率,进而保证更快的存取。该方法我们称之为等量扩容。
概念:
Go中的channel是一个队列,遵循先进先出的原则,负责协程之间的通信(Go语言提倡不要通过共享内存来通信,而要通过通信来实现内存共享,CSP(CommunicatingSequential Process)并发模型,就是通过goroutine和channel来实现的)
接收策略
有缓冲先放到缓冲里,放满了阻塞。
加互斥锁
【Go面试】Go channel发送和接收什么情况下会发生死锁?_哔哩哔哩_bilibili
CSP(communicating sequential processes)并发模型_csp并发模型_ScarletMeCarzy的博客-CSDN博客
M指的是Machine,一个M直接关联了一个内核线程。由操作系统管理。
P指的是”processor”,代表了M所需的上下文环境,也是处理用户级代码逻辑的处理器。它负责衔接M和G的调度上下文,将等待执行的G与M对接。
G指的是Goroutine,其实本质上也是一种轻量级的线程。包括了调用栈,重要的调度信息,例如channel等。
P的数量由环境变量中的GOMAXPROCS决定,通常来说它是和核心数对应,例如在4Core的服务器上回启动4个线程。G会有很多个,每个P会将Goroutine从一个就绪的队列中做Pop操作,为了减小锁的竞争,通常情况下每个P会负责一个队列。
抛弃P(Processor)
你可能会想,为什么一定需要一个上下文,我们能不能直接除去上下文,让Goroutine的runqueues挂到M上呢?答案是不行,需要上下文的目的,是让我们可以直接放开其他线程,当遇到内核线程阻塞的时候。
一个很简单的例子就是系统调用sysall,一个线程肯定不能同时执行代码和系统调用被阻塞,这个时候,此线程M需要放弃当前的上下文环境P,以便可以让其他的Goroutine被调度执行。
如上图左图所示,M0中的G0执行了syscall,然后就创建了一个M1(也有可能来自线程缓存),(转向右图)然后M0丢弃了P,等待syscall的返回值,M1接受了P,将·继续执行Goroutine队列中的其他Goroutine。
当系统调用syscall结束后,M0会“偷”一个上下文,如果不成功,M0就把它的Gouroutine G0放到一个全局的runqueue中,将自己置于线程缓存中并进入休眠状态。全局runqueue是各个P在运行完自己的本地的Goroutine runqueue后用来拉取新goroutine的地方。P也会周期性的检查这个全局runqueue上的goroutine,否则,全局runqueue上的goroutines可能得不到执行而饿死。
【Go面试】Go goroutine的底层实现原理?_哔哩哔哩_bilibili
创建好的这个goroutine会新建一个自己的栈空间,同时在G的sched中维护栈地址与程序计数器这些信息。
每个G在被创建之后,都会被优先放入到本地队列中,如果本地队列已经满了,就会被放入到全局队列中。
创建消耗小,用户级,函数负责调度,切换快消耗小
大概就是协程因为某某原因一直不能释放,类似内存泄漏
死磕 java线程系列之线程模型 - 知乎 (zhihu.com)
【Go面试】Go 线程实现模型?_哔哩哔哩_bilibili
Go实现的是两级线程模型(M:N),准确的说是GMP模型,是对两级线程模型的改进实现,使它能够更加灵活地进行线程之间的调度。
也就是说go的协程其实地位相当于其他语言的用户级线程?只是协程因为自身的特性非常轻量级,是轻量级线程。
线程模型有 :内核线程 :用户线程 = M:N 或者 1 : 1 或者 1: N
1:1
N:1
M:N
优点:
缺点:
Golang-企业题库 | GOLANG ROADMAP · 知识星球
G(Goroutine):代表Go 协程Goroutine,存储了 Goroutine 的执行栈信息、Goroutine 状态以及 Goroutine 的任务函数等。G的数量无限制,理论上只受内存的影响,创建一个 G 的初始栈大小为2-4K,配置一般的机器也能简简单单开启数十万个 Goroutine ,而且Go语言在 G 退出的时候还会把 G 清理之后放到 P 本地或者全局的闲置列表 gFree 中以便复用。
M(Machine): Go 对操作系统线程(OS thread)的封装,可以看作操作系统内核线程,想要在 CPU 上执行代码必须有线程,通过系统调用 clone 创建。M在绑定有效的 P 后,进入一个调度循环,而调度循环的机制大致是从 P 的本地运行队列以及全局队列中获取 G,切换到 G 的执行栈上并执行 G 的函数,调用 goexit 做清理工作并回到 M,如此反复。M 并不保留 G 状态,这是 G 可以跨 M 调度的基础。M的数量有限制,默认数量限制是 10000,可以通过 debug.SetMaxThreads() 方法进行设置,如果有M空闲,那么就会回收或者睡眠。
**P(Processor):虚拟处理器,M执行G所需要的资源和上下文,只有将 P 和 M 绑定,才能让 P 的 runq 中的 G 真正运行起来。P 的数量决定了系统内最大可并行的 G 的数量,**P的数量受本机的CPU核数影响,可通过环境变量$GOMAXPROCS或在runtime.GOMAXPROCS()来设置,默认为CPU核心数。
Sched:调度器结构,它维护有存储M和G的全局队列,以及调度器的一些状态信息
goroutine调度的本质就是将 **Goroutine (G)**按照一定算法放到CPU上去执行。
CPU感知不到Goroutine,只知道内核线程,所以需要Go调度器将协程调度到内核线程上面去,然后操作系统调度器将内核线程放到CPU上去执行
M是对内核级线程的封装,所以Go调度器的工作就是将G分配到M
Go 调度器的实现不是一蹴而就的,它的调度模型与算法也是几经演化,从最初的 GM 模型、到 GMP模型,从不支持抢占,到支持协作式抢占,再到支持基于信号的异步抢占,经历了不断地优化与打磨。
线程复用(work stealing 机制和hand off 机制)
利用并行(利用多核CPU)
抢占调度(解决公平性问题)
Go 调度器
Go 调度器是属于Go runtime中的一部分,Go runtime负责实现Go的并发调度、垃圾回收、内存堆栈管理等关键功能
当线程M⽆可运⾏的G时,尝试从其他M绑定的P偷取G,减少空转,提高了线程利用率(避免闲着不干活)。
当从本线程绑定 P 本地 队列、全局G队列、netpoller都找不到可执行的 g,会从别的 P 里窃取G并放到当前P上面。
从netpoller 中拿到的G是_Gwaiting状态( 存放的是因为网络IO被阻塞的G),从其它地方拿到的G是_Grunnable状态
从全局队列取的G数量:N = min(len(GRQ)/GOMAXPROCS + 1, len(GRQ/2)) (根据GOMAXPROCS负载均衡)
从其它P本地队列窃取的G数量:N = len(LRQ)/2(平分)
取G优先级:本地队列->全局队列->网络轮询器netpoller ->偷取其他P本地队列下的G
也称为P分离机制,当本线程 M 因为 G 进行的系统调用阻塞时,线程释放绑定的 P,把 P 转移给其他空闲的 M 执行,也提高了线程利用率(避免站着茅坑不拉shi)。
当前线程M阻塞时,释放P,给其它空闲的M处理
在1.2版本之前,Go的调度器仍然不支持抢占式调度,程序只能依靠Goroutine主动让出CPU资源才能触发调度,这会引发一些问题,比如:
编译器会在调用函数前插入 runtime.morestack,让运行时有机会在这段代码中检查是否需要执行抢占调度
Go语言运行时会在垃圾回收暂停程序、系统监控发现 Goroutine 运行超过 10ms,那么会在这个协程设置一个抢占标记
当发生函数调用时,可能会执行编译器插入的 runtime.morestack,它调用的 runtime.newstack会检查抢占标记,如果有抢占标记就会触发抢占让出cpu,切到调度主协程里
这种解决方案只能说局部解决了“饿死”问题,只在有函数调用的地方才能插入“抢占”代码(埋点),对于没有函数调用而是纯算法循环计算的 G,Go 调度器依然无法抢占。
比如,死循环等并没有给编译器插入抢占代码的机会
真正的抢占式调度是基于信号完成的,所以也称为“异步抢占”。不管协程有没有意愿主动让出 cpu 运行权,只要某个协程执行时间过长,就会发送信号强行夺取 cpu 运行权。
抢占分为_Prunning
和_Psyscall
,_Psyscall
抢占通常是由于阻塞性系统调用引起的,比如磁盘io、cgo。_Prunning
抢占通常是由于一些类似死循环的计算逻辑引起的。
Go语言内置运行时(就是runtime),抛弃了传统的内存分配方式,改为自主管理。这样可以自主地实现更好的内存使用模式,比如内存池、预分配等等。这样,不会每次内存分配都需要进行系统调用。
TCMalloc算法
,每个线程都会自行维护一个独立的内存池,进行内存分配时优先从该内存池中分配,当内存池不足时才会向加锁向全局内存池申请,减少系统调用并且避免不同线程对全局内存池的锁竞争(先本地再全局)Go的内存管理组件主要有:mspan
、mcache
、mcentral
和mheap
在一段程序中,每一个函数都会有自己的内存区域存放自己的局部变量、返回地址等,这些内存会由编译器在栈中进行分配,每一个函数都会分配一个栈桢,在函数运行结束后进行销毁,但是有些变量我们想在函数运行结束后仍然使用它,那么就需要把这个变量在堆上分配,这种从"栈"上逃逸到"堆"上的现象就成为内存逃逸。
在栈上分配的地址,一般由系统申请和释放,不会有额外性能的开销,比如函数的入参、局部变量、返回值等。在堆上分配的内存,如果要回收掉,需要进行 GC,那么GC 一定会带来额外的性能开销。编程语言不断优化GC算法,主要目的都是为了减少 GC带来的额外性能开销,变量一旦逃逸会导致性能开销变大。
编译器会根据变量是否被外部引用来决定是否逃逸:
逃逸分析也就是由编译器决定哪些变量放在栈,哪些放在堆中,通过编译参数-gcflag=-m
可以查看编译过程中的逃逸分析,发生逃逸的几种场景如下:
package main
func escape1() *int {
var a int = 1
return &a
}
func main() {
escape1()
}
当栈空间足够时,不会发生逃逸,但是当变量过大时,已经完全超过栈空间的大小时,将会发生逃逸到堆上分配内存。局部变量s占用内存过大,编译器会将其分配到堆上
package main
func escape2() {
s := make([]int, 0, 10000)
for index, _ := range s {
s[index] = index
}
}
func main() {
escape2()
}
当栈空间足够时,不会发生逃逸,但是当变量过大时,已经完全超过栈空间的大小时,将会发生逃逸到堆上分配内存。局部变量s占用内存过大,编译器会将其分配到堆上
func escape3() {
number := 10
s := make([]int, number) // 编译期间无法确定slice的长度
for i := 0; i < len(s); i++ {
s[i] = i
}
}
func main() {
escape3()
}
编译期间无法确定slice的长度,这种情况为了保证内存的安全,编译器也会触发逃逸,在堆上进行分配内存。直接s := make([]int, 10)
不会发生逃逸
动态类型就是编译期间不确定参数的类型、参数的长度也不确定的情况下就会发生逃逸
空接口 interface{} 可以表示任意的类型,如果函数参数为 interface{},编译期间很难确定其参数的具体类型,也会发生逃逸。
fmt.Println(a …interface{})函数参数为interface,编译器不确定参数的类型,会将变量分配到堆上
package main
func escape5() func() int {
var i int = 1
return func() int {
i++
return i
}
}
func main() {
escape5()
}
闭包函数中局部变量i在后续函数是继续使用的,编译器将其分配到堆上
栈上分配内存比在堆中分配内存效率更高
栈上分配的内存不需要 GC 处理,而堆需要
逃逸分析目的是决定内分配地址是栈还是堆
逃逸分析在编译阶段完成
为了能让CPU可以更快的存取到各个字段,Go编译器会帮你把struct结构体做数据的对齐。所谓的数据对齐,是指内存地址是所存储数据大小(按字节为单位)的整数倍,以便CPU可以一次将该数据从内存中读取出来。 编译器通过在结构体的各个字段之间填充一些空白已达到对齐的目的。
不同硬件平台占用的大小和对齐值都可能是不一样的,每个特定平台上的编译器都有自己的默认"对齐系数",32位系统对齐系数是4,64位系统对齐系数是8
不同类型的对齐系数也可能不一样,使用Go
语言中的unsafe.Alignof
函数可以返回相应类型的对齐系数,对齐系数都符合2^n
这个规律,最大也不会超过8
提高可移植性,有些CPU
可以访问任意地址上的任意数据,而有些CPU
只能在特定地址访问数据,因此不同硬件平台具有差异性,这样的代码就不具有移植性,如果在编译时,将分配的内存进行对齐,这就具有平台可以移植性了
提高内存的访问效率,32位CPU下一次可以从内存中读取32位(4个字节)的数据,64位CPU下一次可以从内存中读取64位(8个字节)的数据,这个长度也称为CPU的字长。CPU一次可以读取1个字长的数据到内存中,如果所需要读取的数据正好跨了1个字长,那就得花两个CPU周期的时间去读取了。因此在内存中存放数据时进行对齐,可以提高内存访问效率。
【Go面试】Go GC实现原理?_哔哩哔哩_bilibili
垃圾回收也称为GC (Garbage dollection),是一种自动内存管理机制
现代高级编程语言管理内存的方式分为两种:自动和手动,像C、C++等编程语言使用手动管理内存的方式,工程师编写代码过程中需要主动申请或者释放内存;而PHP、Java和Go等语言使用自动的内存管理系统,有内存分配器和垃圾收集器来代为分配和回收内存,其中垃圾收集器就是我们常说的GC。
在应用程序中会使用到两种内存,分别为堆(Heap)和栈(Stack) ,GC负责回收堆内存,而不负责回收栈中的内存:
栈是线程的专用内存,专门为了函数执行而准备的,存储着函数中的局部变量以及调用栈,函数执行完后,编译器可以将栈上分配的内存可以直接释放,不需要通过GC来回收。
堆是程序共享的内存,需要GC进行回收在堆上分配的内存。
常见三种:
STW:Stop the world全局暂停
此算法是在Go 1.5版本开始使用,Go语言采用的是标记清除算法,并在此基础上使用了三色标记法和混合写屏障技术,CC过程和其他用户goroutine可并发运行,但需要一定时间的STW
三色标记法只是为了叙述方便而抽象出来的一种说法,实际上的对象是没有三色之分的。这里的三色,对应了垃圾回收过程中对象的三种状态∶
step 1:创建:白、灰、黑三个集合
step 2:将所有对象放入白色集合中
step 3:遍历所有root对象,把遍历到的对象从白色集合放入灰色集合(这里放入灰色集合的都是根节点的对象)
step 4:遍历灰色集合,将灰色对象引用的对象从白色集合放入灰色集合,自身标记为黑色
step 5:重复步骤4,直到灰色中无任何对象,其中用到2个机制:
step 6:收集所有白色对象(垃圾)
一次完整的垃圾回收会分为四个阶段,分别是标记准备、标记开始、标记终止、清理:
1.标记准备(Mark Setup)︰打开写屏障((Write Barrier),需STW (stop the world)
2.标记开始(Marking)︰使用三色标记法并发标记,与用户程序并发执行
3.标记终止(Mark Termination)∶对触发写屏障的对象进行重新扫描标记,关闭写屏障(Write Barrier),需STW (stop the word)
4.清理(Sweeping)︰将需要回收的内存归还到堆中,将过多的内存归还给操作系统,与用户程序并发执行