赞
踩
数组和切片是 Go 语言中常见的数据结构,很多刚刚使用 Go 的开发者往往会混淆这两个概念。数组作为最常见的集合在编程语言中是非常重要的,除了数组之外,Go 语言引入了另一个概念 — 切片,切片与数组有一些类似,但是它们的不同导致了使用上的巨大差别。
这次主要讨论 Go 语言的数组(array)类型和切片(slice)类型。数组和切片有时候会让初学者感到困惑。
数组是 Go 语言中重要的数据结构,了解它的实现能够帮助我们更好地理解这门语言,通过对其实现的分析,我们知道了对数组的访问和赋值需要同时依赖编译器和运行时,它的大多数操作在编译期间都会转换成直接读写内存,在中间代码生成期间,编译器还会插入运行时方法 runtime.panicIndex 调用防止发生越界错误。
数组是由相同类型元素的集合组成的数据结构,计算机会为数组分配一块连续的内存来保存其中的元素,我们可以利用数组中元素的索引快速访问特定元素,常见的数组大多都是一维的线性数组,而多维数组在数值和图形计算领域却有比较常见的应用。
数组作为一种基本的数据类型,我们通常会从两个维度描述数组,
在 Go 语言中我们往往会使用如下所示的方式来表示数组类型:
[10]int
[200]interface{}
Go 语言数组在初始化之后大小就无法改变,存储元素类型相同、但是大小不同的数组类型在 Go 语言看来也是完全不同的,只有两个条件都相同才是同一类型。
func NewArray(elem *Type, bound int64) *Type {
if bound < 0 {
Fatalf("NewArray: invalid bound %v", bound)
}
t := New(TARRAY)
t.Extra = &Array{Elem: elem, Bound: bound}
t.SetNotInHeap(elem.NotInHeap())
return t
}
编译期间的数组类型是由上述的 cmd/compile/internal/types.NewArray 函数生成的,该类型包含两个字段,分别是元素类型 Elem 和数组的大小 Bound,这两个字段共同构成了数组类型,而当前数组是否应该在堆栈中初始化也在编译期就确定了。
Go 语言的数组有两种不同的创建方式,一种是显式的指定数组大小,另一种是使用 […]T 声明数组,Go 语言会在编译期间通过源代码推导数组的大小:
arr1 := [3]int{1, 2, 3}
arr2 := [...]int{1, 2, 3}
上述两种声明方式在运行期间得到的结果是完全相同的,后一种声明方式在编译期间就会被转换成前一种,这也就是编译器对数组大小的推导,下面我们来介绍编译器的推导过程。
(1)上限推导
两种不同的声明方式会导致编译器做出完全不同的处理,
如果我们使用第一种方式 [10]T,那么变量的类型在编译进行到类型检查阶段就会被提取出来,随后使用cmd/compile/internal/types.NewArray
创建包含数组大小的cmd/compile/internal/types.Array
结构体。
当我们使用 […]T 的方式声明数组时,编译器会在的 cmd/compile/internal/gc.typecheckcomplit
函数中对该数组的大小进行推导:
func typecheckcomplit(n *Node) (res *Node) { ... if n.Right.Op == OTARRAY && n.Right.Left != nil && n.Right.Left.Op == ODDD { n.Right.Right = typecheck(n.Right.Right, ctxType) if n.Right.Right.Type == nil { n.Type = nil return n } elemType := n.Right.Right.Type length := typecheckarraylit(elemType, -1, n.List.Slice(), "array literal") n.Op = OARRAYLIT n.Type = types.NewArray(elemType, length) n.Right = nil return n } ... switch t.Etype { case TARRAY: typecheckarraylit(t.Elem(), t.NumElem(), n.List.Slice(), "array literal") n.Op = OARRAYLIT n.Right = nil } }
这个删减后的 cmd/compile/internal/gc.typecheckcomplit 会调用 cmd/compile/internal/gc.typecheckarraylit 通过遍历元素的方式来计算数组中元素的数量。
所以我们可以看出 […]T{1, 2, 3} 和 [3]T{1, 2, 3} 在运行时是完全等价的,[…]T 这种初始化方式也只是 Go 语言为我们提供的一种语法糖,当我们不想计算数组中的元素个数时可以通过这种方法减少一些工作量。
(2)语句转换
对于一个由字面量组成的数组,根据数组元素数量的不同,编译器会在负责初始化字面量的 cmd/compile/internal/gc.anylit
函数中做两种不同的优化:
func anylit(n *Node, var_ *Node, init *Nodes) {
t := n.Type
switch n.Op {
case OSTRUCTLIT, OARRAYLIT:
if n.List.Len() > 4 {
...
}
fixedlit(inInitFunction, initKindLocalCode, n, var_, init)
...
}
}
当数组的元素小于或者等于四个时,cmd/compile/internal/gc.fixedlit
会负责在函数编译之前将 [3]{1, 2, 3} 转换成更加原始的语句:
func fixedlit(ctxt initContext, kind initKind, n *Node, var_ *Node, init *Nodes) { var splitnode func(*Node) (a *Node, value *Node) ... for _, r := range n.List.Slice() { a, value := splitnode(r) a = nod(OAS, a, value) a = typecheck(a, ctxStmt) switch kind { case initKindStatic: genAsStatic(a) case initKindLocalCode: a = orderStmtInPlace(a, map[string][]*Node{}) a = walkstmt(a) init.Append(a) } } }
当数组中元素的个数小于或者等于四个并且 cmd/compile/internal/gc.fixedlit 函数接收的 kind 是 initKindLocalCode 时,上述代码会将原有的初始化语句 [3]int{1, 2, 3} 拆分成一个声明变量的表达式和几个赋值表达式,这些表达式会完成对数组的初始化:
var arr [3]int
arr[0] = 1
arr[1] = 2
arr[2] = 3
但是如果当前数组的元素大于四个,cmd/compile/internal/gc.anylit 会先获取一个唯一的 staticname,然后调用 cmd/compile/internal/gc.fixedlit 函数在静态存储区初始化数组中的元素并将临时变量赋值给数组:
func anylit(n *Node, var_ *Node, init *Nodes) { t := n.Type switch n.Op { case OSTRUCTLIT, OARRAYLIT: if n.List.Len() > 4 { vstat := staticname(t) vstat.Name.SetReadonly(true) fixedlit(inNonInitFunction, initKindStatic, n, vstat, init) a := nod(OAS, var_, vstat) a = typecheck(a, ctxStmt) a = walkexpr(a, init) init.Append(a) break } ... } }
假设代码需要初始化 [5]int{1, 2, 3, 4, 5},那么我们可以将上述过程理解成以下的伪代码:
var arr [5]int
statictmp_0[0] = 1
statictmp_0[1] = 2
statictmp_0[2] = 3
statictmp_0[3] = 4
statictmp_0[4] = 5
arr = statictmp_0
总结起来,在不考虑逃逸分析的情况下,
无论是在栈上还是静态存储区,数组在内存中都是一连串的内存空间,我们通过指向数组开头的指针、元素的数量以及元素类型占的空间大小表示数组。如果我们不知道数组中元素的数量,访问时可能发生越界;而如果不知道数组中元素类型的大小,就没有办法知道应该一次取出多少字节的数据,无论丢失了哪个信息,我们都无法知道这片连续的内存空间到底存储了什么数据:
数组访问越界是非常严重的错误,Go 语言中可以在编译期间的静态类型检查判断数组越界,cmd/compile/internal/gc.typecheck1 会验证访问数组的索引:
func typecheck1(n *Node, top int) (res *Node) { switch n.Op { case OINDEX: ok |= ctxExpr l := n.Left // array r := n.Right // index switch n.Left.Type.Etype { case TSTRING, TARRAY, TSLICE: ... if n.Right.Type != nil && !n.Right.Type.IsInteger() { yyerror("non-integer array index %v", n.Right) break } if !n.Bounded() && Isconst(n.Right, CTINT) { x := n.Right.Int64() if x < 0 { yyerror("invalid array index %v (index must be non-negative)", n.Right) } else if n.Left.Type.IsArray() && x >= n.Left.Type.NumElem() { yyerror("invalid array index %v (out of bounds for %d-element array)", n.Right, n.Left.Type.NumElem()) } } } ... } }
数组和字符串的一些简单越界错误都会在编译期间发现,例如:直接使用整数或者常量访问数组;但是如果使用变量去访问数组或者字符串时,编译器就无法提前发现错误,我们需要 Go 语言运行时阻止不合法的访问:
arr[4]: invalid array index 4 (out of bounds for 3-element array)
arr[i]: panic: runtime error: index out of range [4] with length 3
Go 语言运行时在发现数组、切片和字符串的越界操作会由运行时的 runtime.panicIndex 和 runtime.goPanicIndex 触发程序的运行时错误并导致崩溃退出:
Go 语言对于数组的访问还是有着比较多的检查的,它不仅会在编译期间提前发现一些简单的越界错误并插入用于检测数组上限的函数调用,还会在运行期间通过插入的函数保证不会发生越界。
数组的赋值和更新操作 a[i] = 2 也会生成 SSA 生成期间计算出数组当前元素的内存地址,然后修改当前内存地址的内容,这些赋值语句会被转换成如下所示的 SSA 代码:
b1:
...
v21 (5) = LocalAddr <*[3]int> {arr} v2 v19
v22 (5) = PtrIndex <*int> v21 v13
v23 (5) = Store <mem> {int} v22 v20 v19
...
赋值的过程中会先确定目标数组的地址,再通过 PtrIndex 获取目标元素的地址,最后使用 Store 指令将数据存入地址中,从上面的这些 SSA 代码中我们可以看出 上述数组寻址和赋值都是在编译阶段完成的,没有运行时的参与。
在 Go 语言中,切片类型的声明方式与数组有一些相似,不过由于切片的长度是动态的,所以声明时只需要指定切片中的元素类型:
[]int
[]interface{}
从切片的定义我们能推测出,切片在编译期间的生成的类型只会包含切片中的元素类型,即 int 或者 interface{} 等。cmd/compile/internal/types.NewSlice
就是编译期间用于创建切片类型的函数:
func NewSlice(elem *Type) *Type {
if t := elem.Cache.slice; t != nil {
if t.Elem() != elem {
Fatalf("elem mismatch")
}
return t
}
t := New(TSLICE)
t.Extra = Slice{Elem: elem}
elem.Cache.slice = t
return t
}
上述方法返回结构体中的 Extra 字段是一个只包含切片内元素类型的结构,也就是说切片内元素的类型都是在编译期间确定的,编译器确定了类型之后,会将类型存储在 Extra 字段中帮助程序在运行时动态获取。
特性主要分为:引用类型(引用传递)、非线程安全、共享内存(更改切片容易影响其它切片),下面分别说明
1)引用类型
golang 有三个常用的高级类型slice、map、channel, 它们都是引用类型,当引用类型作为函数参数时,可能会修改原内容数据。
func sliceModify(s []int) { s[0] = 100 } func sliceAppend(s []int) []int { s = append(s, 100) return s } func sliceAppendPtr(s *[]int) { *s = append(*s, 100) return } // 注意:Go语言中所有的传参都是值传递(传值),都是一个副本,一个拷贝。 // 拷贝的内容是非引用类型(int、string、struct等这些),在函数中就无法修改原内容数据; // 拷贝的内容是引用类型(interface、指针、map、slice、chan等这些),这样就可以修改原内容数据。 func TestSliceFn(t *testing.T) { // 参数为引用类型slice:外层slice的len/cap不会改变,指向的底层数组会改变 s := []int{1, 1, 1} newS := sliceAppend(s) // 函数内发生了扩容 t.Log(s, len(s), cap(s)) // [1 1 1] 3 3 t.Log(newS, len(newS), cap(newS)) // [1 1 1 100] 4 6 s2 := make([]int, 0, 5) newS = sliceAppend(s2) // 函数内未发生扩容 t.Log(s2, s2[0:5], len(s2), cap(s2)) // [] [100 0 0 0 0] 0 5 t.Log(newS, newS[0:5], len(newS), cap(newS)) // [100] [100 0 0 0 0] 1 5 // 参数为引用类型slice的指针:外层slice的len/cap会改变,指向的底层数组会改变 sliceAppendPtr(&s) t.Log(s, len(s), cap(s)) // [1 1 1 100] 4 6 sliceModify(s) t.Log(s, len(s), cap(s)) // [100 1 1 100] 4 6 }
2)非线程安全
slice不支持并发读写,所以并不是线程安全的,使用多个 goroutine 对类型为 slice 的变量进行操作,每次输出的值大概率都不会一样,与预期值不一致; slice在并发执行中不会报错,但是数据会丢失。
/** * 切片非并发安全 * 多次执行,每次得到的结果都不一样 * 可以考虑使用 channel 本身的特性 (阻塞) 来实现安全的并发读写 */ func TestSliceConcurrencySafe(t *testing.T) { a := make([]int, 0) var wg sync.WaitGroup for i := 0; i < 10000; i++ { wg.Add(1) go func(i int) { a = append(a, i) wg.Done() }(i) } wg.Wait() t.Log("len = ",len(a)) // not equal 10000 }
如果想实现slice线程安全,有两种方式:
方式一:通过加锁实现slice线程安全,适合对性能要求不高的场景。
func TestSliceConcurrencySafeByChanel(t *testing.T) { buffer := make(chan int) a := make([]int, 0) // 消费者 go func() { for v := range buffer { a = append(a, v) } }() // 生产者 var wg sync.WaitGroup for i := 0; i < 10000; i++ { wg.Add(1) go func(i int) { defer wg.Done() buffer <- i }(i) } wg.Wait() t.Log(len(a)) // equal 10000 }
3)共享存储空间
多个切片如果共享同一个底层数组,这种情况下,对其中一个切片或者底层数组的更改,会影响到其他切片。
func TestSliceShareMemory(t *testing.T) { slice1 := []string{"1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12"} Q2 := slice1[3:6] t.Log(Q2, len(Q2), cap(Q2)) // [4 5 6] 3 9 Q3 := slice1[5:8] t.Log(Q3, len(Q3), cap(Q3)) // [6 7 8] 3 7 Q3[0] = "Unkown" t.Log(Q2, Q3) // [4 5 Unkown] [Unkown 7 8] a := []int{1, 2, 3, 4, 5} shadow := a[1:3] t.Log(shadow, a) // [2 3] [1 2 3 4 5] shadow = append(shadow, 100) // 会修改指向数组的所有切片 t.Log(shadow, a) // [2 3 100] [1 2 3 100 5] }
Go 语言中包含三种初始化切片的方式:
1)使用下标
使用下标创建切片是最原始也最接近汇编语言的方式,它是所有方法中最为底层的一种,编译器会将 arr[0:3] 或者 slice[0:3] 等语句转换成 OpSliceMake 操作,我们可以通过下面的代码来验证一下:
// ch03/op_slice_make.go
package opslicemake
func newSlice() []int {
arr := [3]int{1, 2, 3}
slice := arr[0:1]
return slice
}
通过 GOSSAFUNC 变量编译上述代码可以得到一系列 SSA 中间代码,其中 slice := arr[0:1] 语句在 “decompose builtin” 阶段对应的代码如下所示:
v27 (+5) = SliceMake <[]int> v11 v14 v17
name &arr[*[3]int]: v11
name slice.ptr[*int]: v11
name slice.len[int]: v14
name slice.cap[int]: v17
2)字面量
当我们使用字面量 []int{1, 2, 3} 创建新的切片时,cmd/compile/internal/gc.slicelit 函数会在编译期间将它展开成如下所示的代码片段:
var vstat [3]int
vstat[0] = 1
vstat[1] = 2
vstat[2] = 3
var vauto *[3]int = new([3]int)
*vauto = vstat
slice := vauto[:]
第 5 步中的 [:] 就是使用下标创建切片的方法,从这一点我们也能看出 [:] 操作是创建切片最底层的一种方法。
3)关键字
如果使用字面量的方式创建切片,大部分的工作都会在编译期间完成。但是当我们使用 make 关键字创建切片时,很多工作都需要运行时的参与;调用方必须向 make 函数传入切片的大小以及可选的容量,类型检查期间的 cmd/compile/internal/gc.typecheck1 函数会校验入参:
func typecheck1(n *Node, top int) (res *Node) { switch n.Op { ... case OMAKE: args := n.List.Slice() i := 1 switch t.Etype { case TSLICE: if i >= len(args) { yyerror("missing len argument to make(%v)", t) return n } l = args[i] i++ var r *Node if i < len(args) { r = args[i] } ... if Isconst(l, CTINT) && r != nil && Isconst(r, CTINT) && l.Val().U.(*Mpint).Cmp(r.Val().U.(*Mpint)) > 0 { yyerror("len larger than cap in make(%v)", t) return n } n.Left = l n.Right = r n.Op = OMAKESLICE } ... } }
上述函数不仅会检查 len 是否传入,还会保证传入的容量 cap 一定大于或者等于 len。除了校验参数之外,当前函数会将 OMAKE 节点转换成 OMAKESLICE,中间代码生成的 cmd/compile/internal/gc.walkexpr 函数会依据下面两个条件转换 OMAKESLICE 类型的节点:
当切片发生逃逸或者非常大时,运行时需要 runtime.makeslice 在堆上初始化切片,如果当前的切片不会发生逃逸并且切片非常小的时候,make([]int, 3, 4) 会被直接转换成如下所示的代码:
var arr [4]int
n := arr[:3]
上述代码会初始化数组并通过下标 [:3] 得到数组对应的切片,这两部分操作都会在编译阶段完成,编译器会在栈上或者静态存储区创建数组并将 [:3] 转换成上一节提到的 OpSliceMake 操作。
分析了主要由编译器处理的分支之后,我们回到用于创建切片的运行时函数 runtime.makeslice,这个函数的实现很简单:
func makeslice(et *_type, len, cap int) unsafe.Pointer {
mem, overflow := math.MulUintptr(et.size, uintptr(cap))
if overflow || mem > maxAlloc || len < 0 || len > cap {
mem, overflow := math.MulUintptr(et.size, uintptr(len))
if overflow || mem > maxAlloc || len < 0 {
panicmakeslicelen()
}
panicmakeslicecap()
}
return mallocgc(mem, et, true)
}
上述函数的主要工作是计算切片占用的内存空间并在堆上申请一片连续的内存,它使用如下的方式计算占用的内存:
虽然编译期间可以检查出很多错误,但是在创建切片的过程中如果发生了以下错误会直接触发运行时错误并崩溃:
runtime.makeslice 在最后调用的 runtime.mallocgc 是用于申请内存的函数,这个函数的实现还是比较复杂,如果遇到了比较小的对象会直接初始化在 Go 语言调度器里面的 P 结构中,而大于 32KB 的对象会在堆上初始化。
编译期间的切片是 cmd/compile/internal/types.Slice 类型的,但是在运行时切片可以由如下的 reflect.SliceHeader 结构体表示,其中:
type SliceHeader struct {
Data uintptr
Len int
Cap int
}
Data 是一片连续的内存空间,这片内存空间可以用于存储切片中的全部元素,数组中的元素只是逻辑上的概念,底层存储其实都是连续的,所以我们可以将切片理解成一片连续的内存空间加上长度与容量的标识。
从上图中,我们会发现切片与数组的关系非常密切,切片引入了一个抽象层,提供了对数组中部分连续片段的引用,而作为数组的引用,我们可以在运行区间可以修改它的长度和范围。当切片底层的数组长度不足时就会触发扩容,切片指向的数组可能会发生变化,不过在上层看来切片是没有变化的,上层只需要与切片打交道不需要关心数组的变化。
编译器在编译期间简化了获取数组大小、读写数组中的元素等操作:因为数组的内存固定且连续,多数操作都会直接读写内存的特定位置。但是切片是运行时才会确定内容的结构,所有操作还需要依赖 Go 语言的运行时,下面的内容会结合运行时介绍切片常见操作的实现原理。
使用 append 关键字向切片中追加元素也是常见的切片操作,中间代码生成阶段的 cmd/compile/internal/gc.state.append 方法会根据返回值是否会覆盖原变量,选择进入两种流程:
(1)如果 append 返回的新切片不需要赋值回原有的变量,就会进入如下的处理流程:
// append(slice, 1, 2, 3)
ptr, len, cap := slice
newlen := len + 3
if newlen > cap {
ptr, len, cap = growslice(slice, newlen)
newlen = len + 3
}
*(ptr+len) = 1
*(ptr+len+1) = 2
*(ptr+len+2) = 3
return makeslice(ptr, newlen, cap)
我们会先解构切片结构体获取它的数组指针、大小和容量,如果在追加元素后切片的大小大于容量,那么就会调用 runtime.growslice 对切片进行扩容并将新的元素依次加入切片。
如果使用 slice = append(slice, 1, 2, 3) 语句,那么 append 后的切片会覆盖原切片,这时 cmd/compile/internal/gc.state.append 方法会使用另一种方式展开关键字:
// slice = append(slice, 1, 2, 3)
a := &slice
ptr, len, cap := slice
newlen := len + 3
if uint(newlen) > uint(cap) {
newptr, len, newcap = growslice(slice, newlen)
vardef(a)
*a.cap = newcap
*a.ptr = newptr
}
newlen = len + 3
*a.len = newlen
*(ptr+len) = 1
*(ptr+len+1) = 2
*(ptr+len+2) = 3
是否覆盖原变量的逻辑其实差不多,最大的区别在于得到的新切片是否会赋值回原变量。如果我们选择覆盖原有的变量,就不需要担心切片发生拷贝影响性能,因为 Go 语言编译器已经对这种常见的情况做出了优化。
到这里我们已经清楚了 Go 语言如何在切片容量足够时向切片中追加元素,不过仍然需要研究切片容量不足时的处理流程。当切片的容量不足时,我们会调用 runtime.growslice 函数为切片扩容,扩容是为切片分配新的内存空间并拷贝原切片中元素的过程,我们先来看新切片的容量是如何确定的:
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 { for 0 < newcap && newcap < cap { newcap += newcap / 4 } if newcap <= 0 { newcap = cap } } }
在分配内存空间之前需要先确定新的切片容量,运行时根据切片的当前容量选择不同的策略进行扩容:
上述代码片段仅会确定切片的大致容量,下面还需要根据切片中的元素大小对齐内存,当数组中元素所占的字节大小为 1、8 或者 2 的倍数时,运行时会使用如下所示的代码对齐内存:
var overflow bool var lenmem, newlenmem, capmem uintptr 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): ... default: ... }
runtime.roundupsize 函数会将待申请的内存向上取整,取整时会使用 runtime.class_to_size 数组,使用该数组中的整数可以提高内存的分配效率并减少碎片,我们会在内存分配一节详细介绍该数组的作用:
var class_to_size = [_NumSizeClasses]uint16{
0,
8,
16,
32,
48,
64,
80,
...,
}
在默认情况下,我们会将目标容量和元素大小相乘得到占用的内存。如果计算新容量时发生了内存溢出或者请求内存超过上限,就会直接崩溃退出程序,不过这里为了减少理解的成本,将相关的代码省略了。
var overflow bool var newlenmem, capmem uintptr switch { ... default: lenmem = uintptr(old.len) * et.size newlenmem = uintptr(cap) * et.size capmem, _ = math.MulUintptr(et.size, uintptr(newcap)) capmem = roundupsize(capmem) newcap = int(capmem / et.size) } ... var p unsafe.Pointer if et.kind&kindNoPointers != 0 { p = mallocgc(capmem, nil, false) memclrNoHeapPointers(add(p, newlenmem), capmem-newlenmem) } else { p = mallocgc(capmem, et, true) if writeBarrier.enabled { bulkBarrierPreWriteSrcOnly(uintptr(p), uintptr(old.array), lenmem) } } memmove(p, old.array, lenmem) return slice{p, old.len, newcap} }
如果切片中元素不是指针类型,那么会调用 runtime.memclrNoHeapPointers 将超出切片当前长度的位置清空并在最后使用 runtime.memmove 将原数组内存中的内容拷贝到新申请的内存中。这两个方法都是用目标机器上的汇编指令实现的,这里就不展开介绍了。
runtime.growslice 函数最终会返回一个新的切片,其中包含了新的数组指针、大小和容量,这个返回的三元组最终会覆盖原切片。
var arr []int64
arr = append(arr, 1, 2, 3, 4, 5)
简单总结一下扩容的过程,当我们执行上述代码时,会触发 runtime.growslice 函数扩容 arr 切片并传入期望的新容量 5,这时期望分配的内存大小为 40 字节;不过因为切片中的元素大小等于 sys.PtrSize,所以运行时会调用 runtime.roundupsize 向上取整内存的大小到 48 字节,所以新切片的容量为 48 / 8 = 6。
它们的共同点是都属于集合类的类型,并且,它们的值也都可以用来存储某一种类型的值(或者说元素)。
不过,它们最重要的不同是:数组类型的值(以下简称数组)的长度是固定的,而切片类型的值(以下简称切片)是可变长的。
数组的长度在声明它的时候就必须给定,并且之后不会再改变。可以说,数组的长度是其类型的一部分。比如,[1]string和[2]string就是两个不同的数组类型。
而切片的类型字面量中只有元素的类型,而没有长度。切片的长度可以自动地随着其中元素数量的增长而增长,但不会随着元素数量的减少而减小。
(数组与切片的字面量)
我们其实可以把切片看做是对数组的一层简单的封装,因为在每个切片的底层数据结构中,一定会包含一个数组。数组可以被叫做切片的底层数组,而切片也可以被看作是对数组的某个连续片段的引用。
也正因为如此,
- Go 语言的切片类型属于引用类型,同属引用类型的还有字典类型、通道类型、函数类型等;
- 而 Go 语言的数组类型则属于值类型,同属值类型的有基础数据类型以及结构体类型。
注意,Go 语言里不存在像 Java 等编程语言中令人困惑的“传值或传引用”问题。在 Go 语言中,我们判断所谓的“传值”或者“传引用”只要看被传递的值的类型就好了。如果传递的值是引用类型的,那么就是“传引用”。如果传递的值是值类型的,那么就是“传值”。从传递成本的角度讲,引用类型的值往往要比值类型的值低很多。我们在数组和切片之上都可以应用索引表达式,得到的都会是某个元素。我们在它们之上也都可以应用切片表达式,也都会得到一个新的切片。
我们通过调用内建函数len,得到数组和切片的长度。通过调用内建函数cap,我们可以得到它们的容量。
但要注意,数组的容量永远等于其长度,都是不可变的。切片的容量却不是这样,并且它的变化是有规律可寻的。
slice是无固定长度的数组,包含指向底层数组的指针 array
、切片的长度 len
、切片的容量cap
3个属性。
type slice struct {
array unsafe.Pointer
len int
cap int
}
slice并不是真正意义上的动态数组,而是一个引用类型。slice总是指向一个底层array,slice的声明也可以像 array一样,只是长度可变。
1)slice 的底层数据是数组,slice 是对数组的封装,它描述一个数组的片段。两者都可以通过下标来访问单个元素。
2)数组是定长的,长度定义好之后,不能再更改。在 Go 中,数组是不常见的,因为其长度是类型的一部分,限制了它的表达能力,比如 [3]int 和 [4]int 就是不同的类型。
而切片则非常灵活,它可以动态地扩容。切片的类型和长度无关。
3)数组就是一片连续的内存, slice 实际上是一个结构体,包含三个字段:长度、容量、底层数组。
// runtime/slice.go
type slice struct {
array unsafe.Pointer // 元素指针
len int // 长度
cap int // 容量
}
slice 的数据结构如下:
注意,底层数组是可以被多个 slice 同时指向的,因此对一个 slice 的元素进行操作是有可能影响到其他 slice 的。
扩容会发生在slice append的时候,当slice的cap不足以容纳新元素,就会进行扩容。具体扩容规则如下:
源码:https://github.com/golang/go/blob/master/src/runtime/slice.go
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 } } } // 省略一些后续... }
开发中会经常的把一个变量复制给另一个变量,那么这个过程,可能是深浅拷贝,那么今天帮大家区分一下这两个拷贝的区别和具体的区别;
1)深拷贝
拷贝的是数据本身,创造一个样的新对象,新创建的对象与原对象不共享内存,新创建的对象在内存中开辟一个新的内存地址,新对象值修改时不会影响原对象值。既然内存地址不同,释放内存地址时,可分别释放。
值类型的数据,默认赋值操作都是深拷贝,Array、Int、String、Struct、Float,Bool。引用类型的数据如果想实现深拷贝,需要通过辅助函数完成。
比如golang深拷贝copy 方法会把源切片值(即 from Slice )中的元素复制到目标切片(即 to Slice )中,并返回被复制的元素个数,copy 的两个类型必须一致。copy 方法最终的「复制结果取决于较短的那个切片
」,当较短的切片复制完成,整个复制过程就全部完成了
/** * 深拷贝 */ func TestSliceDeepCopy(t *testing.T) { slice1 := []int{1, 2, 3, 4, 5} slice2 := make([]int, 5, 5) // 深拷贝 copy(slice2, slice1) t.Log(slice1, len(slice1), cap(slice1)) // [1 2 3 4 5] 5 5 t.Log(slice2, len(slice2), cap(slice2)) // [1 2 3 4 5] 5 5 slice1[1] = 100 t.Log(slice1, len(slice1), cap(slice1)) // [1 100 3 4 5] 5 5 t.Log(slice2, len(slice2), cap(slice2)) // [1 2 3 4 5] 5 5 }
2)浅拷贝
拷贝的是数据地址,只复制指向的对象的指针,此时新对象和老对象指向的内存地址是一样的,新对象值修改时老对象也会变化。释放内存地址时,同时释放内存地址。
引用类型的数据,默认全部都是浅拷贝,Slice、Map等
目的切片和源切片指向同一个底层数组,任何一个数组元素改变,都会同时影响两个数组。
/** * 浅拷贝 */ func TestSliceShadowCopy(t *testing.T) { slice1 := []int{1, 2, 3, 4, 5} // 浅拷贝(注意 := 对于引用类型是浅拷贝,对于值类型是深拷贝) slice2 := slice1 t.Logf("%p", slice1) // 0xc00001c120 t.Logf("%p", slice2) // 0xc00001c120 // 同时改变两个数组,这时就是浅拷贝,未扩容时,修改 slice1 的元素之后,slice2 的元素也会跟着修改 slice1[0] = 10 t.Log(slice1, len(slice1), cap(slice1)) // [10 2 3 4 5] 5 5 t.Log(slice2, len(slice2), cap(slice2)) // [10 2 3 4 5] 5 5 // 注意下:扩容后,slice1和slice2不再指向同一个数组,修改 slice1 的元素之后,slice2 的元素不会被修改了 slice1 = append(slice1, 5, 6, 7, 8) slice1[0] = 11 // 这里可以发现,slice1[0] 被修改为了 11, slice1[0] 还是10 t.Log(slice1, len(slice1), cap(slice1)) // [11 2 3 4 5 5 6 7 8] 9 10 t.Log(slice2, len(slice2), cap(slice2)) // [10 2 3 4 5] 5 5 }
「在复制 slice 的时候,slice 中数组的指针也被复制了,在触发扩容逻辑之前,两个 slice 指向的是相同的数组,触发扩容逻辑之后指向的就是不同的数组了
」
切片不是并发安全的,要并发安全,有两种方法: 1)加锁;2)channel
面试题:切片和map的数据结构并发安全吗?
答:切片的写入和map的写入一样都是非线程安全的,但是map有sync.Map{},切片只能通过加锁或channel方式来实现线程安全的并发写操作。
1)方式一:加锁:适合于对性能要求不高的场景,毕竟锁的粒度太大,这种方式属于通过共享内存来实现通信。
代码示例:
func TestSliceConcurrencySafeByMutex(t *testing.T) { var lock sync.Mutex //互斥锁 a := make([]int, 0) var wg sync.WaitGroup for i := 0; i < 10000; i++ { wg.Add(1) go func(i int) { defer wg.Done() lock.Lock() defer lock.Unlock() a = append(a, i) }(i) } wg.Wait() t.Log(len(a)) // equal 10000 }
2)方法二:channel:适合于对性能要求大的场景,channle就是专用于goroutine间通信的,这种方式属于通过通信来实现共享内存,而Go的箴言便是:尽量通过通信来实现内存共享,而不是通过共享内存来实现通信,推荐此方法!
func TestSliceConcurrencySafeByChanel(t *testing.T) { buffer := make(chan int) a := make([]int, 0) // 消费者 go func() { for v := range buffer { a = append(a, v) } }() // 生产者 var wg sync.WaitGroup for i := 0; i < 10000; i++ { wg.Add(1) go func(i int) { defer wg.Done() buffer <- i }(i) } wg.Wait() t.Log(len(a)) // equal 10000 }
Go语言提供了range关键字用于for 循环中迭代数组(array)、切片(slice)、通道(channel)或集合(map)的元素,有两种使用方式:
for k,v := range _ { }
for k := range _ { }
第一种是遍历下标和对应值
,第二种是只遍历下标
,使用range遍历切片时会先拷贝一份
,然后在遍历拷贝数据:
s := []int{1, 2}
for k, v := range s {
}
会被编译器认为是
for_temp := s
len_temp := len(for_temp)
for index_temp := 0; index_temp < len_temp; index_temp++ {
value_temp := for_temp[index_temp]
_ = index_temp
value := value_temp
}
不知道这个知识点的情况下很容易踩坑,例如下面这个例子:
package main import ( "fmt" ) type user struct { name string age uint64 } func main() { u := []user{ {"试剑江湖",23}, {"技术能量站",19}, {"公众号",18}, } for _,v := range u{ if v.age != 18{ v.age = 20 } } fmt.Println(u) } // 运行结果 [{试剑江湖 23} {技术能量站 19} {公众号 18}]
因为使用range遍历切片u,变量v是拷贝切片中的数据,修改拷贝数据不会对原切片有影响。
一旦一个切片无法容纳更多的元素,Go 语言就会想办法扩容。但它并不会改变原来的切片,而是会生成一个容量更大的切片,然后将把原有的元素和新元素一并拷贝到新切片中。在一般的情况下,你可以简单地认为新切片的容量(以下简称新容量)将会是原切片容量(以下简称原容量)的 2 倍。
但是,当原切片的长度(以下简称原长度)大于或等于1024时,Go 语言将会以原容量的1.25倍作为新容量的基准(以下新容量基准)。新容量基准会被调整(不断地与1.25相乘),直到结果不小于原长度与要追加的元素数量之和(以下简称新长度)。最终,新容量往往会比新长度大一些,当然,相等也是可能的。
另外,如果我们一次追加的元素过多,以至于使新长度比原容量的 2 倍还要大,那么新容量就会以新长度为基准。注意,与前面那种情况一样,最终的新容量在很多时候都要比新容量基准更大一些。更多细节可参见runtime包中 slice.go 文件里的growslice及相关函数的具体实现。
确切地说,一个切片的底层数组永远不会被替换。为什么?虽然在扩容的时候 Go 语言一定会生成新的底层数组,但是它也同时生成了新的切片。
它只是把新的切片作为了新底层数组的窗口,而没有对原切片,及其底层数组做任何改动。
请记住,在无需扩容时,append函数返回的是指向原底层数组的原切片,而在需要扩容时,append函数返回的是指向新底层数组的新切片。所以,严格来讲,“扩容”这个词用在这里虽然形象但并不合适。不过鉴于这种称呼已经用得很广泛了,我们也没必要另找新词了。
顺便说一下,只要新长度不会超过切片的原容量,那么使用append函数对其追加元素的时候就不会引起扩容。这只会使紧邻切片窗口右边的(底层数组中的)元素被新的元素替换掉。
由于slice的底层是数组,很可能数组很大,但slice所取的元素数量却很小,这就导致数组占用的绝大多数空间是被浪费的
1)case1: 比如下面的代码,如果传入的slice b是很大的,然后引用很小部分给全局量a,那么b未被引用的部分(下标1之后的数据)就不会被释放,造成了所谓的内存泄漏。
var a []int
func test(b []int) {
a = b[:1] // 和b共用一个底层数组
return
}
那么只要全局量a在,b就不会被回收。
如何避免?
在这样的场景下注意:如果我们只用到一个slice的一小部分,那么底层的整个数组也将继续保存在内存当中。当这个底层数组很大,或者这样的场景很多时,可能会造成内存急剧增加,造成崩溃。
所以在这样的场景下,我们可以将需要的分片复制到一个新的slice中去,减少内存的占用
var a []int
func test(b []int) {
a = make([]int, 1)
copy(a, b[:0])
return
}
2)Case2:比如下面的代码,返回的slice是很小一部分,这样该函数退出后,原来那个体积较大的底层数组也无法被回收
func test2() []int{
s = make([]int, 0, 10000)
for i := 0; i < 10000; i++ {
s = append(s, p)
}
s2 := s[100:102]
return s2
}
如何避免?
将需要的分片复制到一个新的slice中去,减少内存的占用
func test2() []int{
s = make([]int, 0, 10000)
for i := 0; i < 10000; i++ {
// 一些计算...
s = append(s, p)
}
s2 := make([]int, 2)
copy(s2, s[100:102])
return s2
}
方式一:DeepEqual方法
func equal( s1 []int , s2 []int ) bool {
return reflect.DeepEqual(s1, s2)
}
说明:reflect.DeepEqual()接收的是两个interface{}类型的参数,首先判断两个参数的类型是否相同,然后才会根据类型层层判断。
方式二:循环遍历切片逐个元素进行比较
func equal( s1 []int , s2 []int ) bool {
if len(s1) != len(s2) {
return false
}
for i := 0; i < len(s1); i++ {
if s1[i] != s2[i] {
return false
}
}
return true
}
下面我们就通过一道题来了解一下。我们今天的问题就是:怎样正确估算切片的长度和容量?
为此,我编写了一个简单的命令源码文件
package main
import "fmt"
func main() {
// 示例1。
s1 := make([]int, 5)
fmt.Printf("The length of s1: %d\n", len(s1))
fmt.Printf("The capacity of s1: %d\n", cap(s1))
fmt.Printf("The value of s1: %d\n", s1)
s2 := make([]int, 5, 8)
fmt.Printf("The length of s2: %d\n", len(s2))
fmt.Printf("The capacity of s2: %d\n", cap(s2))
fmt.Printf("The value of s2: %d\n", s2)
}
我描述一下它所做的事情。
首先,我用内建函数make声明了一个[]int类型的变量s1。我传给make函数的第二个参数是5,从而指明了该切片的长度。我用几乎同样的方式声明了切片s2,只不过多传入了一个参数8以指明该切片的容量。
现在,具体的问题是:切片s1和s2的容量都是多少?
这道题的典型回答:切片s1和s2的容量分别是5和8。
问题解析
解析一下这道题。
s1的容量为什么是5呢?因为我在声明s1的时候把它的长度设置成了5。当我们用make函数初始化切片时,如果不指明其容量,那么它就会和长度一致。如果在初始化时指明了容量,那么切片的实际容量也就是它了。这也正是s2的容量是8的原因。
我们顺便通过s2再来明确下长度、容量以及它们的关系。我在初始化s2代表的切片时,同时也指定了它的长度和容量。
我在刚才说过,可以把切片看做是对数组的一层简单的封装,因为在每个切片的底层数据结构中,一定会包含一个数组。数组可以被叫做切片的底层数组,而切片也可以被看作是对数组的某个连续片段的引用。
在这种情况下,切片的容量实际上代表了它的底层数组的长度,这里是8。(注意,切片的底层数组等同于我们前面讲到的数组,其长度不可变。
现在你需要跟着我一起想象:有一个窗口,你可以通过这个窗口看到一个数组,但是不一定能看到该数组中的所有元素,有时候只能看到连续的一部分元素。
现在,这个数组就是切片s2的底层数组,而这个窗口就是切片s2本身。s2的长度实际上指明的就是这个窗口的宽度,决定了你透过s2,可以看到其底层数组中的哪几个连续的元素。
由于s2的长度是5,所以你可以看到底层数组中的第 1 个元素到第 5 个元素,对应的底层数组的索引范围是[0, 4]。
切片代表的窗口也会被划分成一个一个的小格子,就像我们家里的窗户那样。每个小格子都对应着其底层数组中的某一个元素。
我们继续拿s2为例,这个窗口最左边的那个小格子对应的正好是其底层数组中的第一个元素,即索引为0的那个元素。因此可以说,s2中的索引从0到4所指向的元素恰恰就是其底层数组中索引从0到4代表的那 5 个元素。
请记住,当我们用make函数或切片值字面量(比如[]int{1, 2, 3})初始化一个切片时,该窗口最左边的那个小格子总是会对应其底层数组中的第 1 个元素。
但是当我们通过切片表达式基于某个数组或切片生成新切片的时候,情况就变得复杂起来了。我们再来了
s3 := []int{1, 2, 3, 4, 5, 6, 7, 8}
s4 := s3[3:6]
fmt.Printf("The length of s4: %d\n", len(s4))
fmt.Printf("The capacity of s4: %d\n", cap(s4))
fmt.Printf("The value of s4: %d\n", s4)
切片s3中有 8 个元素,分别是从1到8的整数。s3的长度和容量都是8。然后,我用切片表达式s3[3:6]初始化了切片s4。问题是,这个s4的长度和容量分别是多少?
这并不难,用减法就可以搞定。首先你要知道,切片表达式中的方括号里的那两个整数都代表什么。我换一种表达方式你也许就清楚了,即:[3, 6)。
这是数学中的区间表示法,常用于表示取值范围。由此可知,[3:6]要表达的就是透过新窗口能看到的s3中元素的索引范围是从3到5(注意,不包括6)。
这里的3可被称为起始索引,6可被称为结束索引。那么s4的长度就是6减去3,即3。因此可以说,s4中的索引从0到2指向的元素对应的是s3及其底层数组中索引从3到5的那 3 个元素。
(切片与数组的关系)
再来看容量。我在前面说过,切片的容量代表了它的底层数组的长度,但这仅限于使用make函数或者切片值字面量初始化切片的情况。
更通用的规则是:一个切片的容量可以被看作是透过这个窗口最多可以看到的底层数组中元素的个数。
由于s4是通过在s3上施加切片操作得来的,所以s3的底层数组就是s4的底层数组。
又因为,在底层数组不变的情况下,切片代表的窗口可以向右扩展,直至其底层数组的末尾。
所以,s4的容量就是其底层数组的长度8, 减去上述切片表达式中的那个起始索引3,即5。
注意,切片代表的窗口是无法向左扩展的。也就是说,我们永远无法透过s4看到s3中最左边的那 3 个元素。
最后,顺便提一下把切片的窗口向右扩展到最大的方法。对于s4来说,切片表达式s4[0:cap(s4)]就可以做到。我想你应该能看懂。该表达式的结果值(即一个新的切片)会是[]int{4, 5, 6, 7, 8},其长度和容量都是5。
不是。因为数组的长度是类型的一部分,这是与 slice 不同的一点。
package main import "fmt" func main() { slice := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9} s1 := slice[2:5] s2 := s1[2:6:7] s2 = append(s2, 100) s2 = append(s2, 200) s1[2] = 20 fmt.Println(s1) fmt.Println(s2) fmt.Println(slice) } // 打印结果 [2 3 20] [4 5 6 7 100 200] [0 1 2 3 20 5 6 7 100 9]
s1 从 slice 索引2(闭区间)到索引5(开区间,元素真正取到索引4),长度为3,容量默认到数组结尾,为8。 s2 从 s1 的索引2(闭区间)到索引6(开区间,元素真正取到索引5),容量到索引7(开区间,真正到索引6),为5。
接着,向 s2 尾部追加一个元素 100:s2 = append(s2, 100)
s2 容量刚好够,直接追加。不过,这会修改原始数组对应位置的元素。这一改动,数组和 s1 都可以看得到。
再次向 s2 追加元素200:s2 = append(s2, 100)
这时,s2 的容量不够用,该扩容了。于是,s2 另起炉灶,将原来的元素复制新的位置,扩大自己的容量。并且为了应对未来可能的 append 带来的再一次扩容,s2 会在此次扩容的时候多留一些 buffer,将新的容量将扩大为原始容量的2倍,也就是10了。
最后,修改 s1 索引为2位置的元素:s1[2] = 20
这次只会影响原始数组相应位置的元素。它影响不到 s2 了,人家已经远走高飞了。
再提一点,打印 s1 的时候,只会打印出 s1 长度以内的元素。所以,只会打印出3个元素,虽然它的底层数组不止3个元素。
总结:多个切片如果共享同一个底层数组,这种情况下,对其中一个切片或者底层数组的更改,会影响到其他切片。
总结一下,我们今天一起探讨了数组和切片以及它们之间的关系。切片是基于数组的,可变长的,并且非常轻快。一个切片的容量总是固定的,而且一个切片也只会与某一个底层数组绑定在一起。
此外,切片的容量总会是在切片长度和底层数组长度之间的某一个值,并且还与切片窗口最左边对应的元素在底层数组中的位置有关系。那两个分别用减法计算切片长度和容量的方法你一定要记住。
另外,如果新的长度比原有切片的容量还要大,那么底层数组就一定会是新的,而且append函数也会返回一个新的切片。还有,你其实不必太在意切片“扩容”策略中的一些细节,只要能够理解它的基本规律并可以进行近似的估算就可以了。
相关文章
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。