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了