Go 底层数据结构 - Slice

510 阅读5分钟

Go 底层数据结构(一)- Slice

当前 Golang 版本 1.18.1

基础概念:

  • Slice(切片)与 Array(数组)的区别:
  1. Array 是一个长度不可改变的数据结构;Slice 是一个支持扩容的数据结构
  2. 在作为参数传递时,数组传递的是拷贝,而切片传递的是指针

重要知识点

  • slice 结构具有三个字段,分别表示:指向数组内存地址指针,容量,长度;
  • 截取操作不会重新创建数组,而是复用原切片数组。内存地址指针根据截取起始点偏移;注意可能造成的内存溢出。同时,考虑截取切片在操作数据时对原数组是否影响;(是否发生扩容
  • 切片扩容规则发生改变。阈值改为 256,低于阈值为 2x 扩容,高于阈值需查表;
  • 提前规划容量可以减少扩容产生的开销

Slice数据结构

type slice struct {
    array unsafe.Pointer // 指向数组内存地址的指针
    len   int            // 已有元素长度
    cap   int            // 切片总容量
}

Slice初始化

go/slice.go at master · golang/go (github.com)

// makeslice 初始化 slice
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 {
                // 提示:这里进行再判断,抛出一个 'len out of range' 错误,而非 'cap out of range' 错误
                // 是因为 cap 是隐式提供的,而 len 相较于 cap 而言更为清晰。
                
                // 此时会计算 类型大小 与 长度 的乘积,更精细的判断
		mem, overflow := math.MulUintptr(et.size, uintptr(len))
                // 如果还是超过,那么抛出 'makeslice: len out of range'
		if overflow || mem > maxAlloc || len < 0 {
			panicmakeslicelen()
		}
                // 如果长度没问题,那么则抛出 makeslice: cap out of range
		panicmakeslicecap()
	}
        // 如果容量没问题,那么则分配 mem 的内存空间
	return mallocgc(mem, et, true)
}

切片截取

我们在实际应用中,很多时候会对切片进行截取操作。例如:

x := []int{2, 3, 5, 7, 11} 
y := x[1:3]    
m := x[1:]    // 语法糖
n := x[:3]    // 语法糖

在进行截取操作时,我们不会额外创建新的数组来传递数据,而是两者共用同一个数组 array,内存地址根据截取起始长度进行偏移

image.png

如上图所示: 切片 x 指向了数组array为 [5]int,假定其内存地址为 t。此时对切片 x 进行截取操作得到切片 y,切片 y 中array的值应为t+8,其中+8为 int 所占字长。

image.png

综上,我们可以不需要过多关注切片截取产生的开销。

并非完全不用担心切片截取所带来的问题。在某些特定情况下,截取可能会带来内存泄露的问题。
例如,原切片 x 被 gc 正常回收,而截取切片 y 因为某些原因没有正常 gc 时,底层的数组数据就一直被截取切片引用,也不会正常被 gc 回收掉,从而造成内存泄露的情况。

源码:

// makeslicecopy 申请 tolen 个元素大小的内存,然后从 from 指针中拷贝 fromlen 个元素到新内存
func makeslicecopy(et *_type, tolen int, fromlen int, from unsafe.Pointer) unsafe.Pointer {
        // 定义
    var tomem, copymem uintptr

    // 如果申请元素大小 > 拷贝大小
    if uintptr(tolen) > uintptr(fromlen) {
        var overflow bool
        // 重新判断新容量是否会造成内存溢出,同时计算所需内存值
        tomem, overflow = math.MulUintptr(et.size, uintptr(tolen))
        if overflow || tomem > maxAlloc || tolen < 0 {
            panicmakeslicelen()
        }
        // 如果不溢出,计算旧切片使用内存大小
        copymem = et.size * uintptr(fromlen)
    } else {
        // 若老的切片容量大于等于新切片容量,且老的切片是可用的,那么新的切片内存空间也不会溢出。
        // 因为他们具有相同的元素宽度(element width)
        // 直接计算内存大小并赋值
        tomem = et.size * uintptr(tolen)
        copymem = tomem
    }
    // 定义一个新切片为指针类型
    var to unsafe.Pointer
    // 判断是否是指针类型
    if et.ptrdata == 0 {
        // 如果不是指针类型,直接分配内存地址
        to = mallocgc(tomem, nil, false)
        if copymem < tomem {
                memclrNoHeapPointers(add(to, copymem), tomem-copymem)
        }
    } else {
        // 提示:不能分配零值内存,因为 gc 回收会扫描未分配的内存并回收
        to = mallocgc(tomem, et, true)
        if copymem > 0 && writeBarrier.enabled {
            // Only shade the pointers in old.array since we know the destination slice to
            // only contains nil pointers because it has been cleared during alloc.
            // 因为我们能够确认新创建的切片只包含会被回收的空指针,所以我们只能使用旧的切片指针值赋给新切片
            bulkBarrierPreWriteSrcOnly(uintptr(to), uintptr(from), copymem)
        }
    }
    // -race 参数
    if raceenabled {
        callerpc := getcallerpc()
        pc := abi.FuncPCABIInternal(makeslicecopy)
        racereadrangepc(from, copymem, callerpc, pc)
    }
    if msanenabled {
        msanread(from, copymem)
    }
    if asanenabled {
        asanread(from, copymem)
    }
    // 复制从 from 到 to 长度
    memmove(to, from, copymem)
    return to
}

切片扩容

规则:

  • 如果当前长度小于 256,每次按照 2倍增长
  • 如果当前长度大于等于 256,根据切片类型大小有不同判定规则,近似为 1.25x + 0.75 * threshold,可以通过 sizeclasses.go 查表获取

P.S. 这里是1.18 版本下新的判定阈值,在 1.18 之前阈值均为 1024,扩容规则为 2x / 1.25x
1.17 Release 版本 go/slice.go at release-branch.go1.17 · golang/go (github.com)
1.18 Release 版本 go/slice.go at release-branch.go1.18 · golang/go (github.com)

image.png

func growslice(et *_type, old slice, cap int) slice {
    // 如果目标容量小于原容量,则不符合扩容标准,抛出错误
    if cap < old.cap {
        panic(errorString("growslice: cap out of range"))
    }
    
    if et.size == 0 {
        // append should not create a slice with nil pointer but non-zero len.
        // We assume that append doesn't need to preserve old.array in this case.
        // 新增操作不应该创建一个长度非零的空指针切片
        // 我们认为在这种情况下,不应该保存原数组
        // 所以直接返回一个
        return slice{unsafe.Pointer(&zerobase), old.len, cap}
    }

    newcap := old.cap
    doublecap := newcap + newcap
    // 如果目标容量仍大于两倍原始容量
    if cap > doublecap {
        newcap = cap
    } else {
        // 定义阈值 256
        const threshold = 256
        // 如果原始容量小于 256,则新容量为两倍
        if old.cap < threshold {
            newcap = doublecap
        } else {
            // 检查新容量为非负数
            for 0 < newcap && newcap < cap {
                // 从小数组的 2 倍增长,过渡到 1.25 倍的大数组增长
                // 这个数值使得数组扩容更加平滑
                newcap += (newcap + 3*threshold) / 4
            }
            // 当新容量溢出(?)的时候,设置为 cap
            if newcap <= 0 {
                newcap = cap
            }
        }
    }
    
    var overflow bool
    var lenmem, newlenmem, capmem uintptr
    // 这里根据数组类型占用字节大小做出不同调整规则
    // For goarch.PtrSize, compiler will optimize division/multiplication into a shift by a constant.
    // For powers of 2, use a variable shift.
    switch {
    // 当大小为 1 时(例如 byte),不做任何调整
    case et.size == 1:
        // 长度
        lenmem = uintptr(old.len)
        // 新容量
        newlenmem = uintptr(cap)
        // 容量
        capmem = roundupsize(uintptr(newcap))
        overflow = uintptr(newcap) > maxAlloc
        newcap = int(capmem)
        
    // 当大小为 goarch.PtrSize 时,编译器会将除法/乘法优化为一个常数的移位。
    // 根据操作系统位数来判断,64位操作系统常数大小为 8,32 位为 4
    case et.size == goarch.PtrSize:
        // 旧切片元素已使用内存
        lenmem = uintptr(old.len) * goarch.PtrSize
        // 旧切片总内存
        newlenmem = uintptr(cap) * goarch.PtrSize
        // 新切片总内存
        // roundupsize 返回 malloc 分配实际内存块大小。具体需要查表:
        // src/runtime/sizeclasses.go
        capmem = roundupsize(uintptr(newcap) * goarch.PtrSize)
        // 判断新切片内存是否溢出
        overflow = uintptr(newcap) > maxAlloc/goarch.PtrSize
        // 使用实际分配内存块大小,与最开始原来计算得到 newcap 已经不同了
        newcap = int(capmem / goarch.PtrSize)
        
    // 当大小为 2 的幂,使用可变移位。
    case isPowerOfTwo(et.size):
        var shift uintptr
        if goarch.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)
    }
}

由于指针指向的数据结构是 数组,所以是其长度是不可改变的。在扩容时,是重新创建一个扩容后的长度的数组,同时将老的数据拷贝到新的数组中,并将array 指向新创建的数组。
故,扩容是一个比较耗费资源的过程。如果能预估切片的容量,我们最好为其指定容量,以避免频繁扩容带来的开销。
例如:

x := make([]int, 100)     // 预创建一个长度为 100 的切片
y := make([]int, 0, 100)  // 预创建一个容量为 100,长度为 0 的切片

参考资料:

go/slice.go at master · golang/go (github.com)

slice · 《深入解析Go 》