Golang 切片扩容与留守引用
书接上文链接,我们继续深入这个「坑」——当切片扩容后,原来的内存到底去哪了?其他还在用这块内存的切片会怎样?
众所周知,切片扩容时会分配新的底层数组。但这里有个容易忽略的细节:原数组并不会立即消失,只要还有切片在引用它,这块内存就会安然无恙地继续存在。
让我们看一个稍复杂的例子:
package main
import (
"fmt"
)
func main() {
// 创建两个共享底层数组的切片
s1 := make([]int, 0, 2)
s1 = append(s1, 1, 2)
s2 := s1[:] // s2 与 s1 共享底层数组
fmt.Println("扩容前:")
fmt.Printf("s1: %v, 地址: %p, 容量: %d\n", s1, &s1[0], cap(s1))
fmt.Printf("s2: %v, 地址: %p, 容量: %d\n", s2, &s2[0], cap(s2))
// s1 扩容,s1 会指向新数组
s1 = append(s1, 3)
fmt.Println("\n——————s1 执行 append 操作后——————")
fmt.Printf("s1: %v, 地址: %p, 容量: %d\n", s1, &s1[0], cap(s1))
fmt.Printf("s2: %v, 地址: %p, 容量: %d\n", s2, &s2[0], cap(s2))
// 关键验证:修改 s1 不会影响 s2
s1[0] = 999
fmt.Println("\n修改 s1[0] = 999 后:")
fmt.Printf("s1: %v\n", s1)
fmt.Printf("s2: %v (未变为 999)\n", s2)
}
// OUTPUT
// 扩容前:
// s1: [1 2], 地址: 0x140001065e0, 容量: 2
// s2: [1 2], 地址: 0x140001065e0, 容量: 2
// ——————s1 执行 append 操作后——————
// s1: [1 2 3], 地址: 0x1400014e040, 容量: 4
// s2: [1 2], 地址: 0x140001065e0, 容量: 2
// 修改 s1[0] = 999 后:
// s1: [999 2 3]
// s2: [1 2] (未变为 999)
看到这里,细心的读者应该已经发现了「华点」:s1 和 s2 的地址已经分道扬镳。s1 欢快地奔向了新的、更大的内存空间,而 s2 却固执地守在原地,仿佛什么都没发生过。
我原本的猜测是 s1 扩容后,s2 应该也会「感知」到这个变化,继续跟着 s1 指向新的地址(毕竟它们曾经共享同一片内存)。但我有一些想当然了。切片虽然常被说成是「引用类型」,但切片本身是一个值类型——它内部包含的指针才是引用。
当 s1 扩容时,只有 s1 这个「值」被修改了(内部的指针指向了新地址),s2 作为独立存在的值,其内部的指针当然还是指向原来的老地方。
这就引出了一个有趣的内存管理问题:那块被 s1 「抛弃」的老地址怎么办?答案是:只要还有引用,GC 就不会动它。s2 还在兢兢业业地指着那块内存,所以它会安然无恙,直到 s2 也离开作用域,原数组彻底无人引用,变为可回收状态,GC 才会来收拾残局。至于具体什么时候被回收——可能是下一秒,可能是几分钟后,取决于 Go runtime 的调度策略和内存压力。
最后提一句,这种「一个切片扩容,另一个切片不知情」的行为,是 Go 切片最容易踩的坑之一。如果你在并发场景下玩切片,或者函数返回内部切片的子切片,务必小心这个陷阱——永远不要假设两个切片永远共享底层数组,一旦扩容发生,这种默契就破裂了。