【译文】原文地址
Go切片的使用很方便,内部实现也很有趣。有很多关于slice的文档和博客,都对其概念进行了讲解包括内部结构。本文更多地关注切片内存管理。让我们从元素的复制和删除操作开始。
复制
由于内置函数copy,Go允许开发人员复制切片。当然append函数用于向切片中追加元素,也可以实现切片的复制。如下两个例子所示:
func main() {
a := []int{4, 2, 1}
b := make([]int, len(a))
copy(b, a)
c := append([]int{}, a...)
}
新创建的切片b和c底层实质上是包含相同值的数组。
这两个函数可以在其他场景中使用,例如删除切片中的一个元素或部分元素。如下是一个使用copy函数删除切片中第二个元素的例子:
package main
func main() {
a := []int{4, 2, 1}
copy(a[1:], a[2:])
a = a[:len(a)-1]
}
使用append函数实现:
func main() {
a := []int{4, 2, 1}
a = append(a[:1], a[2:]...)
}
这种实现是可以的是因为Go规范保证了不管函数参数是否有内存重叠:
内置函数append和copy支持常规的切片操作。对于两个函数,其结果与参数引用的内存是否重叠是无关的。
第一个例子生成的汇编代码显示了底层函数管理内存拷贝:
[...]
0x00a3 00163 (main.go:8) CALL runtime.memmove(SB)
[...]
0x00f4 00244 (main.go:10) CALL runtime.memmove(SB)
这个实现依赖于memmove函数能处理内存重叠。如下是一个带有字节切片的例子能说明重叠问题:
func main() {
a := []byte("hello")
copy(a[2:], a)
}
复制内存从a的第一个字节由前向后到最后一个字节重叠。
memmove通过反向复制的能力解决了这个问题:
反向复制解决了重叠问题,并满足了Go规范中提供的保证。
memmove在汇编中的实现实际上比这个复杂的多。它处理前向和反向复制,并不总是需要循环,并且可以用很少的指令就能处理复制
重置切片
重用一个切片意味着首先要清除其内容。Go提供了编译器优化,以便更快清除一个切片。如下是一个清除整数切片的例子:
func main() {
a := []int{4, 2, 1}
for i := range a {
a[i] = 0
}
}
在切片上循环逐一清除元素可能会很麻烦。为了解决这个问题,Go 1.5提供了一个优化,能够识别这种循环,并通过调用内存清理函数来替换它。这可以从汇编代码中确认:
0x0047 00071 (main.go:6) CALL runtime.memclrNoHeapPointers(SB)
后缀noheappointer指的是不包含任何指针的片。同样的函数也适用于包含指针的切片;在这种情况下,编译器将调用memclrHasPointer。
这种优化大大加快了清理速度。下面是一个包含6、16、64和256个元素的基准测试:
分配与复制
当分配一个切片时,Go首选分配内存,然后通过之前看到的函数memclr*将其置零。
然而,如果下一条指令如果是一个现有切片的复制,清除操作只会浪费时间:
因此,对这种情况的一种优化,切片在立即复制的情况下删除内存清理部分:
这将加快分配速度。以下是一个基准:
func BenchmarkAllocAndCopy(b *testing.B) {
a := make([]int, 250)
for k, _ := range a {
a[k] = k*2
}
b.ResetTimer()
for i := 0;i < b.N; i++ {
b := make([]int, len(a))
copy(b, a)
}
}
以下是基准测试结果:
name old time/op new time/op delta
AllocAndCopy500-8 531ns ± 1% 502ns ± 4% -5.44%
AllocAndCopy250-8 284ns ± 1% 272ns ± 5% -4.10%
AllocAndCopy50-8 78.5ns ± 3% 72.1ns ± 1% -8.16%
AllocAndCopy5-8 30.6ns ± 1% 26.1ns ± 1% -14.80%
使用copy触发这种特殊分配的唯一条件是确保make和copy两条指令相互跟随。