数组:数组是由相同类型元素的集合组成的数据结构
切片:动态数组,其长度并不固定,可以向切片中追加元素,它会在容量不足时自动扩容
Hash表:哈希表示的是键值对之间映射关系
函数参数传递:Go语言选择了传值的方式,无论是传递基本类型、结构体还是指针,都会对传递的参数进行拷贝.
- 传值:函数调用时会对参数进行拷贝,被调用方和调用方两者持有不相关的两份数据;
- 传引用:函数调用时会传递参数的指针,被调用方和调用方两者持有相同的数据,任意一方做出的修改都会影响另一方。
结构体和指针:将指针作为参数传入某个函数时,函数内部会复制指针,也就是会同时出现两个指针指向原有的内存空间;应该尽量使用指针作为参数类型来避免发生数据拷贝进而影响性能。
- 传递结构体时:会拷贝结构体中的全部内容;
- 传递结构体指针时:会拷贝结构体指针.
接口:Java 中的类必须通过implements显式地声明实现的接口,Go语言中接口是隐式的,定义接口需要使用 interface 关键字,只能定义方法签名,不能包含成员变量。
type error interface {
Error() string
}
#实现 error 接口,只需要实现 Error() string 方法即可
type RPCError struct {
Code int64
Message string
}
func (e *RPCError) Error() string {
return fmt.Sprintf("%s, code=%d", e.Message, e.Code)
}
interface{}:特殊的类型不是任意类型
package main
type TestStruct struct{}
func NilOrNot(v interface{}) bool {
return v == nil
}
func main() {
var s *TestStruct
fmt.Println(s == nil) // #=> true
fmt.Println(NilOrNot(s)) // #=> false
}
$ go run main.go
true
false
调用 NilOrNot 函数时发生了隐式的类型转换,除了向方法传入参数之外,变量的赋值也会触发隐式类型转换;
转换后的变量interface{}类型不仅包含转换前的变量,还包含变量的类型信息TestStruct
实现接口的类型和初始化返回的类型不同,编译器的检查能否通过
结构体实现接口 结构体指针实现接口
结构体初始化变量 通过 不通过
结构体指针初始化变量 通过 通过
type Duck interface {
Quack()
}
type Cat struct{}
---------------通过----------
func (c Cat) Quack() {
fmt.Println("meow")
}
func main() {
var c Duck = &Cat{}
c.Quack()
}
----------------不通过----------------
func (c *Cat) Quack() {
fmt.Println("meow")
}
func main() {
var c Duck = Cat{}
c.Quack()
}
$ go build interface.go
./interface.go:20:6: cannot use Cat literal (type Cat) as type Duck in assignment:
Cat does not implement Duck (Quack method has pointer receiver)
无论上述代码中初始化的变量 c 是 Cat{} 还是 &Cat{},使用 c.Quack() 调用方法时都会发生值拷贝:
反射:reflect 实现了运行时的反射能力,能够让程序操作不同类型的对象;reflect.TypeOf 能获取类型信息、reflect.ValueOf 能获取数据的运行时表示。
func main() {
i := 1
v := reflect.ValueOf(i)
v.SetInt(10)
fmt.Println(i)
}
$ go run reflect.go
panic: reflect: reflect.flag.mustBeAssignable using unaddressable value
由于 Go 语言的函数调用都是传值的,所以我们得到的反射对象跟最开始的变量没有任何关系,那么直接修改反射对象无法改变原始变量,程序为了防止错误就会崩溃。
需要这样实现:调用 reflect.Value.Elem 获取指针指向的变量
func main() {
i := 1
v := reflect.ValueOf(&i)
v.Elem().SetInt(10)
fmt.Println(i)
}
快速遍历数组、切片、哈希表以及 Channel 等集合类型
循环永动机
#遍历切片时追加的元素不会增加循环的执行次数
func main() {
arr := []int{1, 2, 3}
for _, v := range arr {
arr = append(arr, v)
}
fmt.Println(arr)
}
$ go run main.go
1 2 3 1 2 3
原因:编译期将原切片或者数组赋值给一个新变量 ha,在赋值的过程中就发生了拷贝
神奇的指针
func main() {
arr := []int{1, 2, 3}
newArr := []*int{}
for _, v := range arr {
newArr = append(newArr, &v)
}
for _, v := range newArr {
fmt.Println(*v)
}
}
$ go run main.go
3 3 3
正确的做法应该是使用 &arr[i] 替代 &v
原因:同时遍历索引和元素的 range 循环时,Go 语言会额外创建一个新的 v2 变量存储切片中的元素,循环中使用的这个变量 v2 会在每一次迭代被重新赋值而覆盖,赋值时也会触发拷贝,因为在循环中获取返回变量的地址都完全相同,所以返回值一样。
随机遍历
func main() {
hash := map[string]int{
"1": 1,
"2": 2,
"3": 3,
}
for k, v := range hash {
println(k, v)
}
}
每次运行上述代码可能会得到不同顺序的结果
Go 团队在设计哈希表的遍历时就不想让使用者依赖固定的遍历顺序,所以引入了随机数保证遍历的随机性
select 是操作系统中的系统调用,我们经常会使用 select、poll 和 epoll 等函数构建 I/O 多路复用模型提升程序的性能。select 能够让 Goroutine 同时等待多个 Channel 可读或者可写,在多个文件或者 Channel状态改变之前,select 会一直阻塞当前线程或者 Goroutine。
func fibonacci(c, quit chan int) {
x, y := 0, 1
for {
select {
case c <- x:
x, y = y, x+y
case <-quit:
fmt.Println("quit")
return
#非阻塞收发使用default,就不会阻塞当前的 Goroutine
default:
println("default")
}
}
}
上述控制结构会等待 c <- x 或者 <-quit 两个表达式中任意一个返回。
无论哪一个表达式返回都会立刻执行 case 中的代码,当 select 中的两个 case 同时被触发时,会随机执行其中的一个。
当不存在可以收发的 Channel 时,执行 default 中的语句
实现原理:
select 语句在编译期间会被转换成 OSELECT 节点。每个 OSELECT 节点都会持有一组 OCASE 节点,如果 OCASE 的执行条件是空,那就意味着这是一个 default 节点
常见流程:
在默认的情况下,编译器会使用如下的流程处理 select 语句:
1、将所有的 case 转换成包含 Channel 以及类型等信息的 runtime.scase 结构体;
2、调用运行时函数 runtime.selectgo 从多个准备就绪的 Channel 中选择一个可执行的 runtime.scase 结构体;
3、通过 for 循环生成一组 if 语句,在语句中判断自己是不是被选中的 case;
selv := [3]scase{}
order := [6]uint16
for i, cas := range cases {
c := scase{}
c.kind = ...
c.elem = ...
c.c = ...
}
chosen, revcOK := selectgo(selv, order, 3)
if chosen == 0 {
...
break
}
if chosen == 1 {
...
break
}
if chosen == 2 {
...
break
}
初始化:
func selectgo(cas0 *scase, order0 *uint16, ncases int) (int, bool) {
cas1 := (*[1 << 16]scase)(unsafe.Pointer(cas0))
order1 := (*[1 << 17]uint16)(unsafe.Pointer(order0))
ncases := nsends + nrecvs
scases := cas1[:ncases:ncases]
pollorder := order1[:ncases:ncases]
lockorder := order1[ncases:][:ncases:ncases]
norder := 0
for i := range scases {
cas := &scases[i]
}
for i := 1; i < ncases; i++ {
j := fastrandn(uint32(i + 1))
pollorder[norder] = pollorder[j]
pollorder[j] = uint16(i)
norder++
}
pollorder = pollorder[:norder]
lockorder = lockorder[:norder]
// 根据 Channel 的地址排序确定加锁顺序
...
sellock(scases, lockorder)
...
}
在编译期间,Go 语言会对 select 语句进行优化,它会根据 select 中 case 的不同选择不同的优化路径:
- 空的 select 语句会被转换成调用 runtime.block 直接挂起当前 Goroutine;
- 如果 select 语句中只包含一个 case,编译器会将其转换成 if ch == nil { block }; n; 表达式;首先判断操作的 Channel 是不是空的;然后执行 case 结构中的内容;
- 如果 select 语句中只包含两个 case 并且其中一个是 default,那么会使用 runtime.selectnbrecv 和 runtime.selectnbsend 非阻塞地执行收发操作;
- 在默认情况下会通过 runtime.selectgo 获取执行 case 的索引,并通过多个 if 语句执行对应 case 中的代码;
编译器已经对 select 语句进行优化之后,Go 语言会在运行时执行编译期间展开的 runtime.selectgo 函数,该函数会按照以下的流程执行:
- 随机生成一个遍历的轮询顺序 pollOrder 并根据 Channel 地址生成锁定顺序 lockOrder;
- 根据 pollOrder 遍历所有的 case 查看是否有可以立刻处理的 Channel;如果存在,直接获取 case 对应的索引并返回;如果不存在,创建 runtime.sudog 结构体,将当前 Goroutine 加入到所有相关 Channel 的收发队列,并调用 runtime.gopark 挂起当前 Goroutine 等待调度器的唤醒;
- 当调度器唤醒当前 Goroutine 时,会再次按照 lockOrder 遍历所有的 case,从中查找需要被处理的 runtime.sudog 对应的索引;
defer 会在当前函数返回前执行传入的函数,它会经常被用于关闭文件描述符、关闭数据库连接以及解锁资源。
func main() {
{
defer fmt.Println("defer runs")
fmt.Println("block ends")
}
fmt.Println("main ends")
}
$ go run main.go
block ends
main ends
defer runs
注:defer 传入的函数不是在退出代码块的作用域时执行的,它只会在当前函数和方法返回之前被调用
func main() {
startedAt := time.Now()
defer fmt.Println(time.Since(startedAt))
time.Sleep(time.Second)
}
$ go run main.go
0s
调用 defer 关键字会立刻拷贝函数中引用的外部参数,所以 time.Since(startedAt) 的结果不是在 main 函数退出之前计算的,而是在 defer 关键字调用时计算的,最终导致上述代码输出 0s
------------------------------------------------------------------------------
func main() {
startedAt := time.Now()
defer func() { fmt.Println(time.Since(startedAt)) }()
time.Sleep(time.Second)
}
$ go run main.go
1s
虽然调用 defer 关键字时也使用值传递,但是因为拷贝的是函数指针,所以 time.Since(startedAt) 会在 main 函数返回前调用并打印出符合预期的结果
panic 能够改变程序的控制流,调用 panic 后会立刻停止执行当前函数的剩余代码,并在当前 Goroutine 中递归执行调用方的 defer;
recover 可以中止 panic 造成的程序崩溃。它是一个只能在 defer 中发挥作用的函数,在其他作用域中调用不会发挥作用;
#跨协程失效
func main() {
defer println("in main")
go func() {
defer println("in goroutine")
panic("")
}()
time.Sleep(1 * time.Second)
}
$ go run main.go
in goroutine
panic:
注:panic 只会触发当前 Goroutine 的延迟函数调用
------------------------------------------------------------------
失效的崩溃恢复:
func main() {
defer fmt.Println("in main")
if err := recover(); err != nil {
fmt.Println(err)
}
panic("unknown err")
}
$ go run main.go
in main
panic: unknown err
goroutine 1 [running]:
main.main()
...
exit status 2
注:recover 是在 panic 之前调用的,并不满足生效的条件,所以我们需要在 defer 中使用 recover 关键字
------------------------------------------------------------
嵌套崩溃:
func main() {
defer fmt.Println("in main")
defer func() {
defer func() {
panic("panic again and again")
}()
panic("panic again")
}()
panic("panic once")
}
$ go run main.go
in main
panic: panic once
panic: panic again
panic: panic again and again
goroutine 1 [running]:
...
exit status 2
...
注:程序多次调用 panic 也不会影响 defer 函数的正常执行
slice := make([]int, 0, 100)
hash := make(map[int]bool, 10)
ch := make(chan int, 5)
i := new(int)
var v int
i := &v
Go 语言中用来设置截止日期、同步信号,传递请求相关值的结构体.
主要作用还是在多个 Goroutine 组成的树中同步取消信号以减少对资源的消耗和占用;
虽然它也有传值的功能,但是传递请求的所有参数是一种非常差的设计,这个功能常见的使用场景是传递请求对应用户的认证令牌以及用于进行分布式追踪的请求 ID。
type Context interface {
//返回 context.Context 被取消的时间,也就是完成工作的截止日期
Deadline() (deadline time.Time, ok bool)
//返回一个 Channel,这个 Channel 会在当前工作完成或者上下文被取消后关闭,多次调用 Done 方法会返回同一个 Channel
Done() <-chan struct{}
//返回 context.Context 结束的原因,它只会在 Done 方法对应的 Channel 关闭时返回非空的值
//如果 context.Context 被取消,会返回 Canceled 错误
//如果 context.Context 超时,会返回 DeadlineExceeded 错误
Err() error
//从 context.Context 中获取键对应的值,对于同一个上下文来说,
//多次调用 Value 并传入相同的 Key 会返回相同的结果,该方法可以用来传递请求特定的数据
Value(key interface{}) interface{}
}
context.Background、context.TODO、context.WithDeadline 和 context.WithValue 函数会返回实现该接口的私有结构体
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
go handle(ctx, 500*time.Millisecond)
select {
case <-ctx.Done():
fmt.Println("main", ctx.Err())
}
}
func handle(ctx context.Context, duration time.Duration) {
select {
case <-ctx.Done():
fmt.Println("handle", ctx.Err())
case <-time.After(duration):
fmt.Println("process request with", duration)
}
}
$ go run context.go
process request with 500ms
main context deadline exceeded
//处理请求时间增加至 1500ms,整个程序都会因为上下文的过期而被中止
$ go run context.go
main context deadline exceeded
handle context deadline exceeded
锁是一种并发编程中的同步原语(Synchronization Primitives),它能保证多个 Goroutine 在访问同一片内存时不会出现竞争条件(Race condition)等问题。
type Mutex struct {
state int32 //当前互斥锁的状态
sema uint32 //用于控制锁状态的信号量
}
状态:
-mutexLocked — 表示互斥锁的锁定状态
-mutexWoken — 表示从正常模式被从唤醒
-mutexStarving — 当前的互斥锁进入饥饿状态
-waitersCount — 当前互斥锁上等待的 Goroutine 个数
正常模式:锁的等待者会按照先进先出的顺序获取锁。
饥饿模式:互斥锁会直接交给等待队列最前面的 Goroutine.
正常模式下的互斥锁能够提供更好地性能,饥饿模式的能避免 Goroutine 由于陷入等待无法获取锁而造成的高尾延时。
读写互斥锁 sync.RWMutex 是细粒度的互斥锁,它不限制资源的并发读,但是读写、写写操作无法并行执行。
//sync.WaitGroup 可以等待一组 Goroutine 的返回,将原本顺序执行的代码在多个 Goroutine 中并发执行,加快程序处理的速度
requests := []*Request{...}
wg := &sync.WaitGroup{}
wg.Add(len(requests))
for _, request := range requests {
go func(r *Request) {
defer wg.Done()
// res, err := service.call(r)
}(request)
}
wg.Wait()
保证在 Go 程序运行期间的某段代码只会执行一次
包含条件变量 sync.Cond,它可以让一组的 Goroutine 都在满足特定条件时被唤醒,sync.Cond 结构体在初始化时都需要传入一个互斥锁
sync.Cond.Wait :会将当前 Goroutine 陷入休眠状态,它的执行过程分成以下两个步骤:
- 调用 runtime.notifyListAdd 将等待计数器加一并解锁;
- 调用 runtime.notifyListWait 等待其他 Goroutine 的唤醒并加锁:
sync.Cond.Signal 和 sync.Cond.Broadcast 就是用来唤醒陷入休眠的 Goroutine 的方法- sync.Cond.Signal 方法会唤醒队列最前面的 Goroutine
- sync.Cond.Broadcast 方法会唤醒队列中全部的 Goroutine
- sync.Cond.Wait 在调用之前一定要使用获取互斥锁,否则会触发程序崩溃;
- sync.Cond.Signal 唤醒的 Goroutine 都是队列最前面、等待最久的 Goroutine;
- sync.Cond.Broadcast 会按照一定顺序广播通知等待的全部 Goroutine;
在一组 Goroutine 中提供了同步、错误传播以及上下文取消的功能,我们可以使用如下所示的方式并行获取网页的数据.
var g errgroup.Group
var urls = []string{
"http://www.golang.org/",
"http://www.google.com/",
"http://www.somestupidname.com/",
}
for i := range urls {
url := urls[i]
g.Go(func() error {
resp, err := http.Get(url)
if err == nil {
resp.Body.Close()
}
return err
})
}
if err := g.Wait(); err == nil {
fmt.Println("Successfully fetched all URLs.")
}
信号量是在并发编程中常见的一种同步机制,在需要控制访问资源的进程数量时就会用到信号量,它会保证持有的计数器在 0 到初始化的权重之间波动。
在一个服务中抑制对下游的多次重复请求。一个比较常见的使用场景是:我们在使用 Redis 对数据库中的数据进行缓存,发生缓存击穿时,大量的流量都会打到数据库上进而影响服务的尾延时.
在资源的获取非常昂贵时(例如:访问缓存、数据库),就很适合使用 golang/sync/singleflight.Group 优化服务
type service struct {
requestGroup singleflight.Group
}
func (s *service) handleRequest(ctx context.Context, request Request) (Response, error) {
v, err, _ := requestGroup.Do(request.Hash(), func() (interface{}, error) {
rows, err := // select * from tables
if err != nil {
return nil, err
}
return rows, nil
})
if err != nil {
return nil, err
}
return Response{
rows: rows,
}, nil
}
var timers struct {
lock mutex
gp *g
created bool
sleeping bool
rescheduling bool
sleepUntil int64
waitnote note
t []*timer
}
全局四叉堆
分片四叉堆
支撑 Go 语言高性能并发编程模型的重要结构,Go 核心的数据结构和 Goroutine 之间的通信方式
type hchan struct {
qcount uint
dataqsiz uint
buf unsafe.Pointer
elemsize uint16
closed uint32
elemtype *_type
sendx uint
recvx uint
recvq waitq
sendq waitq
lock mutex
}
qcount — Channel 中的元素个数;
dataqsiz — Channel 中的循环队列的长度;
buf — Channel 的缓冲区数据指针;
sendx — Channel 的发送操作处理到的位置;
recvx — Channel 的接收操作处理到的位置;
创建管道:
如果我们不向 make 传递表示缓冲区大小的参数,那么就会设置一个默认值 0,也就是当前的 Channel 不存在缓冲区
发送数据:
- 直接发送:如果目标 Channel 没有被关闭并且已经有处于读等待的 Goroutine,那么 runtime.chansend 会从接收队列 recvq 中取出最先陷入等待的 Goroutine 并直接向它发送数据;
1、调用 runtime.sendDirect 将发送的数据直接拷贝到 x = <-c 表达式中变量 x 所在的内存地址上;
2、调用 runtime.goready 将等待接收数据的 Goroutine 标记成可运行状态 Grunnable 并把该 Goroutine 放到发送方所在的处理器的 runnext 上等待执行,该处理器在下一次调度时会立刻唤醒数据的接收方;- 阻塞发送:当 Channel 没有接收者能够处理数据时,向 Channel 发送数据会被下游阻塞
接收数据:- 直接接收:当 Channel 的 sendq 队列中包含处于等待状态的 Goroutine 时,该函数会取出队列头等待的 Goroutine,处理的逻辑和发送时相差无几,只是发送数据时调用的是 runtime.send 函数,而接收数据时使用 runtime.recv
- 阻塞接收:当 Channel 的发送队列中不存在等待的 Goroutine 并且缓冲区中也不存在任何数据时,从管道中接收数据的操作会变成阻塞的,
然而不是所有的接收操作都是阻塞的,与 select 语句结合使用时就可能会使用到非阻塞的接收操作- 关闭管道:
编译器会将用于关闭管道的 close 关键字转换成 OCLOSE 节点以及 runtime.closechan 函数
设计原理:内存管理一般包含三个不同的组件,分别是用户程序(Mutator)、分配器(Allocator)和收集器(Collector),当用户程序申请内存时,它会通过内存分配器申请新内存,而分配器会负责从堆中初始化相应的内存区域。
1、线性分配器
只需要在内存中维护一个指向内存特定位置的指针,如果用户程序向分配器申请内存,分配器只需要检查剩余的空闲内存、返回分配的内存区域并修改指针在内存中的位置;
虽然线性分配器实现为它带来了较快的执行速度以及较低的实现复杂度,但是线性分配器无法在内存被释放时重用内存;
因为线性分配器具有上述特性,所以需要与合适的垃圾回收算法配合使用;标记压缩(Mark-Compact)、复制回收(Copying GC)和分代回收(Generational GC)等算法,它们可以通过拷贝的方式整理存活对象的碎片,将空闲内存定期合并,这样就能利用线性分配器的效率提升内存分配器的性能了
它在内部会维护一个类似链表的数据结构。当用户程序申请内存时,空闲链表分配器会依次遍历空闲的内存块,找到足够大的内存,然后申请新的资源并修改链表
首次适应(First-Fit)— 从链表头开始遍历,选择第一个大小大于申请内存的内存块;
循环首次适应(Next-Fit)— 从上次遍历的结束位置开始遍历,选择第一个大小大于申请内存的内存块;
最优适应(Best-Fit)— 从链表头遍历整个链表,选择最合适的内存块;
隔离适应(Segregated-Fit)— 将内存分割成多个链表,每个链表中的内存块大小相同,申请内存时先找到满足条件的链表,再从链表中选择合适的内存块;
TCMalloc(线程缓存分配):使用多级缓存将对象根据大小分类,并按照类别实施不同的分配策略
Go 语言的内存分配器会根据申请分配的内存大小选择不同的处理逻辑,运行时根据对象的大小将对象分成微对象、小对象和大对象三种
内存分配器不仅会区别对待大小不同的对象,还会将内存分成不同的级别分别管理,运行时分配器都会引入线程缓存(Thread Cache)、中心缓存(Central Cache)和页堆(Page Heap)三个组件分级管理内存
Go 语言 1.10 以前的版本堆区的内存空间都是连续的,在1.11 版本使用稀疏的堆内存空间替代了连续的内存,解决了连续内存带来的限制。
线性内存:
- spans 区域存储了指向内存管理单元 runtime.mspan 的指针,每个内存单元会管理几页的内存空间,每页大小为 8KB;
- bitmap 用于标识 arena 区域中的那些地址保存了对象,位图中的每个字节都会表示堆区中的 32 字节是否空闲;
- arena 区域是真正的堆区,运行时会将 8KB 看做一页,这些内存页中存储了所有在堆上初始化的对象;
线性的堆内存需要预留大块的内存空间,但是申请大块的内存空间而不使用是不切实际的,不预留内存空间却会在特殊场景下造成程序崩溃
稀疏内存:
稀疏的内存布局不仅能移除堆大小的上限,还能解决 C 和 Go 混合使用时的地址空间冲突问题,但内存管理更加复杂。
内存最终都是要从操作系统中申请的,所以 Go 语言的运行时构建了操作系统的内存管理抽象层:
组件:
堆上所有的对象都会通过调用 runtime.newobject 函数分配内存,该函数会调用 runtime.mallocgc 分配指定大小的内存空间,根据对象的大小执行不同的分配逻辑
用户程序(Mutator)会通过内存分配器(Allocator)在堆上申请内存,而垃圾收集器(Collector)负责回收堆上的内存空间,内存分配器和垃圾收集器共同管理着程序中的堆内存空间。
最常见标记清除(Mark-Sweep)垃圾收集算法是跟踪式垃圾收集器
- 标记阶段 — 从根对象出发查找并标记堆中所有存活的对象;
- 清除阶段 — 遍历堆中的全部对象,回收未被标记的垃圾对象并将回收的内存加入空闲链表;
标记阶段结束后会进入清除阶段,在该阶段中收集器会依次遍历堆中的所有对象,释放其中没有被标记的 B、E 和 F 三个对象并将新的空闲内存空间以链表的结构串联起来,方便内存分配器的使用
整个过程需要标记对象的存活状态,用户程序在垃圾收集的过程中也不能执行
三色抽象将程序中的对象分成白色、黑色和灰色三类以缩短 STW 的时间
- 白色对象 — 潜在的垃圾,其内存可能会被垃圾收集器回收;
- 黑色对象 — 活跃的对象,包括不存在任何引用外部指针的对象以及从根对象可达的对象;
- 灰色对象 — 活跃的对象,因为存在指向白色对象的外部指针,垃圾收集器会扫描这些对象的子对象;
步骤:- 从灰色对象的集合中选择一个灰色对象并将其标记成黑色;
- 将黑色对象指向的所有对象都标记成灰色,保证该对象和被该对象引用的对象都不会被回收;
- 重复上述两个步骤直到对象图中不存在灰色对象;
当三色的标记清除的标记阶段结束之后,应用程序的堆中就不存在任何的灰色对象,我们只能看到黑色的存活对象以及白色的垃圾对象,垃圾收集器可以回收这些白色的垃圾
屏障技术:像是一个钩子方法,它是在用户程序读取、创建对象以及更新对象指针时执行的一段代码,根据操作类型的不同,我们可以将它们分成读屏障(Read barrier)和写屏障(Write barrier)两种
- 强三色不变性 — 黑色对象不会指向白色对象,只会指向灰色对象或者黑色对象;
- 弱三色不变性 — 黑色对象指向的白色对象必须包含一条从灰色对象经由多个白色对象的可达路径;
垃圾收集器的演进过程:
栈区的内存一般由编译器自动分配和释放,存储函数的入参以及局部变量,会随着函数的创建而创建,函数的返回而消亡。
- 寄存器:栈寄存器是 CPU 寄存器中的一种,它的主要作用是跟踪函数的调用栈
Go 语言的汇编代码包含 BP 和 SP 两个栈寄存器,分别存储了栈的基址指针和栈顶的地址- 线程栈:多数架构上默认栈大小都在 2 ~ 4 MB 左右,极少数架构会使用 32 MB 的栈,用户程序可以在分配的栈上存储函数参数和局部变量
- 逃逸分析:编译器使用逃逸分析决定哪些变量应该在栈上分配,哪些变量应该在堆上分配
指向栈对象的指针不能存在于堆中;指向栈对象的指针不能在栈对象回收后存活;- 栈内存空间:Go 语言使用用户态线程 Goroutine 作为执行上下文,它的额外开销和默认栈大小都比线程小很多
操作系统为每个进程分配一个连续的虚拟内存地址空间,并将该进程内存空间划分成多个不同用途的逻辑区域:
Java程序的运行时内存被划分为元数据区(方法区)、堆、虚拟机栈、本地方法栈、程序计数器;
JVM的堆内存,虚拟机栈、本地方法栈、程序计数器则对应了上图中JVM的栈区,元数据区则是JVM另外开辟的内存块;
元数据区、堆内存为所有线程共享,多线程访问时需要进行同步控制。虚拟机栈、本地方法栈、程序计数器都是线程私有的内存空间,访问时无需加锁,速度很快;
- 元数据区是JVM向操作系统申请的堆外内存,用于实现“方法区”,主要存储虚拟机加载class的类信息、JIT编译的代码、运行时常量池等数据,其默认大小由系统的可用物理内存上限限制;
- 堆内存是被所有线程共享的一块内存区域,在虚拟机启动时创建。它存放了包括几乎所有的Java对象以及数组,这也是垃圾收集器关注的主要内存区;
- 虚拟机栈即我们常说的栈空间,其生命周期与线程相同。线程的栈空间存储了方法调用的栈帧,每个栈帧则存储局部变量表、操作数栈、动态链接、方法出口等信息;
- 本地方法栈为JVM使用到的Native方法提供内存空间,而虚拟机栈为JVM执行Java方法提供内存空间。HotSpot将虚拟机栈与本地方法栈合并管理;
- 程序计数器是一块较小的内存空间,用来指向当前线程需要执行的下一条字节码指令;
- 新生代将内存划分为eden空间和两块较小的survivor空间,每次使用eden和其中一块survivor。
当回收时,将eden和survivor中还存活着的对象一次性地复制到另外一块survivor空间上,最后清理掉eden和刚才用过的survivor空间,这为新生代“标记-复制”回收内存提供算法实现便捷性。eden区与survivor区的大小比例是8:1:1;- 老年代一般存放存活周期较长的对象以及大对象。老年代采用“标记-清除”或“标记-整理”算法回收内存;
- 新建对象总是优先在新生代的eden区中分配,“熬过”多次GC的对象可以从新生代晋升到老年代;
Go的内存分配器是分层级的,由mcache/mcentral/mheap 三个组件构成,mcentral和mheap为所有工作线程所共享,在内存分配时存在同步竞争的情况。
- 缓存组件mcache与工作线程(goroutine)绑定,是goroutine私有的内存空间。在mcache中为对象分配内存时,无需竞争,性能很高;
- 中间组件mcentral 只负责一种规格的内存块,为mcache缓存组件提供备用的特定规格的可用空间;
mcache的内存扩容请求会被分散到不同的mcentral 组件上,以减小共享内存的竞争锁粒度;- 堆组件mheap负责管理用户程序的所有可用堆内存空间以及为大对象直接分配内存。它为上层组件提供扩容支持。当空间不足时,mheap组件向操作系统申请内存;
Go以及Java都支持变量的逃逸分析,逃逸到栈上的对象会随着方法退出而自然回收,而分配到堆内存的对象则需要垃圾收集器回收才能释放出内存空间。
JVM会为新创建的线程在stack栈区分配一块私有的线程栈空间。某一个线程中创建的Java对象可能被分配在新生代,也可能分配在老年代:
- 首先检查该类是否已加载、解析、初始化。如果没有,则执行类加载的过程;
- 分配对象内存时,检查是否启用-XX: +UseTLAB参数。如果启用TLAB,则直接从当前线程的TLAB空间以lock free方式分配指定大小的内存块,速度很快;
- 如果未启用UseTLAB或者TLAB分配失败,JVM将继续在eden区或老年代上为对象分配空间,这时需要做同步操作;
- 对象内存分配成功后,JVM初始化对象零值、设置对象头等元信息,执行对象初始化方法,最后将对象引用压入线程的栈内存中;
Java大对象的分配过程稍有不同,JVM总是直接在老年代中为其分配存储空间。我们可以通过-XX:PretenureSizeThreshold参数来设定大对象的阈值,该参数默认值为0,说明对象总是先在eden区分配,不管这个对象有多大
Go内存分配器管理span以及object两种类型的内存块,span是Go内存管理的基本单元,由多个地址连续的页(8k大小的page内存块)组成的⼤块内存。object则是将span按照特定规格(size class)切分成的多个⼩块,每个⼩块都可以用来存储⼀个对象;
Go对象的内存分配就是从有限的67种规格中找出与对象大小最合适的一块可用内存块的过程。Go使用“空闲列表”方式管理可分配的内存空间,相同规格的内存块连接成一个双向链表。
- mcache缓存是goroutine的私有内存空间,直接为当前goroutine无锁分配小对象的内存块,速度很快。内存分配器首先根据对象大小获取mcache中对应size class的span链表,并从表头span中提取object块进行分配;
- 如果分配器发现mcache下没有对应规格的可用span资源,则会尝试从堆区相应class的mcentral区域中申请扩容。分配器将申请到的span资源链接到mcache链表,继续为对象分配object块空间;
- 如果mcentral中没有找到可用的内存块,分配器会向mheap申请扩容,扩容成功后继续为对象分配内存;
对于大对象,Go的内存分配器直接在mheap分配内存,如果没有找到合适的span内存块,分配器将向操作系统申请扩容后继续分配。
提取的span内存块如果超过了对象规格所需的页数,分配器将尝试分割该span合适大小分配给对象,并合并剩余的空间归还给mheap管理,以减少堆内存碎片。
Go与Java的GC策略是判断对象是否存活,并对其进行标记,或采用“复制”算法、“清除”算法、“整理”算法等完成不可引用对象的内存回收操作。
JVM的垃圾收集是分代的收集
JVM的GC策略根据内存分代可以分为Minor GC(新生代垃圾收集)和Full GC(Major GC,老年代垃圾收集)两类
Go语言最大的特色就是从语言层面支持并发(Goroutine),Goroutine是Go中最基本的执行单元。事实上每一个Go程序至少有一个Goroutine:主Goroutine。当程序启动时,它会自动创建
线程(Thread):有时被称为轻量级进程(Lightweight Process,LWP),是程序执行流的最小单元。一个标准的线程由线程ID,当前指令指针(PC),寄存器集合和堆(heap, 一般由程序员分配释放)栈(stack,由编译器自动分配释放 ,存放函数的参数值,局部变量的值等)组成。另外,线程是进程中的一个实体,是被系统独立调度和分派的基本单位,线程自己不拥有系统资源,只拥有一点儿在运行中必不可少的资源,但它可与同属一个进程的其它线程共享进程所拥有的全部资源。
- 线程拥有自己独立的栈和共享的堆,共享堆,不共享栈,线程的切换一般也由操作系统调度
协程(coroutine):又称微线程与子例程(或者称为函数)一样,协程(coroutine)也是一种程序组件。相对子例程而言,协程更为一般和灵活,但在实践中使用没有子例程那样广泛;
- 和线程类似,共享堆,不共享栈,协程的切换一般由程序员在代码中显式控制。它避免了上下文切换的额外耗费,兼顾了多线程的优点,简化了高并发程序的复杂
package main
import (
"sync"
)
var wg sync.WaitGroup
func say(s string) {
for i := 0; i < 5; i++ {
println(s)
}
wg.Done()
}
func main() {
wg.Add(2)
go say("Hello")
go say("World")
wg.Wait()
}
顺序打印5个hello,5个world
package main
import (
"sync"
)
var wg sync.WaitGroup
func say(s string, c chan string) {
for i := 0; i < 5; i++ {
c <- s
}
wg.Done()
}
func main() {
wg.Add(2)
ch := make(chan string) // 实例化一个管道
go say("Hello", ch)
go say("World", ch)
for {
println(<-ch) //循环从管道取数据
}
wg.Wait()
}
go启动的协程同时向这个2个管道输出数据,主线程使用了一个for循环从管道里面取数据,其实就是一个生产者和消费者模式
World 和 Hello 进入管道的顺序是不固定的,如果循环数据放大,或者在里面加个睡眠会发生死锁
标准的做法是主动关闭管道,或者你知道你应该什么时候关闭管道, 当然你结束程序管道自然也会关掉
i := 1
for {
str := <- ch
println(str)
if i >= 10{
close(ch)
break
}
i++
}
package main
import (
"strconv"
"fmt"
"time"
)
func main() {
ch1 := make(chan int)
ch2 := make(chan string)
go pump1(ch1)
go pump2(ch2)
go suck(ch1, ch2)
time.Sleep(time.Duration(time.Second*30))
}
func pump1(ch chan int) {
for i := 0; ; i++ {
ch <- i * 2
time.Sleep(time.Duration(time.Second))
}
}
func pump2(ch chan string) {
for i := 0; ; i++ {
ch <- strconv.Itoa(i+5)
time.Sleep(time.Duration(time.Second))
}
}
func suck(ch1 chan int, ch2 chan string) {
chRate := time.Tick(time.Duration(time.Second*5)) // 定时器
for {
select {
case v := <-ch1:
fmt.Printf("Received on channel 1: %d\n", v)
case v := <-ch2:
fmt.Printf("Received on channel 2: %s\n", v)
case <-chRate:
fmt.Printf("Log log...\n")
}
}
}
pump1 和 pump2是2个不同的管道,通过select可以实现在不同管道之间切换,
哪个管道有数据就从哪个管道里面取数据,如果都没数据就等着,还有一个定时器功能可以每隔一段时间向管道输出内容
参考
不要以共享内存的方式来通信,相反,要通过通信来共享内存
DO NOT COMMUNICATE BY SHARING MEMORY; INSTEAD, SHARE MEMORY BY COMMUNICATING.
普通的线程并发模型,就是像Java、C++、或者Python,他们线程间通信都是通过共享内存的方式来进行的。非常典型的方式就是,在访问共享数据(例如数组、Map、或者某个结构体或对象)的时候,通过锁来访问,因此,在很多时候,衍生出一种方便操作的数据结构,叫做“线程安全的数据结构”。例如Java提供的包”java.util.concurrent”中的数据结构。
无论语言层面何种并发模型,到了操作系统层面,一定是以线程的形态存在的。而操作系统根据资源访问权限的不同,体系架构可分为用户空间和内核空间;内核空间主要操作访问CPU资源、I/O资源、内存资源等硬件资源,为上层应用程序提供最基本的基础资源,用户空间呢就是上层应用程序的固定活动空间,用户空间不可以直接访问资源,必须通过“系统调用”、“库函数”或“Shell脚本”来调用内核空间提供的资源。
用户级线程模型: 多个用户态的线程对应着一个内核线程,程序线程的创建、终止、切换或者同步等线程工作必须自身来完成。它可以做快速的上下文切换。缺点是不能有效利用多核CPU
内核级线程模型: 直接调用操作系统的内核线程,所有线程的创建、终止、切换、同步等操作,都由内核来完成。一个用户态的线程对应一个系统线程,它可以利用多核机制,但上下文切换需要消耗额外的资源。C++就是这种
两级线程模型: 介于用户级线程模型和内核级线程模型之间的一种线程模型
一个进程中可以对应多个内核级线程,但是进程中的线程不和内核线程一一对应,这种线程模型会先创建多个内核级线程,然后用自身的用户级线程去对应创建的多个内核级线程,自身的用户级线程需要本身程序去调度,内核级的线程交给操作系统内核去调度
四个结构体
两个队列
抢占式调度逻辑
调度本质:调度器P将协程G合理地分配到系统线程M上执行
- M绑定的P首先有1/61概率从全局队列获取G,60/61概率从本地队列获取G
- 全局队列情况下如果没有获取到G,那么从本地队列获取G
- 如果本地队列没有G,那么P从其他P的本地队列窃取G
- 如果窃取不到G,那么从全局队列中获取一部分G到本地队列,获取n = min(len(GQ)/GOMAXPROCS + 1, len(GQ/2))个
- P获取到G后,绑定的M负责执行G,M必须是运行状态的线程,否则不会真正执行
如果当前的M执行的G调用syscall阻塞,P会与M进行分离,M负责执行阻塞的G,P带着队列中的G绑定到新的M中,继续执行其他G;使得虽然当前G进入阻塞,但并没有影响到P去执行其他G
M执行的G阻塞操作返回后,由于没有了P,失去切换上下文执行后续逻辑的机会,因此尝试获取新的P去执行,如果获取不到P,M就把当前G放入全局队列等待调度,自己置于休眠状态
窃取
线程自旋
线程自旋相对于线程阻塞,表现为循环执行指定的逻辑,而不进入阻塞状态。在go的调度逻辑中,为了实现高性能的并发,如果全局队列和本地队列都为空,绑定P的M没有G可以执行,会进入自旋状态等待新的G,不会进入阻塞状态休眠,减少了M的上下文切换成本;
只有绑定了P的M会进入自旋状态,因此最多会有GOMAXPROCS个自旋线程,避免了浪费过多系统资源,其余未绑定的空闲M依然会进入休眠状态
优点:
1、开销小
POSIX的thread API虽然能够提供丰富的API,例如配置自己的CPU亲和性,申请资源等等,线程在得到了很多与进程相同的控制权的同时,开销也非常的大,在Goroutine中则不需这些额外的开销,所以一个Golang的程序中可以支持10w级别的Goroutine。
每个 goroutine (协程) 默认占用内存远比 Java 、C 的线程少(goroutine:2KB ,线程:8MB)
2、调度性能好
在Golang的程序中,操作系统级别的线程调度,通常不会做出合适的调度决策。例如在GC时,内存必须要达到一个一致的状态。在Goroutine机制里,Golang可以控制Goroutine的调度,从而在一个合适的时间进行GC。
在应用层模拟的线程,它避免了上下文切换的额外耗费,兼顾了多线程的优点。简化了高并发程序的复杂度。