golang知识总结

1、slice扩容规则

  • 如果原有的cap的两倍,比我现在append后的容量还要小,那么扩容到append后的容量。例如:ints := []int{1,2} ints = append(ints, 3,4,5)会扩容到5
  • 否则,如果原切片长度小于1024,直接翻倍扩容;原切片长度大于等于1024,1.25倍扩容

扩容后的容量需要分配多大内存呢,并不是拿扩容后新的待拷贝数组的长度乘以切片类型。而是向语言自身的内存管理模块申请最接近的规格。内存管理模块向操作系统申请了各种内存规格进行管理,语言向内存管理模块去申请内存

例子:

a := []string{"My", "name", "is"}
a = append(a, "eggo")

// 第一步
oldCap = 3
cap = 4
3 * 2 > 4 // 未命中第一种扩容规则
3 < 1024  // 命中第二种的第一分类
newCap = 3*2 = 6 // 直接两倍扩容

// 第二步
6 * 16 = 96byte // 新容量成语string类型占用的字节数。需要96字节的内存

// 第三步向内存管理模块找最匹配的内存规格进行分配
// 8,16,32,48,64,80,96,112...
// 找到最匹配的内存规格为96。那么实际分配的就是96字节
cap(a) = 96/16 = 6 // 最终扩容后的容量为6

2、内存寻址、内存对齐,go结构体内存对齐策略

  • cpu向内存寻址,需要看地址总线的个数,32位操作系统就是32根地址总线,可以向内存寻址的空间为2的32次方也就是4G
  • 32位总线的寻址空间是4G,但是每次寻址是32个bit,所以每次操作内存的字节大小是4字节。所以64位每次操作内存的字节大小是8字节。这里每次操作的字节数称为机器字长
  • Go语言结构体的内存对齐边界,取各成员的最大的内存占用作为结构体的内存对齐边界。结构体整体内存大小需要是内存对齐的倍数,不够的话补

3、go语言map类型分析

3.1 hash冲突

  • Hash表,就是一排桶。一个键值对过来时,先用hash函数把键处理一下,得到一个hash值。利用这个hash值,从m个桶中选择一个,常用的是取模法(利用hash值 % m确定桶编号)、与运算法(hash值 & (m-1)),与运算法需要保证m是2的整数次幂,否则会出现有些桶不会被选中的情况
  • hash冲突:如果后来的键值对和之前的某个键值对运算出来相同的桶序号,就是hash冲突。常用来解决hash冲突的办法有两种:

1、开放地址法:冲突的这个键值顺延到下一个空桶,在查找该键时,通过比对键是否相等,不相等顺延到下一个桶继续查找,直到遇到空桶证明这个key不存在

2、拉链法:冲突的桶,后面再链一个链表,或者平衡二叉搜索树,放进去。在查找该键时,通过对比键是否相等,不相等去桶背后的链表或者平衡搜索二叉树上继续进行查找,也没找到就不存在这个key

  • Hash冲突的发生,会影响Hash表的读写效率,选择散列均匀的hash函数,可以减少hash冲突的发生。适时的对hash表进行扩容,也是保证hash表读写效率的一种手段

3.2 hash表扩容

  • 通常会把该hash表存储的键值对的数目与桶的数目的比值作为是否扩容的依据。比值被称为负载因子。扩容需要把旧桶内存储的键值,迁移到新扩容的新桶内
  • 渐进式扩容:hash表结构较大的时候,一次性迁移比较耗时。所以扩容时,先分配足够多的新桶,再通过一个字段记录旧桶的位置,再增加一个字段记录旧桶迁移的进度,在Hash表正常操作是,检测到当前hash表正在处于扩容阶段,就完成一部分迁移,当全部迁移完成,旧桶不再使用,此时才算真正完成了一次hash迁移。渐进式扩容可以避免一次性扩容带来的瞬时抖动

3.3 go语言中的map结构是hash表。

  • map结构的变量本质是一个指针,指向底层的hash表,也就是hmap结构体
type hmap struct {
    count int  // 已经存储的键值对数目
    flags uint8 // 
    B uint8 // 记录桶的个数为2的多少次幂。由于选择桶的时候用的是与运算方法
    noverflow uint16 // 
    hash0 uint32
    
    buckets unsafe.Pointer // 记录桶在哪
    oldbuckets unsafe.Pointer // 扩容阶段保存旧桶在哪
    nevacuate uintptr // 渐进式扩容阶段,下一个要迁移的旧桶的编号
    
    extra *mapextra // 记录溢出桶相关信息
}

3.4 go中Map的扩容规则

  • go语言的map的默认负载因子是6.5。

1、情况1翻倍扩容: count / (2 ^ B) > 6.5时扩容发生。触发翻倍扩容,

2、情况2等量扩容: 负载因子没超标,但是使用的溢出桶较多,触发等量扩容。如果常规桶的数目不大于15,即B <= 15,那么使用溢出桶的数目超过常规桶就算是多了;如果常规桶的数目大于15,即B > 15,那么溢出桶数目一旦超过2的15次方就算是多了。所谓等量扩容,就是创建和旧桶数目一样多的新桶,把旧桶中的值迁移到新桶中。

注意:等量扩容有什么用,如果负载因子没超,但是用了很多的溢出桶。那么只能说明存在很多的删除的键值对。扩容后更加紧凑,减少了溢出桶的使用

4、闭包

闭包就是一个匿名函数,和一个外部变量(成员变量)组成的一个整体。通俗的讲就是一个匿名函数中引用了其外部函数内的一个变量(非全局变量)而这个变量和这个匿名函数的组合就叫闭包。闭包外部定义,内部使用的这个变量,称为闭包的捕获变量;闭包也是有捕获列表的funcval

wiki百科对闭包的解释,有两个关键点:1、必须有一个在匿名函数外部定义,匿名函数内部引用的自由变量; 2、脱离了闭包的上下文,闭包也能照常使用这些自由变量

func closure1() func() int{
	i :=0
	return func() int{
		i++ //该匿名函数引用了closure1函数中的i变量故该匿名函数与i变量形成闭包
		return i
	}
}

func main() {
    
    f := closure1()
    // 直接调用闭包函数,而非closure1函数,仍然可以使用本属于closure1的i变量
	fmt.Println(f())
	fmt.Println(f())
	fmt.Println(f())
	fmt.Println(f())
}

Go语言中函数可以作为参数传递,也可以作为返回值,也可以绑定到变量。Go语言称这样的变量或返回值为function-value;绑定到函数的变量并不是直接指向函数的入口地址,而是指向funcval结构体由funcval结构体指向函数的入口地址。

为什么函数值的变量不直接指向函数的入口地址而是通过一个二级指针来调用呢?

通过funcval接口体指向函数入口是为了处理闭包情况

5、方法

方法本质上就是函数,接受者实质就是函数的第一个参数。接受者无论是指针还是值,相应的值和指针都能调用该方法。go编译阶段做了处理。但是建议方法的接受者都使用指针接受者

package test

import (
	"fmt"
	"testing"
)

type A struct {
	name string
}

func (a A) Name() string  {
	return a.name
}

func TestHello(t *testing.T) {
	a := A{name:"eggo"}

	// 可以直接调用方法,是go的语法糖。
	fmt.Println(a.Name())
	// 等价于
	fmt.Println(A.Name(a))
}

6、defer

defer函数在函数返回之前,倒序执行。先注册,后调用,才实现了defer延迟调用的效果。defer会先注册到一个链表中,当前的goroution会持有这个链表的头指针,新的defer会添加到链表的头部,所以遍历链表达到的效果就是倒序执行。

Go语言存在defer池,新建立的defer会向defer池申请内存,避免频繁的堆内存分配

func A() {
    defer B()
    // code to do something
}

编译后:

func A() {
    r = deferproc(8, B) // defer注册
    if r > 0 { // 遇到panic
        goto ret
    }
    
    // code to do something
    runtime.deferrnturn() // return 之前,执行注册的defer函数
    return
  ret
    runtime.deferreturn
}
  • 在Go1.14版本后,官方对defer做了优化,通过在编译阶段插入代码,把defer函数的执行逻辑展开在所属函数内,从而免于创建defer结构体,也不需要注册到defer链表中。但是这种方式不适用于循环中的defer,循环中的defer仍然和上述原始策略一样。1.14后的defer处理方式,通过增加字段,在程序发生panic或者runtime.Goexit时,依然可以发现未注册到链表上的defer,并按照正确的顺序执行。1.14后的版本,defer变的更快了,提升30%左右,但是panic时变得更慢了

7、panic和recover

7.1 panic

通过上文的defer我们知道,一个goroutine中有指向defer的头指针。实质上goroutine也有一个指向panic的头指针,panic也是通过链表连接起来的。

例如下面这段程序:

func A() {
    defer A1()
    defer A2()
    // ...
    panic("panicA")

    // do something
}

当前的goroutine的defer链表中注册了A1和A2的defer后,发生了panic。panic后的代码不再执行,转而进入panic处理逻辑,也就是执行当前goroutine的panic链表的头,结束后从头到尾执行defer链表。如果A1中也有panic,那么A1的panic后的代码也不再执行,把A1的panicA1插入panic链表中,此时panic链表中的头是panicA1,执行完panicA1,再去执行defer链表,以此类推。panic会对defer链表,先标记后释放,标记是不是当前panic触发的

panic结构体说明:

type _panic struct {
    argp unsafe.Pointer // defer的参数空间地址
    arg interface{} // panic的参数
    link *_panic // link to earlier panic
    recovered bool // 是否被恢复
    aborted bool // 是否被终止
}

注意:panic打印异常信息,是从panic链表的尾部开始打印。和defer相反。所以panic输出信息和发生panic的先后顺序一致

7.2 recover

recover只做一件事,就是把当前的panic置为已恢复,也就是把panic结构的recovered字段置为true。达到移除并跳出当前Panic的效果

func A() {
    defer A1()
    defer A2()
    // ...
    panic("panicA")

    // do something
}

// A2函数中,执行recover把当前panic的recovered字段置为true,再打印。相当于捕捉异常
func A2() {
    p := recover()
    fmt.Println(p)
}

实质上每个defer函数执行结束后,都会检查当前panic是否被当前的defer恢复了,如果恢复了,把当前panic从panic链表中移除,再把当前defer从defer链表中移除,移除defer之前保存_defer.sp和_defer.pc 这两个信息会跳出panic恢复到defer调用之前的栈帧。也就是通过goto ret继续执行下面的defer

可以画图,梳理流程,来应对复杂的panic和defer嵌套的情形

8、接口和类型断言

一个变量要想赋值给一个非空接口类型,必须要实现该接口要求的所有方法才行。

8.1 类型断言

接口这种抽象类型分为空接口和非空接口。类型断言作用在接口值之上,可以是空接口也可以是非空接口。断言的目标类型可以是具体类型,也可以是非空接口类型;

具体操作类似为:非空接口.(具体类型)。四种接口断言分别为:

  • 1、空接口.(具体类型)
var e interface{}

f, _ := os.Open("eggo.txt")

e = f

// 判断e的动态类型是否为*os.File。这里断言成功,r被赋值为e的动态值,ok赋值为true
r,ok := e.(*os.File)

var e interface{}

f := "eggo"

e = f

// 判断e的动态类型是否为*os.File。这里断言失败,r被赋值为*os.File的零值nil。ok赋值为false
r,ok := e.(*os.File)

  • 2、非空接口.(具体类型)
var rw io.ReadWriter 

f, _ := os.Open("eggo.txt")

rw = f

// 判断rw的动态类型是否为*os.File。这里断言成功,r被赋值为rw的动态值,ok赋值为true
r,ok := rw.(*os.File)

var rw io.ReadWriter 

f := eggo{name:"eggo"}

rw = f

// 判断rw的动态类型是否为*os.File。这里断言失败,r被赋值为*os.File的零值nil。ok赋值为false
r,ok := rw.(*os.File)
  • 3、空接口.(非空接口)
var e interface{}

f, _ := os.Open("eggo.txt")

e = f

// 判断e的动态类型是否为*os.File。这里断言成功,r被赋值为e的动态值,ok赋值为true
r,ok := e.(*os.File)

  • 4、非空接口.(非空接口)
var w io.Writer

f, _ := os.Open("eggo.txt")

w = f

// 判断w的动态类型是否为*os.File。这里断言成功,r被赋值为w的动态值,ok赋值为true
r,ok := w.(*os.File)

9、reflect反射

反射的作用,就是把类型元数据暴露给用户使用;通过反射,可以得到名称,对齐边界,方法,可比较等信息。我们已经知道runtime包中关于类型的元数据,以及空接口和非空接口结构,由于runtime包中这些结构定义为未导出的,reflect按照1:1重新定义了这些结构,并且是可导出的。

9.1 TypeOf函数用来获取一个变量的类型信息

func TypeOf(i interface{}) Type {
    eface := *(*emptyInterface)(unsafe.Pointer(&i))
    return toType(eface.type)
}

返回Type结构,reflect.Type结构中包含大量信息,结构体如下:

type Type interface {
    Align() int // 对齐边界信息
    FieldAlign() int 
    Method(int) Method // 方法
    MethodByName(string) (Method, bool) 
    NumMethod() int
    Name() string // 类型名称
    PkgPath() string // 包路径
    Size() uintptr
    String() string
    Kind() Kind
    Implements(u Type) bool // 是否实现指定接口
    AssignableTo(u Type) bool
    ConvertibleTo(u Type) bool
    Comparable() bool // 是否可比较
    // ...
}

测试类型:

package eggo

type Eggo struct {
    Name string
}

func (e Eggo) A() {
    println("A")
}

func (e Eggo) B() {
    pringln("B")
}

main包中测试:

package main

func main() {
    // 初始化结构体
    a := eggo.Eggo(Name:"eggo")
    // 返回reflect.Type类型
    t := reflect.TypeOf(a)
    
    println(t.Name(), t.NumMethod())
}

9.2 通过反射修改变量的值

type Value struct { 
    typ *rtype // 存储反射变量的类型元数据指针
    ptr unsafe.Poniter // 存储数据地址
    flag // 位标识符,是否是指针,是否是方法,是否只读等等
}
func ValueOf(i interface{}) Value {
    if i == nil {
        return Value{}
    }
    
    escapes(i) // 把参数对象逃逸到堆上
    return unpackEface(i)
}

例子,通过反射修改变量:

func main() {
    a := "eggo"
    // v是Value类型。这里反射a是行不通的,需要反射a的地址
    // v := reflect.ValueOf(a)
    v := reflect.ValueOf(&a)
    v.SetString("new eggo") // 使用地址后,这里输出new eggo
    println(a)
}

局部变量a会逃逸到堆上,对a的地址反射,可以修改到堆上的a具体的值。如果对a反射,那么值拷贝,不会修改到堆,会发生panic

10、GPM模型

一个hello-word程序,编译后成为可执行文件,执行时,可执行文件被加载到内存,进行一系列检查和初始化的工作后,main函数会以runtime.main为main线程的程序入口,创建main goroutine。main goroutine执行起来后,才会调用我们的main.main函数

Go语言中协程对应的数据结构是runtime.g;工作线程对应的数据结构对应的是runtime.m;全局变量g0就是主协程对应的g,全局变量m0就是主线程对应的m,g0持有m0的指针,同样的m0里也记录着g0的指针。

一开始m0上执行的协程正是g0,g0和m0就这样联系了起来。全局变量allgs记录着所有的g,全局变量allm记录着所有的m。最初go语言的调度模型里只有GM

10.1 原始调度模型GM

待执行的go,等待在队列中,每个m来到这里获取一个g,获取g时需要加锁。多个m分担着多个g的执行任务,会因为频繁加锁解锁产生频繁等待,影响程序并发性能。所以后来的版本在GM之外又引入了一个P

关键点:全局变量g,全局变量m,全局变量allgs,全局变量allm

10.2 改进调度模型GMP

P对应的数据结构是runtime.p;它有一个本地runq。把一个P关联到一个M上,这样M就可以直接从P这里直接获取待执行的G。这样避免了众多M去抢G的队列中的G。P有一个本地runq。对应有一个全局变量sched,sched对应的结构是runtime.schedt代表调度器,这里记录着所有的空闲的m和空闲的p等许多和调度相关的内容,其中也包括一个全局的runq。allp表示调度器初始化的个数,一般默认为GOMAXPROCS环境变量来控制的初始创建多少个P,并且把第一个P[0]和M[0]关联起来

如果P的本地队列已满,那么等待执行的G就会被放到全局队列中,M会先从关联P所持有的本地runq中获取待执行的G,如果没有的话再去全局队列中领取一些G来执行,如果全局队列中也没有多余的G,那就去别的P那里领取一些G。

关键点:runtime.p称为P, P的本地变量runq, 全局变量sched, 调度器数量allp

简单理解:M是线程,G是协程,P是调度中心

chan对应的结构是runtime.hchan,里面有channel缓冲区地址,大小,读写下标,也记录着元素类型,大小,及chanel是否已经关闭,还记录着等待channel的那些g的读队列和写队列,还有保护channel并发安全的锁lock

package main

func hello(ch chan struct{}) {

    println("Hello Goroutine")
    // 当前goroutine关闭通道,runtime.hchan修改close状态,所以读停止阻塞,读到的是零值nil
    close(ch)
    
}

func main() {
    ch := make(chan struct{})
    
    go hello(ch)
    // 主协程阻塞,其他goroutine有执行调度的机会
    <- ch
    
}

sleep和chan阻塞都会触发底层的调用gopark函数让当前协程等待,也就是会从_Grunning变为_Gwaiting状态。阻塞结束或者睡眠结束,都会使用goready让协程恢复到runable状态放回到runq中

在协程没有睡眠,和阻塞操作的时候,也是会存在协程让出的。这就是调度器的工作。监控线程会有一种公平调度原则,对运行时间过长的P进行抢占。一般超过10ms就会被抢占。P中会有变量记录时间

11 GC

从进程虚拟地址空间来看,程序要执行的指令在代码段(Code Segment),全局变量,静态数据等都会分配在数据段(Data Segment)。而函数的局部变量,参数和返回值,都会在函数栈帧中找到。由于当前函数调用栈会在该函数运行结束够销毁,如果不能够在编译阶段确定数据对象的大小,或者对象的生命周期会超出当前函数,那就不适合分配在函数栈上

分配在函数调用栈上的内存,随着函数调用结束,随之销毁。而在堆上分配的内存,需要程序主动释放才可以被重新分配,否则就会成为垃圾。有些语言比如c和c++需要程序员手动释放那些不再需要的,在堆上的数据(手动垃圾回收)。而有些语言会有垃圾收集器负责管理这些垃圾(自动垃圾回收)。

11.1 自动垃圾回收

自动垃圾回收如何区分那些数据对象是垃圾?

从虚拟栈来看,程序用到的数据,一定可以从栈,数据段这些根节点追踪的到的数据。虽然能追踪的到不代表后续一定能用的到。但是从这些根节点追踪不到的数据,一定是垃圾。市面上,目前主流的垃圾回收算法,都是使用‘可达性’近似等于‘存活性’的。

11.2 标记-清扫算法

要识别存活对象,可以把栈,数据段上的数据对象作为根root,基于他们进一步追踪,把能追踪到的数据都进行标记。剩下的追踪不到的就是垃圾了。

11.2.1 标记清扫-三色标记算法

  • 垃圾回收开始时,所有数据都为白色,然后把直接追踪到的root节点标记为灰色,灰色代表基于当前节点展开的追踪还未完成。
  • 当基于某个root节点的追踪任务完成后,便会把该root节点标记为黑色,黑色表示它是存活数据,而且无需基于它再次追踪了。
  • 基于黑色节点找到的所有节点都被标记为灰色,表示还要基于它们进一步展开追踪
    。当没有灰色节点时,意味着标记工作可以结束了。此时有用数据都为黑色,无用数据都为白色,接下来回收这些白色对象的内存地址即可

11.2.2 标记清扫-标记整理算法

标记和上述相同,只是在标记的时候移动可用数据,使其更紧凑,减少内存碎片。但这样会对频繁的移动数据

11.2.3 标记清扫-复制式算法

一般把堆内存划分为两个相等的区域,From区和To区。程序执行时使用From空间,垃圾回收执行时会扫描From空间, 把能追踪到的数据复制到To空间,当所有有用的数据都复制到To空间后,把From和To空间的角色交换一下。原To变From,原From把剩余没拷贝的到原To的数据清扫,之后变为To

这种复制式回收,不会带来碎片化的问题,但是只有一半的堆内存可以实实在在的使用。为了提高内存使用率,通常会和其他垃圾回收器混合使用,一半在分代模型中搭配复制回收

11.2.4 标记清扫-分代回收

思想来源于‘弱分代假说’,即大部分对象都会在年轻时死亡。我们把新创建的对象当成新生代对象,把经受住特定次数的GC依然存在的对象称为老年代对象。这样划分后,降低老年代垃圾回收的频率,降明显提升垃圾回收的速率。而且新生代和老年代还可以采用不同的回收策略,进一步提升回收效率并减少开销

11.3 引用计数算法

引用计数表示的是,一个数据对象被引用的次数。程序执行过程中会更新对象的引用计数,当引用计数更新为0时,就表示这个对象不再有用,可以回收该对象占用的内存。所以在引用计数算法中,垃圾识别的操作被分担到每次对数据对象的操作中了。虽然引用计数法可以及时回收无用的内存,但是高频率的更新引用计数也会造成不小的开销,而且如果A引用B,B也引用A这种循环引用,当A和B的引用更新到只剩下彼此,引用计数无法更新到0,也就不能回收内存,这也是一个问题


以上的垃圾回收,都需要暂停用户程序,也就是STW(Stop The World),但是用户可以接受多长时间的暂停?

实际上我们总是希望能尽量缩短STW的时间:

1、可以将垃圾回收工作分多次完成。即用户程序和垃圾回收交替执行。(增量式垃圾回收)

增量式垃圾回收会有一个问题,比如第一次垃圾回收标记了一个对象为黑色,但是交替的用户程序又修改了它,下次垃圾回收时,该对象实际上不是黑色。

三色标记中黑色指向白色会被当成垃圾,如果避免黑色指向白色,也就是三色标记中的‘强三色不变式’,允许黑色指向白色,也允许灰色指向,被称为‘弱三色不变式’。实现强弱三色不变式,需要读写屏障,这里不再展开

  • 强三色不变的原则,不允许黑色指向白色,遇到这种情况,可以把灰色退回到灰色,也可以把白色变为灰色。(借助插入写屏障)
  • 弱三色不变的原则是,提醒我们关注那些白色对象路径的破坏行为

2、多核情况下,STW时,垃圾回收时并行回收的,被称为并行垃圾回收算法。并行场景下,同步是不可回避的问题,

3、并发垃圾回收时,垃圾回收和用户程序,并发执行。

11.4 Go语言中的垃圾回收

Go语言中的垃圾回收采用标记清扫算法,支持主体并发增量式回收。使用插入和删除两种写屏障的混合写屏障,并发是用户程序和垃圾回收可以并发执行,增量式回收保证一次垃圾回收的STW分摊到多次。

14.4.1 Go语言GC

Go语言的GC在准备阶段(Mark Setup)会为每个P创建一个mark worker协程,把对应的g指针存储到P中。这些后台mark worker创建后很快进入休眠。等到标记阶段得到调度执行

GC默认对CPU的使用率为25%

GC的触发方式:

  • 1、手动触发:入口在runtime.GC()函数中
  • 2、分配内存时:需要检查下是否会触发GC, runtime.mallocgc
  • 3、系统监控sysmon:由监控线程来强制触发GC, runtime包初始化时,会开启一个focegchelper协程。只不过该协程被创建后很快休眠。监控线程在检测到距离上次GC已经超过指定时间时,就会把focegchelper协程添加到全局runq中。等它得到调度执行时,就会开启新一轮的GC了
posted @ 2021-02-27 13:15  x1aoda1  阅读(772)  评论(0编辑  收藏  举报