一文详解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的原型模式,感兴趣的通过跟着小编一起来看看吧
    2023-07-07
  • golang xorm 自定义日志记录器之使用zap实现日志输出、切割日志(最新)

    golang xorm 自定义日志记录器之使用zap实现日志输出、切割日志(最新)

    这篇文章主要介绍了golang xorm 自定义日志记录器,使用zap实现日志输出、切割日志,包括连接postgresql数据库的操作方法及 zap日志工具 ,本文结合实例代码给大家介绍的非常详细,对大家的学习或工作具有一定的参考借鉴价值,需要的朋友可以参考下
    2022-10-10
  • Golang如何读取单行超长的文本详解

    Golang如何读取单行超长的文本详解

    这篇文章主要给大家介绍了关于Golang如何读取单行超长文本的相关资料,文中通过实例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友可以参考下
    2021-12-12
  • go编译标签build tag注释里语法详解

    go编译标签build tag注释里语法详解

    这篇文章主要为大家介绍了go编译标签build tag注释里语法详解,有需要的朋友可以借鉴参考下,希望能够有所帮助,祝大家多多进步,早日升职加薪
    2023-09-09
  • GO的range具体使用

    GO的range具体使用

    GO语言的for…range 能做什么呢?golang的for…range是go 身的语法,可以用来遍历数据结构,本文就详细的来介绍一下具体使用,感兴趣的可以了解一下
    2021-10-10
  • Golang因Channel未关闭导致内存泄漏的解决方案详解

    Golang因Channel未关闭导致内存泄漏的解决方案详解

    这篇文章主要为大家详细介绍了当Golang因Channel未关闭导致内存泄漏时盖如何解决,文中的示例代码讲解详细,感兴趣的小伙伴可以了解一下
    2023-07-07
  • golang等待触发事件的实例

    golang等待触发事件的实例

    这篇文章主要介绍了golang等待触发事件的实例,具有很好的参考价值,希望对大家有所帮助。一起跟随小编过来看看吧
    2020-12-12
  • Go语言中io.Reader和io.Writer的详解与实现

    Go语言中io.Reader和io.Writer的详解与实现

    在Go语言的实际编程中,几乎所有的数据结构都围绕接口展开,接口是Go语言中所有数据结构的核心。在使用Go语言的过程中,无论你是实现web应用程序,还是控制台输入输出,又或者是网络操作,不可避免的会遇到IO操作,使用到io.Reader和io.Writer接口。下面来详细看看。
    2016-09-09
  • go语言题解LeetCode453最小操作次数使数组元素相等

    go语言题解LeetCode453最小操作次数使数组元素相等

    这篇文章主要为大家介绍了go语言题解LeetCode453最小操作次数使数组元素相等示例详解,有需要的朋友可以借鉴参考下,希望能够有所帮助,祝大家多多进步,早日升职加薪
    2022-12-12
  • go语言使用中提示%!(NOVERB)的解决方案

    go语言使用中提示%!(NOVERB)的解决方案

    o语言的设计目标是提供一种简单易用的编程语言,同时保持高效性和可扩展性,它支持垃圾回收机制,具有强大的并发编程能力,可以轻松处理大规模的并发任务,Go语言还拥有丰富的标准库和活跃的开发社区,使得开发者能够快速构建出高质量的应用程序,需要的朋友可以参考下
    2023-10-10

最新评论