GO切片slice详细解析
Slice数据结构
type slice struct{
array unsafe.Pointer
len int
cap int
}
创建slice的方式
数组方式创建
ain
import "fmt"
func main() {
a := []int{1, 2, 3, 4, 5, 6, 7, 8, 9}
fmt.Println(len(a), cap(a))
}
依靠数组创建的切片,容量是与底层数组相关。其源码如下:
func newobject(typ *_type) unsafe.Pointer{
return mallocgc(typ.Size_,typ,true)
}
这是Go语言内置new
关键字的底层实现,用于在堆上分配指定类型的内存空间。其核心职责包括:
- 类型感知的内存分配
- 与GC系统的交互
- 内存对齐保证
- 逃逸分析的基础支持
参数解析
参数 | 类型 | 作用说明 | 内存管理关联 |
---|---|---|---|
typ | *_type | Go类型系统的类型描述符 | 包含类型大小、GC掩码等信息 |
Size_ | uintptr | 类型的内存占用量 | 直接决定分配空间大小 |
第三个参数 | bool | 是否需要内存清零(本例为true) | 影响分配性能特征 |
注意 |
- 内存分配性能指标:
- 每秒分配次数(allocs/op)
- 每次分配耗时(ns/op)
- 堆外分配比例
new
package main
import "fmt"
func main() {
c := *new([]int)
//c 是一个指向切片的指针,类型是 *[]int,而 len(c) 或 cap(c) 只能作用于 []int 类型本身。
//如果你想要获取切片的长度或容量,需要先解引用指针。
fmt.Println(len(c), cap(c))
}
切片创建
package main
import "fmt"
func main() {
a := []int{1, 2, 3}
d := a[1:2:3]
fmt.Println(d, len(d), cap(d))
}
//如果是通过`array[low:high]`方式创建的,cap为从low到len(array)的值。也即cap = len(array) - low。
//如果指定了max,cap = max - low,所以max不允许大于len(array)
make
package main
import "fmt"
func main() {
d := make([]int, 5, 5)
fmt.Println(len(d), cap(d))
}
make
方式通过makeslice
方法来创建切片。在看makeslice
源码前首先要了解math.MulUintptr
这个函数,其作用是计算slice切片需要的内存空间。
// MulUintptr第一行代码的判断条件可以简化成 a|b < 1 << 4 * 8 || a == 0,也即 a|b < 2^32|| a== 0
// 其中a == 0的情况,a * b 一定不溢出
// a|b < 2^32的情况下,代表a或者b都小于2^32,也就是说a * b 一定小于 2^64-1,也不会溢出
// 现在翻译一下MulUintptr的逻辑:
// ① 如果a、b乘积不溢出或者a == 0,直接返回乘积a * b和false
// ② 其它情况, overflow = a * b > MaxUintptr(uintptr最大值),返回a * b和true
// MulUintptr returns a * b and whether the multiplication overflowed.
// On supported platforms this is an intrinsic lowered by the compiler.
func MulUintptr(a, b uintptr) (uintptr, bool) {
if a|b < 1<<(4*goarch.PtrSize) || a == 0 {
return a * b, false
}
overflow := b > MaxUintptr/a
return a * b, overflow
}
const MaxUintptr = ^uintptr(0)
// uintptr(0):这将无符号整数 0 转换为 uintptr 类型。uintptr 是一个无符号整数类型,其大小等于指针的大小,通常是 32 位或 64 位。
// ^uintptr(0):这是对无符号整数 0 进行按位取反操作,得到所有位都为 1 的值。在 64 位系统上,结果为 0xffffffffffffffff,在 32 位系统上,结果为 0xffffffff。
// ^uintptr(0) >> 63:这是将上一步得到的结果右移 63 位,结果是 64 位系统下为 1(0x1),32 位系统下为 0(0x0)。
// 4 << (^uintptr(0) >> 63):这是将上一步得到的结果作为位移量,对数字 4 进行左移运算。如果在 64 位系统上,结果是 4 << 1,即 8;如果在 32 位系统上,结果是 4 << 0,即 4。
const PtrSize = 4 << (^uintptr(0) >> 63)
-
计算内存需求
- 通过
元素大小(elemSize)* 容量(cap)
计算切片底层数组所需内存空间。 - 关键检查:确保乘积结果不溢出
uintptr
范围(如 64 位系统下最大为2^64-1
),防止数值异常。
- 通过
-
合法性校验
- 内存溢出:若
elemSize * cap
超出uintptr
范围,触发 panic。 - 内存上限:若计算结果超过系统单次分配最大内存
maxAlloc
(如 64 位系统通常为1<<48
),触发 panic。 - 逻辑错误:若
len < 0
或len > cap
(如len
为负数或容量不足),触发 panic。
- 内存溢出:若
-
内存分配
- 通过
mallocgc
函数在堆上申请连续内存空间,作为切片的底层数组。 mallocgc
是 Go 运行时内存分配的核心函数,负责管理垃圾回收(GC)元数据,确保内存安全回收。
- 通过
关键点解析:
步骤 | 作用 | 技术细节 |
---|---|---|
内存计算 | 确定底层数组所需物理内存大小 | 依赖平台指针大小(goarch.PtrSize ),64 位系统为 8 字节,计算时需兼容所有架构。 |
溢出检查 | 防止非法内存请求导致未定义行为 | 通过 overflow 标志位判断乘法溢出,确保内存计算值合法。 |
系统限制 | 遵循操作系统内存分配规则 | maxAlloc 由运行时根据系统内存布局动态计算(如虚拟地址空间限制)。 |
逻辑校验 | 确保业务逻辑的合理性(如 len 非负且不超过 cap ) | 避免运行时越界访问,属于开发者责任,编译器静态检查无法完全覆盖。 |
内存分配 | 为切片提供底层存储空间 | mallocgc 根据内存大小选择分配策略(小对象用 mcache ,大对象直接 mheap ),并标记 GC 信息。 |
扩容切片
规则
1.当cap>=len+num,直接对相应的数组进行操作
2. 当cap< len+num 则需要扩容
扩容算法
1. 旧版 *2 1024 *1.25
2. 新版 *2 256 new = old + ( 3 * 256 + old ) / 4
但整体的思路始终是在减少扩容次数的同时,最大限度的避免浪费内存空间。
内存对齐
Go 语言中的 内存对齐(memory alignment)是指编译器如何将数据结构的字段放置在内存中的规则。内存对齐的目的是为了提高访问效率和避免潜在的性能损失。不同类型的变量(如 int
, float
, struct
)在内存中可能会按照不同的对齐方式进行排列。
为什么需要内存对齐?
-
性能优化: 许多 CPU 对不同数据类型的访问有特定的要求。例如,在许多架构上,读取 4 字节的数据(如
int32
)要求其地址必须是 4 的倍数。如果数据未对齐,CPU 可能需要额外的周期来处理访问,这样会降低性能。 -
硬件要求: 一些架构(如 ARM 和 x86)要求数据在特定边界对齐,否则可能会导致硬件异常或效率低下。
Go 中的内存对齐
1. 对齐方式
在 Go 中,对齐方式通常与数据类型的大小有关。例如:
int8
和byte
是 1 字节,对齐方式是 1。int16
对齐方式是 2。int32
对齐方式是 4。int64
和float64
对齐方式是 8。float32
对齐方式是 4。
默认情况下,Go 会根据数据类型的大小自动选择对齐方式。例如:
type MyStruct struct {
a int8 // 1 byte
b int32 // 4 bytes
}
在内存中,Go 会将 a
放在一个字节上,接着将 b
放在离 a
之后的 4 字节位置上。这样做的原因是 int32
类型要求它的地址是 4 的倍数,所以编译器会自动为 b
分配一个合适的位置来确保它正确对齐。
2. 内存布局
Go 中结构体(struct
)的内存布局会受到字段类型对齐要求的影响。如果一个结构体包含多个字段,Go 会为每个字段按其对齐要求进行布局,并可能插入 内存填充(padding)来确保对齐。
例如,考虑以下结构体:
type MyStruct struct {
a int8 // 1 byte
b int32 // 4 bytes
}
虽然 a
只占 1 字节,但 b
占用 4 字节,因此编译器会为 b
添加 3 字节的填充,以确保 b
的地址是 4 的倍数。结构体的内存布局如下:
a
占用 1 字节。- 接下来的 3 字节是填充字节。
b
占用 4 字节。
因此,MyStruct
占用 8 字节(1 字节的 a
+ 3 字节填充 + 4 字节的 b
)。这种方式确保了对齐,但可能导致内存的浪费。
3. 结构体对齐的例子
package main
import "fmt"
import "unsafe"
type MyStruct struct {
a int8 // 1 byte
b int32 // 4 bytes
}
func main() {
var m MyStruct
fmt.Println(unsafe.Sizeof(m)) // 输出结构体的大小
fmt.Println(unsafe.Alignof(m)) // 输出结构体的对齐要求
}
输出结果:
8
4
这说明结构体 MyStruct
的大小为 8 字节(1 字节 + 3 字节填充 + 4 字节),并且结构体的对齐要求为 4 字节。
4. 对齐方式与 unsafe
包
Go 提供了 unsafe
包来查看变量的大小、对齐方式等。unsafe.Sizeof()
返回变量的大小,而 unsafe.Alignof()
返回变量的对齐要求。对于结构体来说,它的对齐方式通常是其中对齐要求最大的字段的对齐方式。
例如:
package main
import "fmt"
import "unsafe"
type A struct {
x int8
y int64
}
func main() {
fmt.Println(unsafe.Sizeof(A{})) // 16 字节
fmt.Println(unsafe.Alignof(A{})) // 8 字节(因为 int64 对齐要求是 8)
}
5. 如何控制对齐
Go 并没有直接提供控制结构体字段对齐的机制,但你可以通过调整字段的顺序来减少内存填充。通过将对齐要求更高的字段放在前面,可以减少填充的空间。
例如,以下结构体会减少内存浪费:
type MyOptimizedStruct struct {
b int32 // 4 bytes
a int8 // 1 byte
}
在这种情况下,结构体的内存布局将是:
b
占用 4 字节,按 4 字节对齐。a
占用 1 字节。- 接下来,3 字节为填充,以确保结构体大小为 8 字节。
这种布局比将 a
放在 b
前面更节省内存,因为它避免了额外的填充。
6. Go 1.18 和以后的变动
在 Go 1.18 引入了泛型后,对于结构体内存对齐的影响基本没有改变,依然遵循结构体成员对齐的规则。不过,需要注意的是,Go 语言的编译器和标准库已经做了很多优化,以减少内存浪费并提高访问效率。
总结
- Go 会根据每种数据类型的大小自动决定对齐方式。
- 内存对齐可以提升 CPU 访问数据的效率,但可能导致内存的浪费(填充)。
- 你可以通过调整结构体字段的顺序来减少内存浪费,避免不必要的填充。
unsafe.Sizeof()
和unsafe.Alignof()
可以帮助你检查内存对齐和结构体的大小。
拷贝slice
// slicecopy is used to copy from a string or slice of pointerless elements into a slice.
func slicecopy(toPtr unsafe.Pointer, toLen int, fromPtr unsafe.Pointer, fromLen int, width uintptr) int {
if fromLen == 0 || toLen == 0 {
return 0
}
n := fromLen
if toLen < n {
n = toLen
}
if width == 0 {
return n
}
size := uintptr(n) * width
if raceenabled {
callerpc := getcallerpc()
pc := abi.FuncPCABIInternal(slicecopy)
racereadrangepc(fromPtr, size, callerpc, pc)
racewriterangepc(toPtr, size, callerpc, pc)
}
if msanenabled {
msanread(fromPtr, size)
msanwrite(toPtr, size)
}
if asanenabled {
asanread(fromPtr, size)
asanwrite(toPtr, size)
}
if size == 1 { // common case worth about 2x to do here
// TODO: is this still worth it with new memmove impl?
*(*byte)(toPtr) = *(*byte)(fromPtr) // known to be a byte pointer
} else {
memmove(toPtr, fromPtr, size)
}
return n
}
copy
只看两个切片的长度。如果目标len小于源len,就只拷贝目标长度的内容。注意这里是len不是cap。
slice的避坑指南
引用类型
package main
import "fmt"
func main() {
a := []int{1, 2, 3, 4, 5}
b := a[1:2]
fmt.Println(a, b)
a[1] = 6
fmt.Println(a, b)
}
//结果如下
[1 2 3 4 5] [2]
[1 6 3 4 5] [6]
参数传递
// slice作为参数呢,下面定义一个函数change,里面修改参数`b`的值,会不会对`a`影响呢?
package main
import "fmt"
func change(b []int) {
b[1] = 6
}
func main() {
a := []int{1, 2, 3, 4, 5, 6}
fmt.Println(a)
change(a)
fmt.Println(a)
}
//结果如下
[1 2 3 4 5 6]
[1 6 3 4 5 6]
改动一下
package main
import "fmt"
func change(b []int) {
b = append(b, 7)
b[1] = 6
}
func main() {
a := []int{1, 2, 3, 4, 5, 6}
fmt.Println(a)
change(a)
fmt.Println(a)
}
//结果
[1 2 3 4 5 6]
[1 2 3 4 5 6]
这是因为
a
的len和cap都是5。第一次时a[1]
= 6只更新了值,所以对a
的值也有影响。第二次时change
中append
追加了一个值5,b
的cap < len + 1发生了扩容,b
变成了len = 6, cap = 10,是一个新的切片。追加的5和修改的值都影响在b
上,而a
还是原来的值,所以结果才是这样。
那我们再来看看下面一段代码
package main
import "fmt"
func change(b []int) {
c := b
b = append(b, 7)
c[1] = 6
}
func main() {
a := []int{1, 2, 3, 4, 5, 6}
fmt.Println(a)
change(a)
fmt.Println(a)
}
遍历切片
func main() {
a := []int{1, 2, 3, 4, 5}
fmt.Println(a)
for i, v := range a {
if i < 4 {
a[i+1] += v
}
fmt.Println(v)
}
fmt.Println(a)
}
//结果如下
[1 2 3 4 5]
1
3
6
10
15
[1 3 6 10 15]