Golang中利用切片优化数组缩容的技巧

Golang中利用切片优化数组缩容的技巧 我知道切片是对数组的抽象,它提供了许多标准库函数;在扩展(添加/追加)时,内部数组会被创建,值从旧数组复制到新数组,然后旧数组被丢弃,因此使用 make 可以帮助避免所有这些操作。

那么对于收缩(删除一些值)是否也会有相同的操作/行为呢?如果是,具体是如何实现的?被删除的单元格/值所在的内存位置是包含垃圾值,还是被设置为该类型的默认值!或者内部会发生复制/移位操作。

谢谢。

3 回复

是的,就存储而言,根据标准,向量会以当前容量的2倍增长,而数组列表会以当前容量的3/2倍增长。

更多关于Golang中利用切片优化数组缩容的技巧的实战系列教程也可以访问 https://www.itying.com/category-94-b0.html


当向一个已满的切片追加元素时,会分配一个新的切片,其容量是旧切片的两倍*,并将旧切片的元素复制到新切片中。使用 make 并指定初始长度或容量可以减少切片重新分配的次数(例如,向一个 nil 切片追加 8 个元素会导致 4 次分配:0 → 1, 1 → 2, 2 → 4, 最后 4 → 8)。如果指定初始容量为 8,则不会发生重新分配。

当缩小切片的长度时,不会发生同样的事情。底层数组不会自动缩小。如果需要,你可以手动操作。当切片缩小时,其元素不会被设置为 nil,并且仍然持有垃圾值。如果你的元素持有指针,这可能是个问题,因为即使无法通过切片的边界访问这些元素,垃圾收集器仍然认为该内存是可访问的,例如:切片中“浪费”的空间会怎样? - #10 by Keith_Randall

这在以下情况下尤其相关:

t1 := new(T)
t2 := new(T)
s := []*T{t1, t2}
s = s[1:]

即使 t1 不再可访问,垃圾收集器仍然认为它是存活的,因为 s 指向一个分配(一个 [2]*T),而该分配指向 t1。如果 t1 还指向其他东西,它可能会保持任意数量的存储空间存活。因此,当 T 包含指针时,这样做可能更有意义:

t1 := new(T)
t2 := new(T)
s := []*T{t1, t2}
s[0] = nil
s = s[1:]

以确保 s 的底层存储不再指向 t1。

  • 容量实际上并不总是翻倍。随着容量变得非常大,它开始以 1.25 倍(或类似的比例)增长,而不是翻倍。

在Go中,切片缩容时确实会发生内存重新分配和复制操作,但行为取决于具体的操作方式。以下是详细分析和示例:

1. 删除元素时的内存行为

当使用切片表达式或append删除元素时,被删除位置的内存不会立即被垃圾回收或重置为默认值,而是保留原有值直到被覆盖:

package main

import "fmt"

func main() {
    // 原始切片
    s := []int{10, 20, 30, 40, 50}
    fmt.Printf("原始切片: %v, 长度: %d, 容量: %d\n", s, len(s), cap(s))
    
    // 删除索引2的元素(30)
    s = append(s[:2], s[3:]...)
    fmt.Printf("删除后: %v, 长度: %d, 容量: %d\n", s, len(s), cap(s))
    
    // 查看底层数组(通过容量访问)
    fmt.Printf("底层数组(通过全切片): %v\n", s[:cap(s)])
}

输出:

原始切片: [10 20 30 40 50], 长度: 5, 容量: 5
删除后: [10 20 40 50], 长度: 4, 容量: 5
底层数组(通过全切片): [10 20 40 50 50]

可以看到:

  • 容量保持不变(5)
  • 最后一个位置(索引4)仍然保留着原来的值50
  • 没有发生内存重新分配

2. 显式缩容操作

如果需要真正释放内存,需要创建新的切片:

package main

import "fmt"

func main() {
    s := make([]int, 1000, 1000)
    // 填充数据
    for i := range s {
        s[i] = i
    }
    
    fmt.Printf("初始: 长度=%d, 容量=%d\n", len(s), cap(s))
    
    // 删除大量元素后(保留前10个)
    s = s[:10]
    fmt.Printf("截断后: 长度=%d, 容量=%d\n", len(s), cap(s))
    
    // 真正缩容(创建新切片)
    s2 := make([]int, len(s))
    copy(s2, s)
    fmt.Printf("缩容后: 长度=%d, 容量=%d\n", len(s2), cap(s2))
}

3. 性能优化技巧

对于频繁缩容的场景,可以手动控制重新分配的阈值:

package main

import "fmt"

func shrinkSlice(s []int, newLen int) []int {
    // 如果新长度小于容量的一半,则重新分配
    if newLen <= cap(s)/2 {
        newSlice := make([]int, newLen)
        copy(newSlice, s[:newLen])
        return newSlice
    }
    return s[:newLen]
}

func main() {
    s := make([]int, 1000, 1000)
    for i := range s {
        s[i] = i
    }
    
    // 缩容到100个元素
    s = shrinkSlice(s, 100)
    fmt.Printf("长度: %d, 容量: %d\n", len(s), cap(s))
}

4. 内存泄漏风险

注意切片缩容可能导致的内存泄漏:

package main

import "runtime"

func main() {
    largeSlice := make([]byte, 100*1024*1024) // 100MB
    smallSlice := largeSlice[:10]           // 只引用前10个字节
    
    // 但底层100MB数组不会被GC回收
    // 因为smallSlice仍然引用整个底层数组
    
    // 正确做法:
    smallSlice = make([]byte, 10)
    copy(smallSlice, largeSlice[:10])
    
    // 现在largeSlice可以被GC回收
    runtime.GC()
}

关键点总结:

  1. 简单的切片操作(如s = s[:n])不会触发重新分配,底层数组保持不变
  2. 被删除的元素位置保留原值,直到被新数据覆盖
  3. 真正释放内存需要创建新切片并复制数据
  4. 缩容操作通常发生在:
    • 使用append删除中间元素时
    • 显式创建新切片并复制时
  5. 内存管理策略应根据具体场景选择,平衡性能和内存使用
回到顶部