一文详解Go语言中切片的底层原理

 更新时间:2023年06月28日 15:19:49   作者:7small7  
在Go语言中,切片作为一种引用类型数据,相对数组而言是一种动态长度的数据类型,使用的场景也是非常多,所以本文主要来和大家聊聊切片的底层原理,需要的可以参考一下

大家好,我是二条,在上一篇我们学习了轻松理解Go中的内存逃逸问题,今天接着我们学习Go中切片的相关知识。本文不会单独去讲解切片的基础语法,只会对切片的底层和在开发中需要注意的事项作分析。

在Go语言中,切片作为一种引用类型数据,相对数组而言是一种动态长度的数据类型,使用的场景也是非常多。但在使用切片的过程中,也有许多需要注意的事项。例如切片函数传值、切片动态扩容、切片对底层数组的引用问题等等。今天分享的主题,就是围绕切片进行。

切片的函数传值

切片作为一种引用数据类型,在作为函数传值时,如果函数内部对切片做了修改,会影响到原切片上。

package main
import "fmt"
func main() {
 sl1 := make([]int, 10)
 for i := 0; i < 10; i++ {
 }
 fmt.Println("切片sl1的值是", sl1)
 change(sl1)
 fmt.Println("切片sl2的值是", sl1)
}
func change(sl []int) {
 sl[0] = 100
 fmt.Println("形参sl切片的值是", sl)
}

打印上述代码:

切片sl1的值是 [1 2 3 4 5 6 7 8 9 10]
形参sl切片的值是 [100 2 3 4 5 6 7 8 9 10]
切片sl2的值是 [100 2 3 4 5 6 7 8 9 10]

通过上面的结果,不难看出来,在函数change()中修改了切片,原切片的小标0的值也发生了改变。这是因为切片是一种引用类型数据,在传递到函数change()时,使用的都是相同的底层数组(切片底层本质仍是一个数组)。因此,底层数组的值改变了,就会影响到其他指向该数组的切片上。

针对上述的问题,有什么解决方案,使得传递切片,不会影响原切片的值呢?可以采用切片复制的方式,重新创建一个新的切片当做函数的参数进行传递。

package main
import "fmt"
func main() {
 sl1 := make([]int, 10)
 for i := 0; i < 10; i++ {
  sl1[i] = i + 1
 }
 fmt.Println("切片sl1的值是", sl1)
 // 创建一个新的切片,当做参数传递。
 sl2 := make([]int, 10)
 copy(sl2, sl1)
 change(sl2)
 fmt.Println("切片sl2的值是", sl1)
}
func change(sl []int) {
 sl[0] = 100
 fmt.Println("形参sl切片的值是", sl)
}

打印上述代码:

切片sl1的值是 [1 2 3 4 5 6 7 8 9 10]
形参sl切片的值是 [100 2 3 4 5 6 7 8 9 10]
切片sl2的值是 [1 2 3 4 5 6 7 8 9 10]

通过上述运行结果,在change函数中,对切片下标为0做了值修改,对切片sl1的值没有影响。

切片动态扩容机制

在Go中,切片是一种动态长度引用数据类型。当切片的容量不足以容纳新增加的元素时,底层会实现自动扩容用来存储新添加的元素。先查看下面的一段实例代码,证明切片存在动态扩容。

package main
import "fmt"
func main() {
 var sl1 []int
 fmt.Println("切片sl1的长度是", len(sl1), ",容量是", cap(sl1))
 for i := 0; i < 10; i++ {
  sl1 = append(sl1, i)
  fmt.Println("切片sl1的长度是", len(sl1), ",容量是", cap(sl1))
 }
}

打印上述代码:

切片sl1的长度是 0 ,容量是 0
切片sl1的长度是 1 ,容量是 1
切片sl1的长度是 2 ,容量是 2
切片sl1的长度是 3 ,容量是 4
切片sl1的长度是 4 ,容量是 4
切片sl1的长度是 5 ,容量是 8
切片sl1的长度是 6 ,容量是 8
切片sl1的长度是 7 ,容量是 8
切片sl1的长度是 8 ,容量是 8
切片sl1的长度是 9 ,容量是 16
切片sl1的长度是 10 ,容量是 16

可以看出,切片的长度是随着for操作,依次递增。但切片的容量就不是依次递增,从明面上看,有点像以2的倍数在增加。具体增加的规律是怎么样的呢?

要弄明白Go中的切片是如何实现扩容的,这就需要关注一下Go的版本。

在Go的1.18版本以前,是按照如下的规则来进行扩容:

1、如果原有切片的长度小于 1024,那么新的切片容量会直接扩展为原来的 2 倍。

2、如果原有切片的长度大于等于 1024,那么新的切片容量会扩展为原来的 1.25 倍,这一过程可能需要执行多次才能达到期望的容量。

3、如果切片属于第一种情况(长度小于 1024)并且需要扩容的容量小于 1024 字节,那么新的切片容量会直接增加到原来的长度加上需要扩容的容量(新容量=原容量+扩容容量)。

从Go的1.18版本开始,是按照如下的规则进行扩容:

1、当原slice容量(oldcap)小于256的时候,新slice(newcap)容量为原来的2倍。

2、原slice容量超过256,新slice容量newcap = oldcap + (oldcap+3*256) / 4

使用上面的代码,将循环的值调到非常大,例如10w,甚至更大,你会发现切片的容量和长度始终是比较趋近,而不是差距很大。

例如我将循环设置到100w,这里就只打印最后几行结果,不进行全部打印。

package main
import "fmt"
func main() {
 var sl1 []int
 fmt.Println("切片sl1的长度是", len(sl1), ",容量是", cap(sl1))
 for i := 0; i < 1000000; i++ {
  sl1 = append(sl1, i)
  fmt.Println("切片sl1的长度是", len(sl1), ",容量是", cap(sl1))
 }
}

打印上述代码结果为:

.................
切片sl1的长度是 999990 ,容量是 1055744
切片sl1的长度是 999991 ,容量是 1055744
切片sl1的长度是 999992 ,容量是 1055744
切片sl1的长度是 999993 ,容量是 1055744
切片sl1的长度是 999994 ,容量是 1055744
切片sl1的长度是 999995 ,容量是 1055744
切片sl1的长度是 999996 ,容量是 1055744
切片sl1的长度是 999997 ,容量是 1055744
切片sl1的长度是 999998 ,容量是 1055744
切片sl1的长度是 999999 ,容量是 1055744
切片sl1的长度是 1000000 ,容量是 1055744

上面讲到的不同版本之间的规律,这个规律是怎么来的,我们可以直接源代码。

首先看1.18版本开始的底层代码,你需要找到Go的源码文件,路径为runtime/slice.go,该文件中有一个名为growslice()函数。这个函数的代码很长,我们重点关注下述代码,其他的代码除了做一些逻辑处理,还处理了内存对齐问题,关于内存对齐就不在本篇提及。

// type切片期望的类型,old旧切片,cap新切片期望最小的容量
func growslice(et *_type, old slice, cap int) slice {
  newcap := old.cap// 老切片容量
  doublecap := newcap + newcap// 老切片容量的两倍
  if cap > doublecap {// 期望最小的容量 > 老切片的两倍(新切片的容量 = 2 * 老切片的容量)
    newcap = cap
  } else {
    const threshold = 256
    if old.cap < threshold {
      newcap = doublecap
    } else {
      for 0 < newcap && newcap < cap {
        // 在2倍增长以及1.25倍之间寻找一种相对平衡的规则
        newcap += (newcap + 3*threshold) / 4
      }
      if newcap <= 0 {
        newcap = cap
      }
    }
  }
}

接着来看1.18版本之前的源代码,可以直接通过GitHub上进行查看。1.16GitHub源码地址。

// type切片期望的类型,old旧切片,cap新切片期望最小的容量
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 {// 小于1024,直接扩容为2倍
   newcap = doublecap
  } else {
   // 原 slice 容量超过 1024,新 slice 容量变成原来的1.25倍
   for 0 < newcap && newcap < cap {
    newcap += newcap / 4
   }
   if newcap <= 0 {
    newcap = cap
   }
  }
 }
}

通过上述的代码,已经总结出切片扩容的规律。如果你在实际的案例中,并非按照总结的规律进行扩容,这是因为切片扩容之后还考虑了内存对齐问题,也就是上述growslic()函数剩余部分。

切片操作对数组的影响

在Go中,切片和数组有一些共性,也有一些不同之处。

相同之处:

1、切片和数组在定义时,需要指定内部的元素类型,一旦定义元素类型,就只能存储该类型的元素。

2、切片虽然是单独的一种类型,底层仍然是一个数组,在Go源码中,有这样一段定义,通过阅读这段代码,可以总结出切片底层是一个struct数据结构。

type slice struct {
 array unsafe.Pointer # 指向底层数组的指针
 len   int # 切片的长度,也就是说当前切片中的元素个数
 cap   int # 切片的容量,也就是说切片最大能够存储多少个元素
}

不同之处:

1、切片和数组最大的不同之处,在于切片的长度和容量是动态的,可以根据实际情况实现动态扩容,而数组是固定长度,一经定义长度,存储的元素就不能超过定义时的长度。

下面有这样一种场景,需要特别注意。

从一个切片中生产新的切片,使用截取实现。

func clipSliceBySlice() {
 s := make([]int, 1000000)
 start := time.Now()
 _ = s[0:500000]
 elapsed := time.Since(start)
 fmt.Printf("Time taken to generate slice from slice: %s\n", elapsed)
}

从一个切片中生成新的切片,使用copy()函数实现。

func clipSliceByCopy() {
 s := make([]int, 1000000)
 start := time.Now()
 s2 := make([]int, 500000)
 copy(s2, s[0:500000])
 elapsed := time.Since(start)
 fmt.Printf("Time taken to copy slice using copy() function: %s\n", elapsed)
}

这两段代码,都是从一个切片中生成一个新的切片,但谁的性能效果更好呢?

1、第一种方式,生成新切片,底层仍然与原切片共用一个底层数组。在生成切片时,效率会更高一些。但存在一个问题,如果原切片和新切片对自身的元素做了修改,底层数组也会随着改变,这样会导致另外一个切片也跟着受影响。这种方式虽然效率更高,但是共用同一个底层数组,会存在数据安全问题。

2、第二种方式,生成新切片,使用的是copy()函数实现,会发生一个内存拷贝。这样新切片就是存储在新的内存中,其底层的数组和原切片底层的数组,不在是共享。不管是老切片还是新切片内部元素发生变化,都只会影响到自身。这种方式虽然消耗的内存更大,但数据更加安全。

使用归纳

在实际的开发过程中,我们一般使用切片的场景要比数组多,这是为什么呢?

1、动态扩展:切片可以动态扩展或缩减,而数组的长度是固定的。使用切片可以更方便地处理不确定长度的数据集。

2、内存效率:切片的底层实现是数组,但是通过切片可以对底层的数组进行引用,避免了复制底层数据的开销。因此,使用切片可以更高效地处理大量数据。

3、零值初始化:切片有一个默认值为0的长度和容量,这使得初始化切片更加方便。

4、内置函数:切片有许多内置函数,如append()、copy()等,这些函数可以更方便地操作切片。

本文总结

根据上面的几个小问题进行演示,我们在日常开发中,使用切片重点可以关注在动态扩容引用传值上面,这也是经常出现问题的点。下面细分几点进行归纳:

1、由于切片是引用类型,因此容易出现多个变量引用同一个底层数组,导致内存泄露和意外修改数据的情况。

2、当切片长度超过底层数组容量时,可以导致切片重新分配内存,这可能会带来性能问题。

3、在使用切片时没有正确计算长度和容量,也可能导致意料之外的结果。

4、切片常常被用作函数参数,由于其引用类型的特性,可能会导致函数内对切片数据的修改影响到外部变量。

5、如果切片的底层数组被修改,可能会对所有引用该底层数组的切片数据造成影响。

到此这篇关于一文详解Go语言中切片的底层原理的文章就介绍到这了,更多相关Go切片内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!

相关文章

  • Go语言如何使用分布式锁解决并发问题

    Go语言如何使用分布式锁解决并发问题

    这篇文章主要为大家详细介绍了Go 语言生态中基于 Redis 实现的分布式锁库 redsync,并探讨其使用方法和实现原理,感兴趣的小伙伴可以跟随小编一起学习一下
    2025-03-03
  • go defer避坑指南之拆解延迟语句

    go defer避坑指南之拆解延迟语句

    这篇文章主要为大家详细介绍了go defer避坑指南之如何拆解延迟语句,掌握正确使用方法,文中的示例代码讲解详细,感兴趣的小伙伴可以跟随小编一起学习一下
    2023-11-11
  • go mod tidy拉取依赖包bug问题及解决

    go mod tidy拉取依赖包bug问题及解决

    这篇文章主要介绍了go mod tidy拉取依赖包bug问题及解决方案,具有很好的参考价值,希望对大家有所帮助,如有错误或未考虑完全的地方,望不吝赐教
    2023-09-09
  • Go语言实战学习之流程控制详解

    Go语言实战学习之流程控制详解

    这篇文章主要为大家详细介绍了Go语言中的流程控制,文中的示例代码讲解详细,对我们学习Go语言有一定的帮助 ,需要的朋友可以参考下
    2022-08-08
  • Go基于雪花算法生成随机id

    Go基于雪花算法生成随机id

    雪花算法是twitter开源的由64位整数组成的分布式ID,本文主要介绍了Go基于雪花算法生成随机id,具有一定的参考价值,感兴趣的可以了解一下
    2024-05-05
  • Go语言中的字符串拼接方法详情

    Go语言中的字符串拼接方法详情

    本文介绍Go语言中的string类型、strings包和bytes.Buffer类型,介绍几种字符串拼接方法的相关资料,需要的朋友可以参考一下,希望对你有所帮助
    2021-10-10
  • go get 和 go install 对比介绍

    go get 和 go install 对比介绍

    go install和go get都是Go语言的工具命令,但它们之间有一些区别。go get:用于从远程代码存储库(如 GitHub)中下载或更新Go代码包。go install:用于编译并安装 Go 代码包,本文go get和go install对比介绍的非常详细,需要的朋友可以参考一下
    2023-04-04
  • Golang服务中context超时处理的方法详解

    Golang服务中context超时处理的方法详解

    在Go语言中,Context是一个非常重要的概念,它存在于一个完整的业务生命周期内,Context类型是一个接口类型,在实际应用中,我们可以使用Context包来传递请求的元数据,本文将给大家介绍Golang服务中context超时处理的方法和超时原因,需要的朋友可以参考下
    2023-05-05
  • goLang引入自定义包的方法

    goLang引入自定义包的方法

    今天小编就为大家分享一篇goLang引入自定义包的方法,具有很好的参考价值,希望对大家有所帮助。一起跟随小编过来看看吧
    2019-06-06
  • 在Go中实现高效可靠的链路追踪系统

    在Go中实现高效可靠的链路追踪系统

    在当今互联网应用的架构中,分布式系统已经成为主流,分布式系统的优势在于能够提供高可用性、高并发性和可扩展性,本文将介绍链路追踪的概念和原理,并重点介绍如何在Golang中实现高效可靠的链路追踪系统,需要的朋友可以参考下
    2023-10-10

最新评论