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了
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章