1.Golang1.16的扩容

谈到Golang slice的扩容策略,大家可能脱口而出的就是:

  • 1024容量下2倍扩容。

  • 1024以上1.25倍扩容。

下面来看一个例子: Go1.16

func main() {
	s1 := make([]int, 1)
	oldLen, oldCap := len(s1), cap(s1)
	s1 = append(s1, 1)
	fmt.Println(oldLen, len(s1), oldCap, cap(s1)) // 1 2 1 2
	s2 := make([]byte, 1)
	oldLen, oldCap = len(s2), cap(s2)
	s2 = append(s2, '1')
	fmt.Println(oldLen, len(s2), oldCap, cap(s2)) // 1 2 1 8
}

我们在初始化1个容量的int数组,发生扩容时,符合我们的预期,进行了2倍扩容

但是在初始化1个容量的byte数组,发生扩容时,扩容容量变为8,发生了8倍扩容。

造成这一现象的原因是什么呢?

我们来看 slice 扩容的源码:

go/src/runtime/slice.go

func growslice(et *_type, old slice, cap int) slice {
	// ...

	newcap := old.cap
	doublecap := newcap + newcap
	if cap > doublecap {
		newcap = cap
	} else {
		if old.cap < 1024 {
			newcap = doublecap
		} else {
			// Check 0 < newcap to detect overflow
			// and prevent an infinite loop.
			for 0 < newcap && newcap < cap {
				newcap += newcap / 4
			}
			// Set newcap to the requested cap when
			// the newcap calculation overflowed.
			if newcap <= 0 {
				newcap = cap
			}
		}
	}

	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:
		newcap = int(capmem)
	case et.size == 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
		}
		newcap = int(capmem >> shift)
	default:
		newcap = int(capmem / et.size)
	}
    
    // ...
}

从源码我们可以看到:

  1. 第一部分确实按照我们的预期,< 1024 时进行2倍扩容,大于等于1024时,进行1.25倍扩容。

  2. 但是除了第一部分外,还有第二部分的内容。会基于切片存储的数据类型,以及当前的切片容量,进行内存对齐,计算返回内存读取最优的长度作为容量。内存对齐核心方法:roundupsize()。感兴趣的话可以再去了解(🔍揭秘Golang内存分配:slice 扩容策略全解析!

所以,再回答slice扩容机制时,除了考虑上述两点外,谈到内存对齐,也是很必要的点!

2.Golang1.18的扩容

对于Golang1.18版本,出于平衡内存使用效率和性能,扩容策略有了比较大的更改。

查看源码如下:Go1.18

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 {
            // Check 0 < newcap to detect overflow
            // and prevent an infinite loop.
            for 0 < newcap && newcap < cap {
                // Transition from growing 2x for small slices
                // to growing 1.25x for large slices. This formula
                // gives a smooth-ish transition between the two.
                newcap += (newcap + 3*threshold) / 4
            }
            // Set newcap to the requested cap when
            // the newcap calculation overflowed.
            if newcap <= 0 {
                newcap = cap
            }
        }
    }
    
    // ...
}

总结:

  • 原切片容量小于 256:如果原切片容量小于 256,新容量会直接翻倍。例如原切片容量为 128,扩容后的新容量就是 256。

  • 原切片容量大于等于 256:当原切片容量大于等于 256 时,扩容会采用更复杂的策略。新容量会在原容量的基础上增加一个 0 到 25% 之间的 “额外” 容量,具体增加的比例会根据情况动态调整。这种策略旨在平衡内存使用效率和性能。比如原切片容量为 512,扩容后的新容量可能会大于 512 但小于 640(512 + 512 * 25%)。

  • 内存对齐:同1.16一样,1.18也会进行内存对齐,以期望最优的内存处理效率。