Go原理

GC

一、GC介绍

GC是垃圾回收的缩写,是各种高级编程语言所必须的一种内存清理机制。Go中GC的runtime包定义。经历过三个阶段:1.3之前使用STW标记清扫、1.5版本使用三色标记法、1.8版本之后对三色标记进行优化,加上了写屏障机制。

二、GC的触发条件

定时触发,内存阈值触发,手动触发

工作清扫,STW

最早的GC在回收垃圾的过程中,是不允许向堆中写入数据的,GC回收时会暂停全部的work工作线程。等回收结束再通知线程继续工作。

由此导致 SWT 成为了GC性能的一个瓶颈,在每次调用GC时都会暂时停止业务。

1.3版本的GC mark and sweep

工作流程:

1. 触发GC机制后,暂停全部的业务逻辑,找出不可达的对象和可达对象。

2. 开始标记所有的可达对象。

3. 标记完成后,删除所有不可达对象。

4. 停止暂停,继续运行程序。然后等待第二次触发,循环整个清理过程,直到程序的生命周期结束。

缺点:

stw会让程序出现暂停,删除堆区会造成大量的内存碎片。

标记清扫的优化思路:

正是因为 标记清扫需要让程序暂停,出现卡顿。所以提出了一个优化方案就是将标记完成后的清扫工作与程序一并运行。在标记完成后就停止暂停,减少暂停的时间范围。

三、GC 三色标记法原理

三色标记会将堆和栈中的数据维护成三种表,属于广度遍历内存空间。

白色表(未遍历对象)

黑色表(遍历过的对象)

灰色表(临时对象)

三色标记工作流程:

1. 只要是创建在内存的对象,会默认标记成白色,将所有的对象放入白色表中。

2. 每次GC触发开始回收,会从root Roots根节点开始遍历所有对象,并把遍历到的可达对象标记成灰色。

3. 再根据灰色对象的可达对象继续遍历,将可达的对象标记灰色,原灰色对象标记成黑色。

4. 循环以上步骤直到所有可达对象都变成黑色。

5. 最终把白色的垃圾对象清理。

由三色标记产生的问题:(对象丢失问题)

因为三色标记没有采用 STW暂停保护机制,在GC进行回收的过程中还会不断的有数据加入内存,成为某个对象的下游节点。也会有原本存在的对象被删除或者被其它对象引用。

当满足:

一个灰色对象 丢失了它的下游白色对象,也就是对可达关系进行破坏。且这个白色对象又被其它的黑色对象引用时;因为黑色对象无法继续扫描遍历,就会出现白色对象丢失的情况。

为了解决这种对象丢失的问题,又不想通过STW降低性能,所以在 三色标记中加入了两个规则。

1. 强三色不变式

强三色规则不允许已经标记为黑色的对象来引用其它的白色对象。

2. 弱三色不变式

弱三色规则黑色对象可以引用其它的白色对象,但是该白色对象必须有一个它的上游灰色对象。必须满足黑白灰都存在。

三色标记法只需要满足强,弱两种方法之一,就可以保证对象不丢失。为了满足这两种规则,发明了屏障机制。

四、三色标记的屏障机制

为了实现强三式和弱三式规则推出了屏障机制。屏障机制又分为插入屏障和删除屏障。

5-1、插入屏障(实现强三色)

1. 当黑色对象添加下游对象时,下游对象会被标记为灰色。

2. 满足了强三色规则,不存在黑色对象引用白色对象,因为会强制转变成灰色。

3. 插入屏障只存在于堆区,栈区没有。

工作流程:

1. 当GC开始工作时。会触发堆区和栈区的扫描,在堆区开启插入屏障,栈不会开启插入屏障。

2. 此时根据 GC roots根节点进行正常的节点扫描,选出栈区和堆区的灰色对象。

3. 在第二次遍历时,堆区的黑色对象引用了一个白色对象,会将这个白色对象强制标记成灰色。如果是栈区的黑色对象,引用了白色对象,则栈区里面的白色不会标记为灰色。

4. 注意,当所有的灰色节点都被标记为黑色后。会重新遍历一次栈空间。

5. 此时会为栈区空间加上 STW停止一切线程,然后将所有的节点标记为白色,重新进行遍历,最终将栈区内所有的可达对象标记为黑,然后停机STW。

6. 最终清理堆区和栈区中所有的白色标记。

插入屏障的不足:

需要用到STW来重新扫描栈区,大约10~100ms的时间。

结束时会将栈区空间中的对象全部标记为白色,还是会造成程序暂停,虽然时间很短。

5-2、删除屏障:(实现弱三色)

当堆区和栈区中的对象被删除时,会被强制标记删除对象为灰色,然后保活一次遍历阶段。满足若三色不变式,保护灰色对象到白色对象的路径在第一轮遍历时不会断。

工作流程:

1. 当GC开始工作后,会在堆区和栈区中同样开启删除屏障。

2. 根据根节点进行遍历,遍历出灰色对象。

3. 当灰色对象的下游节点要删除时,如果该下游节点为灰色,会强制将其转换成灰色。同时会删除灰色对象和该对象的引用关系。

4. 因为删除屏障有保活机制,所以即便是它们之间没有了引用关系,但是都会变成灰色,然后在二次遍历后,所有的灰色标记都会变成黑色标记。

5. 当灰色对象被遍历完成后,还是会有一步保存STW快照的机制,会对当前内存空间的可达对象进行记录。然后重新遍历,将与根节点不可达的对象进行清理。

删除屏障的不足:

回收精度很低,一个对象即使被删除后,仍然可以活过第一轮的GC标记,在下一轮GC才会被清理。

五、1.8版本GC优化混合写屏障

1.8版本的GC结合了插入屏障和删除屏障的优点,变成了混合写屏障。但是栈空间依旧不会启动混和写屏障。

混合写屏障规则:

1. GC开始后第一时间将栈区所有可达对象标记为黑色。(这一步确保无需STW)

2. GC期间,任何在栈上创建的对象,均为黑色。

3. 不管是否在栈区和堆区,只要被删除的对象都会标记为灰色,并保活一次遍历。

4. 被添加的对象也会标记为灰色。

5. 不管是删除的对象,还是添加的对象,只要这个对象为可达对象,被根节点的其它线程所引用,最后一律标记为黑。

混合写屏障根据它的规则会出现4中场景。

1. 堆对象被堆对象删除,

2. 堆对象被堆对象引用,

3. 栈对象被栈对象删除,

4. 堆对象被栈对象引用

总之不管出现社么场景,只要符合混合写入的规则就不会被GC所清理。

针对内存中的临界资源,两个或者多个线程使用一个临界区时,就要为临界区加锁。锁本质也是一个变量。

一、乐观锁悲观锁

乐观锁,悲观锁是一种思想。

乐观锁:并不是真的对数据上锁,而是监听数据是否发生改变,如果在使用数据期间,该数据被其它线程改动,就会放弃对该数据的操作。一般用于读多写少的环境。

悲观锁:悲观锁是只要访问一个临界区的数据就会对其上锁。一般多用于写入环境。

在实际场景中首先考虑业务的读写要求。如果悲观锁和乐观锁都能够使用,看并发的效率,如果并发时资源竞争不激烈,可以使用乐观锁。并发冲突很严重时就要使用悲观锁,一些数据安全性很高的场景。

一般乐观锁用来做 Watch监听,Version版本号控制,CAS原子性操作,在go中的CAS原子性操作可以有效减少锁的开销,但是原子性操作是依赖于CPU性能的,会消耗CPU资源。

二、读写锁,互斥锁

go 的锁就两个,读写锁和互斥锁。其内部也是依赖于原子性操作进行判断。

RWMutex 读写锁

读写锁通过count++计数器 和 Sem信号量的方式记录读锁数量和写锁的数量。

1. Rlock获取读锁:当一个协程使用读锁时,就会readercount++一个计数,如果readcount小于0为负数就会进入等待状态,写就是有写操作进入,读操作就会被等待。

2. Lock获取写锁:写锁就是使用的go互斥锁。会减去最大的读锁数量,用0和负数来表示已经获取上写锁了。需要等待释放,通过监控写锁的信号来等待。

3. RUnlock释放读锁:读锁的计数器-1。然后释放读锁的goroutine。

4. Unlock 释放写锁:释放互斥锁,还原加锁时减去的读锁数量。唤醒写锁期间所有被阻塞的goroutine。最后释放互斥资源。

Fast path 快速路径:处理预期,理想情况的数据段,达到快速处理TCP数据段的目的。

Slow path 慢速路径:处理非预期,非理想情况的数据,不满足快速路径的数据段。

Mutex 互斥锁

1. Mutex的实现原理

Mutex 就两个字段一个 state状态标识,一个 sema 信号量。

state是int32位类型,

第1位=1(加锁),

第2位=1 (是否有goroutine被唤醒),

第3位=1(mutex工作模式,0正常,1饥饿模式)

其它位用来记录有多少个排队者在排队。

由Lock 和 UnLock 实现加锁解锁,具体的加锁,解锁逻辑由LockSlow和 UnLockSlow 来实现。

Lock() 加锁:

在Lock 中通过原子性操作实现处理预期,理想的锁状态;而那些非预期的数据放在了 lockslow 和 Unlockslow 方法中。

Lock 方法中的 fastpath 期望互斥锁当前处于解锁状态,没有goroutine排队,更不会进饥饿。理想状态下只需要一个自旋操作就能够获取到锁,但是如果一个CAS自旋没有获取到锁,就会进入slow path,也就是lockslow方法中处理出现的 goroutine队列+饥饿模式。

UnLock 解锁:

首先通过原子性操作从state 状态码中减去 mutexLocked,让其=0 也就是释放锁。然后根据 state的新值来判断是否需要继续处理slowpath非预期的数据。

如果新值=0,也就意味着没有其它的 goroutine在排队等待解锁,所以不需要指向其它额外操作。如果新值不=0,则要进入slow path,看看具体唤醒那个goroutine继续工作。

2. Mutex的锁有几种模式?

正常模式:

1. 正常状态得state状态=1,尝试加锁的 goroutine 会自旋几次,尝试通过原子性获得锁,若几次自旋之后不能获得锁,则通过信号量排队等待,所有的等待者都会按照先入先出的顺序排队。

2. 当一个等待的goroutine被唤醒后,并不会直接拥有锁,而是需要根后来者竞争,这这后来者就是处于自选阶段且尚未排队的 goroutine。这种情况下,后来者更具有优势。一方面自旋是在CPU上运行的。另一方面处于自旋阶段的goroutine会有很多,而被唤醒的只有一个。所以被唤醒的goroutine很有可能拿不到锁,这种情况下它会被重新插入到队列的头而不是尾部。

3.当一个goroutine本次加锁的等待时间超过1ms后,会将当前的mutex切换成饥饿模式。

饥饿模式:

1. 在饥饿模式下,Mutex的所有权从执行Unlock的 goroutine,直接传递给等待队列头部的 goroutine,后来者不会自旋,也不会尝试获取锁。

2. 即使Mutex当前处于 Unlock的状态,它们也会到队列尾部排队等待。当一个等待者获取锁之后,会有两种情况将饥饿模式转回正常模式。

- 队列首部的等待时间小于1ms;

- 它是队列尾部,队列已经空了,后来自然就没有饥饿的 goroutine了。

面试回答总结:

在正常模式下,自旋和goroutine执行队列是同时存在的,执行lock的goroutine会率先自旋,如果尝试几次后还没有拿到锁,就需要去排队等待。

这种排队让大家一起来抢锁的模式,会拥有更高的吞吐量,因为频繁的挂机和唤醒goroutine会带来较多的开销。但是又不能无限制的开销,要把自旋控制在较小的范围之内,所以在正常模式下,mutex有更好的性能,但是可能会出现队列抢不到锁的情况。

而饥饿模式不能抢占自旋,所有的goroutine都需要排队,严格的先来后到。

3.深入理解 Lock - Slowpath 和 UnLock -SlowPath

Lock - Slowpath:

当一个goroutine 要加锁时,如果其它goroutine已经加锁且没有释放。slowpath会判断当前的工作场景,如果是单核场景则不需要等待自旋的goroutine让出CPU。只有在多核场景下,且GOMAXPROCS>1,至少有两个 P 队列正在运行中,而当前的P队列中为空的情况,才会自旋。

进入自旋的 goroutine 会去抢 Mutex的唤醒标志位,设置标志位的目的是在正常模式下告诉持续锁的 goroutine在 Unlock时不需要再唤醒其它gouroutine了,已经有自旋的goroutine在等待了。以免唤醒太多的等待协程。

goroutine的自旋上限是4次,而且每自旋一次都会判断锁的状态,如果锁被释放,或者进入了饥饿模式,又或者已经自旋了4次,就会结束自旋。当无需自旋操作后就会使用原子操作修改 mutex。

把此时mutex.State保存到old中,把要修改的新state记为new; 如果old 处于饥饿模式,或者加锁状态,goroutine就得去排队,所以这些情况下排队规模要+1。

如果是正常模式,就要尝试设置 lock位,所以new中这一位置要设置为1。

如果当前goroutine等待的时候超过1毫秒,且锁还没被释放,就要将锁的状态,切换为饥饿模式。这里之所以还要求锁没被释放是因为如果锁已经释放了,那怎么都得去抢一次。要是直接进入饥饿模式就只能去排队了。

把排队规模和标识位都设置好之后,在执行原子操作修改 state之前,若是当前goroutine持有唤醒标识的话,还要将唤醒标识位重置,因为无论接下来是要去抢锁,还是单纯的要去排队。如果原子性操作成功了,要么是成功抢到了锁,要么是成功进入到等待队列。当前goroutine不再是需要被唤醒的 groutine 了,所以要释放唤醒标识。

如果原子性操作不成功,就意味着其它goroutine在我们保存 Mutext.state到 old中之后,又修改了 state的值,当前goroutine就要回过头去,继续从自旋检查开始,再次尝试。

所以也需要释放自己之前抢到的唤醒标识位,从头再来。继续展开这个原子操作成功的分支,如果是抢锁操作成功了,那么加锁的slow path 就可以宣告结束了。

如果是排队规模设置成功了,还要绝对是排在等待队列头部还是尾部。如果当前goroutine已经排过队了,是在Unlock时从等待队列中唤醒的,那就要排到等待队列头部。如果是第一次排就要到队列尾部。并且从第一次排队记录开始,就会记录当前goroutine的等待时间, 接下来就会让出,进入到等待队列中。

当等待队列中的 goroutine被唤醒时,要从上次让出的地方开始继续执行,接下来会判断,如果mutex处于正常模式,那就接着从自旋开始抢锁。如果唤醒后处于饥饿模式,那就没有其它goroutine会和自己抢了,所以已经轮到自己这里。

所以只需要把 mutex.state中lock标识位设置为加锁,把等待队列规模减去1,再看看是不是要切换到正常模式,也就是看看自己的等待时间是否小于1ms,或者队列是不是空了。

最后设置好 mutex.state 就一切OK了。

Mutex Unlock - Slowpath:

说明除去lock标识位外,剩下的去位不全为0,如果处于正常模式,若等待队列位空,或者已经有 goroutine 被唤醒并获得了锁,或者锁进入了饥饿模式,那就不需要唤醒某个goroutine,直接返回即可。否则就要尝试抢占 mutex.Worken 标识位,获取唤醒一个 goroutine的权利。

当抢占成功后,就会通过 runtime_Semrelease 函数唤醒一个 goroutine。如果抢占不成功,就进行循环尝试,直到等待队列为空,或者已经有一个goroutine被唤醒或者获得了锁。或者锁进入了饥饿模式,则退出循环。

而在饥饿模式下,后来的goroutine不会争抢锁,而是直接排队,锁的所有权是直接执行从Unlock的goroutine,传递给等待队列中首个等待者的,所以不用抢占 mutex.Woken 标识位。

第一个等待着唤醒后,会继承当前goroutine的时间片继续运行,也就是继续lock slow这里goroutine 被唤醒之后的逻辑,这就是Unlock 的 slow path。

4.正常模式与饥饿模式哪个更好

在正常模式下,自旋和goroutine执行队列是同时存在的,执行lock的goroutine会率先自旋,如果尝试几次后还没有拿到锁,就需要去排队等待。

这种排队让大家一起来抢锁的模式,会拥有更高的吞吐量,因为频繁的挂机和唤醒goroutine会带来较多的开销。但是又不能无限制的开销,要把自旋控制在较小的范围之内,所以在正常模式下,mutex有更好的性能,但是可能会出现队列抢不到锁的情况。

而饥饿模式不能抢占自旋,所有的goroutine都需要排队,严格的先来后到。

5.sema信号量

信号量是进程间通信处理同步互斥机制,通过一个计数器来控制对共享资源的访问次数。是一个非负数的全局变量,通过pv操作实现互斥,p获取资源,v释放资源。

6.自旋锁CAS

CAS是CPU中的一个轮询指令,自旋锁的实现依赖于CAS。

自旋锁是指当一个线程在获取锁的时候,如果锁已经被其他线程获取,那么该线程将循环等待,然后不断地判断是否能够被成功获取,知直到获取到锁才会退出循环。获取锁的线程一直处于活跃状态 Golang中的自旋锁用来实现其他类型的锁,与互斥锁类似,不同点在于,它不是通过休眠来使进程阻塞,而是在获得锁之前一直处于活跃状态(自旋)。

7.死锁

go中的可重用资源,被两个或两个以上的协程同时执行,由于竞争资源而造成的一种阻塞现象。这种阻塞互相等待的进程,称为死锁进程。

出现死锁的原因:

1. 请求互斥:线程对资源的访问是排他性的,如果一个线程对资源进行了占用,那么其它线程一定是等待状态。

2. Channel互相等待写入数据后读取,管道间互相等待也会造成死锁。

3. 无缓冲channel只写不读会死锁。

4. channel不关闭会死锁

5. 有缓冲的channel,缓存满了不读也会死锁。

内存管理

一、内存对齐

CPU在读取数据时不会一个一个字节的读取和写入。CPU会按照块的方式来读取数据,也叫做内存访问粒度。32位操作系统一次取4字节,64位操作系统一次取8字节。内存不对齐会导致CPU性能浪费,如法在其它操作系统上编译。

1. 为什么要对齐?

- 平台移植性的问题:不是所有的硬件平台都能够访问任意地址上的任意数据。

- 性能原因:访问未对齐的内存,将会导致 CPU两次进行内存访问,需要花费额外的时间周期来处理对齐计算。而本身就对齐的内存仅需要一次访问就可以完成读取动作。

2. 内存对齐的原理

假设有一些不同的数据类型,它们所占用的字节数也不一样。

那么在内存中进行读取时,会根据操作系统的偏移量来进行读取。如果数据类型的字节大小差距太大,就需要根据内存访问粒度使用0进行填充未满足的偏移量。

如果字节排列顺序非常紧凑,多个字节间并没有超过一次内存访问的颗粒度,就无需padding填充,提高了CPU计算效率。

3. 内存对齐的规律 以及go中数值类型字节大小

结构体内字段由小到达依次排序,从1到8。

byte,uint8,int8,bool 1字节

uint16,int16 2字节

uint32,int32,float32 4字节

uint64,int64,float64 8字节

string 16字节

二、go 堆区、栈区、指针的关系

栈区:

由编译器进行管理,自动申请,分配,释放。go程序中定义的局部变量地址,函参数地址,都在栈区。

堆区:

堆区是一整块内存空间,由GC进行管理。go中产生的栈空间都会放在堆里面。当一个栈函数在调用结束后会从堆中释放自己的空间。堆区的空间只由GC进行释放。

指针:

指针指向具体的空间地址,也是栈区地址,不同的栈区使用一个数据时,就要用到指针来指向该地址。

三、栈区、堆区的缓存方式

栈:

栈使用的是一级缓存, 他们通常都是被调用时处于存储空间中,调用完毕立即释放。

堆:

堆则是存放在二级缓存中,生命周期由虚拟机的垃圾回收算法来决定(并不是一旦成为孤儿对象就能被回收)。所以调用这些对象的速度要相对来得低一些。

申请到 栈内存 好处:函数返回直接释放,不会引起垃圾回收,对性能没有影响。

四、栈逃逸

1.什么是栈逃逸?

指变量 在函数执行结束时,没有随着函数栈释放,值超出函数栈的生命周期,逃到堆空间上了。能够被外部共享。

2.什么情况会发送栈逃逸?

- 函数执行最后的 return,return 时是一个指针变量,或者new一个新的变量,就会发生逃逸。

- 函数中声明slice,chan,map这种带有容量的数据类型时,具体的容量大小由堆空间的变量来指定。这样在编译期编译器无法知道具体的数值大小,也就无法在栈区中进行赋值,只能把它分配到堆空间中执行。

3.在什么阶段会确立逃逸

go在编译器为变量赋值,为函数传参时会确立逃逸。彻底运行后不会确立。

4.有没有什么影响?

因为堆栈的分配更多是编译器自己实现的,所以无需过度考虑变量的分配和调用。个人觉得堆栈分析属于代码优化性能调优的一部分,而且具体是值传递和引用传递需要根据具体的代码作用域决定,不能泛指必须用哪个。

5.分析栈逃逸的命令

go build -gcflags "-m -m -l" test.go

五、内存泄漏

Goroutine内正在进行 channel/mutex 等读写操作,但由于逻辑问题,某些情况下会一直阻塞。

Goroutine 内的业务逻辑进入死循环,资源一直无法释放。

Goroutine 内的业务逻辑进入长时间等待,有不断新增的goroutine进入等待。

Channel 使用不当。

一般内存泄漏都是 goroutine 没有及时 close导致的。读、写都会造成goroutine泄漏。这个要根据具体的代码去分析。

如果发生内存泄漏,首先要准备一台测试环境机器。然后使用压测工具模拟生产用户场景。

go 内存泄漏的排查思路

1.首先要排除goroutine泄漏,模拟用户操作,然后关闭释放看看开启的goroutine都关了吗,如果都关了排除 goroutine泄漏。如果没关

2.通过pprof 观察goroutine的数量变化,如果持续增长,那么肯定就是泄漏了。然后查看各个goroutine的数量,看看有没有持续增加的。

3.如果不是goroutine泄漏...那就要观察全局状态变量,也是通过pprof 看看堆区里面哪行代码申请内存排在了第一位。

4.在申请chan的时候 ,尽量使用带缓冲的,因为带缓冲的数据使用过后会自己销毁,不会出现数据泄漏。

六、go运行时内存配分的策略

小对象:<32kb,先从缓存中获取,然后从heap堆中获取。

大对象:>32kb,直接从堆中获取

七、如果若干个线程中有一个线程出现OOM会发生什么?

OOM:

oom是系统内存严重不足时linux 内核采用释放内存的机制。出现OOM代表着程序申请的内存过大,而GC中又没有可以被清理的内存空间时就会出现OOM。

最终导致内存激增、内存泄漏。

项目中出现的OOM情况:

# 出现的问题

select 中使用 time.After定时器来定义超时时间。

有一个底层的数据结构 queue,通过管道+for循环向queue注入数据。

当queue有任务时,那么time.After不会在该select里唤醒。

每次for循环select时,都会实例化一个新的定时器。该定时器在3分钟后才会被激活。但是3分钟激活后跟select已经没有任何引用关系,也没有关闭定时器的处理。所以定时器的任务会在堆空间里面继续等待超时。

# 内存激增的问题分析

内存激增的原因就是每次for循环都会创建定时器对象,当GC清掉了第一个定时器后,不会立马把内存还给系统,而是要等待5分钟之后由scvg来释放内存。

# 解决方法,修改创建计时器的方法:

通过 NewTimer来创建一个定时时间,并且及时defer stop,在函数执行return之后如果没有超时就自动关闭计时器,不需要到堆区中由GC清理。

在 for循环里面只会重新初始化时间,不再是创建定时器,减少重复的栈区空间创建。

八、虚拟内存

虚拟内存是通过线性地址来映射真实的物理内存地址。

1. 为什么要诞生虚拟内存?

早先的DOS系统中,进程直接使用内存的物理地址,进程可以任意修改物理内存。甚至会占用操作系统所使用的内存。后来发明了虚拟内存来保护真实的物理内存空间。

2. 虚拟内存如何实现物理映射,进程隔离是什么?

32位实现物理映射,如下:

虚拟内存的地址又叫做线性地址,操作系统负责把虚拟内存映射到物理内存。

怎么映射?

1.保护模式提供内存分页机制,比如在32位系统下,物理内存每4K划为一页。从虚拟内存到物理内存是以页为单位映射的。

2.操作系统会以链表的形式记录各个进程的控制信息,在linux中对应 task_struct 结构体,每个进程的控制信息中都有这样一个指针,存储的是当前进程“页目录”的物理地址。页目录也是一个内存页,存储的是一系列指针,指向同样用来存储物理内存页起始地址的页表。

3.32位下一个内存地址占4字节,一个页目录就可以寻址1024个页表,而每个页表又可以寻址1024个物理内存页,每个页4KB。 4*1024*1024*4 = 4GB

也就是说,32位操作系统只需要有一个两级页表就足够寻址4G大小的内存空间。而一个32位的线性地址中,会以 10 - 10 - 12 来进行分页。

第一个10位,页目录,记录对应的页表

第二个10位,页表,记录对应的物理内存页

第三个12位,内存页起始地址的偏移值,12位正好覆盖4kb的偏移值,足够定位到内存页的每一个地址。

总结:

这样就实现了虚拟内存中一个内存到一个物理内存页的映射。不仅没有在进程中直接使用物理内存地址,而且每个进程对应自己的页表。这样在不同的进程中,相同的线性地址也会被映射到不同的物理地址,从而实现进程空间地址的隔离。

还可以通过把同一组物理页面映射到不同进程的页表中,来实现进程间共享内存。

3. 虚拟内存里页表存储的记录

虚拟内存中,每一个页表的记录不只是一个内存页起始地址这么简单。

因为内存页大小都是4kb,所以内存的起始地址,一定是4kb的整数倍,也就是说它的低12位一定是0,(1000 0000 0000 0000)所以页表中的每一条记录都有12位的空闲空间可以使用,它们可以用来标识对应物理内存页是否可读,可写,可执行,是否可映射等信息。

4. 进程如何申请内存

进程在向操作系统申请映射内存时,通常不会一申请就立马分配,操作系统会先进行记录。

例如linux中,通过进程对应的 task_struct 可以找到记录内存分配的链表,每个链表项都是一个VMA(Virtual Memory Area)结构体,里面记录着该进程已经申请的一段连续内存地址区间。

例如进程申请区间 A~B这段内存,它的VMA链表就会增加一项,或者对相邻区间进行扩张,标记上地址区间A~B已经申请了。

但是真正的映射,要到进程访问这段内存时才会进行,所以说进程的虚拟地址空间,只是他可以申请使用的一个范围。只有真正被映射到物理内存,才属于合法使用的虚拟内存。而没有被映射到物理内存的部分,不属于合法的线性地址。要使用就必须先映射。

5. CPU如何处理虚拟内存映射?

因为内存使用的是线性地址,那么程序执行时CPU拿到的也是线性地址,而线性地址到物理地址的转换,会交由CPU中的内存管理单元(MMU)负责,当前进程持有的页目录的物理地址会被保存到特定寄存器。

这样CPU就可以借助页目录和页表,把线性地址转换成物理地址了,由于频繁查页表会影响效率,CPU会把已经转换过的地址映关系缓存到TLB(地址转换缓冲器)中,需要转换地址时先到TLB中去查找,如果没有再去查页表然后写入TLB。

如果要切换到另一个进程执行内存映射,那么寄存器存储的页目录也会改变,之前的TLB缓存就会失效,需要重新查询页表,重新缓存数据。

如果CPU查询页表时发现,对应物理内存页还没有映射,就会发生 page Fault,处理这个异常的Page Fault Handler 就会去进程控制信息这里,查询该进程是否申请了这段内存。如果已经申请了,就实际分配物理页面并完成页表映射,然后正常使用。

如果没有申请过,就会发生内存访问异常,这样设计的目是为了保障系统运行效率,毕竟内存映射比较耗时。

RPC

一、什么是rpc

Rpc的全称翻译是远程过程调用。程序与程序之间通过网络I/O进行通信,遵循 sever/client 模型。使用客户端在调用服务端时就像是在本地调用函数一样。

Rpc 最大的特点就是多路复用+流模式传输,它会将所有要传递的数据封装到一个TCP字节流里面进行传递,降低网络I/O的次数,提高传递数据的效率。

Rpc 使用http2.0作为底层传输协议。

二、gRPC 和 restful API 的区别

Grpc 和 restful 都提供了一套通信机制,且都使用 server /client 通信模型。都是用http协议作为底层传输协议。

区别在于:

1. grpc 使用 protobuf 作为序列化编码,而restful使用json,xml等。Protobuf序列化后的消息体积小,解析速度快,支持各种语言。

2.Grpc使用protobuf减少了数据大小也就降低了I/O的数据量,大幅度提高性能。毕竟I/O是各种服务间调用的瓶颈。

2. 流模式一次性传递所有数据

三、RPC都能做什么?你用RPC做什么了?

Rpc包含:序列化、压缩算法、http协议

可以实现:

动态代理,服务注册,加密,网络编程,连接管理,健康监测,负载均衡,优雅启停,异常重试,业务分组,熔断限流等等。

基础知识

一、String

1. string类型为什么占16个字节

Go 的 string 类型16字节,前8字节是指针指向字符串变量地址,后8字节是一个整数,标识字符串的长度。

2.介绍一下utf-8编码

Utf-8编码是go的默认编码,采用区间的方式来对字符进行解码和编码。

区间[0~127]: 高位模板 0???????

区间[128~2047]: 高位模板 110????? , 10??????

区间[2048~65535]: 高位模板 1110????, 10?????? , 10??????

解码:

在转换时将高位去除,剩下的二进制位组合起来,就得到了该字符的二进制编码,再转换成十进制对应汉字。

编码:

假如对 界 这个字进行编码,首先界占用3字节对应编码:30028,属于[2048~65535]区间,所以采用该区间的模板

然后 30028 的二进制表示方法:01110101 01001100,对照高位模板进行补齐,如下:

01110101 01001100

1110???? 10?????? 10??????

11100111 10010101 10001100

二、Slice

1. slice 的组成

由 data len cap ,元素存在哪里,存了多少,一共可以存多少

slice 的元素存在一整段连续的内存中,实际上就是数组。

2. make 和 new 开辟切片的区别

make开辟切片

如果是用make初始化的,会开辟cap容量的数量,并初始化每一个的默认值。

需要注意的是,如果make初始化时不是从 0 开始初始化,而是 make([]int,2,5) 则说明是从第二位开始初始化,追加的一个元素也是从第二位开始追加。

所有存储在切片中的数据都是可以安全读写的。

new开辟切片

new 会创建一个切片的结构,但是它不会创建底层数组结构。new的返回值是一个内存地址,而不是数值。

如果使用 new([ ]string) 来开辟切片,是没有底层数组结构的,也就是无法进行赋值。

想要对new出来的切片进行赋值,必须使用 append的方式,为切片创建一个底层的数组结构才可以。

3. Slice 扩容

Slice要先预估扩容后容量 newCap,在进行扩容。

预估规则:

1.如果扩容前容量*2 < 所需容量 ,则直接让扩容容量=所需容量;如果扩容容量 *2 > 所需容量,则扩容容量=所需容量

2.如果旧容量<1024,则默认规则*2

3.如果旧容量>1024,则默认规则*1.24

Slice 扩容后所占多少内存?

内存管理模块,它会提前向操作系统申请一批内存,分成常用的规格管理起来。当数据类型申请内存时,会匹配到足够大且最接近的规格。

在扩容之后,预估扩容要匹配到合适的内存规格。

预估容量 * 元素类型大小 = 所需字节数,然后匹配所需字节数的内存规格。

三、Map

Tophash : tophash是一个长度为8的数组,它不仅用来存放key的哈希高8位,在不同的场景下还会标记是否处于迁移状态,桶是否位空。

1. Map实现的底层原理

Map 底层使用的是hash表来存储 key:value 数据,当出现数据后要选择一个hash表(桶)来存储,map的结构体是 runtime/map.go/hmap。

Hash 记录数据的方式分为两种:幂运算,与运算。而Map使用的是hash的与运算方式。通过幂运算拿到存储数据的hash表。

Map还有一个bmap 结构体,该结构体记录了每一个map中的kv。在bmap中一个哈希桶里面可以存8个键值对,但是为了让内存排列更加紧凑,会将8个key放在一起,再将8value放在一起。8个key的上面是tophash,每个tophash对应哈希的高8位。

一、创建Map:

Map的创建比较简单,在参数校验之后,需要找到合适的B来申请桶的内存空间,然后初始化map。初始化时会初始化它的元素个数,桶地址,旧桶地址等。其实就是对 hmap结构体进行一次初始化,刚开始的默认值基本都为0

二、访问Map:

计算hash值,计算桶的索引,是否使用旧桶数组查找,对hash值高8位进行判断。如果索引的数据不对,如果找不到则出退循环,接着再判断桶内的8个key值是否与所给的 key值相等。

三、分配

为一个key分配空间的逻辑,大致与查找类似;增加了写保护和扩容。在分配的删除的过程中都没有在oldbuckets中进行查找,因为map在分配之前会进行扩容判断。

当一个map准备扩容是时会确定桶地址和hashtop,看看桶是否满了,看看有没有可用的溢出桶。如果一个桶满了,而且没有其它可用的溢出桶时,就会在桶后面链一个溢出桶,继续向溢出桶里面存数据。

2、什么是溢出桶

溢出桶由bmap结构体最后字段,会指向溢出桶。

溢出桶的内存布局与常规桶相同,是为了减少扩容次数而引入,当一个桶存满了,还有可用的溢出桶时,就会在桶后面链一个溢出桶,继续往这里面存。v

实际上,如果哈希表要分配的桶数目大于 2^4 次方,就认为使用到溢出桶的几率比较大。就会预分配2^(B-4)次方 个溢出桶备用。

这些溢出桶与常规桶在内存中是连续的,只是前2^B次方个用作常规桶,后面的用作溢出桶。

3、map扩容

触发扩容的两种情):

1.go语言map的默认负载因子是6.5,超过这个数就会触发翻倍扩容,分配新桶的数目是旧桶的两倍。(翻倍扩容)。

2.go语言map的负载因子没有超标,但是需要用到的溢出桶较多。(等量扩容)。

如果一个map哈希表已经存满了,接下来再继续存储新的键值对时怎么办?

是创建溢出桶还是发生扩容呢?具体要看map的扩容规则。假如是默认的map规则,则每个旧桶的键值对,都会分流到两个新桶中。例如:如果旧桶数量=4,则新桶数量=8。

选择新桶的结果只有两种,这取决于哈希值的三位是0,还是1,如果第三位=0,则选择编号为0的新桶,如果第三位为1,则选择编号为4的新桶。因为 map中选择幂运算哈希,桶的数量一定是2的整数次幂。

所以无论数量是多少,每个旧桶在扩容后都会按照这样的规律,分流到两个新桶中。

4、等量扩容

等量扩容:

有多少个溢出桶算多:如果常规桶数目 < 2^15次方 则使用溢出桶的数目超过常规桶就算是多了,

如果常规桶数目 >(大于) 2*15次方,那么使用溢出桶数目一旦超过2^15次方就算是多了。

所谓等量扩容就是创建和旧桶一样数目的新桶。然后把原来的键值对,迁移到新桶中。

等量扩容的作用?如果桶的负载因子没有上限,则在什么情况下使用了很多的溢出桶?

什么情况下桶的负载因子明明没有超过上限值,却偏偏使用了很多的溢出桶。

当一个哈希表中存在很多个被删除的键值对时,如果满足等量触发的前提条件,就会分配等量的新桶。编号为0的旧桶依然会迁移到同样编号的新桶。

同样数目的键值对,迁移到新桶中能够排列的更加紧凑,从而减少溢出桶的使用。这就是等量扩容的意义。

5、map输出是否有序

Go map在遍历输出时是无序的,非固定输出。

当gomap在循环时,会迭代两个runtime方法:runtime.mapiterinit,runtime.mapiternext

通过对 mapiterinit方法阅读,可以知道其主要用途是在map进行遍历迭代的时候进行初始化动作。初始化动作包括:1.读取当前哈希表的类型信息,2.当前哈希表的存储信息,3.遍历迭代的数据。

在mapiterinit 中包含一个 fastrand的部分,这时一个生产随机数的方法。通过随机数的方法来决定从哪里开始循环迭代,作为起点进行遍历。

所以每次for range map时见到的结果都不一样,因为底层开始遍历的起始位置就是不固定的。

当mapiterinit 在确定了初始化代打的起始位置后,就会交给mapiternext进行具体的循环遍历动作,寻找下一个元素并进行处理。

四、Tag 解析用的是什么

Tag本身是一个k:v 字符串,是结构体struct的一部分。通过反射来解析tag,获取结构体的字段数据。

五、反射原理

其它

一、make 和 new的区别

Make:

在 Go 语言中,内置函数 make 仅支持 slice、map、channel 三种数据类型的内存创建,其返回值是所创建类型的本身,而不是新的指针引用。在使用make的时候可以指定长度和容量。

New:

在 Go 语言中,内置函数 new 可以对类型进行内存创建和初始化。其返回值是所创建类型的指针引用,与 make 函数在实质细节上存在区别。

new函数也可以初始化make的三种类型:

make函数在初始化时,会初始化slice ,map,chan类型的内存空间与内部结构,而new不会只能开辟一个新的内存空间。

一个数据类型合理的长度和容量可以提高效率,减少开销。

二、go支go持类型继承吗

go中没有原生的继承支持,只能通过interface{}的接口来实现继承,另外结构体也能够实现继承。

go中接口的继承要比结构体继承更加灵活易于扩展。

Go的多态继承

只要实现接口的方法,就属于接口继承。如果有很多个结构体都要实现该接口的方法,就叫做参数多态。在参数多态的基础上还有多态数组。

参数多态:

指一个接口可以为N个结构体提供方法。

多态数组:

将接口定义成 [ ]接口数组,在这个数组中,所有使用该接口方法的结构体都可以追加到数组中,形成多态数组。

多态其实就是优化实现接口方法代码的可读性,精简代码。

四、Context包的用途

context 包主要用于控制并发,有效的避免 goroutine泄漏。在实际的业务中,可能会需要主动通知某一个 goroutine结束。假设开启了一个 goroutine做事情,当不再需要该goroutine时,就要跟踪并通知它,防止泄漏

也就是 goroutine的上下文。

3-1.Context 的使用原则

1.不要把context放在结构体中,要以参数进行传参。

2.以 context 作为参数的函数,context应该作为第一个参数,放在第一位。

3.给 函数传递 具体的context方法时,不要传递nil,如果没有什么可传递的就 Context.TODP

4.context 是线程安全的,可以放心的在多个 goroutine中传递。

五、如果若干个goroutine的其中一个panic会发生什么?

运行中发生恐慌,如果没有任何处理会直接中断主进程抛出错误。

六、defer可以捕获到go程的子goroutine中的panic吗?

不能。

go中的 defer+recover 不能捕获 groutine中的子goroutine的panic。

如果一个父子groutine只开了一个 defer + recover,那么一旦子groutine出现panic就会进程中断。

# 为什么不能?

1.panic属于最上层的恐慌操作,不管是几层goutine,如果没有 recover都会直接中断进程报错。

2.每个goroutine中的panic只会在当前的gourtine中才会被defer调用。

# 怎么处理子grouetine 抛出panic

1.方法一:是每写一个goroutine,就写下一个recover,但是这也代码有些冗余。

2.定义一个 error 的channel,把所有的异常信息都往channel里面抛。如果最上层的goroutine监听到了channel中的错误数据,就在当前层进行panic。这也就把底层的panic抛给了一个 recover来处理。

七、go怎么做参数校验?Gin怎么做参数校验

1. 通过正则表达式进行校验。

2. 通过特殊的标签进行校验。

3. 通过validator 提供的方法,或者自定义validator校验规则

在结构体字段后面写上标签验证信息,如果使用validator 必须加上from,binding标签并设定具体的校验规则。

八、函数传递指针比传值效率更高吗?

传递指针相比值传递减少了底层拷贝,可以提高效率,但是拷贝的数据量较小,由于指针传递会产生逃逸,可能会使用堆,也可能增加gc的负担,所以指针传递不一定是高效的。

九、服务器受到攻击如何快速定位

1. 首先清楚自己主机开放了哪些服务和端口

2. 一般云服务都有后台,在后台中可以看到具体的攻击方法

3. 通过一些系统命令来检查资源性能消耗情况top,netstat

4. 通过监控系统,一旦某些资源利用率瞬间飙升的阈值立刻告警。

5. 查看请求日志。

对外提供服务的服务器不要有任何的可视化界面和多余的程序。

SSH暴力破解:

改端口号,设定SSH连接策略密码超过3次直接封1个月。白名单,黑名单。

SYN洪水攻击:

拒绝服务攻击后,攻击者非法占用服务器资源,一般多为CPU\内存\带宽。使得服务器无法响应正常用户请求。

主要利用的就是TCP三次握手的缺陷,当服务器接收请求准备响应ACK时,对端直接关闭TCP请求不再进行ACK确认,让服务器无法成功响应ACK,此时这个连接就处于挂起的阶段。因为响应失败所以服务端会持续发送ACK进行重复响应。

解决方法:1.设置防火墙,2.修改SYN内核响应参数,过期时间 3.启用cookies来处理SYN队列。

DDOS攻击:

主要攻击网络带宽,当网站的流量峰值超过平常的5倍左右就要小心。大量的网络攻击包导致网络带宽被阻塞,合法的请求无法到达服务器。

出现的情况:

1. 被攻击的主机上有大量的等待连接。

2. Netstat 查看连接的ip数,同样的ip有成百上千的连接请求数。

3. 网络中充斥着大量的无用数据包,源地址为假。

解决方法:

1. 软硬防火墙

2. 买专业的DDOS高防工具。

3. CDN,跳板机。

阿里云是真的恶心,我怀疑阿里云内部人员自己攻击买家的服务器。因为我使用华为云的时候所有的攻击都没碰到过。但是我得感谢阿里云,正是因为有了它才让我认识到了这些基础的网络攻击。

最后更新于