Go 语言切片扩容规则是扩容2倍?1.25倍?到底几倍

简介: Go 语言切片扩容规则是扩容2倍?1.25倍?到底几倍

本次主要来聊聊关于切片的扩容是如何扩的,还请大佬们不吝赐教

切片,相信大家用了 Go 语言那么久这这种数据类型并不陌生,但是平日里聊到关于切片是如何扩容的,很多人可能会张口就来,切片扩容的时候,如果老切片的容量小于 1024 那么就再扩容 1倍,也就是新的切片容量是老切片容量的两倍,同理,如果老切片容量大于 1024,那么就扩容1.25 倍

一个人这么说,多个人这么说,你可能就信了😂😂,可是大家都这么认为,我们就应该盲从吗?还是要自己去确认真实的扩容逻辑和实现方式,那就开始吧😁

结论先行,切片对于扩容并不一定是 2 倍,1.25倍,这个要看实际情况

本文分别从如下几点来聊聊切片的扩容

  • 扩容是针对切片的,数组无法扩容
  • 切片扩容到底是扩容到原来的几倍?
  • 我们一般使用切片的时候可以如何避免频繁的扩容?

扩容是针对切片的,数组无法扩容

首先需要明确,数组是不能扩容的,数组定义的时候就已经是定长的了,无法扩容

切片是可以扩容的,我们可以通过 append 追加的方式来向已有的切片尾部进行追加,若原有切片已满,那么就会发生扩容

另外,我们知道数组是一段连续的内存地址,同一种数据类型的数据集合,例如这样

func main() {
   log.SetFlags(log.Lshortfile)
   var demoArray = [5]int{1, 2, 3, 4, 5}
   log.Print("unsafe.sizeof(int) == ",unsafe.Sizeof(demoArray[0]))
   for i, _ := range demoArray {
      log.Printf("&demoAraay[%d] == %p", i, &demoArray[i])
   }
 }

可以看到在这个案例的环境中,一个 int 类型的变量占用 8 个字节,自然对于 demoArray 数组中,地址是连续的,每一个元素占用的空间也是我们所期望的

那么切片的数据地址也是连续的吗??

如果有人问这个问题,实际上是想问切片的底层数组的地址是不是也是连续的

我们知道,切片 slice 在 Go 中是一个结构体,其中 array 字段是一个指针,指向了一块连续的内存地址,也就是底层数组

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

其中 len 字段记录了当前底层数组的实际有的元素个数,cap 表示底层数组的容量,自然也是切片slice 的容量

func main(){
    var demoSli = []int{1,2,3,4,5}
    log.Printf("len == %d,cap == %d",len(demoSli),cap(demoSli))
    for i, _ := range demoSli {
       log.Printf("&demoSli[%d] == %p", i, &demoSli[i])
    }
}

自然,demoSli 中的元素打印出来,地址也是连续的,没有毛病

此处 xdm 模拟的时候,切勿去打印拷贝值的地址,例如下面这种方式是相当不明智的

现在简单的去给 切片追加一个元素

可以看到切片的容量变成了原来的两倍(容量从 5 扩容成 10),且切片中底层数组的元素地址自然也是连续的,不需要着急下结论,继续往下看,好戏在后头

切片扩容到底是扩容到原来的几倍?

案例1 向一个cap 为 0 的切片中追加 2000 个元素,查看被扩容了几次

总共是扩容了 14 次

可以看到切片容量小于 1024 时,触发扩容都是扩容到原来的 2 倍,但是 大于 1024 之后,有的是 1.25 倍,有的是 1.35 倍,有的大于 1.35 倍,那么这是为什么呢?后面统一看源码

案例2 再次验证切片容量小于 1024,触发到扩容就一定是扩容 2 倍吗

  • 先初始化一个切片,里面有 5 个元素,len 为 5,cap 为 5
  • 再向切片中追加 6 个元素,分别是 6,7,8,9,10,11
  • 最终查看切片的容量是多少
func main(){
    var demoSli = []int{1, 2, 3, 4, 5}
    log.Printf("len == %d,cap == %d", len(demoSli), cap(demoSli))
    for i, _ := range demoSli {
       log.Printf("&demoSli[%d] == %p", i, &demoSli[i])
    }
    demoSli = append(demoSli,6,7,8,9,10,11)
    log.Printf("len == %d,cap == %d",len(demoSli),cap(demoSli))
    for i, _ := range demoSli {
       log.Printf("&demoSli[%d] == %p", i, &demoSli[i])
    }
}

通过这一段代码,我们可以看到,讲一个 len 为 5,cap 为 5 的切片,追加数字 6 的时候,切片应该要扩容到 10,然后追加到数字 11 的时候,切片应该扩容到 20,可实际真的是这样吗?

xdm 可以将上述 demo 贴到自己环境试试,得到的结果仍然会是切片的容量 cap 最终是 12,并不是 20

那么这一切都是为什么呢?我们来查看源码一探究竟

源码赏析

查看公共库中 runtime/slice.gogrowslice 函数就可以解开我们的疑惑

可以看出在我们使用 append 对切片追加元素的时候,实际上会调用到 growslice 函数, growslice 中的核心逻辑我们就可以理解为计算基本的 newcap 和进行字节对齐

  1. 进行基本的新切片容量计算
// 省略部分
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
      }
   }
}
// 省略部分

此处逻辑可以知道

  • 如果当前传入的 cap 是比原有切片 cap 的 2 倍还要大,那么就会按照当前传入的 cap 来作为新切片的容量
  • 否则去校验原有切片的容量是否小于 1024
  • 若小于 1024 ,则按照原有的切片容量的 2 倍进行扩容
  • 若大于等于 1024 ,那么就按照原有切片的 1.25 倍继续扩容

然后是否看到这里就就结束了呢?就下定论来呢?并不,我们切莫断章取义,需要看全整个流程

  1. 进行基本的字节对齐

growslice 函数 计算出基本的 newcap 之后,还需要按照类型进行基本的字节对齐,此处字节对齐之后主要是 roundupsize 的函数实现,顺便将其涉及到的常量放到一起给大家展示一波

const (
   _MaxSmallSize = 32768
   smallSizeDiv = 8
   smallSizeMax = 1024
   largeSizeDiv = 128
   _NumSizeClasses = 68
   _PageShift = 13
)
func roundupsize(size uintptr) uintptr {
   if size < _MaxSmallSize {
      if size <= smallSizeMax-8 {
         return uintptr(class_to_size[size_to_class8[divRoundUp(size, smallSizeDiv)]])
      } else {
         return uintptr(class_to_size[size_to_class128[divRoundUp(size-smallSizeMax, largeSizeDiv)]])
      }
   }
   if size+ _PageSize < size {
      return size
   }
   return alignUp(size, _PageSize)
}
func divRoundUp(n, a uintptr) uintptr {
   // a is generally a power of two. This will get inlined and
   // the compiler will optimize the division.
   return (n + a - 1) / a
}
var size_to_class8 = [smallSizeMax/smallSizeDiv + 1]uint8{0, 1, 2, 3, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, ...}

光看这个函数,没啥感觉,函数逻辑比较简单,就是基本的计算和索引,那么我们讲上述的案例2带入,来计算一下

此处很明确,当前旧的切片的 cap 为 5

也就是 growslice 函数 中 old.cap 为 5,传入的 cap 为 11,因此 cap > 2*old.cap

因此 newcap 此处等于 11

开始计算字节对齐之后的结果

  • roundupsize(uintptr(newcap) * sys.PtrSize) ,其中 newcap = 11,sys.PtrSize = 8,则 roundupsize 参数传入 88 ,此环境指针占用 8 字节
  • 按照如下逻辑进行计算
  • divRoundUp(88, 8) = 11
  • size_to_class8[11] = 8
  • class_to_size[8] = 96

此处环境我们的 int 类型是占用 8 个字节,因此最终的 newcap = 96/8 = 12

经过上述源码的处理,最终我们就可以正常的得到最终切片容量被扩容到 12 ,xdm 可以去看实际的源码

小结

使用 append 进行切片扩容的时候,先会按照基本的逻辑来计算 newcap 的大小

  • 如果当前传入的cap是比原有切片cap的2倍还要大,那么就会按照当前传入的cap来作为新切片的容量,否则去校验原有切片的容量是否小于 1024
  • 若小于1024,则按照原有的切片容量的2倍进行扩容
  • 若大于等于 1024,那么就按照原有切片的 1.25 倍继续扩容
    最终再进行字节对齐

那么实际上,最终的切片容量一般是会等于或者大于原有的 2倍 或者是 1.25 倍的

我们一般使用切片的时候可以如何避免频繁的扩容?

一般在使用切片的时候,尽量避免频繁的去扩容,我们可以对已知数据量的数据,进行一次性去分配切片的容量

例如,数据量有 1000 个,那么我们就可以使用 make 的方式来进行初始化

sli := make([]int, 0, 1000)

本次就是这样,如果对源码还挺感兴趣的话,xdm 可以去实际查看一下源码哦,希望对你有帮助

感谢阅读,欢迎交流,点个赞,关注一波 再走吧

欢迎点赞,关注,收藏

朋友们,你的支持和鼓励,是我坚持分享,提高质量的动力

技术是开放的,我们的心态,更应是开放的。拥抱变化,向阳而生,努力向前行。

我是阿兵云原生,欢迎点赞关注收藏,下次见~

文中提到的技术点,感兴趣的可以查看这些文章:

相关文章
|
1天前
|
JavaScript 前端开发 Go
Go语言的入门学习
【4月更文挑战第7天】Go语言,通常称为Golang,是由Google设计并开发的一种编程语言,它于2009年公开发布。Go的设计团队主要包括Robert Griesemer、Rob Pike和Ken Thompson,这三位都是计算机科学和软件工程领域的杰出人物。
9 1
|
1天前
|
Go
|
2天前
|
分布式计算 Java Go
Golang深入浅出之-Go语言中的分布式计算框架Apache Beam
【5月更文挑战第6天】Apache Beam是一个统一的编程模型,适用于批处理和流处理,主要支持Java和Python,但也提供实验性的Go SDK。Go SDK的基本概念包括`PTransform`、`PCollection`和`Pipeline`。在使用中,需注意类型转换、窗口和触发器配置、资源管理和错误处理。尽管Go SDK文档有限,生态系统尚不成熟,且性能可能不高,但它仍为分布式计算提供了可移植的解决方案。通过理解和掌握Beam模型,开发者能编写高效的数据处理程序。
131 1
|
2天前
|
算法 关系型数据库 MySQL
Go语言中的分布式ID生成器设计与实现
【5月更文挑战第6天】本文探讨了Go语言在分布式系统中生成全局唯一ID的策略,包括Twitter的Snowflake算法、UUID和MySQL自增ID。Snowflake算法通过时间戳、节点ID和序列号生成ID,Go实现中需处理时间回拨问题。UUID保证全局唯一,但长度较长。MySQL自增ID依赖数据库,可能造成性能瓶颈。选择策略时需考虑业务需求和并发、时间同步等挑战,以确保系统稳定可靠。
109 0
|
2天前
|
缓存 NoSQL Go
Go语言中的分布式锁实现与选型
【5月更文挑战第6天】本文探讨了Go语言中分布式锁的实现,包括Redis、ZooKeeper和Etcd三种方式,强调了选型时的性能、可靠性和复杂度考量。通过代码示例展示了Redis分布式锁的使用,并提出了避免死锁、公平性等问题的策略。结论指出,开发者应根据业务需求选择合适实现并理解底层原理,以确保系统稳定和高效。
126 0
|
2天前
|
NoSQL 算法 Go
Go语言中的分布式事务处理方案
【5月更文挑战第6天】本文探讨了Go语言在分布式事务处理中的应用,包括2PC、3PC和TCC协议。通过示例展示了如何使用Go的`goroutine`和`channel`实现2PC。同时,文章指出了网络延迟、单点故障、死锁和幂等性等常见问题,并提供了相应的解决策略。此外,还以Redis Redlock为例,展示了如何实现分布式锁。理解并实施这些方案对于构建高可用的分布式系统至关重要。
96 0
|
3天前
|
缓存 测试技术 持续交付
Golang深入浅出之-Go语言中的持续集成与持续部署(CI/CD)
【5月更文挑战第5天】本文介绍了Go语言项目中的CI/CD实践,包括持续集成与持续部署的基础知识,常见问题及解决策略。测试覆盖不足、版本不一致和构建时间过长是主要问题,可通过全面测试、统一依赖管理和利用缓存优化。文中还提供了使用GitHub Actions进行自动化测试和部署的示例,强调了持续优化CI/CD流程以适应项目需求的重要性。
42 1
|
3天前
|
Kubernetes Cloud Native Go
Golang深入浅出之-Go语言中的云原生开发:Kubernetes与Docker
【5月更文挑战第5天】本文探讨了Go语言在云原生开发中的应用,特别是在Kubernetes和Docker中的使用。Docker利用Go语言的性能和跨平台能力编写Dockerfile和构建镜像。Kubernetes,主要由Go语言编写,提供了方便的客户端库与集群交互。文章列举了Dockerfile编写、Kubernetes资源定义和服务发现的常见问题及解决方案,并给出了Go语言构建Docker镜像和与Kubernetes交互的代码示例。通过掌握这些技巧,开发者能更高效地进行云原生应用开发。
40 1
|
3天前
|
负载均衡 监控 Go
Golang深入浅出之-Go语言中的服务网格(Service Mesh)原理与应用
【5月更文挑战第5天】服务网格是处理服务间通信的基础设施层,常由数据平面(代理,如Envoy)和控制平面(管理配置)组成。本文讨论了服务发现、负载均衡和追踪等常见问题及其解决方案,并展示了使用Go语言实现Envoy sidecar配置的例子,强调Go语言在构建服务网格中的优势。服务网格能提升微服务的管理和可观测性,正确应对问题能构建更健壮的分布式系统。
25 1
|
4天前
|
消息中间件 Go API
Golang深入浅出之-Go语言中的微服务架构设计与实践
【5月更文挑战第4天】本文探讨了Go语言在微服务架构中的应用,强调了单一职责、标准化API、服务自治和容错设计等原则。同时,指出了过度拆分、服务通信复杂性、数据一致性和部署复杂性等常见问题,并提出了DDD拆分、使用成熟框架、事件驱动和配置管理与CI/CD的解决方案。文中还提供了使用Gin构建HTTP服务和gRPC进行服务间通信的示例。
20 0