切片
定义
概念
一片连续的内存区域,不能进行扩容,在复制和传递时为值传递。
声明方式
在声明过程中,可以同时对数组进行赋值操作。初始化数组时,必须明确指定其长度;若未指定长度,则必须在后续为数组赋予具体值,此时编译器将自动推断数组的长度。
var arr [cap]int
var arr [cap]int{1,2,3,4}
arr :=[...]int{2,3,4}
不同类型的数组之间不能进行比较。
数组长度可以内置len函数获取,数组中的元素可以通过下标获取,只能访问数组中已有的元素,如果数组访问越界,则在编译时会报错。
数组值复制
数组在赋值和函数调用时形参都是值复制。
a:=[3]int{2,5,8}
b=a
func chaneg(c [3]int){}
在赋值给b,还是函数调用c都是值赋值,不管修改b还是c。对a没有影响。
原理
内存优化
当数组的长度小于4时,在运行时数组会被放置在栈中,当数组的长度大于4时,数组会被放到内存的静态只读区。
这是关于Go语言中数组内存分配的一个重要优化策略:
- 当数组长度小于4时(数组较小):
-
- 数组被分配在栈(Stack)上
- 这样做的好处是分配和释放都很快
- 由于数据在栈上,可以减少垃圾回收的压力
- 适合临时性的小数组使用
- 当数组长度大于4时(数组较大):
-
- 数组被分配在静态只读区
- 这样可以避免大数组频繁在栈上分配释放
- 静态只读区的数据可以被多个goroutine共享
- 适合存储较大的、不会频繁变化的数据
索引与访问越界原理
数组越界访问是一个严重的编程错误,其检测在Go语言中部分在编译期间通过类型检查阶段完成。具体而言,typecheck1
函数被用于对数组索引进行验证,确保访问操作的安全性。
- 编译期检查:利用
typecheck1
函数,对数组索引的常量值进行静态分析 - 运行时检查:对于非常量索引,编译器会插入相应的边界检查代码,以防止越界错误的发生
// typecheck1 处理特定类型的节点并返回类型检查后的节点
func typecheck1(n *Node, top int) (res *Node) {
// ... 其他类型处理代码 ...
case OINDEX: // 处理索引操作 a[i]
// 检查左操作数是否为数组、切片或字符串
a := n.Left
i := n.Right
// 类型检查和转换
a = typecheck(a, Erv)
i = typecheck(i, Ev)
// 检查索引类型是否为整数
if !i.Type.IsInteger() {
yyerror("non-integer index %v", i)
n.Type = nil
return n
}
// 处理数组索引的特殊情况
if a.Type.IsArray() {
// 检查索引是否为常量
if i.Op == OLITERAL {
// 常量索引:在编译期直接检查越界
x := i.Int64()
if x < 0 || uint64(x) >= uint64(a.Type.NumElem()) {
yyerror("constant index %d out of bounds [0:%d)", x, a.Type.NumElem())
n.Type = nil
return n
}
} else {
// 非常量索引:插入运行时边界检查
if !n.Bounded() {
n = mkcall1(chanop("runtime.panicIndex"), nil, typename("index out of range"), a, i)
n.SetType(types.Types[TUNSAFEPTR])
return n
}
}
}
// ... 其他索引操作处理代码 ...
}
// ... 其他类型处理代码 ...
}
编译时检查的关键逻辑
上述代码中,编译时检查的核心逻辑是:
// 检查数组索引是否为字面常量
if i.Op == OLITERAL {
// 获取索引的整数值
x := i.Int64()
// 检查索引是否越界:
// 1. x < 0:检查是否为负数
// 2. uint64(x) >= uint64(a.Type.NumElem()):检查是否超过数组长度
if x < 0 || uint64(x) >= uint64(a.Type.NumElem()) {
// 如果索引越界,输出错误信息
// 错误格式:constant index {索引值} out of bounds [0:{数组长度})
yyerror("constant index %d out of bounds [0:%d)", x, a.Type.NumElem())
// 将节点类型设置为nil
n.Type = nil
// 返回错误节点
return n
}
}
这里会检查:
- 索引是否为常量表达式(
i.Op == OLITERAL
) - 常量值是否在合法范围内(
0 ≤ x < len(array)
)
如果检测到越界,会直接报编译错误,阻止生成有问题的代码。
运行时检查的插入
对于非常量索引,Go 编译器会插入运行时检查代码:
// 检查索引是否在边界范围内
if !n.Bounded() {
// 如果索引越界:
// 1. 调用runtime.panicIndex函数创建一个运行时恐慌
// 2. 传入数组a和索引i作为参数
// 3. 错误信息为"index out of range"
n = mkcall1(chanop("runtime.panicIndex"), nil, typename("index out of range"), a, i)
// 将节点类型设置为unsafe.Pointer
n.SetType(types.Types[TUNSAFEPTR])
// 返回包含恐慌调用的节点
return n
}
这会在运行时调用 runtime.panicIndex
函数进行边界检查,如果越界会触发 panic。
总结
typecheck1
函数通过以下机制确保数组访问安全:
- 静态分析:对常量索引直接在编译期检查,提前发现越界错误
- 动态防护:对变量索引插入运行时检查,防止运行时越界
- 类型安全:强制要求索引类型为整数,避免非整数索引
这种双检查机制有效减少了数组越界的风险,但需要注意:如果通过 unsafe 包绕过类型系统,这些检查会失效。
切片
定义
一种轻量级的数据结构,切片是对底层数组的一个动态视图,可以动态增长和缩减。切片本身并不存储数据,而是描述底层数组的一段区间。可以动态管理一组数据。
切片的数据结构由指针,长度,容量组成
- 指针:指向底层数组的起始元素。
- 长度:切片当前包含的元素个数。
- 容量:从切片起始位置到底层数组末尾的元素个数。
// runtime/slice.go
// 切片的底层结构体定义
type slice struct {
array unsafe.Pointer // 指向底层数组的指针
len int // 切片长度
cap int // 切片容量
}
底层数据结构
初始化
在声明设为不初始值的情况,切片的值nil。切片初始化需要使用内置 的make函数
var slice []int
var slice []int=make([]int,5)
var slcie []int=make([]int,5,6)
切片的初始化可以通过截取数组的一部分来实现,截取后,切片依然保持对原数组的引用。因此,对切片所做的任何修改都会直接反映在原数组上。
空切片与nil切片
var num []int这种方式声明出的切片为nil切片,切片的指针,长度和容量的零值分别为nil、0和0
num:=make([]int,0)这种方式声明的时空切片,切片的指针,长度分别为空结构体、0和0,底层数组为空,但底层数值的指针非空。
make函数进行初始化切片源码
// 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() // 长度非法时触发panic
}
panicmakeslicecap() // 容量非法时触发panic
}
// 分配内存,返回切片底层数组的指针
return mallocgc(mem, et, true)
}
// MulUintptr 用于无符号整数乘法并检测溢出
func MulUintptr(a, b uintptr) (uintptr, bool) {
// 计算乘积
c := a * b
// 检查是否发生溢出(乘法结果除以任一因子不等于另一因子则溢出)
if a != 0 && c/a != b {
return 0, true // 溢出
}
return c, false // 未溢出
}
- makeslice首先用MulUintptr计算切片底层数组所需内存,防止溢出和非法参数。
- 若容量或长度非法,分别触发panic。
- math.MulUintptr用于安全地计算内存大小,避免溢出。
切片值复制与数据引用
切片的复制本质上是值复制,然而,在运行时,此过程涉及对SliceHeader结构的复制。尽管底层指针依然指向同一数组地址,实现了引用传递,这种机制在处理大量数据时,能够以较低的成本高效完成复制操作。
// reflect.SliceHeader 的定义
// Data字段为底层数组的指针,Len为切片长度,Cap为切片容量
type SliceHeader struct {
Data uintptr
Len int
Cap int
}
切片的复制本质上是SliceHeader结构体的值复制(浅拷贝),即复制Data、Len、Cap三个字段。这样复制后的切片与原切片共享同一底层数组,但它们的SliceHeader是独立的。
扩容
使用内置函数append向切片中追加元素,返回与原切片完全相同尾部追加新元素的更大新切片,如果追加新元素后的新切片的容量大于底层数组的容量,就会发生扩容,创建一个新的底层数组,将现有的值复制到新数组中,然后再添加新值。
扩容规则
// growslice 根据所需容量扩容切片
func growslice(et *_type, old slice, cap int) slice {
// ... 前置检查代码 ...
// 计算新容量
newcap := old.cap
doublecap := newcap + newcap
if cap > doublecap {
// 需求容量超过当前容量的2倍,直接使用需求容量
newcap = cap
} else {
// 否则根据切片大小选择扩容策略
const threshold = 256
if old.cap < threshold {
// 小切片(容量少于256)按2倍扩容
newcap = doublecap
} else {
// 大切片按约1.25倍扩容(添加0.75*threshold的增长因子)
for 0 < newcap && newcap < cap {
newcap += (newcap + 3*threshold) / 4
}
// 确保新容量满足最小需求
if newcap <= 0 {
newcap = cap
}
}
}
// ... 内存对齐和溢出检查代码 ...
// 分配新的底层数组
var overflow bool
var lenmem, newlenmem, capmem uintptr
// ... 计算内存大小代码 ...
var p unsafe.Pointer
if et.ptrdata == 0 {
// 元素类型不含指针,直接分配内存
p = mallocgc(capmem, nil, false)
// 用零值填充新分配的内存
memclrNoHeapPointers(add(p, newlenmem), capmem-newlenmem)
} else {
// 元素类型包含指针,需要更复杂的内存分配和GC处理
p = mallocgc(capmem, et, true)
if lenmem > 0 && writeBarrier.enabled {
// 启用写屏障的情况下需要特殊处理
bulkBarrierPreWriteSrcOnly(uintptr(p), uintptr(old.array), lenmem)
}
}
// 将旧数据复制到新数组
memmove(p, old.array, lenmem)
// 返回新切片
return slice{p, old.len, newcap}
}
新的长度newLen=oldLen+num
-
若追加后所需容量(cap)大于原容量的两倍,则新容量直接为cap。
-
若原容量小于阈值(如256),则新容量为原容量的2倍。
-
若原容量大于等于阈值,则新容量每次增加原容量的1.25倍(即newcap += (newcap + 3*threshold)/4),直到满足所需容量。
-
计算完newcap后,还会通过roundupsize函数进行内存对齐,保证分配效率和内存利用率。
-
扩容时会分配新的底层数组,并将原有数据拷贝到新数组。
这种策略兼顾了小切片的快速扩容和大切片的内存利用率,且内存分配有进一步优化。
为什么采用这种策略?
这种扩容策略的设计权衡了内存使用效率和性能:
- 小切片快速增长:小切片按 2 倍扩容可以减少频繁扩容的开销
- 大切片渐进增长:大切片按较小比例扩容可以避免浪费过多内存
- 内存对齐优化:确保内存块大小符合系统分配规则,提高内存利用率