Go语言切片底层原理:函数传值、动态扩容机制

2023-07-1308:19:22编程语言入门到精通Comments1,123 views字数 5726阅读模式

本文不会单独去讲解切片的基础语法,只会对切片的底层和在开发中需要注意的事项作分析。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/ymba/51445.html

在Go语言中,切片作为一种引用类型数据,相对数组而言是一种动态长度的数据类型,使用的场景也是非常多。但在使用切片的过程中,也有许多需要注意的事项。例如切片函数传值、切片动态扩容、切片对底层数组的引用问题等等。今天分享的主题,就是围绕切片进行。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/ymba/51445.html

切片的函数传值

切片作为一种引用数据类型,在作为函数传值时,如果函数内部对切片做了修改,会影响到原切片上。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/ymba/51445.html

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)
}

打印上述代码:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/ymba/51445.html

切片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()时,使用的都是相同的底层数组(切片底层本质仍是一个数组)。因此,底层数组的值改变了,就会影响到其他指向该数组的切片上。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/ymba/51445.html

针对上述的问题,有什么解决方案,使得传递切片,不会影响原切片的值呢?可以采用切片复制的方式,重新创建一个新的切片当做函数的参数进行传递。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/ymba/51445.html

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)
}

打印上述代码:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/ymba/51445.html

切片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的值没有影响。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/ymba/51445.html

切片动态扩容机制

在Go中,切片是一种动态长度引用数据类型。当切片的容量不足以容纳新增加的元素时,底层会实现自动扩容用来存储新添加的元素。先查看下面的一段实例代码,证明切片存在动态扩容。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/ymba/51445.html

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))
 }
}

打印上述代码:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/ymba/51445.html

切片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的倍数在增加。具体增加的规律是怎么样的呢?文章源自菜鸟学院-https://www.cainiaoxueyuan.com/ymba/51445.html

要弄明白Go中的切片是如何实现扩容的,这就需要关注一下Go的版本。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/ymba/51445.html

在Go的1.18版本以前,是按照如下的规则来进行扩容:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/ymba/51445.html

1、如果原有切片的长度小于 1024,那么新的切片容量会直接扩展为原来的 2 倍。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/ymba/51445.html

2、如果原有切片的长度大于等于 1024,那么新的切片容量会扩展为原来的 1.25 倍,这一过程可能需要执行多次才能达到期望的容量。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/ymba/51445.html

3、如果切片属于第一种情况(长度小于 1024)并且需要扩容的容量小于 1024 字节,那么新的切片容量会直接增加到原来的长度加上需要扩容的容量(新容量=原容量+扩容容量)。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/ymba/51445.html

从Go的1.18版本开始,是按照如下的规则进行扩容:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/ymba/51445.html

1、当原slice容量(oldcap)小于256的时候,新slice(newcap)容量为原来的2倍。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/ymba/51445.html

2、原slice容量超过256,新slice容量newcap = oldcap + (oldcap+3*256) / 4文章源自菜鸟学院-https://www.cainiaoxueyuan.com/ymba/51445.html

使用上面的代码,将循环的值调到非常大,例如10w,甚至更大,你会发现切片的容量和长度始终是比较趋近,而不是差距很大。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/ymba/51445.html

例如我将循环设置到100w,这里就只打印最后几行结果,不进行全部打印。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/ymba/51445.html

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))
 }
}

打印上述代码结果为:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/ymba/51445.html

.................
切片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

上面讲到的不同版本之间的规律,这个规律是怎么来的,我们可以直接源代码。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/ymba/51445.html

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

// 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源码地址。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/ymba/51445.html

// 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()函数剩余部分。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/ymba/51445.html

切片操作对数组的影响

在Go中,切片和数组有一些共性,也有一些不同之处。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/ymba/51445.html

相同之处:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/ymba/51445.html

1、切片和数组在定义时,需要指定内部的元素类型,一旦定义元素类型,就只能存储该类型的元素。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/ymba/51445.html

2、切片虽然是单独的一种类型,底层仍然是一个数组,在Go源码中,有这样一段定义,通过阅读这段代码,可以总结出切片底层是一个struct数据结构。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/ymba/51445.html

type slice struct {
 array unsafe.Pointer # 指向底层数组的指针
 len   int # 切片的长度,也就是说当前切片中的元素个数
 cap   int # 切片的容量,也就是说切片最大能够存储多少个元素
}
Go语言切片底层原理:函数传值、动态扩容机制
Go切片底层数据结构

不同之处:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/ymba/51445.html

1、切片和数组最大的不同之处,在于切片的长度和容量是动态的,可以根据实际情况实现动态扩容,而数组是固定长度,一经定义长度,存储的元素就不能超过定义时的长度。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/ymba/51445.html

下面有这样一种场景,需要特别注意。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/ymba/51445.html

从一个切片中生产新的切片,使用截取实现。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/ymba/51445.html

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()函数实现。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/ymba/51445.html

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)
}

这两段代码,都是从一个切片中生成一个新的切片,但谁的性能效果更好呢?文章源自菜鸟学院-https://www.cainiaoxueyuan.com/ymba/51445.html

1、第一种方式,生成新切片,底层仍然与原切片共用一个底层数组。在生成切片时,效率会更高一些。但存在一个问题,如果原切片和新切片对自身的元素做了修改,底层数组也会随着改变,这样会导致另外一个切片也跟着受影响。这种方式虽然效率更高,但是共用同一个底层数组,会存在数据安全问题。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/ymba/51445.html

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

使用归纳

在实际的开发过程中,我们一般使用切片的场景要比数组多,这是为什么呢?文章源自菜鸟学院-https://www.cainiaoxueyuan.com/ymba/51445.html

1、动态扩展:切片可以动态扩展或缩减,而数组的长度是固定的。使用切片可以更方便地处理不确定长度的数据集。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/ymba/51445.html

2、内存效率:切片的底层实现是数组,但是通过切片可以对底层的数组进行引用,避免了复制底层数据的开销。因此,使用切片可以更高效地处理大量数据。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/ymba/51445.html

3、零值初始化:切片有一个默认值为0的长度和容量,这使得初始化切片更加方便。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/ymba/51445.html

4、内置函数:切片有许多内置函数,如append()、copy()等,这些函数可以更方便地操作切片。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/ymba/51445.html

本文总结

根据上面的几个小问题进行演示,我们在日常开发中,使用切片重点可以关注在动态扩容引用传值上面,这也是经常出现问题的点。下面细分几点进行归纳:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/ymba/51445.html

1、由于切片是引用类型,因此容易出现多个变量引用同一个底层数组,导致内存泄露和意外修改数据的情况。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/ymba/51445.html

2、当切片长度超过底层数组容量时,可以导致切片重新分配内存,这可能会带来性能问题。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/ymba/51445.html

3、在使用切片时没有正确计算长度和容量,也可能导致意料之外的结果。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/ymba/51445.html

4、切片常常被用作函数参数,由于其引用类型的特性,可能会导致函数内对切片数据的修改影响到外部变量。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/ymba/51445.html

5、如果切片的底层数组被修改,可能会对所有引用该底层数组的切片数据造成影响。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/ymba/51445.html

  • 本站内容整理自互联网,仅提供信息存储空间服务,以方便学习之用。如对文章、图片、字体等版权有疑问,请在下方留言,管理员看到后,将第一时间进行处理。
  • 转载请务必保留本文链接:https://www.cainiaoxueyuan.com/ymba/51445.html

Comment

匿名网友 填写信息

:?: :razz: :sad: :evil: :!: :smile: :oops: :grin: :eek: :shock: :???: :cool: :lol: :mad: :twisted: :roll: :wink: :idea: :arrow: :neutral: :cry: :mrgreen:

确定