堆与栈

什么是堆栈?

在计算机领域中,堆栈是非常重要的概念,数据结构中有堆栈,内存分配中也有堆栈,两者在定义和用途上虽不同,但也有些许关联,比如内存分配中栈的压栈和出栈操作,类似于数据结构中的栈的操作方式。

数据结构中的堆栈

数据结构是将数据按序排列的一种方式,在数据结构的堆栈是两种数据结构:堆与栈,定义如何将数据按序进行存储和取出,是软件程序组织编排数据的手段。需要将内存分配中的堆栈区别开,本文也主要探究内存分配中的堆栈。

内存分配中的堆栈

软件程序在运行过程中,必不可少的会使用变量、函数(也是一种变量)和数据,变量和数据在内存中存储的位置可分为:栈区和堆区,一般由 C 或者 C++ 编译的程序占用的内存可分为:

  • 栈区(Stack)
  • 堆区(Heap)
  • 全局区(静态区 Static)
  • 常量区
  • 程序代码区

软件程序中的数据和变量都会被分配到程序所在的虚拟内存空间中,内存空间包含两个重要区域:栈区(Stack)和堆区(Heap), 下面针对 Golang 语言分配的栈空间和堆空间进行探讨。

栈(Stack)

PUSHRELEASE
malloc

堆(Heap)

堆空间没有特定的结构,也没有固定的大小,可以动态进行分配和调整,所以内存占用较大的局部变量会放在堆空间上,在编译时不知道该分配多少大小的变量,在运行时也会分配到堆上,在堆上分配内存开销比在栈上大,而且堆上分配的内存需要手动释放,对于 Golang 这种有 GC 机制的语言, 也会增加 GC 压力, 也容易造成内存碎片。

题外话:

之前开发 iOS 时,系统的 OC (Objective-C)语言,之前采用 MRC(手动管理内存)的方式管理内存,标记变量采用引用计数的方式,变量被引用一次则引用计数加 1,减少引用一次则引用计数减一,直到引用计数减到 0,标记释放该变量的内存,+1 和 -1 操作必须配对,否则导致内存泄露,各种野指针 Crash。后来 OC && Swift 逐步的采用 ARC (自动引用计数)管理方式,即由系统去管理变量的引用计数,无需开发者去手动操作 +1 和 -1 操作,极大的提高了代码质量和降低了内存泄露的风险, 但同时也增加了系统开销。

Golang 语言也是同样的机制,由 GC 系统去管理堆上的内存,只是 GC 算法不同,采用三色标记法,GC 的好处在于不用手动去管理堆上的内存,但是同时增加了系统的开销,可能会产生微秒级 STW。

Note:栈是线程级别的,堆是进程级别的。

内存逃逸

什么是内存逃逸?

一言以蔽之, 本该分配到函数栈空间的变量,被分配到了堆空间,称为内存逃逸;过多的内存逃逸会导致 GC 压力变大,堆空间内存碎片化。

内存逃逸策略

newmake

How do I know whether a variable is allocated on the heap or the stack?

From a correctness standpoint, you don't need to know. Each variable in Go exists as long as there are references to it. The storage location chosen by the implementation is irrelevant to the semantics of the language.

The storage location does have an effect on writing efficient programs. When possible, the Go compilers will allocate variables that are local to a function in that function's stack frame. However, if the compiler cannot prove that the variable is not referenced after the function returns, then the compiler must allocate the variable on the garbage-collected heap to avoid dangling pointer errors. Also, if a local variable is very large, it might make more sense to store it on the heap rather than the stack.

In the current compilers, if a variable has its address taken, that variable is a candidate for allocation on the heap. However, a basic escape analysis recognizes some cases when such variables will not live past the return from the function and can reside on the stack.

大致的意思表明一个原则:只要局部变量不能证明在函数结束后不能被引用,那么就分配到堆上。换句话说,如果局部变量被其他函数所捕获,那么就被分配到堆上。

由上可知,根据栈空间的特性可以知道,函数栈帧的大小是有限的且在编译时就已经确定,如果在编译时无法确定变量大小或者变量过大,在 runtime 运行时分配到堆上。

逃逸分析(Escape analysis)

在程序编译阶段根据程序代码中的数据流,对代码中哪些变量需要在栈空间上分配,哪些变量需要在堆空间上分配,进行静态分析。一个理想的逃逸分析算法,能将开发者认为需要分配在栈空间上的变量尽可能保留在栈空间上,尽可能少的“逃逸”到堆空间上。理想过于丰满,现实却很骨感,语言情况不同,语言版本不同,逃逸算法的精确度以及实际优化情况也不尽相同。对于开发者而言,只需掌握逃逸分析工具以及逃逸分析的目标即可。

go1.13.6 darwin/amd64

源文件注释了 Golang 语言逃逸分析算法的原理,是内存逃逸的指导思想,有两个基本的不变性:

The two key invariants we have to ensure are:

(1) pointers to stack objects cannot be stored in the heap

(2) pointers to a stack object cannot outlive that object (e.g., because the declaring function returned and destroyed the object's stack frame, or its space is reused across loop iterations for logically distinct variables).

  • 指向栈对象的指针不能存储在堆中
  • 指向栈对象的指针不能超过该栈对象的存活期(即指针不能在栈对象被销毁后依旧存活)

逃逸分析算法的大致原理和过程注释中也有说明, 大致步骤:

  1. Golang 编译器解析 Golang 源文件后获取抽象语法树(AST)。
  2. 构建有向加权图, 遍历该有向加权图寻找可能违反上述两个不变性的赋值路径, 如果一个变量的地址是储存在堆或者其他可能会超过它存活期的地方, 则该变量就会被标记为需要在堆上分配。
  3. 分析函数之间的数据时,逃逸分析算法记录每个函数的数据流等手段,具体算法可移步至源码。
newmake

分析工具

使用编译工具

go build -gcflags '-m -l' xxxx.go

编译参数(-gcflags):

  • -N: 禁止编译优化
  • -l: 禁止内联
  • -m: 逃逸分析
  • -benchmem: 压测时打印内存分配统计
mainbadBoy
package main

type Person struct {
    Name   string
    EnName string
}

func main() {
    badBoy := &Person{
        Name: "法外狂徒张三",
        EnName: "ZhangSan",
    }
    _ = badBoy
}
go build -gcflags '-m -l' main.go-l inlineinline
structbadBoy := &Person{Name: "法外狂徒张三", EnName: "ZhangSan"}
// ============= Command && Output ==================
Command :
go build -gcflags '-m -l' main.go

Output :
# command-line-arguments
./main.go:11:11: main &Person literal does not escape

使用汇编

go tool compile -S xxxx.go
fmt.Println
package main

import "fmt"

type Person struct {
    Name   string
    EnName string
}

func main() {
    badBoy := &Person{
        Name:   "法外狂徒张三",
        EnName: "ZhangSan",
    }
    fmt.Println(badBoy)
}
go tool compile -S main.go | grep xxxx

查看某一行的汇编,能直接定位到源代码某一行反编译后的汇编代码,有针对性的定位对象是否逃逸

// ============= Command && Output ==================
Command: 
go tool compile -S main.go | grep main.go:15

Output:                                                                                                                    czp-bytedance@MacBook-Pro
0x0071 00113 (main.go:15)       PCDATA  $0, $2
0x0071 00113 (main.go:15)       PCDATA  , 
0x0071 00113 (main.go:15)       XORPS   X0, X0
0x0074 00116 (main.go:15)       MOVUPS  X0, ""..autotmp_14+64(SP)
0x0079 00121 (main.go:15)       PCDATA  $0, $3
0x0079 00121 (main.go:15)       LEAQ    type.*"".Person(SB), AX
0x0080 00128 (main.go:15)       PCDATA  $0, $2
0x0080 00128 (main.go:15)       MOVQ    AX, ""..autotmp_14+64(SP)
0x0085 00133 (main.go:15)       PCDATA  $0, $0
0x0085 00133 (main.go:15)       MOVQ    DI, ""..autotmp_14+72(SP)
0x00e5 00229 (main.go:15)       MOVQ    DX, DI
go tool compile -S main.go | grep new
newruntime.newobject
// ============= Command && Output ==================
Command: 
go tool compile -S main.go | grep new

Output: 
0x002c 00044 (main.go:13)       CALL    runtime.newobject(SB)
rel 45+4 t=8 runtime.newobject+0

Note: 上述两段示例代码,逻辑上没有任何不同,只是增加了 fmt.Println 打印的代码,为何局部变量就发生逃逸了呢?下面就来看看有我们日常开发中,常见的发生逃逸的场景。

逃逸场景

1. 函数返回局部变量指针

返回局部变量的指针, 在函数之外引用该局部变量的地址, 根据上面的分析内存逃逸策略,两个不变性之一“在编译时能发现,指针不能在栈对象被销毁后依旧存活”    当外部没有引用局部变量地址时, 局部变量不会发生逃逸。

buildPersonmain
package main

type Person struct {
    Name   string
    EnName string
}

func buildPerson(name, enName string) *Person {
    return &Person{
        Name:   name,
        EnName: enName,
    }
}

func main() {
    badBoy := buildPerson("法外狂徒张三", "ZhangSan")
    badBoy.EnName = "ZhangSan"
}

// ============= Command && Output ==================
Command: 
go build -gcflags '-m -l' main.go

Output: 
# command-line-arguments
./main.go:8:18: leaking param: name
./main.go:8:24: leaking param: enName
./main.go:11:3: &Person literal escapes to heap // 发生逃逸,第 11 行其实是第 9 行

2. 动态反射 interface{} 变量

fmt.Println
fmt.Println
package main

import "fmt"

type Person struct {
    Name   string
    EnName string
}

func main() {
    badBoy := &Person{
        Name:   "法外狂徒张三",
        EnName: "ZhangSan",
    }
    fmt.Println(badBoy)
}

// ============= Command && Output ==================
Command: 
go build -gcflags '-m -l' main.go

Output: 
# command-line-arguments
./main.go:13:11: &Person literal escapes to heap
./main.go:15:13: main ... argument does not escape
./main.go:15:13: badBoy escapes to heap

3. 申请栈空间过大

栈空间大小是有限的,如果编译时发现局部变量申请的空间过大,则会发生内存逃逸,在堆空间上给大变量分配内存。

main
package main

func main() {
    num := make([]int, 0, 10000)
    _ = num
}

// ============= Command && Output ==================
Command: 
go build -gcflags '-m -l' main.go

Output: 
# command-line-arguments
./main.go:4:13: make([]int, 0, 10000) escapes to heap
num := make([]int, 0, 8192)int
package main

func main() {
    num := make([]int, 0, 8191)
    _ = num
}

# command-line-arguments
./main.go:4:13: main make([]int, 0, 8191) does not escape

// ===========================================
package main

func main() {
    num := make([]int, 0, 8192)
    _ = num
}

# command-line-arguments
./main.go:4:13: make([]int, 0, 8192) escapes to heap

再进一步测试, slice, map 这类可扩容的类型和 int 之类的基础类型是否一致呢?多大的容量会发生逃逸呢?

例如:可扩容类型变量,Slice 指定 8192Byte(64KB)的预估容量(Cap),刚好发生内存逃逸;基础类型变量,10MB 大小发生内存逃逸。

package main

type Person struct {
    Name   [4]byte
    EnName [4]byte
}

func main() {
    // 8B * 8192 = 64KB
    testSlice := make([]int, 0, 8192)
    testPersonSlice := make([]Person, 0, 8192)
    _ = testSlice
    _ = testPersonSlice

    // 10M
    var testNum [1024*1024*1.25]int
    _ = testNum

    var testNum2 [1024*1024*1.25 + 1]int
    _ = testNum2
}

// ============= Command && Output ==================
Command: 
go build -gcflags '-m -l' main.go

Output: 
# command-line-arguments
./main.go:19:6: moved to heap: testNum2
./main.go:10:19: make([]int, 0, 8192) escapes to heap
./main.go:11:25: make([]Person, 0, 8192) escapes to heap

由上可知,申请大局部变量时,基础类型变量如 int 最大可在栈上申请 <10MB,slice 之类可扩容的类型,可在栈上申请 <64KB 的内存空间。这种分配策略,猜测可能跟 Go 内存管理,mspan 的页大小为 8KB 有关系。

Note:在上述测试中,slice 的元素是基础变量,如果 slice 的元素是对象指针又是如何进行内存分配呢?

4. 切片变量自身和元素的逃逸

  1. 未指定 slice 的 length 和 cap 时,slice 自身未发生逃逸,slice 的元素发生了逃逸。因为 slice 会动态扩容,编译器不知道容量大小,无法提前在栈空间分配内存,扩容后 slice 的元素可能会被分配到堆空间,所以 slice 容器自身也不能被分配到栈空间。
package main

type Person struct {
    Name   string
    EnName string
}

func main() {
    var personList []*Person

    p1 := &Person{Name: "test1"}
    p2 := &Person{Name: "test2"}

    personList = append(personList, p1)
    personList = append(personList, p2)
}

// ============= Command && Output ==================
Command: 
go build -gcflags '-m -l' main.go

Output: 
# command-line-arguments
./main.go:11:22: &Person literal escapes to heap
./main.go:12:22: &Person literal escapes to heap
  1. 只指定 slice 的长度即 array, 数组本身和元素均在栈上分配内存,均未发生逃逸
package main

type Person struct {
    Name   string
    EnName string
}

func main() {
    var personList [3]*Person

    p1 := &Person{Name: "test1"}
    p2 := &Person{Name: "test2"}
    p3 := &Person{Name: "test3"}

    personList[0] = p1
    personList[1] = p2
    personList[2] = p3
}

// ============= Command && Output ==================
Command: 
go build -gcflags '-m -l' main.go

Output: 
# command-line-arguments
./main.go:11:22: main &Person literal does not escape
./main.go:12:22: main &Person literal does not escape
./main.go:13:22: main &Person literal does not escape

5. 闭包捕获变量

闭包其本质就是函数,当捕获其他函数的局部变量后,该局部变量会发生内存逃逸,因为闭包有可能是延迟函数,会晚于当前函数执行完毕,如果当前函数内的局部变量分配在栈空间, 那么闭包在执行时就无法找到该变量,出现野指针,所以当闭包捕获局部变量时,该局部变量一定会发生内存逃逸。

例如: 闭包捕获局部变量指针,该指针被分配到堆空间,基于不变性第二点,所指对象也被分配到堆空间。

package main

type Person struct {
    Name   string
    EnName string
}

func getPersonNameFunc(p *Person) func() string {
    return func() string {
        return p.Name
    }
}

func main() {
    badBoy := &Person{Name: "法外狂徒张三", EnName: "ZhangSan"}
    goodBoy := &Person{Name: "李四", EnName: "LiSi"}

    defer func() {
        _ = badBoy
    }()

    getPersonNameFunc(goodBoy)
}

// ============= Command && Output ==================
Command: 
go build -gcflags '-m -l' main.go

Output: 
# command-line-arguments
./main.go:8:24: leaking param: p
./main.go:9:9: func literal escapes to heap
./main.go:16:44: main &Person literal does not escape
./main.go:17:45: &Person literal escapes to heap
./main.go:19:8: main func literal does not escape
goodBoybadBoygoodBoybadBoybadBoydeferreturndeferbadBoy

6. 发送指针或带有指针的值到 channel 中

Golang 中经典的设计:不要通过共享内存的方式进行通信,而是应该通过通信的方式共享内存。

从而诞生了 channel 管道设计,实现 goroutine 之间共享内存数据,所以往 channel 中发送内存数据时,编译器不知道哪个 goroutine 何时会从 channel 中读出数据,所以不会分配到 goroutine 的栈空间,再者栈空间是 goroutine 的独立空间,共享内存数据也应该分配到堆空间才能实现共享。

例如:发送值对象到 channel 会拷贝,不会发生内存逃逸;发送对象指针到 channel 被捕获,发生内存逃逸,指针所指对象在堆空间分配。

package main

type Person struct {
    Name   string
    EnName string
}

func main() {
    badBoy := Person{Name: "法外狂徒张三", EnName: "ZhangSan"}
    goodBoy := &Person{Name: "李四", EnName: "LiSi"}

    // 发送值对象到 channel
    valueChannel := make(chan Person, 1)
    valueChannel <- badBoy

    // 发送对象指针到 channel
    pointerChannel := make(chan *Person, 1)
    pointerChannel <- goodBoy

    go func() {
        _, _ = <-valueChannel, <-pointerChannel
    }()
}

// ============= Command && Output ==================
Command: 
go build -gcflags '-m -l' main.go

Output: 
# command-line-arguments
./main.go:10:45: &Person literal escapes to heap
./main.go:20:5: func literal escapes to heap

思考:

  • 发送 slice 到 channel,会发生内存逃逸吗?
  • 发送 array 到 channel,会发生内存逃逸吗?
  • 发送 slice 到 channel,元素为值对象时,容器自身发生了内存逃逸,由于元素是值对象,发生了拷贝,原来的局部变量未发生内存逃逸;元素为指针对象时,容器自身和元素均发生内存逃逸。
package main

type Person struct {
    Name   string
    EnName string
}

func main() {
    badBoy := Person{Name: "张三", EnName: "ZhangSan"}
    goodBoy := &Person{Name: "李四", EnName: "LiSi"}

    // 发送值对象 slice 到 channel
    valueChannel := make(chan []Person, 1)
    valueChannel <- []Person{badBoy}

    // 发送对象指针 slice 到 channel
    pointerChannel := make(chan []*Person, 1)
    pointerChannel <- []*Person{goodBoy}

    go func() {
        _, _ = <-valueChannel, <-pointerChannel
    }()

    _, _ = <-valueChannel, <-pointerChannel
}

// ============= Command && Output ==================
Command: 
go build -gcflags '-m -l' main.go

Output: 
# command-line-arguments
./main.go:10:45: &Person literal escapes to heap
./main.go:14:26: []Person literal escapes to heap
./main.go:18:29: []*Person literal escapes to heap
./main.go:20:5: func literal escapes to heap
  • 发送 array 到 channel,元素为值对象时,容器和元素均未发生逃逸;元素为指针对象时,容器自身和元素均发生内存逃逸。
package main

type Person struct {
    Name   string
    EnName string
}

func main() {
    badBoy := Person{Name: "张三", EnName: "ZhangSan"}
    goodBoy := &Person{Name: "李四", EnName: "LiSi"}

    // 发送值对象 array 到 channel
    valueChannel := make(chan [1]Person, 1)
    valueChannel <- [1]Person{badBoy}

    // 发送对象指针 array 到 channel
    pointerChannel := make(chan [1]*Person, 1)
    pointerChannel <- [1]*Person{goodBoy}

    go func() {
        _, _ = <-valueChannel, <-pointerChannel
    }()

    _, _ = <-valueChannel, <-pointerChannel
}

// ============= Command && Output ==================
Command: 
go build -gcflags '-m -l' main.go

Output: 
# command-line-arguments
./main.go:10:45: &Person literal escapes to heap
./main.go:20:5: func literal escapes to heap

番外篇:当 channel element 大于 8192 Byte 时提示,不能大于 64KB。

// 发送值对象到 channel
valueChannel := make(chan [10086]Person, 1)
valueChannel <- [10086]Person{badBoy}

// 发送对象指针到 channel
pointerChannel := make(chan [10086]*Person, 1)
pointerChannel <- [10086]*Person{goodBoy}

# command-line-arguments
./main.go:13:22: channel element type too large (>64kB)
./main.go:17:24: channel element type too large (>64kB)

逃逸分析的作用

  1. 通过逃逸分析能确定哪些变量分配到栈空间,哪些分配到堆空间,对空间需要 GC 系统回收资源,GC 系统会有微秒级的 STW,降低 GC 的压力能提高系统的运行效率。
  2. 栈空间的分配比堆空间更快性能更好,对于热点数据分配到栈上能提高接口的响应。
  3. 栈空间分配的内存,在函数执行完毕后由系统回收资源,不需要 GC 系统参与,也不需要 GC 标记清除,可降低内存的占用,减少 RSS (常驻内存资源集),降低系统发生 OOM 的风险。

逃逸分析的总结

go1.13.6 darwin/amd64 newmakeliteral
项目实践

问题背景

本次探索源于 Sup 服务内存逐步升高,偶发性 OOM,隔几天就需要原地升级,稳定性隐患较大,@凌硕豪 在群里抛出担忧,大家开始讨论出现的原因。

  

从上可以分析出:

  1. 地址 node 没有成环,如果成环,接口 15s 超时而且内存应该直接暴涨,不是缓慢的上升。
  2. GC 系统正常工作,没有压力。
  3. 可能是 RSS 导致的,一直有块内存在堆空间无法得到释放,而且越来越多。

基于上述三点,猜测问题所在:堆空间内存一直在分配,不知道被谁引用着,导致无法释放

那么就有两个问题:

  1. 局部变量为何被分配到堆空间?
  2. 什么地方一直引用着该局部变量,导致无法 GC?
deepcopy.Copy
deepcopy.CopycacheObjectcacheObject

原来在缓存框架中,创建缓存时,会被捕获且一直引用着,如下: 

解决方案

  1. @凌硕豪 去掉了 framework cache 中对 country 数据的租户隔离,避免 cache 占用和租户数正相关。
  2. @王军 去掉了多余的一次 deepcopy 库的深拷贝。

解决结果

sup 内存基本稳定在 35%,pprof heap 中也没有 country cache 的占用,目前占用较多的是灰度 feature。

总结

逃逸分析对于日常开发而言不可或缺,对于提高代码质量、降低接口延时、提高系统稳定性都有非常大的帮助,有时犹如一把利器割开束缚的口子,困难就迎刃而解。

欢迎大家斧正!

加入我们

我们来自字节跳动飞书商业应用研发部(Lark Business Applications),目前我们在北京、深圳、上海、武汉、杭州、成都、广州、三亚都设立了办公区域。我们关注的产品领域主要在企业经验管理软件上,包括飞书 OKR、飞书绩效、飞书招聘、飞书人事等 HCM 领域系统,也包括飞书审批、OA、法务、财务、采购、差旅与报销等系统。欢迎各位加入我们。

扫码发现职位&投递简历