Array、Slice、Map原理淺析

chengkai發表於2018-07-20

Array

陣列(值型別),是用來儲存集合資料的,這種場景非常多,我們編碼的過程中,都少不了要讀取或者儲存資料。當然除了陣列之外,我們還有切片、Map對映等資料結構可以幫我們儲存資料,但是陣列是它們的基礎。

宣告和初始化

陣列初始化的幾種方式

a := [10]int{ 1, 2, 3, 4 } // 未提供初始化值的元素為預設值 0
b := [...]int{ 1, 2 } // 由初始化列表決定陣列⻓度,不能省略 "...",否則就成 slice 了。
c := [10]int{ 2:1, 5:100 } // 按序號初始化元素
複製程式碼

陣列⻓度下標 n 必須是編譯期正整數常量 (或常量表示式)。 ⻓度是型別的組成部分,也就是說 "[10]int" 和 "[20]int" 是完全不同的兩種陣列型別。

var a [20]int
var b [10]int
// 這裡會報錯,不同型別,無法比較
fmt.Println(a == b)
複製程式碼

陣列是值型別,也就是說會拷⻉整個陣列記憶體進⾏值傳遞。可⽤ slice 或指標代替。

func test(x *[4]int) {
  for i := 0; i < len(x); i++ {
    println(x[i])
  }
  x[3] = 300
}

// 取地址傳入
a := [10]int{ 1, 2, 3, 4 }
test(&a)

// 也可以⽤ new() 建立陣列,返回陣列指標。
var a = new([10]int) // 返回指標。
test(a)
複製程式碼

Slice

d

一個slice是一個陣列某個部分的引用。在記憶體中,它是一個包含3個域的結構體:指向slice中第一個元素的指標,slice的長度,以及slice的容量。長度是下標操作的上界,如x[i]中i必須小於長度。容量是分割操作的上界,如x[i:j]中j不能大於容量。

src/pkg/runtime/runtime.h

struct Slice {
  byte* array  // actual data
  uint32 len  // number of elements
  uint32 cap  // allocated number of elements
};
複製程式碼

對 slice 的修改就是對底層陣列的修改。

func main() {
	x := [...]int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
	s := x[:6]
	s = append(s, 10)
	s[0] = 100
	fmt.Println(x)
	fmt.Println(s)
}
複製程式碼

輸出

[100 1 2 3 4 5 10 7 8 9]
[100 1 2 3 4 5 10]
複製程式碼

但是當slice的len超出了原底層陣列的cap的時候,此時就會新開闢一塊記憶體區域用來儲存新建的底層陣列。

func main() {
	x := [...]int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
	s := x[:]
	s = append(s, 10)
	s[0] = 100
	fmt.Println(x)
	fmt.Println(s)
}
複製程式碼

輸出

[0 1 2 3 4 5 6 7 8 9]
[100 1 2 3 4 5 6 7 8 9 10]
複製程式碼

d

函式 copy ⽤於在 slice 間複製資料,可以是指向同⼀底層陣列的兩個 slice。複製元素數量受限於src 和 dst 的 len 值 (兩者的最⼩值)。在同⼀底層陣列的不同 slice 間拷⻉時,元素位置可以重疊。

func main() {
  s1 := []int{ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 }
  s2 := make([]int, 3, 20)
  var n int
  n = copy(s2, s1) // n = 3。不同陣列上拷⻉。s2.len == 3,只能拷 3 個元素。
  fmt.Println(n, s2, len(s2), cap(s2)) // [0 1 2], len:3, cap:20
  s3 := s1[4:6] // s3 == [4 5]。s3 和 s1 指向同⼀個底層陣列。
  n = copy(s3, s1[1:5]) // n = 2。同⼀陣列上拷⻉,且存在重疊區域。
  fmt.Println(n, s1, s3) // [0 1 2 3 1 2 6 7 8 9] [1 2]
}
複製程式碼

輸出

3 [0 1 2] 3 20
2 [0 1 2 3 1 2 6 7 8 9] [1 2]
複製程式碼

陣列的slice並不會實際複製一份資料,它只是建立一個新的資料結構,包含了另外的一個指標,一個長度和一個容量資料。 如同分割一個字串,分割陣列也不涉及複製操作:它只是新建了一個結構來放置一個不同的指標,長度和容量。

由於slice是不同於指標的多字長結構,分割操作並不需要分配記憶體,甚至沒有通常被儲存在堆中的slice頭部。這種表示方法使slice操作和在C中傳遞指標、長度對一樣廉價。

slice的擴容規則

在對slice進行append等操作時,可能會造成slice的自動擴容。其擴容時的大小增長規則是:

  • 如果新的大小是當前大小2倍以上,則大小增長為新大小
  • 否則迴圈以下操作:如果當前大小小於1024,按每次2倍增長,否則每次按當前大小1/4增長。直到增長的大小超過或等於新大小。

make和new

Go有兩個資料結構建立函式:new和make。基本的區別是new(T)返回一個*T,返回的這個指標可以被隱式地消除引用。而make(T, args)返回一個普通的T。通常情況下,T內部有一些隱式的指標。一句話,new返回一個指向已清零記憶體的指標,而make返回一個複雜的結構。

總結

  • 多個slice指向相同的底層陣列時,修改其中一個slice,可能會影響其他slice的值;
  • slice作為引數傳遞時,比陣列更為高效,因為slice的結構比較小;
  • slice在擴張時,可能會發生底層陣列的變更及記憶體拷貝;
  • 因為slice引用了陣列,這可能導致陣列空間不會被gc,當陣列空間很大,而slice引用內容很少時尤為嚴重;

Map

Go中的map在底層是用雜湊表實現的。Golang採用了HashTable的實現,解決衝突採用的是鏈地址法。也就是說,使用陣列+連結串列來實現map。

Map儲存的是無序的鍵值對集合。

不是所有的key都能作為map的key型別,只有能夠比較的型別才能作為key型別。所以例如切片,函式,map型別是不能作為map的key型別的。

map 查詢操作⽐線性搜尋快很多,但⽐起⽤序號訪問 array、slice,⼤約慢 100x 左右。

通過 map[key] 返回的只是⼀個 "臨時值拷⻉",修改其⾃⾝狀態沒有任何意義,只能重新 value 賦值或改⽤指標修改所引⽤的記憶體。

每個bucket中存放最多8個key/value對, 如果多於8個,那麼會申請一個新的bucket,並將它與之前的bucket鏈起來。

注意一個細節是Bucket中key/value的放置順序,是將keys放在一起,values放在一起,為什麼不將key和對應的value放在一起呢?如果那麼做,儲存結構將變成key1/value1/key2/value2… 設想如果是這樣的一個map[int64]int8,考慮到位元組對齊,會浪費很多儲存空間。不得不說通過上述的一個小細節,可以看出Go在設計上的深思熟慮。

資料結構及記憶體管理

hashmap的定義位於 src/runtime/hashmap.go 中,首先我們看下hashmap和bucket的定義:

type hmap struct {
  count     int    // 元素的個數
  flags     uint8  // 狀態標誌
  B         uint8  // 可以最多容納 6.5 * 2 ^ B 個元素,6.5為裝載因子
  noverflow uint16 // 溢位的個數
  hash0     uint32 // 雜湊種子

  buckets    unsafe.Pointer // 桶的地址
  oldbuckets unsafe.Pointer // 舊桶的地址,用於擴容
  nevacuate  uintptr        // 搬遷進度,小於nevacuate的已經搬遷
  overflow *[2]*[]*bmap 
}
複製程式碼

其中,overflow是一個指標,指向一個元素個數為2的陣列,陣列的型別是一個指標,指向一個slice,slice的元素是桶(bmap)的地址,這些桶都是溢位桶;為什麼有兩個?因為Go map在hash衝突過多時,會發生擴容操作,為了不全量搬遷資料,使用了增量搬遷,[0]表示當前使用的溢位桶集合,[1]是在發生擴容時,儲存了舊的溢位桶集合;overflow存在的意義在於防止溢位桶被gc。

擴容

擴容會建立一個大小是原來2倍的新的表,將舊的bucket搬到新的表中之後,並不會將舊的bucket從oldbucket中刪除,而是加上一個已刪除的標記。

正是由於這個工作是逐漸完成的,這樣就會導致一部分資料在old table中,一部分在new table中, 所以對於hash table的insert, remove, lookup操作的處理邏輯產生影響。只有當所有的bucket都從舊錶移到新表之後,才會將oldbucket釋放掉。

Golang map 的底層實現

相關文章