Go语言底层基础知识

Go语言底层基础知识,基础语法学习…

new和make的区别

  1. new和make都是全部用来分配内存的关键字,new(T)创建一个没有任何数据的类型为T的实例,并返回该实例的指针;make(T, args)只能创建
    slice、map和channel,并且返回一个有初始值args(非零)的T类型的实例,非指针。
  2. 对于返回值,new用于类型的内存分配,并且内存置零,返回的是一个指向类型的指针;make是对他们的初始化,(非0值)返回的是一个类型引用对象
    ,对于引用类型的变量,我们不光要声明它,还要为它分配内容空间 。
  3. 对于入参,new只有一个Type参数,Type可以是任何类型的数据;make传入Type参数(map、slice、chan)中的一种,还有一个size
    。(map:根据slice大小分配资源,以足够存储size个元素,如果省略size,会默认分配一个小的起始size ;slice
    :第一个是长度,第二个是cap(容量);对于chan,size表示缓冲区容量;如果省略则表示channel为无缓冲的channel)
    总结:
  • new() 是分配内存给一个零值的指针,返回的是这个类型的指针,还可以返回自己定义的结构体,置零之后的。

  • make()是开辟一块内存,并且初始化,返回这个经过初始化的对象,只能初始化slice、map、chan,返回的是这个类型的引用,它可以设置初始化的长度,如果是slice,则可以添加一个参数,容量,容量的大小要小于slice的大小。

array和slice的区别

  1. array在定义的时候必须传入数组的长度,并且是一个常量,并且不可改变,数组的赋值都是值传递,所以一般情况会就比较耗费内存。
  2. slice是通过指针引用底层数组,是对数组一个连续片段的引用,这个片段可以是全部的数组,也可以是其中的一个片段,slice自身是一个结构体,切片的长度可以改变。切片在进行append时,未超过切片的容量,进行浅拷贝(传递引用),超过容量,进行扩容的时候才会执行深拷贝(生成一个新的内存空间,不共享。)

通道是什么

通道是go中的一个通信方式,提供goroutine进行通信,可以为并发的两个实体之间提供通信通道,而不是共享内存的方式。
通道分为无缓冲通道和有缓冲通道:

  • 对于无缓冲通道,接收方从通道接收东西的时候,如果通道中没有消息,则接收方进入阻塞,直到通道中有消息。对于发送方,直到接收方收到消息之后,才能继续给通道发送消息。
  • 对于有缓冲通道,指的是在通道中可以缓冲指定数量的数据,直到数据填满,发送方阻塞,直到接收方接收,采用了环形数组的方式进行存储。

通道在Go中的代码定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
type hchan struct {
qcount uint // 当前队列中剩余元素个数
dataqsiz uint // 环形队列长度,即可以存放的元素个数
buf unsafe.Pointer // 环形队列指针
elemsize uint16 // 每个元素的大小
closed uint32 // 标识关闭状态
elemtype *_type // 元素类型
sendx uint // 队列下标,指示元素写入时存放到队列中的位置
recvx uint // 队列下标,指示元素从队列的该位置读出
recvq waitq // 等待读消息的goroutine队列
sendq waitq // 等待写消息的goroutine队列
lock mutex // 互斥锁,chan不允许并发读写
}

通道内部维护一个环形队列,队列的长度是在用户创建的时候指定的。

  • sendx代表写入时的位置
  • recvx代表读数据时的位置
  • chan内部还维护了两个等待队列
  • 一个等待读消息的groutine队列
  • 一个等待写消息的groutine队列
  • 一般情况下,recvq和sendq至少有一个为空;一个例外(同一个groutine使用select向channel一边写数据,一边读数据)

注意:

  • channel关闭之后任然可以读,如果channel中仍然有未读取的数据,则仍然可以读取到,没有数据会返回0。
  • 关闭一个未初始化的channel会产生panic。
  • 重复关闭同一个channel会产生painc。
  • 从已经关闭的channel中读取消息不会产生painc,且能读出channel中还未被读取的消息,如果消息已经读出,则返回0值;(
    有缓冲无缓冲都是)。
  • 关闭的channel中读取消息永远不会阻塞,并且会返回一个为false的ok-idiom。

map的底层实现

golang的map是hasmap,使用数组+链表的形式 实现的,使用拉链法消除hash冲突。
map类型的变量默认初始值为nil,需要使用make()函数来分配内存。语法为:

1
2
map[keyType]ValueType
make(map[KeyType]ValueType, [cap])

golang的map有两种重要的结构:hmap,bmap,主要就是hmap中包含bmap的一个指针。
存储时key不能重复,如果重复则覆盖value,通过key进行hash运算(可以简单的理解为把key转化为一个整数)然后对数组的长度取余,得到key存储在数组的哪个下标位置,咱以后将key和value组装成一个结构体,放入下标的位置。

1. 关于hash冲突

数组一个下标只能存储一个元素,也就是说一个数组下标只能存储一对key,value,hashkey(小明)=4占用了下标0的位置,假设我们遇到了另一个key,它的hashkey(
xiaowang)也是4,这就是hash冲突(不同的key经过hash之后得到的值一样)那么key=xiaowang怎么存储?

  • 开放定址法:当我们存储一个key,value时,发现下标已经被占了,那么我们再这个数组中重新找一个没被占用的存储这个冲突的key,那么没有被占用的有很多,找哪个呢?常见的有线性探测法,线性补偿探测法、随机探测法。

  • 线性探测:按照顺序来,从冲突的下标开始往后探测,到达数组末尾时,从数组开始探测,直到找到一个空位置存储这个key,当数组找不到的情况下会扩容(当数组快满的时候就会扩容了);
    如下图:首先存储key=xiaoming在下标0处,当存储key=xiaowang时,hash冲突了,按照线性探测,存储在下标1处,(红色的线是冲突或者下标已经被占用了)
    再者key=xiaozhao存储在下标4处,当存储key=xiaoliu是,hash冲突了,按照线性探测,从头开始,存储在下标2处 (黄色的是冲突或者下标已经被占用了)

  • 拉链法:拉链简单理解为链表,当key的hash冲突的时候,我们在冲突位置的元素上形成一个链表,通过指针相互连接,当查找时,发现key冲突,顺着链表一直往下找,直到链表的尾结点,找不到则返回空;
    如下图描述:

    开放定址和拉链的优缺点:

    • 拉链比线性探测处理简单
    • 线性探测查找会比拉链更消耗时间
    • 线性探测会比拉链更容易导致扩容,而拉链不会
    • 拉链存储了指针,所以空间上会比线性探测占用多一点
    • 拉链是动态申请存储空间的,所以更适合链长不确定的。

2. HashMap怎么扩容

首先需要知道哈希表的存储过程,当有新的数据进行存储的时候,需要根据key计算出它的哈希值h,假设哈希表的
容量是n,那么键值对就会放在h%n个位置中,如果该位置已经有了键值对,会分局开放寻址法或者拉链法解决冲突;
哈希表的扩容会创建原来的两倍容量,因此即使key的哈希值不变,求余结果也会改变,因此所有的键值对存放的位置都会发生改变,此时需要重新哈希。扩容的时候需要分配一个新的数组,新数组是老数组的2倍长,然后遍历旧的哈希,重新分配到新的结果中。

Go中的interface关键字

  • interface是方法声明的集合
  • 任何类型的对象实现在interface接口中声明的全部方法,则表明这个类型实现了接口
  • interface可以作为一种数据类型,实现了该接口的任何对象,都可以给对应的接口类型变量赋值
  • interface可以被任意对象实现,一个类型/对象也可以实现多个(interface)接口
  • 继承和多态的特点,在golang的语法中对多态的特点体现从语法上不是很明显
  • 父类是子类的私有内部类(组合)
  • 发生多态的几个要素:(满足这三个条件,就可以产生多态的效果,父类可以调用子类的具体方法)
  • 有interface接口,并且有接口定义的方法(可以看做一个类型,父类)
  • 有子类去重写interface的接口
  • 有父类指针指向子类的具体对象

GMP并发模型:

  • G代表goroutine,占用内存更小(几kb),能在有限的内存空间支持更多的并发,调度灵活度更高(runtime调度)
  • M代表操作系统线程,负责执行 Goroutine
  • P代表逻辑处理器,负责调度 Goroutine
  • 包含了运行goroutine的资源processor处理
  • 可以运行的G队列
    在Go中线程是运行goroutine的实体,调度器(runtime)的功能是把可运行的 goroutine分配到工作线程上。
    Goroutine调度器和OS调度器是通过M结合起来的,每个M都代表了1个内核线程,OS调度器负责把内核线程分配到CPU执行。

1. GMP 模型的优势

  • 高效利用 CPU:通过多 P 和多 M 的绑定,充分利用多核 CPU 的计算能力。
  • 低延迟:Goroutine 的调度由 Go 运行时负责,避免了操作系统线程切换的开销。
  • 高并发:Goroutine 的轻量级特性使得 Go 程序可以轻松创建成千上万的并发任务。
    以下是一个简单的 Go 程序,展示了 Goroutine 的创建和调度:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package main

import (
"fmt"
"time"
)

func worker(id int) {
for i := 0; i < 3; i++ {
fmt.Printf("Worker %d: %d\n", id, i)
time.Sleep(time.Second)
}
}

func main() {
for i := 0; i < 5; i++ {
go worker(i)
}
time.Sleep(5 * time.Second) // 等待所有 Goroutine 执行
}

GO的GC垃圾回收机制

三色标记清楚法:

  • 初始化所有的对象为白色;
  • 从root根出发扫描所有的根对象,将他们引用的对象标记为灰色;
  • 这里的根对象是程序运行到当前时刻的栈和全局数据区域;
  • 分析灰色对象是否引用了其他的对象,如果没有引用其他的对象,则将该灰色标记为黑色,如果有引用,则将它变为黑色的同时将引用对象标记为灰色;
  • 重复步骤三,直到灰色对象队列为空,此时白色的对象是垃圾,进行回收。

Context关键字

Go1.7加入了一个新的标准库context,它定义了Context类型,专门用来简化 对于处理单个请求的多个 goroutine
之间与请求域的数据、取消信号、截止时间等相关操作,这些操作可能涉及多个 API 调用。
对服务器传入的请求应该创建上下文,而对服务器的传出调用应该接受上下文。它们之间的函数调用链必须传递上下文,或者可以使用WithCancel(
获得数据通知关闭)、(获得数据定时关闭)WithDeadline、(超时关闭)WithTimeout或(传递KV)
WithValue创建的派生上下文。当一个上下文被取消时,它派生的所有上下文也被取消。
context.Background()返回的是全局的上下文根(我在文章中多次提到),context.TODO()返回的是空的上下文(表明应用的不确定性)

GO内存分配与逃逸分析

  • 逃逸分析的好处是为了减少gc的压力,不逃逸的对象分配到栈上,当函数返回释放即可,不需要gc标记删除
  • 逃逸分析完成之后可以确定哪些变量可以分配在栈上,栈的分配比堆快,性能好(逃逸变量分配到堆,不逃逸变量分配到栈)
  • 同步消除,如果你定义的对象的方法上有同步锁,在运行时,却只有一个线程在访问,此时逃逸分析后的机器码会去掉同步锁运行
    总结:
  • 栈上分配内存比在堆中分配内存有更高的效率
  • 栈上分配的内存不需要GC处理
  • 堆上分配的内存使用完毕会交给GC处理
  • 逃逸分析的目的是决定分配地址是栈还是堆
  • 逃逸分析在编译阶段完成

1. 函数传递指针真的比传值效率高吗?

  • 传递指针可以减少底层值拷贝,可以提高效率,但是如果拷贝数据量小,由于指针传递会产生逃逸;
  • 可能会使用堆,也可能会增加GC的负担,所以传递指针不一定是高效的;
    :函数内存申请临时变量,并不会作为返回值返回,它就会被编译器申请到栈中
    在函数中申请一个新对象,如果在栈中,函数执行结束会自动将内存回收;
    申请到栈内存好处:函数返回直接释放,不会引起垃圾回收,对性能没有影响.
    :函数申请内存之后,作为返回值返回了,编译器会默认变量之后还会被使用,
    当函数返回之后并不会将内存归还,那么他就会被申请到堆中.
    如果分配到堆中,则函数执行结束之后交给GC处理;
    申请到堆的内存:会引起垃圾回收,如果这个过程(特指垃圾回收不断被触发)过高频次就会导致gc压力过大,性能变低。
1
2
3
4
5
6
7
8
9
10
11
12
func F() []int{
a := make([]int, 0, 20)
return a
}

func F() {
a := make([]int, 0, 20) // 栈 空间小
b := make([]int, 0, 20000) // 堆 空间过大

l := 20
c := make([]int, 0, l) // 堆 动态分配不定空间,也会在堆
}

2. 什么是逃逸分析,有哪些场景

逃逸分析指的是由编译器决定内存分配的位置,不需要开发者指定。

1.逃逸场景(什么时候才会被分配到堆中)

  • 指针逃逸 : go可以返回局部变量指针,典型的变量逃逸案例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
package main
type Student struct {
Name string
Age int
}
func StudentRegister(name string,age int)*Student {
s :=new(Student) //局部变量s逃逸到堆
s.Name = name
s.Age = age
return s
}
func main() {
StudentRegister("Jin",19)
}

虽然在函数StudentRegister中内存s 为局部变量,它的值通过函数返回值返回,s本身为一个指针

它指向发内存地址不会是对,而是栈,这是典型的逃逸案例

终端执行: go build -gcflags = - m

1
2
3
4
5
6
7
8
C:\Users\Administrator\go\src\内存逃逸分析>go build -gcflags=-m
# 内存逃逸分析
.\1.指针逃逸.go:8:6: can inline StudentRegister
.\1.指针逃逸.go:15:6: can inline main
.\1.指针逃逸.go:16:17: inlining call to StudentRegister
.\1.指针逃逸.go:8:22: leaking param: name
.\1.指针逃逸.go:9:9: new(Student) escapes to heap //显示指针逃逸
.\1.指针逃逸.go:16:17: new(Student) does not escape
  • 栈空间不足逃逸(空间开辟过大)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package main

func Slice() {
s := make([]int, 10000, 10000)

for index, _ := range s {
s[index] = index
}
}
func main() {
Slice()
}
//当切片长度扩大到100000时会逃逸
//实际上当栈空间不足以存放当前对象或者无法判断当前切片长度时会将对象分配到堆中

C:\Users\Administrator\go\src\内存逃逸分析>go build -gcflags=-m
# 内存逃逸分析
.\1.指针逃逸.go:11:6: can inline main
.\1.指针逃逸.go:4:11: make([]int, 10000, 10000) escapes to heap
  • 动态类型逃逸(不确定长度大小)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
package main
import "fmt"
func main() {
s := "Escape"
fmt.Println(s)
}

C:\Users\Administrator\go\src\内存逃逸分析>go build -gcflags=-m
# 内存逃逸分析
.\1.指针逃逸.go:7:13: inlining call to fmt.Println
.\1.指针逃逸.go:7:13: s escapes to heap
.\1.指针逃逸.go:7:13: []interface {} literal does not escape
<autogenerated>:1: .this does not escape
<autogenerated>:1: .this does not escape
  • 闭包引用对象逃逸
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
package main

import "fmt"

func Fibonacci() func() int {
a, b := 0, 1
return func() int {
a, b = b, a+b
return a
}
}

func main() {
f := Fibonacci()

for i := 0; i < 10; i++ {
fmt.Printf("Fibonacci: %d\n", f())
}
}

C:\Users\Administrator\go\src\内存逃逸分析>go build -gcflags=-m
# 内存逃逸分析
.\1.指针逃逸.go:7:9: can inline Fibonacci.func1
.\1.指针逃逸.go:17:13: inlining call to fmt.Printf
.\1.指针逃逸.go:6:2: moved to heap: a
.\1.指针逃逸.go:6:5: moved to heap: b
.\1.指针逃逸.go:7:9: func literal escapes to heap
.\1.指针逃逸.go:17:34: f() escapes to heap
.\1.指针逃逸.go:17:13: []interface {} literal does not escape
<autogenerated>:1: .this does not escape
<autogenerated>:1: .this does not escape

runtime机制是什么

go 语言的可执行文件已经包含了 golang 的 runtime,它为用户的 go 程序提供协程调度、内存分配、垃圾回收等功能.此外还会与系统内核进行交互,从而真正的利用好
CPU 等资源。

  • Gosched:让当前线程让出 cpu 以让其它线程运行,它不会挂起当前线程,因此当前线程未来会继续执行
  • NumCPU:返回当前系统的 CPU 核数量
  • GOMAXPROCS:设置最大的可同时使用的 CPU 核数
  • Goexit:退出当前 goroutine(但是defer语句会照常执行)
  • NumGoroutine:返回正在执行和排队的任务总数
  • GOOS:目标操作系统
1
2
3
fmt.Println("cpus:", runtime.NumCPU())
fmt.Println("goroot:", runtime.GOROOT())
fmt.Println("archive:", runtime.GOOS)

Go语言底层基础知识
https://zhyyao.me/2021/02/01/technology/golang/go_basic/
作者
zhyyao
发布于
2021年2月1日
许可协议