【2022版】Golang面试题目全网超全超详细的口语化解答总结

本文是Go语言面试的全方位总结,覆盖了特性、切片、映射、并发、垃圾回收等核心主题。探讨了Golang的数据类型、切片扩容机制、并发模型GMP、三色标记并发GC策略,以及内存逃逸等关键概念,为Go程序员面试提供详尽指南。
摘要由CSDN通过智能技术生成

【2022版】 Golang面试题目全网超全总结

面试go语言的题目总结,来自于全网,参考链接均写在解析里面。
部分题目加入了自己的理解,希望大家不吝赐教,多多留言交流。
正在逐步整理中,如果看不懂我写的地方,可以参考链接。
甚至有参考链接的,建议直接看参考链接,哈哈哈哈,人家写的真的是太好了

1.特性篇

1.1 Golang 使用什么数据类型?

布尔型、数值型(整型、浮点型)、字符串
指针、数组、结构体、切片、map、chan、接口、函数

1.2 字符串的小问题

①可以用==比较
②不可以通过下标的方式改变某个字符,字符串是只读的
③不能和nil比较

1.3 数组定义问题

数组是可以以指定下标的方式定义的,例如:

array := [...]int{1,2,3,9:34}  表示array[9]==34len(array)就是10

来看一道题目:
1.对 rune 字面量的理解和数组的语法
2.常量表达式这个规则应该了解下

package main

import (
	"fmt"
)

func main() {
	m := [...]int{
		'a': 1,
		'b': 2,
		'c': 3,
	}
	m['a'] = 3
	fmt.Println(len(m))
}
输出:100

原因:以下标的方式定义数组内的元素,'c’的ascll为99,故长度为100。

1.4 内存四区

代码区:存放代码
全局区:常量+全局变量。最终在进程退出时,由操作系统回收。
堆区:空间充裕,数据存放时间较久。一般由开发者分配,启动Golang的GC由GC清除机制自动回收。
栈区:空间较小,要求数据读写性能高,数据存放时间较短暂。由编译器自动分配和释放,存放函数的参数值、局部变量、返回值等、局部变量等(局部变量如果产生逃逸现象,可能会挂在在堆区)

1.5 Go 支持什么形式的类型转换?

Go支持显示类型的转换,以满足严格的类型要求

1.6 空结构体的作用

Go 最细节篇——空结构体是什么?

不包含任何字段的结构体叫做空结构体 struct{}
定义:
var et struct{}
et := struct{}{}
type ets struct {} / et := ets{} / var et ets
特性:

  • 所有的空结构体的地址都是同一地址,都是zerobase的地址,且大小为0
    使用场景:
  • 用于保存不重复的元素的集合,Go的map的key是不允许重复的,用空结构体作为value,不占用额外空间。
  • 用于channel中信号传输,当我们不在乎传输的信号的内容的时候,只是说只要用信号过来,通知到了就行的时候,用空结构体作为channel的类型
  • 作为方法的接收者,然后该空结构体内嵌到其他结构体,实现继承

1.7 单引号,双引号,反引号的区别?

单引号,表示byte或者rune类型,对应uint8和int32类型;默认直接赋值的话是rune类型。
双引号,字符串类型,不允许修改。实际上是字符数组,可以用下标索引其中的某个字节。
反引号,表示字符串字面量,反引号中的字符不支持任何转义,写什么就是什么。

1.8 如何停止一个 Goroutine?

①for - select方法,采用通道,通知协程退出
②采用context包

1.9 Go 语言中 cap 函数可以作用于哪些内容?

数组、切片、通道

1.10 Printf(),Sprintf(),FprintF() 都是格式化输出,有什么不同?

Printf()是标准输出,一般用于打印。
Sprintf()把格式化字符串输出到字符串,并返回
FprintF()把格式化字符串输出到实现了io.witer方法的类型,比如文件

1.11 golang 中 make 和 new 的区别?(基本必问)

共同点:都会分配内存空间(堆上)
不同点:
①作用变量不同,new可以为任意类型分配内存;但是make只能给切片、map、chan分配内存
②返回类型不同,new返回的是指向变量的指针;make返回的是上边三种变量类型本身
③new对分配的内存清零;make会根据你的设定进行初始化,比如在设置长度、容量的时候

1.12 关于Go中值类型、引用类型、值传递、引用传递的疑惑

用汇编带你看Golang里到底有没有值类型、引用类型
Go语言参数传递是传值还是传引用
Go语言 参数传递究竟是值传递还是引用传递的问题分析

值类型和值传递,这里不再解释;如果一个值类型通过值传递到一个函数内,如果在函数内对其进行修改,不会影响到函数外边的值的改变。
引用类型:先说以下,在c++中,引用类型就为变量声明别名,然后两者之间共用内存(其实就是简化了指针的操作)。如果函数的形参是引用类型的话,那么在函数内部对其进行修改,也会影响到函数外边的值。

但是Go中,大多数技术文章都称切片、chan、map是引用类型。但是我认为在某些情况下,它表现出了引用类型的特性,在另一些情况下又表型出了值类型的特性。接下来分四点说(拿切片举例):
① 切片是可以和nil进行比较的,然后返回true或者fasle。从这点看,有引用类型的特性
② 切片在从实参赋值到形参(或者通过一个切片初始化另一个切片)的过程中,通过Go语言的汇编信息可以发现,它不是传递了切片结构体的指针,而是将切片底层的指向底层数组的指针、len和cap都采用赋值的方式,赋值给了形参,很明显是一种值类型的特性。
③ 当切片传入到函数内部之后,我们对其进行改变后(假设仅仅是改变了数值,没有发生扩容),依旧会对函数外的原切片造成影响。这里又表现出了引用类型的特性。【至于为什么生了改变,了解过底层原理的都清楚】
④ 如果切片是引用类型的话,对切片进行append操作,为什么还要赋值给自身呢?

值传递:Go中的函数参数的传递都是值传递(要么是该值的副本,要么是指针的副本)。没有引用传递【通过汇编能看到】

对于值传递,引用传递,都只不过是一个名字而已,语言开发者方便语言使用者了解特性定义的名称而已,只要我们知道了底层原理,就没必要很纠结于它的名字是什么。

2.特性篇

2.1 for-range切片的时候,它的地址会发生变化么?

在for a,b := range slice的遍历中,a和b内存中的地址只有一份,每次循环遍历的时候,都会对其进行覆盖,其地址始终不会改变。对于切片遍历的话,b是复制的切片中的元素,改变b,并不会影响切片中的元素。

2.2 context 使用场景和用途?

context的主要作用:协调多个 groutine 中的代码执行“取消”操作,并且可以存储键值对。最重要的是它是并发安全的
① 可以存储键值对,供上下文(协程间)读取【建议不要使用】
② 优雅的主动取消协程(Cancel)。主动取消子协程运行,用不到子协程了,回收资源。比如一个http请求,客户端突然断开了,就直接cancel,停止后续的操作;
③ 超时退出协程(Timeout),比如如果三秒之内没有执行结束,直接退出该协程;
④ 截止时间退出协程(Deadline),如果一个业务,2点到4点为业务活动期,4点截止结束任务(协程)

2.3 常量计数器iota

【彻底搞懂 golang 里的 iota】

用来干啥的:
go语言中用常量定义代替枚举类型,这时候就用到了iota常量计数器,具有自增的特点,可以简化有关于数字增长的常量的定义

再说一下特点:
① iota只能出现在const代码块中
② 不同const代码块中的iota互不影响
③ 从第一行开始算,ioat出现在第几行,它的值就是第几行减一 【所有注释行和空白行忽略;_代表一行,不能忽略;这一行即使没有iota也算一行。】
④ 没有表达式的常量定义复用上一行的表达式

题目怎么做:
① 删除所有的空白行和注释行
② 没有表达式的常量定义复用上一行的表达式
③ 从头开始标记iota等于多少
④ 替换iota的值

2.4 defer特性相关

【刘丹冰:Golang中的Defer必掌握的7知识点,内有代码实例,最后的题一定要做】

  1. defer的作用
    defer为延迟函数,为防止开发人员,在函数退出的时候,忘记释放资源或者执行某些收尾工作;比如,解锁、关闭文件、异常捕获等操作;
  2. defer的执行顺序
    每个defer对应一个实例,多个defer,也就是多个实例,使用指针连接成一个单链表,每次写一个defer实例,就插入到这个单链表的头部,函数结束的时候,从头部依次取出,并执行defer。可以类比“栈”的先进后出方式
  3. defer与return先后顺序
    return后的语句先执行,defer后的语句后执行
  4. 具名返回值遇到defer的情况(看下面的例子)
    return虽先执行,但是defer中有改变具名返回值的操作,导致返回值发生了改变(至于为什么,只能说Go就是这样定义的)
package main
import "fmt"
func returnButDefer() (t int) {  //t初始化0, 并且作用域为该函数全域
    defer func() {
        t = t * 10
    }()
    return 1
}
func main() {
    fmt.Println(returnButDefer())   //输出 10
}
  1. defer遇见panic
    遇见return(或函数体到末尾)和遇见panic都会触发defer

① defer遇见panic,但是并不捕获异常的情况
和return一样,只不过panic前面的defer执行完之后,跳出函数,直接报异常
② defer遇见panic,并捕获异常
和上述不同的是,当运行的defer中捕获异常,并恢复之后,跳出函数,不会报异常,会继续执行。
但是需要注意的是,在发生恐慌的函数内,panic之后的程序都不会被执行。

  1. 面试题目:
package main

import "fmt"

func DeferFunc1(i int) (t int) {
    t = i
    defer func() {
        t += 3
    }()
    return t
}

func DeferFunc2(i int) int {
    t := i
    defer func() {
        t += 3
    }()
    return t
}

func DeferFunc3(i int) (t int) {
    defer func() {
        t += i
    }()
    return 2
}

func DeferFunc4() (t int) {
    defer func(i int) {   //传入的实参t,将defer放入链表时的t,并不是执行defe时候的t
        fmt.Println(i)
        fmt.Println(t)
    }(t)        //传入实参t
    t = 1
    return 2
}

func main() {
    fmt.Println(DeferFunc1(1))
    fmt.Println(DeferFunc2(1))
    fmt.Println(DeferFunc3(1))
    DeferFunc4()
}

输出:这类题目记住,return返回值的时候,是赋值操作,并没指针

4
1
3
0
2

2.5 介绍下rune类型

关于rune的解释

rune是int32的别名,等同于int32,常用来处理unicode或utf-8字符,用来区分字符值和整数值
这里和byte进行对比,byte是uint8,常用来处理ascii字符
那么有什么不同呢?举个例子


import (
	"fmt"
	"unicode/utf8"
)
 
func main() {
 
	var str = "hello 世界"
 
	//golang中string底层是通过byte数组实现的,直接求len 实际是在按字节长度计算  所以一个汉字占3个字节算了3个长度
	fmt.Println("len(str):", len(str))
 
	//以下两种都可以得到str的字符串长度
 
	//golang中的unicode/utf8包提供了用utf-8获取长度的方法
	fmt.Println("RuneCountInString:", utf8.RuneCountInString(str))
 
	//通过rune类型处理unicode字符
	fmt.Println("rune:", len([]rune(str)))

输出为:12 8 8
golang中string底层是通过byte数组实现的。中文字符在unicode下占2个字节,在utf-8编码下占3个字节,而golang默认编码正好是utf-8。
所以len计算的时候,一个中文字符占用3个字节。
而转换为rune计算,就刚好能得到字符的实际个数,比如我们想取出‘界’,rune的方式就很友好

fmt.Println(string([]rune(str)[7:]))  //就能取出‘界’

2.6 interface相关问题

饶大佬:深度解密Go语言之关于 interface 的10个问题

2.6.1 介绍一下interface

① interface是go语言的一种类型。
② 它是一个方法的集合,但是并没有方法的实现,也没有数据字段。
③ 大白话讲就是,拿函数传参来说,如果该函数的形参是一个接口,无论实参是什么类型,只要实现了形参接口中的全部的方法,就能作为实参传入。也就是说,在函数内部,只在乎传入的实参有没有实现形参接口的全部方法,只要实现了,就能传入,没实现,就不能。
④ go语言是静态语言,在编译阶段就能检测出赋值给接口的值,有没有实现该接口全部的方法;而python动态语言,需要运行期间才能检测出来。

2.6.2 值接收者和指针接收者(值调用者和指针调用者)

① 给用户自定义的类型添加新的方法的时候,与函数的区别是,需要给函数前添加一个接收者。这个接受者可以是自定义类型的值类型,也可以是自定义类型的指针类型。
② 在调用方法的时候,值类型既可以调用值接收者的方法,也可以调用指针接收者的方法;指针类型既可以调用指针接收者的方法,也可以调用值接收者的方法。
③ 在方法内部,如果对接收者进行了修改(例如对某一字段的值加一),无论是值类型调用还是指针类型调用,只有当接收者的类型为指针类型的时候,才会影响到接收者。(值类型的接收者,都是以“副本”的方式调用)
④ 对于自定义类型实现接口的方法的时候,需要注意了又,直接看表格:
在这里插入图片描述

2.6.3 接口的类型检查

2.6.3.1 方法

① 断言

<目标类型的值>,<布尔参数> := <表达式>.( 目标类型 ) // 安全类型断言
<目标类型的值> := <表达式>.( 目标类型 )  //非安全类型断言

type Student struct {
	Name string
	Age  int
}

func main() {
	stu := &Student{
		Name: "小有",
		Age:  22,
	}

	var i interface{} = stu
	s1 := i.(*Student) //断言成功,s1为*Student类型   不安全断言
	fmt.Println(s1)

	s2, ok := i.(Student) //断言失败,ok为false     安全型断言
	if ok {
		fmt.Println("success:",s2)
	}
	fmt.Println("failed:",s2)
}

② 如果接口类型可能有多种情况的话,采用Type Switch 方法。

func typeCheck(v interface{}){
	
//	switch v.(type) {       //只用判断类型,不需要值
	switch msg := v.(type) {   //值和判断类型都需要
		case int :
			...
		case string:
			...
		case Student:
			...
		case *Student:
			...
		default:
			...
	}
	
}
2.6.3.2 小题目

① fmt.Println参数为interface,其打印机制是什么?
若为内置类型,会穷举真实类型,然后打应;
若为自定义类型,会先检查是否实现了String()方法,如果实现了,则直接调用,如果没有,则利用反射来遍历对象成员,进行打印;
注意:别再自定义类型的String()方法里面fmt打印自己,会造成递归打印

② 关于switch type的一个问题
先看一段代码

type base interface{ F() }

type student struct{ Name string }

func (s *student) F() {}

type class struct{ Name string }

func (c *class) F() {}

type teacher struct{ Name string }

func (t *teacher) F() {}

func isType(v interface{}) {
	switch msg := v.(type) {
	case student, teacher:
		fmt.Println(msg.Name) //这里会报错,因为这个msg是interface类型,没有Name属性
	case class:
		fmt.Println(msg.Name) //这里不会报错,因为这个msg是class类型,有Name属性
	}
}

如果switch type的case后面只有一个类型T1,那么msg对应的类型就是这个类型T1。
如果switch type的case后面有多个类型(T2,T3),那么msg对应的类型就是interface。

2.6.4 空接口

空interface(interface{})不包含任何的method。
正因为如此,所有的类型都实现了空interface。
空interface对于描述起不到任何的作用(因为它不包含任何的method),但是空interface在我们需要存储任意类型的数值的时候相当有用,因为它可以存储任意类型的数值。

下面代码,哪一行存在编译错误?(多选)

type Student struct {
}

func Set(x interface{}) {
}

func Get(x *interface{}) {
}

func main() {
 s := Student{}
 p := &s
 // A B C D
 Set(s)
 Get(s)
 Set(p)
 Get(p)
}

B、D会输出错误,所有的类型都是实现了interface{},但是*interface{}和这个是没关系的它就是接口的指针类型,这里只能放入接口的指针类型。

2.6.5 go语言如何实现面对对象编程

面对对象编程的三个基本特征:封装、继承和多态
go通过结构体实现封装和继承,通过接口实现多态

封装:在结构体中,字段为成员变量(c++中的成员变量),字段名大写开头为可以导出,也就是外部可以访问;字段名为小写开头为不可以导出,也就是外部不可以访问(也就是说,在本package中,这两种情况是没有区别的,如果这个结构体在其他的package中使用的话,小写开头的不能直接被访问);将结构体的类型作为函数的接收器,来为这个结构体写方法,也就是c++中的成员函数。

继承:通过在子结构体中,添加父结构体作为成员变量,以这种组合的方式来实现继承,这样子结构体声明的变量也就能访问父结构体中的变量和方法了。

多态:多态是通过接口实现的,只要结构体实现了该接口的方法,那它们就能赋值给该接口,从而可以根据不同的赋值变量,而去执行不同的方法。

2.7 反射的相关问题

强烈推荐:深度解密Go语言之反射

2.7.1 Go的反射包怎么找到对应的方法

t := reflect.TypeOf(o) //获取类型
m1 := t.Method(0) //获取第几个方法
m2 := t.MethodByName(" funcName ") //根据方法名字获取方法

2.7.2 DeepEqual 的作用及原理

2.7.2.1 比较符号==的比较

不能用==比较的情况

  • 切片、map、函数
  • 以及含有以上三种的结构体和数组;

注意:

  • 同一类型的chan,可以用等号比较,也能作为map的key,实际上是对地址的比较;
  • 不同类型的chan,不能比较,编译报错;

go的结构体能不能比较?

  • 结构体中含有不能比较的类型时,不能比较;
  • 声明两个比较值的结构体的名字不同,即使字段名、类型、顺序相同,也不能比较(强转类型可以比较),说白了,必须用同一个结构体类型声明的值,才能比较;
sn1 := struct {
  age  int
  name string
 }{age: 11, name: "qq"}

 sn2 := struct {
  age  int
  name string
 }{age: 11, name: "qq"}

if sn1 == sn2 {       //这种情况是可以比较的
  fmt.Println("sn1 == sn2")
 }
type s1 struct {
  age  int
  name string
}
type s2 struct {
  age  int
  name string
}

sn1 := s1{age: 11, name: "qq"}
sn2 := s2{age: 11, name: "qq"}

if sn1 == sn2 {       //这种情况,直接编译失败
  fmt.Println("sn1 == sn2")
 }

2.7.2.2 作用

判断两个变量的实际内容完全一致

在这里插入图片描述

2.7.2.3 原理(如何比较)

① 先会判断两者中是否存在nil,当两者都是nil 的时候,返回true
② 利用反射,获取两者类型,若不相同直接返回false
③ 然后是调用了deepValueEqual函数去判断的,它起始是一个递归函数。比如判断数组类型,这个数组中元素的类型可能是切片类型,然后这个切片中元素的类型,可能是一个结构体,结构体中可能还有复杂的类型,所以是一直循环递归到最基本的类型使用“==”去进行判断,然后再层层返回,得到比较结果。
④ 对于一些特殊处理,比如map、slice类型,会先判断长度是否相同(不同直接fasle),指针是否相同(相同直接true),然后再去判断里面具体的值是否相同,其实就是一个快速对比处理。

2.8 init函数

在包初始化的时候会调用init函数,不能被显示调用

适用场景

  • 初始化变量
  • 检查或者修复程序状态
  • 注册任务
  • 仅仅需要执行一次的情况

特征

  • 同一个包,可以有多个init函数
  • 包中的每个源文件中可以有多个init函数

执行顺序(可以自己实验验证)

  • 同一个源文件中的init函数,是按照先后顺序执行的(且在全局变量初始化之后)
  • 同一个包中的源文件的init函数,是按照源文件的字母顺序执行的
  • 不同包的init函数,按照包导入的依赖关系决定先后顺序

2.9 sync.waitGroup相关

Go梦工厂 : 源码剖析sync.WaitGroup

一个waitGroup对象,可以实现同一时间启动n个协程,并发执行,等n个协程全部执行结束后,在继续往下执行的一个功能。
通过Add()方法设置启动了多少个协程,在每一个协程结束的时候调用Done()方法,计数减一,同时使用wait()方法阻塞主协程,等待全部的协程执行结束。

3.切片篇

看了这个就不用看我的了:深度解密Go语言之Slice

0.切片与数组的区别

共同点:
①都是存储一系列相同类型的数据结构
②都可以通过下标来访问
③都有len和cap这种概念
不同点:
①数组是定长的,且大小不能更改,是值类型。比如在函数参数传入的时候,形参和实参类型必须一模一样的
②切片是不定长的,容量是可以自动扩容的。切片传入函数的时候是值类型

1.切片的创建

序号方式示例
1直接声明var slice []int
2newslice := *new([]int)
3字面量slice := []int{1,2,3,4,5}
4makeslice := make([]int,10)
5从切片或数组截取slice := array[:5] 或 slice := souceSlice[2:4]

1.1 直接声明

这里重点说一下nil切片和空切片

  • nil切片:
var slice []int
slice := *new([]int)  //new前的*是解引用
  • 空切片
slice := []int{}
slice := make([]int)

这两种方式的len和cap均为0
但是不同的是:
nil切片和nil的比较结果是true
空切片和nil的比较结果是false,且同一程序里面,任何类型的空切片的底层数组指针的都指向同一地址

1.2 截取

问题1:如何使用以及坑

知识点:新切片从数组或者旧切片中截取一小段,在底层并不是把数据复制过来,而是共用底层数组,如果对新切片进行append操作,且超出其容量的话,且又赋值给原切片的话,就不是再共用底层数组了。

  • 使用

看上边链接的截取部分有一个雨痕大佬的例子,讲解的很详细

	slice := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
	s1 := slice[2:5]  //若没有容量限制,则容量直接到共享数组的最后一个值
	s2 := s1[2:6:7]  //最后一值代表容量,7-1闭区间

这里写几点需要我自己需要注意的:
① s1的长度是3,容量是8
② s2的长度是4,容量是5

问题2:若截取旧切片的一小段给新切片,旧切片就不用了,那么共享数组的未截取的值会被释放内存吗?

答案是:不会被释放
看它的3.2.1和3.2.2
拉到文章最下边看:内存优化

2.切片的底层原理以及扩容机制

2.1 底层原理

切片本身是一个结构体
具有三个字段{指向底层数组的指针,切片的长度,切片的容量},
切片本身非常小,只占24个字节(64位机器)。

func main() {
	a := make([]int, 5)
	fmt.Printf("a的数据类型是%T,a的字节大小是%d", a, unsafe.Sizeof(a))
}

2.2 切片是如何实现扩容的?

2.2.1 扩容方式

切片的底层的数组内存空间是连续分配的,可以通过下标获取。但是是固定长度的,当append发生扩容的时候就会创建新的切片,并发生迁移复制。所以扩容操作是为切片分配新的内存空间并复制迁移原切片中元素的过程。

2.2.2 扩容大小的计算【1.18版本与之前版本不同】

切片既然要扩容 的话,就意味着cap容量不足,那么就要先计算出扩容所需要的新cap的大小。
这里要分两步:先计算大致容量需求,再考虑内存对齐计算更为符合的容量需求。

先说一下期望容量是什么?
扩容操作是通过func growslice(et *_type, old slice, cap int) slice {}函数来实现的
et就是切片类型,old代表旧的切片,cap表示期望容量(也就是新的切片cap应该多大),也就是说扩容到多大,能刚好装下append的元素,例如,旧切片s的len为2,cap为3,然后执行append(s,1,2,3,4,5),当1放入之后,旧切片s的len为3了,接下来需要扩容,调用growslice函数,那么第三个参数应该写多少呢,应该是旧切片s的cap+ 4,4代表2,3,4,5这几个还没有被加入的元素个数。也就是说第三个参数的大小是保证append刚好把元素都放入的容量大小。这里我们称为期望容量。

  • 具体实现【1.18版本,即在growslice函数内发生了什么】
    222
  1. ①当期望容量大于原容量的两倍,那么新cap就直接使用期望容量;
    ②当期望容量小于原容量的两倍,且原容量小于256,直接将旧容量翻倍作为新cap大小(×2.0)
    ③当期望容量小于原容量的两倍,且原容量大于256,是将旧容量以一定的倍数(与原容量的大小有关系)扩充作为新cap大小。具体的是,随着原容量大小的增加,扩充系数从×2.0慢慢地、平滑地过渡到×1.25,不再是无论原容量只要超过了阈值,就直接从×2.0直接降低到×1.25
    解释:GoLang之Go1.17、1.18源码分析slice扩容规则
  2. 根据切片类型大小的不同,还会进行内存对齐(具体操作不清楚),对于元素占用字节大小为1,2或8的倍数以及其他,Go底层都对应一套内存对齐规则,将所需的新cap的大小进行精确计算。
    解释:go1.17 slice扩容机制源码剖析详解

4.map篇

看了这个就不用看我的了:深度解密Go语言之Slice

1.map的底层原理

1.1 map 的底层原理?

go的map使用的是哈希表,解决冲突的方式使用的是拉链法。
map底层结构体中维护了B和buckets字段(当然还有其他字段):buckets是一个数组指针(对应拉链发的数组),2的B次方表示这个数组的长度,这个数组存放的类型是bmap结构体(该结构体对应拉链法每个下标所指向的单链表),也就是我们常说的”桶“,bmap结构体维护了tophash、key、value三个数组和overflow指针,三个数组的长度都是8,表示每个桶最多存放8个键值对。三个数组的下标是一一对应的关系,什么意思呢,比如,要查一个hash,和tophash下标为3的值一样,那么要查找的key和value对应的下标也就是3。bamp的内存模型呢,并不是一个key一个value依次存放,而是所有的key放在一个数组,所有的value放在一个数组,只要使其key与value相应的下标相同即可,这样做的目的是,某些情况下可以省略掉 pad字段,节省内存空间。overflow又是一个bmap类型的指针,表示溢出桶,溢出桶和正常桶结构一样,存储kv时,当正常桶的8个位置用完了,就会申请溢出桶,将kv存放在溢出桶。

1.2 key定位原理?

先说下桶的个数为2的B次方个桶。
计算出key的hash之后,取hash的低B位作为桶号,找到对应的桶;
取hash的高8位,去和bamp中tophash进行比较,找出对应的key的位置,如果正常桶找不到,就去溢出桶依次查找。

2.map的扩容机制

2.1map的扩容条件

① 装载因子大于6.5。 装载因子= 元素个数 / (2^B)
② overflow(溢出桶)的数量过多(一个正常桶,后边可以跟多个溢出桶):当B<15时,如果overflow的数量超过了2^B; 当B>15时,如果overflow的数量超过了2^15;就会触发扩容。【overflow bucket 数量太多,导致 key 会很分散,查找插入效率低】
对于第一种情况,元素太多,而 bucket 数量太少,只需要将桶的数量翻倍,称为翻倍扩容。
对于第二种情况,元素很少,但溢出桶的数量很多,导致元素存储过于分散,说明很多桶都没装满。造成这种情况的原因,可能是,某一个桶号插入的元素过多,导致产生了大量的溢出桶(比如有5个溢出桶),但是此时并未触发①,然后,又将该桶中的元素大量删除,假如删除之后,每个溢出桶上就剩下一个元素了,这是时候,在查找的话,是不是会变得很慢。所以应该提高桶的利用率,只需要申请一个相同桶数量的新的buckets空间,将原buckets的数据搬过来,使得同一个buckets的数据排布更加紧密。称为等量扩容。

2.2map扩容具体是如何进行的

① 首先会分配好新的buckets,然后将旧的buckets放在map的oldbuckets的字段上。
② 由于map扩容,需要将原来的kv复制到新的buckets上,如果同时有大量的kv复制,会影响性能。所以map的扩容动作并不是一次性完成的,而是渐进式的。当map进行插入或修改、删除 key 的时候,会先检查map是否处于扩容中的状态,如果还未搬迁完毕,就会触发搬迁 buckets 的工作。
③ key应该搬迁到哪个桶里面呢?对于等量扩容而言,桶的数量并没有改变,只是将同一桶中的key,排布的更加紧密,所以key仍旧放置于原来的桶号;对于翻倍扩容,因为桶的数量翻倍了,即B加一,所以在进行key定位的时候,需要用hash的后B+1位,来计算迁移至哪一个桶中,所以导致同一桶中的key,会被分到两个不同的桶中。

3.map的遍历

3.1当map处于非扩容状态时

map遍历的时候,并不会从0号桶开始依次遍历,而是会随机出一个开始遍历的桶号,以及从该桶的第几个key开始遍历,然后就依次按顺序遍历即可。

3.2当map处于扩容状态时

①处于扩容状态时候,遍历会选取buckets进行遍历(此时oldbuckets还挂着旧的)。
①然后先随机出起始的桶号和该桶号中的起始位置,然后依次遍历。
②但是和上述不同的是,会先判断该桶是否已经搬迁完毕,如果搬迁完毕,直接遍历即可;如果未搬迁完毕,需要去oldbuckets的对应的桶号中遍历,因为搬迁key,可能会造成key被放到两个不同的桶中,所以这时,只遍历会分到该新桶中的key。

3.3为什么map的遍历是无需的

① 遍历的起始位置每次都是随机的
② 由于扩容,会导致key所处的桶发生变化

4.map的删除

key,value清零
对应位置的tophash置为Empty

5.map的相关问题

5.1 map 使用注意的点,是否并发安全?

① map必须先要初始化才能使用,两种初始化方式:m := map[int]int{} 或 m := make(map[int]int)
② map的类型就是map[key]value,key的类型必须是可以比较的【切片、map、函数类型不能作为key】
③ map[key]可以返回一个值,也可以返回两个值;两个值的第二个值就是供你判断该key是否存在于map中;因为如果一个key不存在于该map中的时候,去使用map[key]获取的话,不会报错,而是会返回vlaue的默认空值,所以需要第二个值,供我们自己判断。
④ map类型是并发不安全的。

5.2 如何设计一个有序的map

申明一个order map的类型:
type OrderMap struct {
keys []interface{}
m map[interface{}]interface{}  
}
keys中按顺序存放map的key

5.3 nil map 和空 map 有何不同?

nil map表示未初始化的map,等同于 var m map[string]int
空map表示map已经被初始化,只是长度为0,还并未赋于键值对
① 直接读取nil map:m[“a”] 并不会报错,会返回默认类型的空值
② 直接给nil map赋值:m[“a”] = 1 直接报错
③ 需要通过map == nil 来判断,是否为nil map

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

① 如果删除的键值对都是值类型(int,float,bool,string以及数组和struct),map的内存不会自动释放
② 如果删除的键值对中有(指针,slice,map,chan等),且该引用未被程序的其他位置使用,则该引用的内存会被释放,但是map中为存放这个类型而申请的内存不会被释放。
上述两种情况,map为存储键值所申请的空间,均不会被立即释放。等待GC到来,才会被释放。
③ 将map设置为nil后,内存被回收。

5.5 运行下面程序发生什么?

package main

import "fmt"

type Student struct {
	Name string
}

var list map[string]Student

func main() {

	list = make(map[string]Student)

	student := Student{"Aceld"}

	list["student"] = student
	list["student"].Name = "LDB"

	fmt.Println(list["student"])
}

编译失败
map[string]Student 的value是一个Student结构值,所以当list[“student”] = student,是一个值拷贝过程。而list[“student”]则是一个值引用。那么值引用的特点是只读。所以对list[“student”].Name = "LDB"的修改是不允许的。
应该改为list = make(map[string]*Student)

5.channel篇

1.并行与并发、进程与线程与协程

1.1并行与并发

并发与并行

并行指物理上同时执行,并发指能够让多个任务在逻辑上交织执行的程序设计

并行,是从物理的角度出发的。

  • 并行就是同时执行的意思。
  • 多个操作(任务)可以在同一时间点执行。即在同一时间点,是有多个任务在执行。
  • 单核单线程不能支持并行。

并发,是从程序的角度出发的,指的是程序结构设计。

  • 正确的并发设计标准是:多个操作(任务)可以在同一时间段内(间隔)执行,也就是多个任务根据轮询的规则在交替切换执行,每一个任务占用一定的时间。
  • 即在同一时间点,只有一个任务在执行。
  • 单核单线程就能支持并发。

并发和并行什么关系呢?
我觉得这两个没有必然的关系,举个例子。
在多核处理器上,运行着两个进程,这两个进程是并行执行的(两个进程分别运行在不同的核上)。其中一个进程所运行的应用程序,是从程序并发角度设计的,所以该进程中的程序是并发执行的;从两个进程的角度看,这两个进程中的程序是并行执行的。

1.2进程 线程 协程 的理解

进程:一个正在执行程序。比如你启动main函数,,跑起来之后,系统分配了各种资源,以及独立的内存空间,就是一个进程。进程因为具有独立的内存空间,稳定安全。但是进程间切换开销太大。
线程:轻量级的进程,是cpu调度的最小单位,一个进程至少包含一个主线程,或者多个线程。多线程之间共享进程的资源。共享内存空间,因此线程间切换开销小(进程内的线程切换仅仅只涉及内核态,进程间的线程切换涉及内核态与用户态的转换)。但是线程是共享内存,在读取数据的时候需要采用互斥或者顺序的手段,保证数据的一致性。
协程:是一种用户态的轻量级线程。协程的调度完全由用户态调度,协程是不被操作系统所管理的,而是被用户管理。协程的切换可以防止用户态向内核态切换。

2.channel相关

2.1 Go的并发哲学

Do not communicate by sharing memory; instead, share memory by communicating.
这是Go的并发哲学,前半句说的是采用sync包的组件进行并发编程,后半句说的是使用channel进行并发编程。

2.2 Go缓冲通道与无缓冲通道的区别

无缓冲通道,在初始化的时候,不用添加缓冲区大小;
无缓冲通道的发送与接收(或者接收与发送)是同步的;
发送者的发送操作将被阻塞,直到有接收者接收数据;接收者的接受操作将被阻塞,直到有发送者发送数据。

有缓冲通道,在初始化的时候,需要指定缓冲区大小;
有缓冲通道的发送与接收(或者接收与发送)是不同步的,也就是异步的;
有缓冲通道,缓冲区满后,再继续执行发送操作,会被阻塞。(其实无缓冲通道可以想象为是一个一直满的通道)

2.3 channel的底层原理介绍

读并分析了这几篇,我的就不用看了
绕全成:深度解密Go语言之channel
【浅谈 Go 语言 select 的实现原理】
channel 数据结构 阻塞、非阻塞操作 多路select

channel的数据结构包括:底层存放缓冲的数据格式是一个循环数组
重要的字段(当然,不止这些字段):
buf表示循环数组的指针
sendx表示循环数组中将要存放元素的位置索引(向通道发送)
recvx表示循环数组中将要读出元素的位置索引(从通道接收)
sendq表示等待发送元素的协程队列(双向链表),其节点的类型是sudog,表示一个协程的信息
recvq表示等待接收元素的协程队列(双向链表)
lock锁保证通道操作是原子的

2.4 channel的不同状态下的发送接收流程

先介绍一下非阻塞与阻塞模式
在select中,如果除了case通道条件,还有default的话,属于非阻塞模式,也就是对应底层代码的block参数为false;剩下的全部情况属于属于阻塞模式,即block参数为true【这里仅仅是根据我自己的看来得到的一个大胆的猜想,并非官方说法】

(1)对于nil的channel:
① 执行发送操作,非阻塞情况下,直接返回,不进行任何操作;阻塞模式下,会调用 gopark 函数挂起 goroutine,这个会一直阻塞下去
② 执行接收操作,非阻塞情况下,直接返回,不进行任何操作;阻塞模式下,会调用 gopark 函数挂起 goroutine,这个会一直阻塞下去(因为为nil的通道,不会被关闭,也不会执行发送操作,否则会报错)。

(2) 对于非缓冲的channel:
① 执行接收操作(在该协程中,此channel为接收操作):

  • 若channel被关闭了,将接收的值置为对应类型的零值
  • 若等待发送队列sendq中有goroutine在等待,会取第一个等待的发送协程g,直接将g协程中要发送的数据拷贝到该协程接收的值的内存。
  • 若等待发送队列sendq中没有roroutine在等待,非阻塞情况下,直接返回;阻塞情况下,先构造一个sudog,将该协程的信息保存在sudog,并将接收的值的地址存储到elem字段,然后将这个sudog添加到这个channel的recvq队列中,然后将其挂起,等待被唤醒。

② 执行发送操作(在该协程中,此channel为发送操作):

  • 若channel被关闭了,报panic
  • 若等待接收队列recvq中有goroutine在等待,会取第一个等待的接收协程g,直接将该协程中要发送的数据拷贝到g协程中接收的值的内存。
  • 若等待接收队列recvq中没有roroutine在等待,非阻塞情况下,直接返回;阻塞情况下,先构造一个sudog,将该协程的信息保存在sudog,并将发送的值的地址存储到elem字段,然后将这个sudog添加到这个channel的sendq队列中,然后将其挂起,等待被唤醒。

(3)对于缓冲的channel:
① 执行接收操作(在该协程中,此channel为接收操作):

  • 若channel被关闭了,且buf中无数据,将接收的值置为对应类型的零值
  • 若等待发送队列sendq中没有goroutine在等待,说明buf未满,有两种情况,0<qcount<datasiz时,将循环数组中的recvx索引位置的值拷贝到该协程接收的值的内存;qcount=0时,且通道未关闭,非阻塞情况下,直接返回,阻塞状态下,先构造一个sudog,将该协程的信息保存在sudog,并将接收的值的地址存储到elem字段,然后将这个sudog添加到这个channel的recvq队列中,然后将其挂起,等待被唤醒。
  • 若等待发送队列sendq中有goroutine在等待,只有一种情况,说明buf满了,会将循环数组中的recvx索引位置的值拷贝到该协程接收的值的内存;然后取发送队列sendq中第一个等待的发送协程g,将g协程中要发送的数据拷贝到循环数组中的send下索引位置上

②执行发送操作(在该协程中,此channel为发送操作):

  • 若channel被关闭了,报panic
  • 若等待接收队列recvq中没有roroutine在等待,说明buf>0,有两种情况,0<qcount<datasiz时,将该协程中要发送的数据拷贝到循环数组中的sendx索引位置;qcount=datasiz,也就是buf满了,非阻塞状态下,直接返回,阻塞状态下,先构造一个sudog,将该协程的信息保存在sudog,并将发送的值的地址存储到elem字段,然后将这个sudog添加到这个channel的sendq队列中,然后将其挂起,等待被唤醒。
  • 若等待发送队列recvq中有goroutine在等待,只有一种情况,说明buf肯定等于0,会取第一个等待的接收协程g,直接将该协程中要发送的数据拷贝到g协程中接收的值的内存。(无需再经过循环数组的传输,这种情况和非缓冲的相同)。

2.5 channel底层的发送接收源码分析

这是我自己的一个记录,大家可以看开头我推荐的链接

2.5.1 chanrecv
func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool) {
	if c == nil {      //若通道为nil
		if !block {
			return    //block=false,非阻塞状态下,直接返回
		}
		//阻塞状态下,挂起此协程,因为chan为nil,故此协程永远不会被唤醒
		gopark(nil, nil, waitReasonChanReceiveNilChan, traceEvGoStop, 2)
		//不会执行到这里
		throw("unreachable")
	}

	/*
		非阻塞状态下(selec中有default)  &&  empty(非缓冲:sendq中无发送协程 或者 缓冲:buf中无数据)
	*/
	if !block && empty(c) {
		/*
			非阻塞状态下,chan未关闭
			①非缓冲,sendq中无发送协程
			②缓冲,buf中无数据
			直接返回false,false
		*/
		if atomic.Load(&c.closed) == 0 {
			return
		}
		/*
			chan已经关闭
			empty(非缓冲:sendq中无发送协程 或者 缓冲:buf中无数据)为真的话,说明无数据
			为什么要在这里判断这个?进入上层if的时候不是判断过了吗?
			官方说的是,在判断为空和检查是否关闭之间,empty不为空了,且chan刚好关闭。这样的话,就不能在执行下面的if了
		*/
		/*
			非阻塞状态下,chan已经关闭
			①非缓冲,sendq中无发送协程
			②缓冲,buf中无数据
			若不忽略返回值,直接返回对应类型的0值
		*/
		if empty(c) {
			if raceenabled {    //该值永久为false
				raceacquire(c.raceaddr())
			}
			if ep != nil {   //如果不忽略从通道接收的值的话,需要返回类型的0值
				typedmemclr(c.elemtype, ep) // typedmemclr 根据类型清理相应地址的内存
			}
			return true, false
			//slected为true,这是在for-select结构中用到的;received为false表示是通道关闭导致返回的0值
		}
	}

	var t0 int64
	if blockprofilerate > 0 {
		t0 = cputicks()
	}

	lock(&c.lock)  //加锁
	
	/*
		阻塞状态下,chan已经关闭 && 缓冲chan中无数据
		①非缓冲关闭(sendq肯定没有等待的协程,因为通道已经关闭了,如果协程等待的话,肯定会报错的)
		②缓冲关闭,且buf中无数据
		这种情况,也可以判断快速返回,和上边一样
	*/
	if c.closed != 0 && c.qcount == 0 {
		if raceenabled {
			raceacquire(c.raceaddr())
		}
		unlock(&c.lock)
		if ep != nil {
			typedmemclr(c.elemtype, ep)
		}
		return true, false
	}


	/*
		无论阻塞型还是非阻塞型
		若sg!=nil,说明sendq中有协程等待
		①非缓冲,刚好我想接收的时候有协程发送
		②缓冲,说明buf已经满了,如果buf没满,sendq根本不用等待,直接将数据放到buf中就好了
		对于①,直接从sg协程的内存中,将数据拷贝到该接收协程即可,也就是直接拷贝到ep
		对于②,将循环数组头部的元素拷贝到ep,再将sg协程中的元素,拷贝到循环数组尾部
	*/
	if sg := c.sendq.dequeue(); sg != nil {
		recv(c, sg, ep, func() { unlock(&c.lock) }, 3)
		return true, true
	}
	/*
		无论阻塞型还是非阻塞型
		②缓冲,说明buf中有数据,但是未满,可以正常接收
	*/
	if c.qcount > 0 {
		qp := chanbuf(c, c.recvx)  //从循环数组的头部拿到接收的元素
		if raceenabled {
			racenotify(c, c.recvx, nil)
		}
		if ep != nil {
			typedmemmove(c.elemtype, ep, qp)  //将数据从qp拷贝到ep
		}
		typedmemclr(c.elemtype, qp)	//清空循环数组的头部
		c.recvx++   //接收指针++
		if c.recvx == c.dataqsiz {   //如果超出了,接收指针重新归零
			c.recvx = 0
		}
		c.qcount--  //buf中数据减一
		unlock(&c.lock)
		return true, true  //selected=true表示该case可执行,received=true表示是实际接收到了数值
	}

	if !block {
		unlock(&c.lock)
		return false, false
	}

	/*
		能走到这里,只有两种情况
		①阻塞状态下,通道未关闭,非缓冲的sendq中无发送协程等待
		②阻塞状态下,通道未关闭,缓冲的buf为空,且sendq中无发送协程等待
	*/
	gp := getg()    //获取该协程指针
	mysg := acquireSudog()   //创建一个sudog结构体,用来装载协程信息
	mysg.releasetime = 0
	if t0 != 0 {
		mysg.releasetime = -1
	}

	mysg.elem = ep      //这个直接将接收值的地址付给了elem字段
	mysg.waitlink = nil
	gp.waiting = mysg
	mysg.g = gp
	mysg.isSelect = false
	mysg.c = c
	gp.param = nil         //到这里,都是填充sudog结构体等
	c.recvq.enqueue(mysg)  //将sudog结构体放入该通道的recvq中
	atomic.Store8(&gp.parkingOnChan, 1)
	//将该协程挂起,等待被唤醒
	gopark(chanparkcommit, unsafe.Pointer(&c.lock), waitReasonChanReceive, traceEvGoBlockRecv, 2)

	//等待被chan对应的发送协程的send函数唤醒
	if mysg != gp.waiting {
		throw("G waiting list is corrupted")
	}
	gp.waiting = nil
	gp.activeStackChans = false
	if mysg.releasetime > 0 {
		blockevent(mysg.releasetime-t0, 2)
	}
	success := mysg.success
	gp.param = nil
	mysg.c = nil
	releaseSudog(mysg)
	return true, success
}
2.5.2 recv

由上面的函数调用

func recv(c *hchan, sg *sudog, ep unsafe.Pointer, unlockf func(), skip int) {
	if c.dataqsiz == 0 {  //非缓冲
		if raceenabled {
			racesync(c, sg)
		}
		if ep != nil {
			// copy data from sender
			recvDirect(c.elemtype, sg, ep)
		}
	} else {              //缓冲,且buf已经满了
		qp := chanbuf(c, c.recvx)   //取循环数组的头部
		if raceenabled {
			racenotify(c, c.recvx, nil)
			racenotify(c, c.recvx, sg)
		}
		// copy data from queue to receiver
		if ep != nil {
			typedmemmove(c.elemtype, ep, qp)  //将头部的数据移动到接收者
		}
		// copy data from sender to queue
		typedmemmove(c.elemtype, qp, sg.elem)  //将发送端的数据拷贝到循环数组的头部
												//这里不应该是拷贝到循环数组的尾部吗?
												//应为buf是满的,所以sendx和recvx重合了
		c.recvx++
		if c.recvx == c.dataqsiz {
			c.recvx = 0
		}
		c.sendx = c.recvx   // c.sendx = (c.sendx+1) % c.dataqsiz
	}
	sg.elem = nil
	gp := sg.g   //取出发送协程的协程指针
	unlockf()
	gp.param = unsafe.Pointer(sg)
	sg.success = true
	if sg.releasetime != 0 {
		sg.releasetime = cputicks()
	}
	goready(gp, skip+1)  //唤醒发送者的协程,等待调度器调度
	/*
		由这里的代码知道,发送协程阻塞,被放入sendq中。
		然后是先接收了它的数据,在激活它的协程。
		而不是先激活它,在接收它的数据。
	*/
}
2.5.3 chansend
func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool {
	//若chan为nil
	if c == nil {
		if !block {  //非阻塞下,直接return
			return false
		}
		//阻塞下,将协程挂起,因为chan==nil,永久不会被唤醒
		gopark(nil, nil, waitReasonChanSendNilChan, traceEvGoStop, 2)
		throw("unreachable")
	}
	/*
		非阻塞下 && 通道未关闭 && 【非缓冲的recvq无协程等待  缓冲的buf满】
		这种情况下直接快速返回
	*/
	if !block && c.closed == 0 && full(c) {
		return false
	}

	var t0 int64
	if blockprofilerate > 0 {
		t0 = cputicks()
	}

	lock(&c.lock)

	//不管阻塞、非阻塞下,只要通道关闭了,在发送数据的话,直接panic
	if c.closed != 0 {
		unlock(&c.lock)
		panic(plainError("send on closed channel"))
	}

	/*
		无论阻塞、非阻塞下
		①非缓冲,刚好我想发送的时候有协程接收
		②缓冲,说明buf为空,如果buf中有数据,recvq根本不用等待,直接从buf中取数据就好了
		对于①,直接从ep拷贝到sg接收协程的字段中即可
		对于②,同①的处理情况相同,无需再将数据拷贝到buf,再从buf拷贝出来
	*/
	if sg := c.recvq.dequeue(); sg != nil {
		send(c, sg, ep, func() { unlock(&c.lock) }, 3)
		return true
	}

	/*
		无论阻塞型还是非阻塞型
		②缓冲,说明buf中有数据,但是未满,可以放入buf中
	*/
	if c.qcount < c.dataqsiz {
		qp := chanbuf(c, c.sendx)   //取循环数组的尾部的指针
		if raceenabled {
			racenotify(c, c.sendx, nil)
		}
		typedmemmove(c.elemtype, qp, ep)   //将发送的值拷贝到尾部
		c.sendx++       //发送指针++
		if c.sendx == c.dataqsiz {
			c.sendx = 0
		}
		c.qcount++
		unlock(&c.lock)
		return true
	}

	if !block {
		unlock(&c.lock)
		return false
	}

	/*
		能走到这里,有两种情况
		①阻塞状态下,chan未关闭,非缓冲的recvq中无接收协程等待
		②阻塞状态下,chan未关闭,缓冲的 buf已满,且recvq中无接收协程等待
	*/
	gp := getg()   //获取当前协程的指针
	mysg := acquireSudog()  //创建sudog结构体
	mysg.releasetime = 0
	if t0 != 0 {
		mysg.releasetime = -1
	}
	mysg.elem = ep    //将要发送的值的地址,直接给了elem字段。
	mysg.waitlink = nil
	mysg.g = gp
	mysg.isSelect = false
	mysg.c = c
	gp.waiting = mysg
	gp.param = nil
	c.sendq.enqueue(mysg)   //将当前协程翻入到相应的chan的发送协程等待队列
	atomic.Store8(&gp.parkingOnChan, 1)
	//将当前协程挂起
	gopark(chanparkcommit, unsafe.Pointer(&c.lock), waitReasonChanSend, traceEvGoBlockSend, 2)
	KeepAlive(ep)

	//当前协程被唤醒
	if mysg != gp.waiting {
		throw("G waiting list is corrupted")
	}
	gp.waiting = nil
	gp.activeStackChans = false
	closed := !mysg.success
	gp.param = nil
	if mysg.releasetime > 0 {
		blockevent(mysg.releasetime-t0, 2)
	}
	mysg.c = nil
	releaseSudog(mysg)
	if closed {
		if c.closed == 0 {
			throw("chansend: spurious wakeup")
		}
		//如果发送协程被唤醒之后,发现被关闭了,直接panic
		panic(plainError("send on closed channel"))
	}
	return true
}
2.5.4 send
func send(c *hchan, sg *sudog, ep unsafe.Pointer, unlockf func(), skip int) {
	if raceenabled {  //fasle,不执行
		if c.dataqsiz == 0 {
			racesync(c, sg)
		} else {
			racenotify(c, c.recvx, nil)
			racenotify(c, c.recvx, sg)
			c.recvx++
			if c.recvx == c.dataqsiz {
				c.recvx = 0
			}
			c.sendx = c.recvx // c.sendx = (c.sendx+1) % c.dataqsiz
		}
	}
	/*
		sg.elem 指向接收到的值存放的位置,如 val <- ch,指的就是 &val
		无论对于缓冲还是非缓冲来说,都是直接从发送者的元素的地址拷贝到接收者的元素的地址
		对于非缓冲来说,是正常的
		对于缓冲来说,减少了中间放入buf的操作。
	*/
	if sg.elem != nil {
		sendDirect(c.elemtype, sg, ep)
		sg.elem = nil
	}
	gp := sg.g
	unlockf()
	gp.param = unsafe.Pointer(sg)
	sg.success = true
	if sg.releasetime != 0 {
		sg.releasetime = cputicks()
	}
	goready(gp, skip+1)   唤醒接收的协程,等待调度器来临
}

2.6 channel的小编程题

2.6.1 交替打印

用GO写数字字母交替打印的协程,要求使用两个协程分别打印
12AB34CD56EF78GH910IJ1112KL1314MN1516OP1718QR1920ST2122UV2324WX2526YZ2728

func main() {
	chNum := make(chan bool)
	chStr := make(chan bool)
	ctx, cancel := context.WithCancel(context.Background())
	var wg sync.WaitGroup

	go func() { //数字
		var i int
		wg.Add(1)
		defer wg.Done()
		for {
			select {
			case <-chNum:
				i++
				fmt.Printf("%d", i)
				i++
				fmt.Printf("%d", i)
				if i == 28 {
					close(chNum)
					return
				}
				chStr <- true
			}
		}
	}()

	go func(ctx context.Context) { //字母
		c := 'A'
		for {
			select {
			case <-chStr:
				fmt.Printf("%c", c)
				c++
				fmt.Printf("%c", c)
				c++
				chNum <- true
			case <-ctx.Done():
				close(chStr)
				return
			}
		}
	}(ctx)

	chNum <- true
	wg.Wait()
	cancel()
	fmt.Println("  printf is over!")
}
2.6.2 生产者消费者模型

最简单生产者消费者模型

type producer struct {
	count     int
	chanLen   int
	workerMap map[int]*worker
}

func NewProducer(count int, chanLen int) *producer {
	return &producer{
		count:     count,
		chanLen:   chanLen,
		workerMap: make(map[int]*worker, count),
	}
}

func (p *producer) CreateWorker() {
	for i := 0; i <= p.count; i++ {
		w := NewWorker(i, p.chanLen)
		p.workerMap[i] = w
		go w.Run()
	}
}

func (p *producer) Product() {
	for {
		temp := rand.Intn(50)
		p.workerMap[rand.Intn(p.count)].ch <- temp
		time.Sleep(time.Second)
	}
}

type worker struct {
	id int
	ch chan int
}

func NewWorker(id int, chanLen int) *worker {
	return &worker{
		id: id,
		ch: make(chan int, chanLen),
	}
}

func (w *worker) Run() {
	for {
		select {
		case c := <-w.ch:
			fmt.Println(c)
		}
	}
}

func main() {
	p := NewProducer(10, 20)
	p.CreateWorker()
	go p.Product()
	select {}
}

6. GMP调度模型篇

1.1 原理讲解

这里我就不照搬原文了,还是直接上链接吧
刘丹冰Aceld :30+张图讲解:Golang调度器GMP原理与调度全分析
Golang深入理解GPM模型

模型

在GO中,线程是运行协程的实体,调度的作用就是将可运行的协程分配到线程上工作的过程。
G代表协程;P代表协程处理器;M代表内核级线程。

  • GMP会维护一个全局队列,用来存放等待运行的G
  • P是协程处理器,其中也维护了一个P的局部的队列,也是用来存放等待运行的G。【P是有一定数量的;新创建的协程G会先往P的局部队列存放,如果全部P的局部队列都满了,就会放到全局队列中。】
  • M是内核级协程,M会去抢占P,进而与P进行关联,然后从P的局部队列中获取G,放到M中进行执行,在G运行完之后,在从P中获取下一个,如此循环。【更多的情况,见下面】
  • 协程调度器与os调度器是通过M结合起来的,前者负责调度协程,后者os负责线程的调度。
  1. 描述一个协程的调度过程

① 通过go func创建一个协程G【这个G肯定是被某一个MP组合中正在运行的G创建的】。
② 有两种存储G的队列,一个是全局队列,一个是P维护的局部队列。新创建的G会先被加入创建它的MP组合的那个P的局部队列中(保证局部性),如果该P的局部队列已经满了,则会被放到全局队列。
③ 【正常】M会从关联的P中获取G(如果有G),放入到M中执行,G执行完毕之后,M在获取下一个G,如此循环。如果没有G,MP组合会去全局队列中获取一定数量的G,放到自己的P的局部队列中,调用执行。如果此时全局队列也没有G,该MP组合会去其他的MP组合的局部队列中窃取队列后半部分的一定数量的G,放到自己的P的局部队列中,调用执行。如果其他MP组合也没有G,那么该MP组合就会进入自旋线程【后序解释】,等待G的到来。
④ 【阻塞】假如M中执行的G发生了阻塞,M会释放所关联的P,P会去寻找一个休眠的M(休眠线程队列)关联,如果没有休眠线程,就会创建M。
⑤ 【苏醒】假如M中执行的G结束了④中的阻塞,又变为了可执行状态,这时M并没有关联P,所以从阻塞中恢复的协程,不能运行,只有关联了P才能运行。M在发生阻塞释放P的时候,会事先记录好是哪一个P处理器,当协程转为非阻塞状态后,M会先去找当时所关联的P,如果该P没有关联其他M,则M会重新绑定该P,并运行该非阻塞的G;如果该P已经绑定了其他M,会去找有没有空闲的P(没有绑定M的P),如果还是没有,该非阻塞的G会被放到全局队列,M会被放到休眠线程队列。

其他的一些小解释:
① P的数量和M的数量问题

  • P的最大个数是由GOMAXPROCS确定的,用户可以进行设置【P的局部队列最大不超过256】,程序启动所有的P便会创建。
  • M的最大个数也是可以进行设置的。
  • M在运行中的个数是不确定的,因为随着空闲忙碌与否,M会被创建以及被回收。

② 调度器的设计策略:线程复用,避免频繁的创建、销毁线程

  • work stealing机制:当本MP中没有G的时候,会去其他的MP组合中窃取一部分G,放入到自己的局部队列中。而不是将本M销毁。
  • hand off机制:当M中运行的G发生了阻塞的时候,M会主动释放掉P,让P去寻找空闲的M。

③ M0和G0

  • M0是程序启动后编号为0的主线程,负责初始化操作以及启动第一个G,之后便和其他的M一样了。
  • G0:每创建一个M都会启动一个G0,也就是每一个M都有属于自己的唯一的一个G0,G0仅用于该M上的G的调度。例如,M运行完G1之后,会先切换到G0,再有G0调度到下一个G2。

④ 自旋线程
定义的话如上边所示,那么为什么要这样设计?

  • 自旋协程运行,肯定是占用一定的CPU资源,但是销毁再创建M也会消耗时间资源,我们希望当新的G到来的时候,能有M可以立即对其进行接收。

⑤ P会周期性的检查全局队列中有无G,防止里面的G被饿死

1.2 面试问题

1.2.1 什么是GMP模型?

先说一下GMP模型的作用:当我们写一个并发程序,操作系统会对其进行调度,线程是操作系统调度的最小单位,而不是协程,所以GMP模型就是想办法将用户创建的众多协程分配到线程上的这么一个过程
再分别说一下G、M、P是什么,以及各自干什么工作

1.2.2 P和M的数量以及何时被创建?

刘丹冰的博客里面有

1.2.3 Go的调度方式?

  • 主动调度:自己主动放弃执行,通过runtime.Gosched()实现。取消G与M之间的绑定关系,将G放入到全局运行对列中去。
  • 被动调度:发生锁、sleep、channel等阻塞的情况。取消P与M之间的绑定关系,P再去找一个空闲的M。
  • 抢占调度:① 抢占执行时间过长的G,发生在函数调用的时候。若该G执行时间超过10ms,发生抢占;② 抢占系统调用。当G在系统调用中超过10ms时,发生抢占;
  • 对于抢占调度,1.14之后,当cpu密集型任务执行时,可能没有发生函数调用,也没有系统调用,这样就没法进行抢占。 1.14是单独起一个线程对运行的协程进行监控,超过10ms的则进行抢占。
    在这里插入图片描述

1.2.4 调度器有哪些设计策略?

  • 复用线程:避免重复的创建、销毁线程,而是对线程的复用(work stealing机制和hand off机制)
  • 抢占机制:一个协程占用cpu的时长是有时间限制的,当该协程运行超时之后,会被其他协程抢占,防止其他协程被饿死,

1.2.5 下面的程序问题(Go的调度局部性)?

func main() {
	runtime.GOMAXPROCS(1)
	var wg sync.WaitGroup
	for i := 0; i < 10; i++ {
		wg.Add(1)
		go func(n int) {
			fmt.Println(n)
			wg.Done()
		}(i)
	}
	wg.Wait()
}

① 上面的程序的输出是固定的吗?是固定的: 9 0 1 2 3 4 5 6 7 8
不知道为啥的话,可以看看这个

② 为什么9是第一个被输出的呢?
这里涉及到了Go调度的局部性的问题,由MP组合创建出来的协程,会先被放到该P的本地队列中。然后P为了实现局部性,除了本地的队列,还有一个runnext字段。
写入模型:在这里插入图片描述
消费模型:
在这里插入图片描述
【深入理解Go】协程设计与调度原理(上)

③ 若for循环中的10改为300,会出现什么结果?如果改成10000呢?

  • 299仍旧是第一个被输出,只不过剩下的数,不再是被顺序输出了。因为就创建了一个P,本地队列最多放256个,顺序就会错了。
  • 改成10000,第一个也不在可能是9999。改成10000,意味着P的runnext一直在跟新,但是P得开始调度G啊,不能说runnext总是更新,就不开始调度运行了吧。

1.2.6 比较综合的问题

字节跳动面试真的也会问这样的问题?!

7. 垃圾回收篇

Golang三色标记混合写屏障GC模式全分析
腾讯妹子图解Golang内存分配和垃圾回收
golang 垃圾回收
Golang 混合写屏障原理深入剖析

1.1 GoV1.3之前的标记-清除法

1.1.1 标记-清除法流程

① 先暂停程序逻辑(stw);
② 然后Go垃圾收集器从根节点开始遍历,执行可达性分析算法,递归的标记所有被引用的对象,为存活状态;
③ 标记结束之后,垃圾收集器会依次遍历整个堆中的对象,将未被标记为存活的对象,进行清除;
④ 最后,停止stw,让程序继续执行。

1.1.2 标记-清除法的优化

在第三步的标记结束之后,先停止stw,再进行清除操作。
也就是就是说,清除操作和用户的协程是可以并发执行的。

1.1.3 标记-清除法的缺点

  • stw,造成程序卡顿
  • 扫描整个堆区
  • 清除数据会使得堆区碎片化

1.2 GoV1.5的三色并发标记法

三色标记法属于追踪式垃圾回收算法的一种。
追踪式垃圾回收算法:① 找出所有全局变量和当前函数栈里的变量,并标记为可达;② 从标记的变量开始,进一步标记它们可访问的变量,以此类推,最终将未标记的变量回收。

1.2.1 三色标记流程

① 初始状态所有的对象都被标记为白色,全都放在白色的集合中;
② 从GC roots,以广度优先搜索的方式开始遍历,只遍历一次,也就是说只遍历出直接引用的一层对象,将这些对象标记为灰色,放入到灰色集合中;
③ 从灰色集合中,取出一个灰色对象,将它直接引用的对象,标记为灰色,放入到灰色集合中(重复步骤②),然后,将这个对象标记为黑色,放入到黑色集合中;
④ 重复步骤③,直到灰色集合为空;
⑤ 到此为止,黑色集合中的数据为存活对象,白色集合中的数据为不可达对象。清除白色集合中的全部对象。

在这里插入图片描述

1.2.2 三色并发标记法,不使用stw,会有什么问题

想要实现三色并发标记的协程和用户业务程序协程,并发的执行。有可能在执行三色标记的过程中,用户程序改变了变量间的引用关系,进而导致发生一些问题:

  1. 原本应该被垃圾回收的对象,被错误标记为了存活。
    有一个对象已经被标记为了黑色,但是用户程序更改了指针,使得这个对象不再被引用,按道理,此时该对象应该被标记为白色,被回收的,但是这种情况下,由于是黑色,所以不会被回收。这种情况下,也不用担心,最多就是下次GC,对这个对象进行回收。
  1. 原本应该存活的对象,被标记为了死亡。
    有一个灰色的对象指向一个白色的对象,然后又有一个黑色的对象也指向了这个白色的对象,然后灰色对象断开了对白色对象的引用。此时按这种状态执行下去,由于灰色对象断开了对白色对象的引用,白色对象不再被标记为黑色,黑色对象引用了白色对象,但是黑色对象有已经被扫描过了,所以不会再去遍历白色对象了,也导致白色对象不会被标记为黑色,由于一直是白色,从而会被回收掉。这种情况,是非常严重的错误,对象直接丢失了。我们必须要避免这种情况。

为了解决上述问题,可以通过添加stw的方式,但是这会严重的影响GC性能。所以就引入下面的技术。

1.2.3 屏障技术

垃圾收集中的屏障技术更像是一个钩子方法,它是在用户程序读取对象、创建新对象以及更新对象指针时执行的一段代码。
比如说,程序要发生内存改动了,会先去调用内存屏障这个hook函数,对此次内存操作进行检查判断,看看接下来应该执行什么样子的操作。

1.2.3.1 强弱三色不变式

强三色不变式:不允许黑色对象引用白色对象

弱三色不变式:黑色对象可以引用白色对象,但是白色对象的上游对象必须存在灰色对象

强弱三色式满足其中之一,即可解决上述问题。

1.2.3.2 插入写屏障

具体操作:当A对象引用B对象的时候,B对象被标记为灰色
这样能满足强三色不变式,就不会存在黑色对象引用白色对象的情况存在了

进行垃圾回收的位置有两种,由于栈空间小,又要求速度快,因为函数调用会造成频繁的入栈出栈,所以插入屏障不在栈空间使用,仅仅在堆空间使用。
那么栈空间该如何回收呢?在GC扫描完之后,不会直接删除白色对象,而是对栈空间加stw保护,再对栈空间重新进行三色标记扫描。

1.2.3.3 删除写屏障(基于起始快照的写屏障)

具体操作:前提:在起始的时候,会启动stw,把整个根部扫描一遍,将根部置为黑色,下一级为灰色,保证所有可达对象都在灰色对象的保护之下。如果从灰色对象和白色对象删除白色指针时,会将被删除的白色对象被标记为灰色。
满足弱三色不变式,保证了白色对象前面必有灰色对象。

存在的问题:回收精度会降低,一个对象即使被删除了最后一个指向它的指针,也依旧可以活过这一轮,在下一轮GC中被清理掉。

1.2.4 Go V1.8混合写屏障

针对插入写屏障和删除写屏障得短板:

  • 插入写屏障:结束的时候,需要STW重新扫描栈。
  • 删除写屏障:回收精度低,且GC开始的时候,需要STW扫描堆栈来记录初始快照,保护所有存活对象。

v1.8采用混合写屏障机制,避免了重新扫描的过程,极大减少了STW的时间。

1.2.4.1 混合写屏障规则

  • GC开始的时候,将栈上的对象全部扫描并标记为黑色;
  • GC期间,任何栈上创建的新对象,均为黑色;
  • 被删除的堆对象标记为灰色;
  • 被引用的堆对象标记为灰色;

需要注意的是:
混合写屏障,并不是不需要STW:混合写屏障是去除整体的STW 的一个改进,转而并发一个一个栈处理的方式(每个栈单独暂停),从而消除了整机 STW 的影响,带来了吞吐的提升。
栈上不会触发写屏障,它只需要满足前两条即可。
如果发生了栈对象引用了堆对象,是不会触发写屏障的,这个被引用的堆对象仍旧是白色。

1.2.5 触发GC的条件

如何进行GC的分析,查看GC的状态,可以查看上面腾讯妹子的连接

  • 申请内存发出GC:
    申请微对象(<16B)和小对象(16B-32KB)的时候,如果当前线程内存管理单元不存在空闲时;
    申请大对象(>32KB),也会尝试触发GC。
  • 后台定时检查触发GC:
    两分钟一次。
  • 手动触发GC:
    用户程序会通过runtime.GC函数在程序运行期间主动通知运行时执行。

8.内存逃逸篇

1.栈区和堆区的区别

  • 栈区用于存储:系统分配的内存,例如,函数调用前后的上下文环境、函数参数、局部变量、函数返回值、函数返回地址等;堆区用于存储:用户通过malloc/new申请的内存。
  • 栈的空间相较于堆小,栈的速度相较于堆快。
  • 栈区是连续的,堆区是不连续的。
  • 栈区的内存扩展方向是从高到低,堆区的内存扩展方向是由低到高。

2.go中变量分配在栈or堆

  • 对于全部变量,值类型的全局变量分配在栈上,引用类型的全局变量分配在堆上;
  • 对于局部变量,不能根据语句语义(var,new)去判断是分配到栈还是堆上,因为会发生内存逃逸(栈区的变量逃逸到堆上);
  • 对于大对象(>32kb),是直接分配到堆区;
  • 对于小对象(16B-32kb)以及微对象(<16B,且不含指针)很复杂,尚未了解;

3.什么是内存逃逸,为什么需要内存逃逸

定义:一个在栈区存储的变量,因为被堆区的变量引用,使得该变量会从栈区逃逸到堆区;

原因:

  • go语言并不需要程序员像使用c/c++那样,需要自己去释放内存,go做了自动化处理。所以go申请的局部变量(无论是var还是new申请的),只要没有超过一定大小,都被先分配到栈上,但是如果该变量被堆上的变量引用了得话,该变量必须逃逸到堆上,防止栈区的内存会被系统全部自动释放掉,从而导致被引用的变量丢失,产生野指针。
  • 逃逸的堆区的变量,在需要被回收的时候,会被GC进行回收。

4.逃逸分析在何时进行

逃逸分析在编译阶段进行,由编译器完成。

5.如何打印逃逸分析信息

go run -gcflags "-m -l" *.go

-m 设置打印信息
-l 禁止内联,内联编译,编译器会优化代码,可能导致不会发生逃逸。

6.逃逸规则

  1. 逃逸范例一

slice、map、chan中元素只要是引用指针类型的,一定会逃逸。

func main() {
	a := make([]*int, 1)
	b := 1
	a[0] = &b

	c := make(map[*int]*int)
	dk := 1
	dv := 1
	c[&dk] = &dv

	e := make(chan *int, 1)
	f := 1
	e <- &f
}

逃逸分析:
go run -gcflags “-m -l” .\main.go
.\main.go:5:2: moved to heap: b
.\main.go:9:2: moved to heap: dk
.\main.go:10:2: moved to heap: dv
.\main.go:14:2: moved to heap: f
.\main.go:4:11: make([]*int, 1) does not escape
.\main.go:8:11: make(map[*int]*int) does not escape
可以看到,被slice、map、chan引用的变量全部都逃逸了

扩展:

  • 对于 slice []typemap[typeK]typeVchan type,只要是type的类型是引用类型的(切片,map,chan,interface{},*type),那么一定会逃逸。

注意:

  • 数组为值类型,所以当数组作为上述类型处的值的话,是不会逃逸的。
  1. 逃逸范例二

某变量被引用类型的全局变量已经逃逸的变量引用,该变量一定逃逸。

var global *int

func main() {
	t1 := 1
	global = &t1 //t1逃逸

	t2 := 1
	s := make([][]*int, 1)
	es := make([]*int, 1)    //es逃逸
	s[0] = es
	es[0] = &t2    //t2逃逸
}

逃逸分析:
.\main.go:6:2: moved to heap: t1
.\main.go:9:2: moved to heap: t2
.\main.go:10:11: make([][]*int, 1) does not escape
.\main.go:11:12: make([]*int, 1) escapes to heap

t1被全局变量引用,所以逃逸
es被s引用,所以逃逸,对应逃逸范例一的情况
t2为指针,被es引用,且es为已经逃逸的变量,所以t2也逃逸
假如把es的类型换为[]int,则es仍旧符合逃逸范例一的情况,但是t2此时不是被引用,所以不会逃逸。

  1. 逃逸范例三

在某个函数中,new或者var出来的变量,将其指针作为函数返回值时,该变量一定逃逸。

func f() *int {
	var a int
	return &a
}

func main() {
	f()
}

逃逸分析:
.\main.go:4:6: moved to heap: a

这种情况就是为了防止函数内的变量,在函数结束的时候,被系统自动回收。

  1. 逃逸范例四(不会逃逸的例子)

func(type)函数类型,进行函数赋值,无论type的类型为什么,如果该函数中,没有逃逸的变量对该参数进行引用的话,该参数以及传入的实参都不会逃逸。

func foo(a *int) {
	return
}

func main() {
	data := 10
	foo(&data)
}

逃逸分析:
.\main.go:3:10: a does not escape
可见a和data均没有逃逸

那么,更改为下面的情况:

var global *int

func foo(a int) {
	global = &a
	return
}

func main() {
	data := 10
	foo(data)
}

逃逸分析:
.\main.go:5:10: moved to heap: a
可见a逃逸了,但是data没有。
原因:global为全局指针,故分配在堆上,a被全局变量的global引用,所以a逃逸了。但是,data只是把数值给了a,并不会逃逸。

那么,再看一种情况:

var global *int

func foo(a *int) {
	global = a
	return
}

func main() {
	data := 10
	foo(&data)
}

逃逸分析:
.\main.go:5:10: leaking param: a
.\main.go:11:2: moved to heap: data
可见a作为了泄露参数,data逃逸了。
原因:global为全局指针,故分配在堆上,a被全局变量的global引用,所以a会leaking(泄露)。data作为实参传入,被a引用,所以data发生内存逃逸。

  1. 逃逸范例五(不会逃逸的例子)

仅仅在函数内部做了取地操作,但是并未被堆区的变量引用,也没有作为返回值返回,这种情况是不会发生逃逸的。

  1. 逃逸范例六
type s struct {
	name string
}

func foo(a s) {
	fmt.Println(a.name)
}

func main() {
	data := s{name: "cjs"}
	foo(data)
}

逃逸分析:
.\main.go:9:10: leaking param: a
.\main.go:10:13: … argument does not escape
.\main.go:10:15: a.name escapes to heap

可以看到,a整体并没有逃逸,只是其中涉及的字段逃逸了。

评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

辛集电子

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值