一、切片的基本结构
- 在 Go 语言中,切片是一种灵活、强大的数据结构,它建立在数组之上。切片由三个部分组成:指针、长度和容量。
- 指针指向底层数组中的某个元素,长度表示切片中当前元素的个数,容量表示切片底层数组从切片指针所指元素开始到底层数组末尾的元素个数。
- 例如,定义一个切片
s := []int{1, 2, 3}
,它的长度和容量都是 3。切片的底层数据结构可以简单地表示为:
type Slice struct {
ptr *T
len int
cap int
}
其中,ptr
所代表的是切片指向的底层数组的首地址,它起着定位底层数据起始位置的关键作用。而len
则明确地表示了该切片当前所存放的数据长度,也就是切片中实际包含的元素个数。cap 则是容量大小,在访问切片中的数据时,会结合ptr
这个首地址以及传入的元素序号来准确指定访问相应的数据
二、切片的小技巧
在内存当中,如果单纯去看其中的数据,它们不过是一串毫无意义的 0 或者 1 罢了。而真正赋予这些数据意义的,实则是我们所编写的程序对它们的解读方式。就拿切片来说,其 Data 字段仅仅是一个指向某一块内存区域的地址,并且,我们能够通过某些存在 “危险性” 的手段,去赋予这块内存特定的意义,进而达成一些在 Go 语法层面并不被允许的操作。需要着重提醒的是,当你运用这些技巧的时候,务必要清晰透彻地知晓自己正在做的事情,以及相应可能产生的副作用。
接下来谈谈字符串与字节切片的零拷贝转换。在 Go 语言里,字符串本质上就是一段有着固定长度的字节数组,同时我们也清楚,切片其实是构建在数组基础之上的一种视图,基于这样的特性,我们便可以按照如下方式来操作:
func String(bs []byte) (s string) {
hdr := (*reflect.StringHeader)(unsafe.Pointer(&s))
hdr.Data = (*reflect.SliceHeader)(unsafe.Pointer(&bs)).Data
hdr.Len = (*reflect.SliceHeader)(unsafe.Pointer(&bs)).Len
return
}
func Bytes(s string) (bs []byte) {
hdr := (*reflect.SliceHeader)(unsafe.Pointer(&bs))
hdr.Data = (*reflect.StringHeader)(unsafe.Pointer(&s)).Data
hdr.Len = (*reflect.StringHeader)(unsafe.Pointer(&s)).Len
hdr.Cap = hdr.Len
return
}
三、切片的陷阱
我们知道 Go 都是以传值方式调用的(切片、通道的内部实现都是一个结构体),看以下例子
func main() {
list := []int{1, 2, 3, 4, 5}
f := func(l []int) {
list[1] = 3
}
fmt.Println(list)
//[1 2 3 4 5]
f(list)
fmt.Println(list)
//[1 3 3 4 5]
}
由于传入的是切片的结构体,哪怕是传值调用,但指针所指向的地址是同一个,也就是说在函数内部修改会对外部生效,这也可以进行以下操作
func main() {
f := func(l []int) {
l = append(l, []int{1, 2, 3, 4}...)
fmt.Printf("l = %v, len = %d, cap = %d\n", l, len(l), cap(l))
// 输出:l = [1234], len = 1, cap = 8
}
list := make([]int, 0, 8)
f(list)
// 由于函数内部虽然写入,但对于长度(非指针)的修改是在函数内部,故不会影响外部,所以外部长度为0
fmt.Printf("l = %v, len = %d, cap = %d\n", list, len(list), cap(list))
// 输出:l = [], len = 0, cap = 8
// 由于实际数据是已经写入了的,故通过直接修改长度可以找到数据
(*reflect.SliceHeader)(unsafe.Pointer(&list)).Len = 4
fmt.Printf("l = %v, len = %d, cap = %d\n", list, len(list), cap(list))
// 输出:l = [1 2 3 4], len = 4, cap = 8
}
四、切片的扩充
- 当向切片中添加元素,且切片的长度小于容量时,可以直接在切片末尾添加元素。但当切片的长度等于容量时,就需要进行扩充。
- 例如,有一个切片
s := make([]int, 3, 3)
(长度为 3,容量为 3),如果再执行s = append(s, 4)
,就会触发切片的扩充机制。
(1)误区
当提及 slice 的扩容机制时,我们常常会看到这样一种比较普遍的说法:在 slice 的容量小于 1024 时,其扩容方式是按照双倍进行的;而当容量大于 1024 时,则会以 1.25 倍的比例来扩容。
不可否认,这样的回答不能算完全错误,然而,它确实存在不够准确和不够完整的问题。之所以会出现这样的情况,主要是因为很多人只是参照了网上给出的一些常见回答,并没有针对 slice 扩容机制展开更进一步的深入探究与理解,从而导致对这一机制的认知仅仅停留在较为浅显、片面的层面。
package main
import "fmt"
func main() {
// int64的切片示例
var s1 = make([]int64, 0)
for i := 0; i < 1025; i++ {
s1 = append(s1, 1)
}
fmt.Printf("slice1 len=%d, cap=%d \n", len(s1), cap(s1))
//输出:slice1 len=1025, cap=1280
// int32的切片示例
var s2 = make([]int32, 0)
for i := 0; i < 1025; i++ {
s2 = append(s2, 1)
}
fmt.Printf("slice2 len=%d, cap=%d \n", len(s2), cap(s2))
// 输出:slice2 len=1025, cap=1344
}
(2)输出结果
slice1 len=1025, cap=1280
slice2 len=1025, cap=1344
(3)原因分析
很首先,我们先看下golang库下runtime/slice.go文件,其中的growslice函数
func growslice(et *_type, old slice, cap int) slice {
// 省略.....
// 计算新的容量(newcap)初始值为旧切片的容量
newcap := old.cap
// 双倍容量计算
doublecap := newcap + newcap
// 如果请求的容量大于双倍容量,则直接使用请求的容量
if cap > doublecap {
newcap = cap
} else {
// 如果旧切片的容量小于1024,则将新容量设置为双倍容量
if old.cap < 1024 {
newcap = doublecap
} else {
// 否则,逐步增加新容量,每次增加当前容量的四分之一
// 检查 0 < newcap 以防止溢出并避免无限循环
for 0 < newcap && newcap < cap {
newcap += newcap / 4
}
// 如果新容量计算结果小于等于0,说明发生了溢出
// 此时将新容量设置为请求的容量
if newcap <= 0 {
newcap = cap
}
}
}
// 省略......
}
从这个最开始的代码逻辑看,似乎很符合我们的常规说法:当容量小于1024时,双倍扩容,大于1024时,1.25倍扩容,但为何int32的扩充不符合这个1024的1.25倍呢
实际上需要接着往下看,我们
func growslice(et *_type, old slice, cap int) slice {
// 省略。。。
var overflow bool
var lenmem, newlenmem, capmem uintptr
// Specialize for common values of et.size.
// For 1 we don't need any division/multiplication.
// For sys.PtrSize, compiler will optimize division/multiplication into a shift by a constant.
// For powers of 2, use a variable shift.
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)
}
// 省略。。。
}
这里growslice 函数的核心逻辑在于内存对齐。其通过多个 switch case 分支来处理不同类型的切片,各分支逻辑相似。以 et.size == sys.PtrSize 分支为例,先是计算切片的内存大小,接着调用 roundupsize 函数完成内存对齐操作,最终得出切片的容量。
capmem = roundupsize(uintptr(newcap) * sys.PtrSize) // 计算内存大小,再内存对齐 newcap = int(capmem / sys.PtrSize) // 计算最终容量
这种设计确保了切片在内存中的存储和管理更加高效、有序,是 Go 语言对内存管理优化的重要体现,有助于提升程序的整体性能和稳定性。
进一步深入到roundupsize函数中
var size_to_class8 = [1024/8 + 1]uint8{0, 1, 2, 3, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 10, 10, 11, 11, 12, 12, 13, 13, 14, 14, 15, 15, 16, 16, 17, 17, 18, 18, 19, 19, 19, 19, 20, 20, 20, 20, 21, 21, 21, 21, 22, 22, 22, 22, 23, 23, 23, 23, 24, 24, 24, 24, 25, 25, 25, 25, 26, 26, 26, 26, 27, 27, 27, 27, 27, 27, 27, 27, 28, 28, 28, 28, 28, 28, 28, 28, 29, 29, 29, 29, 29, 29, 29, 29, 30, 30, 30, 30, 30, 30, 30, 30, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32}
var class_to_size = [68]uint16{0, 8, 16, 24, 32, 48, 64, 80, 96, 112, 128, 144, 160, 176, 192, 208, 224, 240, 256, 288, 320, 352, 384, 416, 448, 480, 512, 576, 640, 704, 768, 896, 1024, 1152, 1280, 1408, 1536, 1792, 2048, 2304, 2688, 3072, 3200, 3456, 4096, 4864, 5376, 6144, 6528, 6784, 6912, 8192, 9472, 9728, 10240, 10880, 12288, 13568, 14336, 16384, 18432, 19072, 20480, 21760, 24576, 27264, 28672, 32768}
var size_to_class128 = [(_MaxSmallSize-smallSizeMax)/largeSizeDiv + 1]uint8{32, 33, 34, 35, 36, 37, 37, 38, 38, 39, 39, 40, 40, 40, 41, 41, 41, 42, 43, 43, 44, 44, 44, 44, 44, 45, 45, 45, 45, 45, 45, 46, 46, 46, 46, 47, 47, 47, 47, 47, 47, 48, 48, 48, 49, 49, 50, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 52, 52, 52, 52, 52, 52, 52, 52, 52, 52, 53, 53, 54, 54, 54, 54, 55, 55, 55, 55, 55, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 57, 57, 57, 57, 57, 57, 57, 57, 57, 57, 58, 58, 58, 58, 58, 58, 59, 59, 59, 59, 59, 59, 59, 59, 59, 59, 59, 59, 59, 59, 59, 59, 60, 60, 60, 60, 60, 60, 60, 60, 60, 60, 60, 60, 60, 60, 60, 60, 61, 61, 61, 61, 61, 62, 62, 62, 62, 62, 62, 62, 62, 62, 62, 62, 63, 63, 63, 63, 63, 63, 63, 63, 63, 63, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 65, 65, 65, 65, 65, 65, 65, 65, 65, 65, 65, 65, 65, 65, 65, 65, 65, 65, 65, 65, 65, 66, 66, 66, 66, 66, 66, 66, 66, 66, 66, 66, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67}
func roundupsize(size uintptr) uintptr {
if size < _MaxSmallSize {
// 32K内存以内
// class_to_size 68个不同的内存长度(包含0),对应runtime/sizeclasses.go中表格
// 当小于等于1016时,从size_to_class8中找,结果时1到32,对应class_to_size中的index
// 否则从size_to_class128中找,结果是32到68。 最终都是根据index到class_to_size中最终的结果。
// 其作用就是67个内存长度中找到大于等于size的最接近的长度。 应该是为了效率考虑,直接查的方式,比挨个比较的效率更高。空间换时间
if size <= smallSizeMax-8 {
return uintptr(class_to_size[size_to_class8[divRoundUp(size, smallSizeDiv)]])
} else {
fmt.Println(divRoundUp(size-smallSizeMax, largeSizeDiv))
return uintptr(class_to_size[size_to_class128[divRoundUp(size-smallSizeMax, largeSizeDiv)]])
}
}
if size+_PageSize < size {
// 这种情况只会发生在溢出时,变成负数了
return size
}
return alignUp(size, _PageSize)
}
这里逻辑看起来很复杂,但其实就是其实就是32K以内的内存,都是事先定义好的一些区间值,通过直接在定义好的class_to_size数组中,大于等于size的最接近的长度。sizeclasses.go的源码注释中,给出了对应的表格。我们也可以通过查表得出结果,和代码逻辑是一致的。
比如:size=200, 那么大于200最接近的208。
而32K上内存对齐,调用的是alignUp函数,就是以8192为阶梯,向上取整。
回到我们之前发现的问题
正常情况下,按照1.25被扩容后,容量为1024 * 1.25 = 1280
int32占4字节,其切片内存大小为,1280 * 4 = 5120
通过查下面的表,可以得出大于等于5120的最接近的是5376
5376/4 =1344
五、1.18版本修改
具体情况如下:
(1)如果请求容量 大于 两倍现有容量 ,则新容量 直接为请求容量
(2)否则(请求容量 小于等于 两倍现有容量)
如果 现有容量 小于 256 ,则新容量是原来的两倍
否则:新容量 = 1.25 原容量 + 3/4 阈值 “这个公式给出了从1.25倍增长 过渡到2 倍增长,两者之间的平滑过渡。” 在此情况下,如果发生了溢出,将新容量设置为请求的容量大小
func growslice(et *_type, old slice, cap int) slice {
// 省略。。。
// 初始化新容量为旧容量
newcap := old.cap
// 计算双倍容量
doublecap := newcap + newcap
// 如果请求的容量大于双倍容量,则直接将新容量设为请求的容量
if cap > doublecap {
newcap = cap
} else {
const threshold = 256 // 定义一个阈值,用于区分小切片和大切片
// 如果旧容量小于阈值,则新容量直接设为双倍容量
if old.cap < threshold {
newcap = doublecap
} else {
// 检查 0 < newcap 以检测溢出并防止无限循环
for 0 < newcap && newcap < cap {
// 从对小切片的2倍增长过渡到对大切片的1.25倍增长。
// 这个公式给出了两者之间的平滑过渡。(这里的系数会随着容量的大小发生变化,从2.0到无线接近1.25)
newcap += (newcap + 3*threshold) / 4
}
// 如果新容量计算溢出,则将新容量设为请求的容量
if newcap <= 0 {
newcap = cap
}
}
}
// 省略。。。
}
为何要这样修改呢
在源码中也有解释,这次调整主要着眼于使内存的变化更为平滑。
不妨通过实例来进一步理解,暂且忽略内存对齐的影响。在旧版本里,当原容量为 1000 时,依据双倍扩容规则,扩容后的容量变为 2000;而若原容量为 1024,按照 1.25 倍扩容计算则为 1280。显然,在 1024 这一临界值处,出现了原容量较大但扩容后容量反而较小的情况,未能呈现出单调递增的态势。
再来看修改后的版本,若原容量是 255,经双倍扩容后达到 510;倘若原容量为 256,扩容后的容量通过计算 1.25 * 256 + 192 得出为 512。可见在临界处实现了平滑过渡,内存扩容呈现出单调递增的特性。
这种调整优化了内存扩容机制,避免了临界值附近容量变化的不合理跳跃,为内存管理带来了更稳定、可预测的行为模式,有助于提升程序在处理不同容量切片时的性能表现和逻辑一致性。
免费试用 更多热门智能应用