一文详解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切片内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!

相关文章

  • golang打包成带图标的exe可执行文件

    golang打包成带图标的exe可执行文件

    这篇文章主要给大家介绍了关于golang打包成带图标的exe可执行文件的相关资料,文中通过实例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友可以参考下
    2023-06-06
  • golang中的net/rpc包使用概述(小结)

    golang中的net/rpc包使用概述(小结)

    本篇文章主要介绍了golang中的net/rpc包使用概述(小结),小编觉得挺不错的,现在分享给大家,也给大家做个参考。一起跟随小编过来看看吧
    2017-11-11
  • 关于golang中死锁的思考与学习

    关于golang中死锁的思考与学习

    本文主要介绍了关于golang中死锁的思考与学习,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧
    2023-03-03
  • Go 语言中程序编译过程详解

    Go 语言中程序编译过程详解

    本文旨在深入探讨Go语言的编译机制和最新的模块管理系统——Go Modules,通过详细的示例和步骤,我们将演示从简单的 “Hello World” 程序到使用第三方库的更复杂项目的开发过程,感兴趣的朋友跟随小编一起看看吧
    2024-05-05
  • golang图片处理库image基本操作

    golang图片处理库image基本操作

    这篇文章主要介绍了golang图片处理库image简介,主要包括图片的基本读取与保存及图片的修改,本文通过通过实例代码给大家介绍的非常详细,需要的朋友可以参考下
    2022-07-07
  • Web框架Gin中间件实现原理步骤解析

    Web框架Gin中间件实现原理步骤解析

    这篇文章主要为大家介绍了Web框架Gin中间件实现原理步骤解析,有需要的朋友可以借鉴参考下,希望能够有所帮助,祝大家多多进步,早日升职加薪
    2023-10-10
  • GoLang中的互斥锁Mutex和读写锁RWMutex使用教程

    GoLang中的互斥锁Mutex和读写锁RWMutex使用教程

    RWMutex是一个读/写互斥锁,在某一时刻只能由任意数量的reader持有或者一个writer持有。也就是说,要么放行任意数量的reader,多个reader可以并行读;要么放行一个writer,多个writer需要串行写
    2023-01-01
  • Go指针内存与安全性深入理解

    Go指针内存与安全性深入理解

    这篇文章主要为大家介绍了Go指针内存与安全性深入理解,有需要的朋友可以借鉴参考下,希望能够有所帮助,祝大家多多进步,早日升职加薪
    2023-09-09
  • Golang中的new()和make()函数本质区别

    Golang中的new()和make()函数本质区别

    在 Go 语言开发中,new() 和 make() 是两个容易让开发者感到困惑的内建函数,尽管它们都用于内存分配,但其设计目的、适用场景和底层实现存在本质差异,本文将通过类型系统、内存模型和编译器实现三个维度,深入解析这两个函数的本质区别,感兴趣的朋友一起看看吧
    2025-02-02
  • Go开发环境搭建详细介绍

    Go开发环境搭建详细介绍

    由于目前网上Go的开发环境搭建文章很多,有些比较老旧,都是基于 GOPATH的,给新入门的同学造成困扰。以下为2023 版 Go 开发环境搭建,可参照此教程搭建Go开发环境,有需要的朋友可以参考阅读
    2023-04-04

最新评论