Go slice擴容深度分析

本文主要是對go slice的擴容機制進行了一些分析。環境,64位centos的docker鏡像+go1.12.1。html

常規操做

擴容會發生在slice append的時候,當slice的cap不足以容納新元素,就會進行growSlicelinux

好比對於下方的代碼golang

slice1 := make([]int,1,)
fmt.Println("cap of slice1",cap(slice1))
slice1 = append(slice1,1)
fmt.Println("cap of slice1",cap(slice1))
slice1 = append(slice1,2)
fmt.Println("cap of slice1",cap(slice1))

fmt.Println()

slice1024 := make([]int,1024)
fmt.Println("cap of slice1024",cap(slice1024))
slice1024 = append(slice1024,1)
fmt.Println("cap of slice1024",cap(slice1024))
slice1024 = append(slice1024,2)
fmt.Println("cap of slice1024",cap(slice1024))
複製代碼

輸出redis

cap of slice1 1
cap of slice1 2
cap of slice1 4

cap of slice1024 1024
cap of slice1024 1280
cap of slice1024 1280
複製代碼

網上不少博客也有提到,slice擴容,cap不夠1024的,直接翻倍;cap超過1024的,新cap變爲老cap的1.25倍。docker

這個說法的相關部分源碼以下, 具體的代碼在$GOROOT/src/runtime/slice.gocentos

func growslice(et *_type, old slice, cap int) slice {
    
	// 省略一些判斷...

    newcap := old.cap
    doublecap := newcap + newcap
    if cap > doublecap {
        newcap = cap
    } else {
        if old.len < 1024 {
            newcap = doublecap
        } else {
            // Check 0 < newcap to detect overflow
            // and prevent an infinite loop.
            for 0 < newcap && newcap < cap {
                newcap += newcap / 4
            }
            // Set newcap to the requested cap when
            // the newcap calculation overflowed.
            if newcap <= 0 {
                newcap = cap
            }
        }
    }
    // 省略一些後續...
}
複製代碼

眼尖的朋友可能看到了問題,上文說的擴容機制其實對應的是源碼中的一個分支,換句話說,其實擴容機制不必定是這樣的,那究竟是怎樣的呢?帶着疑問進入下一節數組

很是規操做

上面的操做是每次append一個元素,考慮另外一種情形,一次性append不少元素,會發生什麼呢?好比下面的代碼,容量各自是多少呢?bash

package main

import "fmt"

func main() {
    a := []byte{1, 0}
    a = append(a, 1, 1, 1)
    fmt.Println("cap of a is ",cap(a))
    
    b := []int{23, 51}
    b = append(b, 4, 5, 6)
    fmt.Println("cap of b is ",cap(b))
    
    c := []int32{1, 23}
    c = append(c, 2, 5, 6)
    fmt.Println("cap of c is ",cap(c))

    type D struct{
        age byte
        name string

    }
    d := []D{
        {1,"123"},
        {2,"234"},
    }

    d = append(d,D{4,"456"},D{5,"567"},D{6,"678"})
    fmt.Println("cap of d is ",cap(d))
}
複製代碼

應該是4個8?基於翻倍的思路,cap從2->4->8。app

或者4個5?給4個5的猜想基於如下推測:若是在append多個元素的時候,一次擴容不足以知足元素的放置,若是我是設計者,我會先預估好須要多少容量才能夠放置元素,而後再進行一次擴容,好處就是,不須要頻繁申請新的底層數組,以及不須要頻繁的數據copy。工具

可是結果有點出人意料。

cap of a is  8
cap of b is  6
cap of c is  8
cap of d is  5
複製代碼

是否感受一頭霧水?"不,我知道是這樣。" 獨秀同志,你能夠關閉這篇文章了。

爲何會出現這麼奇怪的現象呢?上正文

gdb分析

光看源碼已經沒太大的進展了,只能藉助一些輔助工具來看下運行狀況,從而更好地分析下源碼,剛好,GDB就是適合這樣作的工具。

依舊是上面的代碼,咱們編譯下,而後load進gdb

[root@a385d77a9056 jack]# go build -o jack
[root@a385d77a9056 jack]# ls
jack  main.go
[root@a385d77a9056 jack]# gdb jack
GNU gdb (GDB) Red Hat Enterprise Linux 7.6.1-114.el7
Copyright (C) 2013 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.  Type "show copying"
and "show warranty" for details.
This GDB was configured as "x86_64-redhat-linux-gnu".
For bug reporting instructions, please see:
<http://www.gnu.org/software/gdb/bugs/>...
Reading symbols from /home/goblog/src/jack/jack...done.
Loading Go Runtime support.
(gdb)
複製代碼

在發生append那一行代碼打上斷點,而後開始運行程序,爲了比較好的說明狀況,斷點打到擴容後容量爲6的[]int型切片b的append上

gdb) l 10
5	)
6
7	func main() {
8
9		a := []byte{1, 0}
10		a = append(a, 1, 1, 1)
11		fmt.Println("cap of a is ", cap(a))
12
13		b := []int{23, 51}
14		b = append(b, 4, 5, 6)
(gdb) b 14
Breakpoint 2 at 0x4872d5: file /home/goblog/src/jack/main.go, line 14.
(gdb) r
Starting program: /home/goblog/src/jack/jack
cap of a is  8

Breakpoint 2, main.main () at /home/goblog/src/jack/main.go:14
14		b = append(b, 4, 5, 6)
複製代碼

跳進去斷點,看下執行狀況

(gdb) s
runtime.growslice (et=0x497dc0, old=..., cap=5, ~r3=...) at /usr/local/src/go/src/runtime/slice.go:76
76	func growslice(et *_type, old slice, cap int) slice {
(gdb) p *et
$1 = {size = 8, ptrdata = 0, hash = 4149441018, tflag = 7 '\a', align = 8 '\b', fieldalign = 8 '\b', kind = 130 '\202', alg = 0x555df0 <runtime.algarray+80>,
  gcdata = 0x4ce4f8 "\001\002\003\004\005\006\a\b\t\n\v\f\r\016\017\020\022\024\025\026\027\030\031\033\036\037\"%&,2568<BQUX\216\231\330\335\345\377", str = 987, ptrToThis = 45312}
(gdb) p old
$2 = {array = 0xc000074ec8, len = 2, cap = 2}
複製代碼

比較複雜,一開始的時候惟一能看懂就是

1、傳進來的cap是5,也就是上文說起到的思路目前來看是正確的,當append多個元素的時候,先預估好容量再進行擴容。 2、slice是一個struct,而struct是值類型。

直到後面大概瞭解了流程以後才知道,et是slice中元素的類型的一種元數據信息,就分析slice,et中只須要知道size就足夠了,size表明的是,元素在計算機所佔的字節大小。筆者用的是64位centos的docker鏡像,int也就是int64,也就是大小爲8個字節。

繼續往下走,這一部分的分析涉及到了另一部分的代碼,先貼上

switch {
case et.size == 1:
    lenmem = uintptr(old.len)
    newlenmem = uintptr(cap)
    capmem = roundupsize(uintptr(newcap))
    overflow = uintptr(newcap) > maxAlloc
    newcap = int(capmem)
case et.size == sys.PtrSize:
    lenmem = uintptr(old.len) * sys.PtrSize
    newlenmem = uintptr(cap) * sys.PtrSize
    capmem = roundupsize(uintptr(newcap) * sys.PtrSize)
    overflow = uintptr(newcap) > maxAlloc/sys.PtrSize
    newcap = int(capmem / sys.PtrSize)
case isPowerOfTwo(et.size):
    var shift uintptr
    if sys.PtrSize == 8 {
        // Mask shift for better code generation.
        shift = uintptr(sys.Ctz64(uint64(et.size))) & 63
    } else {
        shift = uintptr(sys.Ctz32(uint32(et.size))) & 31
    }
    lenmem = uintptr(old.len) << shift
    newlenmem = uintptr(cap) << shift
    capmem = roundupsize(uintptr(newcap) << shift)
    overflow = uintptr(newcap) > (maxAlloc >> shift)
    newcap = int(capmem >> shift)
default:
    lenmem = uintptr(old.len) * et.size
    newlenmem = uintptr(cap) * et.size
    capmem, overflow = math.MulUintptr(et.size, uintptr(newcap))
    capmem = roundupsize(capmem)
    newcap = int(capmem / et.size)
}
複製代碼

貼上gdb分析的狀況,省略一些細枝末節,只摘取了部分較重要的流程

(gdb) n
96		doublecap := newcap + newcap // 結合常規操做列出的源碼分析,newcap初始化爲old.cap,即爲2,doublecap爲4
(gdb) n
97		if cap > doublecap { // cap是傳進來的參數,值爲5,比翻倍後的doublecap=4要大
(gdb) n
98			newcap = cap // 於是newcap賦值爲計算後的容量5,而len<1024的分支則沒走進去
(gdb) n
123		case et.size == 1:
(gdb) disp newcap   // 打印newcap的值
3: newcap = 5
(gdb) n
129		case et.size == sys.PtrSize: // et.size即類型的字節數爲8,恰好等於64位系統的指針大小
3: newcap = 5
(gdb) n
132			capmem = roundupsize(uintptr(newcap) * sys.PtrSize) // 獲得的capmem是該容量所需的內存,核心步驟,下面重點分析,
3: newcap = 5
(gdb) disp capmem  // 打印capmem,結合下面能夠看到是48
4: capmem = <optimized out>
(gdb) n
134			newcap = int(capmem / sys.PtrSize) // 獲得新的容量
4: capmem = 48
3: newcap = 5
(gdb) n
122		switch {
4: capmem = <optimized out>
3: newcap = 5
(gdb) n
169		if overflow || capmem > maxAlloc { // 這是跳出switch代碼塊以後的代碼,不重要,可是咱們已經看到想要的結果了,newcap容量恰好是6,也就是上文中獲得的cap(b)
4: capmem = 48
3: newcap = 6
複製代碼

後面的代碼就是用capmem進行內存分配,而後將newcap做爲新的slice的cap,咱們來分析這一步capmem = roundupsize(uintptr(newcap) * sys.PtrSize)

round-up,向上取整,roundupsize,向上取一個size。(uintptr(newcap) * sys.PtrSize)的乘積應該爲5*8=40,通過向上取整以後獲得了新的所需內存capmem=48,接着所需內存/類型大小int(capmem / sys.PtrSize),獲得了新的容量,也就是6.

要明白roundupsize爲何會將40變爲48,這裏須要簡單的引進go的內存管理。能夠跟蹤進roundupsize方法,而後再跟蹤進sizeclasses.go文件,在這個文件的開頭,給出了golang對象大小表,大致以下

// class bytes/obj bytes/span objects tail waste max waste
// 1 8 8192 1024 0 87.50%
// 2 16 8192 512 0 43.75%
// 3 32 8192 256 0 46.88%
// 4 48 8192 170 32 31.52%
// 5 64 8192 128 0 23.44%
// 6 80 8192 102 32 19.07%
// 7 96 8192 85 32 15.95%
// 8 112 8192 73 16 13.56%
// 9 128 8192 64 0 11.72%
// 10 144 8192 56 128 11.82%

// ...
// 65 28672 57344 2 0 4.91%
// 66 32768 32768 1 0 12.50%
複製代碼

其餘的暫時不關心,咱們先看bytes/obj的這一列,這一列就是go中預約義的對象大小,最小是8b,最大是32K,還有一類就是超出32K的,共67類(超出32K沒列在這個文件的,66+1=67)。能夠看到,並無size爲40的類型因而40向上取整取到了48,這就是發生在roundupsize的真相。這裏有一個比較專業的名詞,內存對齊。具體爲何須要這樣設計?有興趣的讀者,能夠細看golang的內存管理,這裏篇幅有限,就不展開了。

很是規操做中還有其餘類型的append,這裏就不貼gdb的分析了,同樣都有roundupsize的操做,大同小異,有興趣的朋友能夠自行玩一下。

疑問

在append時,roundupsize並非一個特殊分支纔有的操做,我感受不可能一直都是雙倍擴容和1.25倍擴容啊,懷疑網上挺多博客說的有問題。

因而又測試了下

e := []int32{1,2,3}
fmt.Println("cap of e before:",cap(e))
e = append(e,4)
fmt.Println("cap of e after:",cap(e))

f := []int{1,2,3}
fmt.Println("cap of f before:",cap(f))
f = append(f,4)
fmt.Println("cap of f after:",cap(f))

cap of e before: 3
cap of e after: 8
cap of f before: 3
cap of f after: 6
複製代碼

哎,果不其然。擴容後的slice容量,還和類型有關呢。

summary

內容跳的有點亂,總結一下

append的時候發生擴容的動做

  • append單個元素,或者append少許的多個元素,這裏的少許指double以後的容量能容納,這樣就會走如下擴容流程,不足1024,雙倍擴容,超過1024的,1.25倍擴容。

  • 如果append多個元素,且double後的容量不能容納,直接使用預估的容量。

敲重點!!!!此外,以上兩個分支獲得新容量後,均須要根據slice的類型size,算出新的容量所需的內存狀況capmem,而後再進行capmem向上取整,獲得新的所需內存,除上類型size,獲得真正的最終容量,做爲新的slice的容量。

以上,全劇終,歡迎討論~

相關文章
相關標籤/搜索