Golang中切片与数组的内存分配差异

Golang中切片与数组的内存分配差异 我对以下代码进行了逃逸分析。

package main

func main() {
    u := make([]int, 8191) // 未逃逸到堆
    _ = u

    v := make([]int, 8192) // 逃逸到堆 = 64kb
    _ = v

    var w [1024 * 1024 * 1.25]int
    _ = w

    var x [1024 * 1024 * 1.25+1]int // 移动到堆 > 10 mb
    _ = x
}

逃逸分析的输出如下:

./main.go:3:6: can inline main
./main.go:13:6: moved to heap: x
./main.go:4:11: main make([]int, 8191) does not escape
./main.go:7:11: make([]int, 8192) escapes to heap

当切片大小达到 64 KB 时,其内存分配在堆上。但当数组大小超过 10 MB 时,它才会被移动到堆

为什么 Go 语言要等到数组大小超过 10 MB 才将其移动到堆,而同样的规则却不适用于切片?


更多关于Golang中切片与数组的内存分配差异的实战教程也可以访问 https://www.itying.com/category-94-b0.html

5 回复

谢谢你,Sean。这很有帮助。你对于显式变量的栈分配如此之高有什么见解吗?

更多关于Golang中切片与数组的内存分配差异的实战系列教程也可以访问 https://www.itying.com/category-94-b0.html


找到了:src/cmd/compile/internal/gc/go.go。maxStackVarSizemaxImplicitStackVarSize 之间的区别。

func main() {
    fmt.Println("hello world")
}

👍 这非常有趣;我很想知道是否有人知道答案!我会查看 Go 的源代码,看看是否能找到答案。

编辑: 我放弃了;在我能够找出答案之前,我需要学习更多关于编译器后端的内容。

我不知道为什么,但我可以推测:

  1. 栈分配在计算上更容易管理,因此如果可能的话,会优先选择栈而不是堆。清理一个栈帧可以像将栈顶弹出到CPU的程序计数器一样简单,而堆分配则需要垃圾收集器来跟踪和追踪指针。

  2. 根据我对逃逸分析代码的粗略浏览,值和指针之间的显著区别似乎只是它们的语义。一个指针变量可能由栈分配的值支持,而一个值变量实际上可能存在于堆中。区别仅仅在于语言语义以及Go参考实现的运行时对它们的优化方式。

    如果你希望一个大的值在栈上,你可以通过将其声明为显式的值变量来向编译器“暗示”这一点。如果必须,它仍然会逃逸到堆上,但这就是你向编译器传达意图的方式。同样地,p := new(int) 表示 p 可能会逃逸到堆上(如果它不需要在堆上,为什么要 new 它?),但如果编译器能够证明在保持程序正确和内存安全的同时可以做到,它可能会将其优化为局部变量。

p := new(int)

在Go语言中,切片和数组的内存分配策略确实存在差异,这主要源于它们底层数据结构的本质不同以及编译器逃逸分析的考量。

核心差异分析

1. 切片的内存分配机制

切片是一个三字段的描述符(指针、长度、容量),其底层数组可能分配在栈或堆上:

// 切片描述符结构(概念上)
type slice struct {
    ptr *T      // 指向底层数组的指针
    len int     // 长度
    cap int     // 容量
}

当切片大小超过64KB(8192个int,每个8字节)时,编译器会强制将其分配到堆上。这是因为:

  • 栈空间有限:Go的goroutine栈初始大小较小(通常2KB),可动态增长但有限制
  • 大对象分配开销:大对象在栈上分配可能导致栈溢出或性能问题
  • 64KB阈值:这是Go编译器的一个经验值,平衡了栈分配效率和安全性

2. 数组的内存分配机制

数组是值类型,其大小在编译时确定:

// 数组是连续内存块
var arr [1000]int  // 在栈上分配8000字节连续内存

数组的逃逸分析更保守,10MB阈值的原因:

  • 数组是值语义:函数调用时会发生完整拷贝
  • 栈空间考量:虽然栈可动态增长,但过大的数组会消耗过多栈空间
  • 10MB阈值:这是编译器对大数组的保守估计,避免栈溢出风险

示例代码验证

package main

import "fmt"

// 测试切片逃逸
func testSlice() {
    // 小切片 - 栈分配
    s1 := make([]int, 1000)
    fmt.Printf("小切片地址: %p\n", &s1[0])
    
    // 大切片 - 堆分配(超过64KB)
    s2 := make([]int, 8192) // 8192 * 8 = 65536字节 = 64KB
    fmt.Printf("大切片地址: %p\n", &s2[0])
}

// 测试数组逃逸
func testArray() {
    // 小数组 - 栈分配
    var a1 [1000]int
    fmt.Printf("小数组地址: %p\n", &a1)
    
    // 大数组 - 超过10MB会逃逸到堆
    var a2 [1024*1024*1.25 + 1]int // 超过10MB
    fmt.Printf("大数组地址: %p\n", &a2)
}

func main() {
    testSlice()
    testArray()
}

底层原理

切片分配源码逻辑

在Go编译器的逃逸分析中,切片分配的相关判断:

// 简化版的逃逸分析逻辑
func shouldEscape(size int) bool {
    // 切片:超过64KB强制逃逸
    if isSlice && size > 64*1024 {
        return true
    }
    // 数组:超过10MB保守逃逸
    if isArray && size > 10*1024*1024 {
        return true
    }
    return false
}

内存布局对比

// 切片在栈上的布局(小切片)
栈帧:
+----------------+
| slice header  |  // 24字节(64位系统)
|   ptr *int    |  → 指向堆上的底层数组(大切片)
|   len int     |    或栈上的数组(小切片)
|   cap int     |
+----------------+

// 数组在栈上的布局
栈帧:
+----------------+
| [N]int数组     |  // 直接分配在栈上
| 元素0          |
| 元素1          |
| ...           |
| 元素N-1        |
+----------------+

性能影响示例

package main

import (
    "testing"
)

// 基准测试:栈分配 vs 堆分配
func BenchmarkSliceAllocation(b *testing.B) {
    for i := 0; i < b.N; i++ {
        // 小切片 - 栈分配,快速
        small := make([]int, 1000)
        _ = small
        
        // 大切片 - 堆分配,较慢
        large := make([]int, 8192)
        _ = large
    }
}

func BenchmarkArrayAllocation(b *testing.B) {
    for i := 0; i < b.N; i++ {
        // 小数组 - 栈分配,快速
        var small [1000]int
        _ = small
        
        // 大数组 - 超过10MB会堆分配
        var large [1024*1024*1.25 + 1]int
        _ = large
    }
}

总结

Go语言对切片和数组采用不同的逃逸阈值,主要基于:

  1. 切片本质:切片是引用类型,底层数组可能被多个切片共享,需要更保守的逃逸策略
  2. 数组本质:数组是值类型,生命周期通常与作用域绑定,可以更激进地栈分配
  3. 性能权衡:64KB和10MB的阈值是编译器在栈分配效率和安全性之间的平衡点
  4. 实现差异:切片分配调用runtime.makeslice,而数组分配是编译时的静态布局

这种差异设计使得Go能在保证内存安全的同时,优化常见用例的性能。

回到顶部