Golang中为什么切片缩容到零长度不会释放元素?

Golang中为什么切片缩容到零长度不会释放元素? 大家好

我正在回顾一些代码以复习知识,并在 Go 之旅中发现了数组示例,但我不理解为什么通过设置 array = array[:0] 来减小大小并不会丢弃元素。

以下代码输出:

len=5 cap=5 [1 2 3 4 5] // 原始数组 len=0 cap=5 [] // 数组被缩减 len=5 cap=5 [1 2 3 4 5] // 现在我们可以回到原始数组(元素仍然存在) len=3 cap=3 [3 4 5] // 如果我们使用 [N:],元素会从数组中丢弃

那么,为什么 s[:0] 不会丢弃元素,并且与 s[N:] 的行为不同? 这是设计使然吗?

package main

import "fmt"

func main() {
	s := []int{1, 2, 3, 4, 5}
	printSlice(s)

	// 将切片切片为零长度。
	// 元素不会被丢弃
	s = s[:0]
	printSlice(s)

	// 将数组扩展回原始大小,
	// 所有元素仍然存在
	s = s[:5]
	printSlice(s)

	// 丢弃其前两个值。
	// 但为什么它会丢弃元素,而我们无法回到原始状态?
	s = s[2:]
	printSlice(s)
}

func printSlice(s []int) {
	fmt.Printf("len=%d cap=%d %v\n", len(s), cap(s), s)
}

更多关于Golang中为什么切片缩容到零长度不会释放元素?的实战教程也可以访问 https://www.itying.com/category-94-b0.html

11 回复

你好,

除了之前的回答,这篇文章可能有助于理解切片和数组之间的关系及其产生的行为。

(旁注:请忽略末尾的“如何运行代码”部分,它显然来自Go Modules时代之前。)

更多关于Golang中为什么切片缩容到零长度不会释放元素?的实战系列教程也可以访问 https://www.itying.com/category-94-b0.html


在 Go 博客中有一篇讨论此问题的文章。

Go 切片:用法与内部实现 (Go Slices: usage and internals - The Go Programming Language)

搜索“一个可能的‘陷阱’”部分,其中提到: 如前所述,对切片进行重新切片并不会复制底层数组。整个数组将一直保留在内存中,直到不再被引用。有时,这可能导致程序在只需要一小部分数据时,却将全部数据都保留在内存中。

我尝试通过先将切片赋值为 nil 来解决这个问题,然后重新进行修改。

	s = nil
	printSlice(s)

	// s = s[:3]
	// panic: runtime error: slice bounds out of range [:3] with capacity 0

	s = []int{9, 8, 7, 6, 5}
	printSlice(s) // 不再有泄漏

	s = s[:5]
	printSlice(s)

这样做对吗?

我会研究一下这个问题。那么底层数组仍然在内存中,我们只是将指针向前移动了?

我原本以为我们得到了一个新的内存数组,因为输出中的内存地址发生了变化:

原始地址是 address=0xc00000e1e0

使用下限切片 s[2:] 后,我们得到了一个不同的地址: address=0xc00000e1f0

%p 打印的地址代表的是切片的内存地址,而不是底层数组的内存地址吗?

切片后得到的这个不同地址,是否代表了内存中一个新的切片分配?

嘿,我仔细研究了那些关于 Split 函数分割数组及其结果切片的例子,现在我能看到一些之前没注意到的东西了。有时,切片可能指向底层数组的相同内存地址,有时则可能指向一个全新的内存地址,这取决于某些特定条件。我想,如果我们不了解返回切片的函数行为,切片可能会带来很多麻烦。或许我们可以尝试通过创建测试来确认预期行为,从而避免切片相关的问题。谢谢克里斯托夫和各位。 图片

是的,通过你的例子更容易看清地址发生了什么:

Go Playground - Go 编程语言

Go 是一种开源编程语言,它使得构建简单、可靠且高效的软件变得容易。

slicing with high limit
arr add=0xc0000b8000
sl  add=0xc0000b8000
&arr[0] == &sl[0]: true
len(arr) == cap(sl): true

slicing with low limit
arr add=0xc0000b8000
sl  add=0xc0000b8002 <--- 位置增加了
*** &arr[0] == &sl[0]: false ***
*** &arr[2] == &sl[0]: true ***

当你从原始数组(或切片)的前端进行切片时,生成的切片只是将其指针递增到你所切到的元素的指针。

在你的示例中,原始地址 0xc00000e1e0s[2:] 的起始地址 0xc00000e1f0 之间的差值是 16 字节。

原始切片中的第一个元素(索引 0)是 0xc00000e1e0。 原始切片中的第二个元素(索引 1)是 0xc00000e1e80xc00000e1e0 + 8 字节)。 原始切片中的第三个元素(索引 2)是 0xc00000e1f00xc00000e1e0 + 16 字节)。

这就是为什么 s[2:] 得到的是 0xc00000e1f0

对切片进行重新切片永远不会导致新的内存分配。

通过将下限设置为2来跳过前两个值时,会在内存中创建一个具有不同长度和容量的新数组。

这是不正确的。对切片进行重新切片永远不会导致新的内存分配。我试图在上一个帖子中用“ASCII艺术”来描绘这一点,但我想可能表达得不够清楚。

一个切片包含3个字段:

  • 指向切片中第一个元素的指针
  • 切片的长度
  • 切片的容量

如果我有一个内存数组:var arr [10]byte 然后我可以从中创建一个切片:var sl = arr[:] 之后,以下等式成立:

&arr[0] == &sl[0]
len(arr) == len(sl)
len(arr) == cap(sl)

正如您所注意到的,当您从末尾开始切片时,减少的是长度。指向第一个元素的指针和容量保持不变。

然而,当您从切片的开头开始切片时,发生的情况是指向第一个元素的指针被更改为指向您开始切片的第n个元素(即,sl = arr[2:] 意味着将指针设置为指向 &arr[2])。因为切片的头部向前移动了2个位置,在这种情况下,长度和容量必须减少2。

如果您写入 sl[0],结果将出现在 arr[2] 中。

这里有一个可运行的示例:Go Playground - The Go Programming Language

Go 切片由三个字段组成:

  1. 指向切片中第一个元素的指针
  2. 切片的长度
  3. 切片底层内存的容量

当你将切片重新切片为更小的长度时,指向第一个元素的指针和容量保持不变,但长度会减少(在 slice[:0] 的情况下,长度减少到 0,但其他两个字段保持不变)。

当你从切片的前端重新切片时,会发生一些不同的事情:指向第一个元素的指针被设置为你正在切片的第 n 个元素的指针(即 slice[2:] 意味着将指针从指向 &slice[0] 改为指向 &slice[2])。然后,在这种情况下,长度和容量都减少 2:

memory:
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| | | | | | | | | | | | | | |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

s:
      +-+-+-+-+-+
      | | | | | |
      +-+-+-+-+-+
      ^         ^
      |         |
      +- data   |
         len: 5-+
         cap: 5-+

s[:0]
      +-+-+-+-+-+
      | | | | | |
      +-+-+-+-+-+
      ^         ^
      |         |
      +- data   |
      +- len: 0 |
         cap: 5-+

s[2:]
          +-+-+-+
          | | | |
          +-+-+-+
          ^     ^
          |     +-+
          +- data |
             len: 3
             cap: 3

虽然在技术上可以使用 unsafereflect 包来操作切片数据,以将切片从 s[2:] "倒回"到 s[0:],但在一般情况下,你的切片可能来自任何地方,这样做是危险的,因为你不知道传入你函数的切片是来自 s[2:] 还是普通的 s

我不太理解你在这个上下文中提到的“泄漏”是什么意思。

在我的测试中,我发现当我们像 s[2:] 这样使用下限来切片一个已填充的数组时,我认为我们会得到一个全新的底层数组,它位于一个新的内存地址中。因此我们无法“回退”(倒回),因为位置 0 和 1 上没有元素。

package main

import "fmt"

func main() {
    // 填充数组
    s := []int{1, 2, 3}
    printSlice("s   | ", s)

    // 将数组切片使其长度为零。
    // 元素并未被丢弃。底层数组的内存地址仍然相同
    s = s[:0]
    printSlice("s[:0]   | ", s)

    // 将数组扩展回原始大小,
    // 所有元素仍然存在于底层数组的同一内存地址中
    s = s[:3]
    printSlice("s[:3]   | ", s)

    // 当通过将下限设置为 2 来跳过前两个值时,
    // 内存中会创建一个具有不同长度和容量的新数组
    s = s[2:] // 底层数组被替换为一个新地址
    printSlice("s[2:]   | ", s)
}

func printSlice(m string, s []int) {
    fmt.Printf("%v len=%d cap=%d address=%p value=%v\n", m, len(s), cap(s), s, s)
}

输出

s       |  len=3 cap=3 address=0xc00000e1e0 value=[1 2 3] // 相同内存地址
s[:0]   |  len=0 cap=3 address=0xc00000e1e0 value=[] // 相同内存地址
s[:3]   |  len=3 cap=3 address=0xc00000e1e0 value=[1 2 3] // 相同内存地址
s[2:]   |  len=1 cap=1 address=0xc00000e1f0 value=[3] // 哎呀,我们在一个新的内存地址得到了数组!

如果我错了请纠正我,但我猜当我们调用来自其他包的方法来切片我们的数组时,我们必须了解实现细节,因为这些方法如果使用了下限,可能会改变数组并生成一个具有不同长度和容量的新地址。

在我看来,对于刚开始学习 Go 的人来说,切片的内部机制有点难以理解。

在Go语言中,切片操作 s[:0] 不会释放底层数组元素,而 s[N:] 会丢弃前N个元素,这是由切片的内存模型和设计决定的。

切片包含三个部分:指向底层数组的指针、长度(len)和容量(cap)。s[:0] 只是将长度设置为0,但底层数组指针和容量保持不变,因此元素仍然保留在底层数组中。而 s[N:] 会移动指针位置,丢弃前面的元素,因为新的切片指向底层数组的中间位置。

示例代码说明:

package main

import "fmt"

func main() {
    s := []int{1, 2, 3, 4, 5}
    fmt.Printf("原始切片: len=%d cap=%d %v\n", len(s), cap(s), s)
    fmt.Printf("底层数组地址: %p\n", &s[0])

    // s[:0] 操作
    s1 := s[:0]
    fmt.Printf("s[:0] 后: len=%d cap=%d %v\n", len(s1), cap(s1), s1)
    fmt.Printf("底层数组地址不变: %p\n", &s[0])
    // 仍然可以通过完整切片访问元素
    fmt.Printf("完整底层数组: %v\n", s[:cap(s)])

    // s[2:] 操作
    s2 := s[2:]
    fmt.Printf("s[2:] 后: len=%d cap=%d %v\n", len(s2), cap(s2), s2)
    fmt.Printf("底层数组指针移动: %p\n", &s2[0])
    // 无法再访问前两个元素
}

输出:

原始切片: len=5 cap=5 [1 2 3 4 5]
底层数组地址: 0xc00001a0f0
s[:0] 后: len=0 cap=5 []
底层数组地址不变: 0xc00001a0f0
完整底层数组: [1 2 3 4 5]
s[2:] 后: len=3 cap=3 [3 4 5]
底层数组指针移动: 0xc00001a100

关键区别:

  1. s[:0] 保持指针不变,仅修改长度
  2. s[N:] 移动指针位置,丢弃前面的元素

这是Go切片设计的特性,s[:0] 常用于清空切片而保留容量,s[N:] 用于截断头部元素。

回到顶部