likes
comments
collection
share

GO进阶源码分析之 【Go预分配 slice 内存】

作者站长头像
站长
· 阅读数 14

切片扩容

在 1.18 版本前,切片扩容,在容量小于1024时,以2倍大小扩容。超过1024后,以1.25倍扩容。在扩容后切片的基础上,会根据长度和容量进行 roundupsize 。

在1.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    //这里设置了一个 阈值 -- 256
		if old.cap < threshold { //如果旧容量 小于 256,则两倍扩容
			newcap = doublecap
		} else {
			// 检查 0 < newcap 以检测溢出并防止无限循环。
			for 0 < newcap && newcap < cap { //如果新容量 > 0  并且 原容量 小于 所需容量

				// 从小片的增长2x过渡到大片的增长1.25x。这个公式给出了两者之间的平滑过渡。
				newcap += (newcap + 3*threshold) / 4
				//新容量是 = 1.25 原容量 + 3/4 阈值 (192)
			}
			//当newcap计算溢出时,将newcap设置为请求的上限。
			if newcap <= 0 { // 如果发生了溢出,将新容量设置为请求的容量大小
				newcap = cap
			}
		}
	}
}

函数判断如果所需容量 cap 大于两倍扩容的容量 doublecap,说明 cap 的需求已经超过了两倍扩容的范围,所以将 newcap 直接设为 cap

否则,如果原容量 old.cap 小于一个阈值 threshold(这里设为256),则将 newcap 设置为原容量的两倍 doublecap

如果既不满足上述条件,则进入一个循环,只要 newcap 大于0且小于所需容量 cap,就会进入循环。在每次循环迭代中,newcap 的增长方式为当前 newcap 加上 3*threshold 的四分之一。这个计算方式使得容量的增长逐渐从原容量的两倍过渡到1.25倍,实现了一个平滑的过渡。

最后,在循环结束后,判断如果 newcap 仍然小于等于0(溢出情况),则将 newcap 设为所需容量 cap

如果 现有容量 小于 256 ,则新容量是原来的两倍

新容量 = 1.25 原容量 + 3/4 阈值 (192) “这个公式给出了从1.25倍增长 过渡到2 倍增长,两者之间的平滑过渡。” 在此情况下,如果发生了溢出,将新容量设置为请求的容量大小

代码测试

func main() {
	slice := make([]int, 0)

	for i := 0; i < 512; i++ {
		slice = append(slice, i)
	}

	newSlice := append(slice, 5000)
	fmt.Printf("Before Pointer = %p, len = %d, cap = %d\n", &slice, len(slice), cap(slice))
	fmt.Printf("Before Pointer = %p, len = %d, cap = %d\n", &newSlice, len(newSlice), cap(newSlice))
}

执行输出如下:

GO进阶源码分析之 【Go预分配 slice 内存】

再看一个例子

下面用 go1.17 和 go1.18 两个版本来分开说明。先通过一段测试代码,直观感受一下两个版本在扩容上的区别。

package main  
  
import "fmt"  
  
func main() {  
    s := make([]int0)  
  
    oldCap := cap(s)  
  
    for i := 0; i < 2048; i++ {  
        s = append(s, i)  
  
        newCap := cap(s)  
  
        if newCap != oldCap {  
            fmt.Printf("[%d -> %4d] cap = %-4d  |  after append %-4d  cap = %-4d\n"0, i-1, oldCap, i, newCap)  
            oldCap = newCap  
        }  
    }  
}

运行结果(1.17 版本):


[0 ->   -1] cap = 0     |  after append 0     cap = 1   
[0 ->    0] cap = 1     |  after append 1     cap = 2   
[0 ->    1] cap = 2     |  after append 2     cap = 4   
[0 ->    3] cap = 4     |  after append 4     cap = 8   
[0 ->    7] cap = 8     |  after append 8     cap = 16  
[0 ->   15] cap = 16    |  after append 16    cap = 32  
[0 ->   31] cap = 32    |  after append 32    cap = 64  
[0 ->   63] cap = 64    |  after append 64    cap = 128 
[0 ->  127] cap = 128   |  after append 128   cap = 256 
[0 ->  255] cap = 256   |  after append 256   cap = 512 
[0 ->  511] cap = 512   |  after append 512   cap = 1024
[0 -> 1023] cap = 1024  |  after append 1024  cap = 1280
[0 -> 1279] cap = 1280  |  after append 1280  cap = 1696
[0 -> 1695] cap = 1696  |  after append 1696  cap = 2304

在分配内存空间之前需要先确定新的切片容量,运行时根据切片的当前容量选择不同的策略进行扩容:

  1. 如果期望容量大于当前容量的两倍就会使用期望容量;
  2. 如果当前切片的长度小于 1024 就会将容量翻倍;
  3. 如果当前切片的长度大于等于 1024 就会每次增加 25% 的容量,直到新容量大于期望容量;

运行结果(1.18 版本):

[0 ->   -1] cap = 0     |  after append 0     cap = 1  
[0 ->    0] cap = 1     |  after append 1     cap = 2     
[0 ->    1] cap = 2     |  after append 2     cap = 4     
[0 ->    3] cap = 4     |  after append 4     cap = 8     
[0 ->    7] cap = 8     |  after append 8     cap = 16    
[0 ->   15] cap = 16    |  after append 16    cap = 32    
[0 ->   31] cap = 32    |  after append 32    cap = 64    
[0 ->   63] cap = 64    |  after append 64    cap = 128   
[0 ->  127] cap = 128   |  after append 128   cap = 256   
[0 ->  255] cap = 256   |  after append 256   cap = 512   
[0 ->  511] cap = 512   |  after append 512   cap = 848   
[0 ->  847] cap = 848   |  after append 848   cap = 1280  
[0 -> 1279] cap = 1280  |  after append 1280  cap = 1792  
[0 -> 1791] cap = 1792  |  after append 1792  cap = 2560

在分配内存空间之前需要先确定新的切片容量,运行时根据切片的当前容量选择不同的策略进行扩容:

  1. 如果期望容量大于当前容量的两倍就会使用期望容量;
  2. 如果当前切片的长度小于阈值(默认 256)就会将容量翻倍;
  3. 如果当前切片的长度大于等于阈值(默认 256),就会每次增加 25% 的容量,基准是 newcap + 3*threshold,直到新容量大于期望容量;

内存对齐

但是,后半部分还对 newcap 作了一个内存对齐,这个和内存分配策略相关。进行内存对齐之后,新 slice 的容量是要 大于等于 按照前半部分生成的newcap

之后,向 Go 内存管理器申请内存,将老 slice 中的数据复制过去,并且将 append 的元素添加到新的底层数组中。

最后,向 growslice 函数调用者返回一个新的 slice,这个 slice 的长度并没有变化,而容量却增大了。

测试代码

import "fmt"

func main() {
	s := []int{1,2}
	s = append(s,4,5,6)
	fmt.Printf("len=%d, cap=%d",len(s),cap(s))
}

输出

len=5, cap=6

根据Go语言中切片的扩容机制,当切片容量不足以容纳额外的元素时,它会自动进行扩容。在这种情况下,切片的容量会根据需要自动增长,通常会以原来容量的2倍进行扩容。

根据您的代码,初始切片s的容量为2,添加了3个元素后,长度变为5。实际上,在这种情况下,切片的容量不会立即扩大到8,而是继续保持为6。这是因为Go语言的切片扩容机制会优化以减少内存的浪费。扩容时,切片会选择一个合适的容量,使得容量尽可能靠近但大于所需的最小容量。

源码分析

func growslice(et *_type, old slice, cap int) slice {
    // ……
    newcap := old.cap
	doublecap := newcap + newcap
	if cap > doublecap {
		newcap = cap
	} else {
		// ……
	}
	// ……
	
	capmem = roundupsize(uintptr(newcap) * ptrSize)
	newcap = int(capmem / ptrSize)
}

capmem = roundupsize(uintptr(newcap) * ptrSize):根据 newcap 和指针的大小计算需要分配的内存大小。 newcap = int(capmem / ptrSize):通过将内存大小除以指针大小,将新的容量 newcap 转换为整数。

参考文章: