Go 语言切片如何扩容?(全面解析原理和过程)

简介: Go 语言切片如何扩容?(全面解析原理和过程)

Go 语言切片如何扩容?(全面解析原理和过程)

一、结构介绍

切片(Slice)在 Go 语言中,有一个很常用的数据结构,切片是一个拥有相同类型元素的可变长度的序列,它是基于数组类型做的一层封装。它非常灵活,支持自动扩容。并发不安全。

切片是一种引用类型,它有三个属性:指针,长度和容量


底层源码定义:

type slice struct {
    array unsafe.Pointer
    len   int
    cap   int
}

1.指针: 指向 slice 可以访问到的第一个元素。

2.长度: slice 中元素个数。

3.容量: slice 起始元素到底层数组最后一个元素间的元素个数。

比如使用 make([]byte, 5) 创建一个切片,它看起来是这样的:


二、扩容时机与过程

Go 中切片的扩容机制是基于动态数组的,这意味着切片的底层数组会动态调整大小以适应元素的增加。下面是 Go 切片扩容的一般过程:

1.初始分配:

当使用 make 创建一个切片时,Go 会为其分配一块初始的底层数组,并将切片的长度和容量都设置为相同的值。

2.追加元素:

当你使用 append 向切片追加元素时,Go 会检查是否有足够的容量来容纳新的元素。如果有足够的容量,新元素会被添加到底层数组的末尾,切片的长度会增加。如果没有足够的容量,就需要进行扩容。

3.扩容:

当切片需要扩容时,Go 会创建一个新的更大的底层数组(具体的扩容策略看下面扩容原理)。然后,原数组的元素会被复制到新数组中,新元素会被添加到新数组的末尾。最后,切片的引用会指向新的底层数组,原数组会被垃圾回收。

这个扩容的过程保证了在大多数情况下,append 操作都是高效的。由于每次扩容都会涉及元素的复制,因此在涉及大量元素的情况下可能会导致一些性能开销。如果你知道切片需要存储的元素数量,可以使用 make 函数make([]T, length, capacity)的第三个参数显式指定容量,以减少扩容的次数。

三、扩容原理

Go1.18之前切片的扩容是以容量1024为临界点,当旧容量 < 1024个元素,扩容变成2倍;当旧容量 > 1024个元素,那么会进入一个循环,每次增加25%直到大于期望容量。

然而这个扩容机制已经被Go 1.18弃用了,官方说新的扩容机制能更平滑地过渡。

具体扩容原理分别如下:

Go 1.18版本 之前扩容原理

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

1. 如果期望容量大于当前容量的两倍就会使用期望容量;

2. 如果当前切片的长度小于 1024 就会将容量翻倍;

3. 如果当前切片的长度大于等于 1024 就会每次增加 25% 的容量,直到新容量大于期望容量;


注:解释一下第一条:

比如 nums := []int{1, 2} nums = append(nums, 2, 3, 4),这样期望容量为2+3 = 5,而5 > 2*2,故使用期望容量(这只是不考虑内存对齐的情况下)

记录容量变化如下:

[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

Go 1.18版本 之后扩容原理

和之前版本的区别,主要在扩容阈值,以及这行源码:newcap += (newcap + 3*threshold) / 4。

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


1. 如果期望容量大于当前容量的两倍就会使用期望容量;

2. 如果当前切片的长度小于阈值(默认 256)就会将容量翻倍;

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

记录容量变化如下:

[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

大致规则如下:

其中,当扩容前容量 >= 256时,会按照公式进行扩容

newcap += (newcap + 3*threshold) / 4

这样得到的预估容量并不是最终结果,还有内存对齐,进一步调整newcap

在1.18中,优化了切片扩容的策略,让底层数组大小的增长更加平滑:通过减小阈值并固定增加一个常数,使得优化后的扩容的系数在阈值前后不再会出现从2到1.25的突变,该commit作者给出了几种原始容量下对应的“扩容系数”:

oldcap 扩容系数
256 2.0
512 1.63
1024 1.44
2048 1.35
4096 1.30


可以看到,Go1.18的扩容策略中,随着容量的增大,其扩容系数是越来越小的,可以更好地节省内存。

可以试着求一个极限,当oldcap远大于256的时候,扩容系数将会变成1.25。

四、内存对齐

扩容之后的容量并不是严格按照这个策略的。那是为什么呢?

实际上,growslice 的后半部分还有更进一步的优化(内存对齐等),靠的是 roundupsize 函数,在计算完 newcap 值之后,还会有一个步骤计算最终的容量:

capmem = roundupsize(uintptr(newcap) * ptrSize)
newcap = int(capmem / ptrSize)

举例:

还是上面的例子:

nums := []int{1, 2}
nums = append(nums, 2, 3, 4)
fmt.Printf("len:%v  cap:%v", len(nums), cap(nums))

按照上述策略的结果,应该是 len:5,cap:5。但是最终结果为 len:5,cap:6

解释:容量计算完了后还要考虑到内存的高效利用,进行内存对齐,则会调用这个函数 roundupsize 。(具体可以看源码)

func roundupsize(size uintptr) uintptr {
    if size < _MaxSmallSize {
        if size <= smallSizeMax-8 {
            return uintptr(class_to_size[size_to_class8[(size+smallSizeDiv-1)/smallSizeDiv]])
        } else {
        return uintptr(class_to_size[size_to_class128[(size-smallSizeMax+largeSizeDiv-1)/largeSizeDiv]])
        }
    }
    if size+_PageSize < size {
        return size
    }
    return alignUp(size, _PageSize)
}

size 表示新切片需要的内存大小 我们传入的 int 类型,每个占用 8 字节 (可以调用 unsafe.Sizeof() 函数查看占用的大小),一共 5 个 所以是 40,size 小于_MaxSmallSize 并且小于 smallSizeMax-8 ,那么使用通用公式  (size+smallSizeDiv-1)/smallSizeDiv 计算得到 5,然后到 size_to_class8 找到第五号元素 为 4,再从 class_to_size 找到 第四号元素 为 48,这就是新切片占用的内存大小,每个 int 占用 8 字节,所以最终切片的容量为 6 。所以说切片的扩容有它基本的扩容规则,在规则之后还要考虑内存对齐,这就代表不同数据类型的切片扩容的容量大小是会不一致。

五、总结

切片扩容通常是在进行切片的 append 操作时触发的。在进行 append 操作时,如果切片容量不足以容纳新的元素,就需要对切片进行扩容,此时就会调用 growslice 函数进行扩容。

切片扩容分两个阶段,分为 go1.18 之前和之后:

一、go1.18 之前:

1.如果期望容量大于当前容量的两倍就会使用期望容量;

2.如果当前切片的长度小于 1024 就会将容量翻倍;

3.如果当前切片的长度大于 1024 就会每次增加 25% 的容量,直到新容量大于期望容量;

二、go1.18 之后:

1.如果期望容量大于当前容量的两倍就会使用期望容量;

2.如果当前切片的长度小于阈值(默认 256)就会将容量翻倍;

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


总的来说,Go的设计者不断优化切片扩容的机制,其目的只有一个:就是控制让小的切片容量增长速度快一点,减少内存分配次数,而让大切片容量增长率小一点,更好地节省内存。


如果只选择翻倍的扩容策略,那么对于较大的切片来说,现有的方法可以更好的节省内存。

如果只选择每次系数为1.25的扩容策略,那么对于较小的切片来说扩容会很低效。

之所以选择一个小于2的系数,在扩容时被释放的内存块会在下一次扩容时更容易被重新利用。

参考文章:

https://juejin.cn/post/7101928883280150558

https://www.51cto.com/article/750934.html

https://yufengbiji.com/posts/golang-slice

目录
相关文章
|
7月前
|
数据采集 数据挖掘 测试技术
Go与Python爬虫实战对比:从开发效率到性能瓶颈的深度解析
本文对比了Python与Go在爬虫开发中的特点。Python凭借Scrapy等框架在开发效率和易用性上占优,适合快速开发与中小型项目;而Go凭借高并发和高性能优势,适用于大规模、长期运行的爬虫服务。文章通过代码示例和性能测试,分析了两者在并发能力、错误处理、部署维护等方面的差异,并探讨了未来融合发展的趋势。
694 0
|
5月前
|
Cloud Native 安全 Java
Go语言深度解析:从入门到精通的完整指南
🌟蒋星熠Jaxonic,Go语言探索者。深耕云计算、微服务与并发编程,以代码为笔,在二进制星河中书写极客诗篇。分享Go核心原理、性能优化与实战架构,助力开发者掌握云原生时代利器。#Go语言 #并发编程 #性能优化
534 43
Go语言深度解析:从入门到精通的完整指南
|
9月前
|
存储 JSON 安全
Go语言切片,使用技巧与避坑指南
Go语言中的切片(Slice)是动态引用数组的高效数据结构,支持扩容与截取。本文从切片基础、常用操作到高级技巧全面解析,涵盖创建方式、`append`扩容机制、共享陷阱及安全复制等内容。通过代码示例详解切片特性,如预分配优化性能、区分`nil`与空切片、处理多维切片等。掌握这些核心知识点,可编写更高效的Go代码。
316 2
|
6月前
|
Cloud Native 安全 Java
Go语言深度解析:从入门到精通的完整指南
🌟 蒋星熠Jaxonic,执着的星际旅人,用Go语言编写代码诗篇。🚀 Go语言以简洁、高效、并发为核心,助力云计算与微服务革新。📚 本文详解Go语法、并发模型、性能优化与实战案例,助你掌握现代编程精髓。🌌 从goroutine到channel,从内存优化到高并发架构,全面解析Go的强大力量。🔧 实战构建高性能Web服务,展现Go在云原生时代的无限可能。✨ 附技术对比、最佳实践与生态全景,带你踏上Go语言的星辰征途。#Go语言 #并发编程 #云原生 #性能优化
|
8月前
|
数据采集 机器学习/深度学习 存储
Go语言实战案例 - 找出切片中的最大值与最小值
本案例通过实现查找整数切片中的最大值与最小值,帮助初学者掌握遍历、比较和错误处理技巧,内容涵盖算法基础、应用场景及完整代码示例,适合初学者提升编程能力。
|
9月前
|
存储 设计模式 安全
Go 语言单例模式全解析:从青铜到王者段位的实现方案
单例模式确保一个类只有一个实例,并提供全局访问点,适用于日志、配置管理、数据库连接池等场景。在 Go 中,常用实现方式包括懒汉模式、饿汉模式、双重检查锁定,最佳实践是使用 `sync.Once`,它并发安全、简洁高效。本文详解各种实现方式的优缺点,并提供代码示例与最佳应用建议。
289 5
|
7月前
|
缓存 监控 安全
告别缓存击穿!Go 语言中的防并发神器:singleflight 包深度解析
在高并发场景中,多个请求同时访问同一资源易导致缓存击穿、数据库压力过大。Go 语言提供的 `singleflight` 包可将相同 key 的请求合并,仅执行一次实际操作,其余请求共享结果,有效降低系统负载。本文详解其原理、实现及典型应用场景,并附示例代码,助你掌握高并发优化技巧。
507 0
|
7月前
|
数据采集 JSON Go
Go语言实战案例:实现HTTP客户端请求并解析响应
本文是 Go 网络与并发实战系列的第 2 篇,详细介绍如何使用 Go 构建 HTTP 客户端,涵盖请求发送、响应解析、错误处理、Header 与 Body 提取等流程,并通过实战代码演示如何并发请求多个 URL,适合希望掌握 Go 网络编程基础的开发者。
|
9月前
|
Go 索引
Go语言中使用切片需要注意什么?
本文详细讲解了Go语言中切片(Slice)的使用方法与注意事项。切片是对数组连续片段的引用,具有灵活的操作方式。文章从定义与初始化、长度与容量、自动扩容、共享底层数组、复制、边界检查、零值到拼接等方面展开,并配以示例代码演示。通过学习,读者可深入了解切片的工作原理及优化技巧,避免常见陷阱,提升编程效率与代码质量。
220 2
|
10月前
|
人工智能 Go
[go]Slice 切片原理
本文详细介绍了Go语言中的切片(slice)数据结构,包括其定义、创建方式、扩容机制及常见操作。切片是一种动态数组,依托底层数组实现,具有灵活的扩容和传递特性。文章解析了切片的内部结构(包含指向底层数组的指针、长度和容量),并探讨了通过`make`创建切片、基于数组生成切片以及切片扩容的规则。此外,还分析了`append`函数的工作原理及其可能引发的扩容问题,以及切片拷贝时需要注意的细节。最后,通过典型面试题深入讲解了切片在函数间传递时的行为特点,帮助读者更好地理解和使用Go语言中的切片。
320 0

推荐镜像

更多
  • DNS