内存逃逸分析

1.什么是堆/栈

在这里并不打算详细介绍堆栈,仅简单介绍本文所需的基础知识。如下:

  • 堆(Heap):一般来讲是人为手动进行管理,手动申请、分配、释放。一般所涉及的内存大小并不定,一般会存放较大的对象。另外其分配相对慢,涉及到的指令动作也相对多(GC回收内存)
  • 栈(Stack):由编译器进行管理,自动申请、分配、释放。一般不会太大,我们常见的函数参数(不同平台允许存放的数量不同),局部变量等等都会存放在栈上(自动回收内存)

今天我们介绍的 Go 语言,它的堆栈分配是通过 Compiler 进行分析,GC 去管理的,而对其的分析选择动作就是今天探讨的重点

2.什么是逃逸分析

在编译程序优化理论中,逃逸分析是一种确定指针动态范围的方法,简单来说就是分析在程序的哪些地方可以访问到该指针

通俗地讲,逃逸分析就是确定一个变量要放堆上还是栈上,规则如下:

  1. 是否有在其他地方(非局部)被引用。只要有可能被引用了,那么它一定分配到堆上。否则分配到栈上
  2. 即使没有被外部引用,但对象过大,无法存放在栈区上。依然有可能分配到堆上

对此你可以理解为,逃逸分析是编译器用于决定变量分配到堆上还是栈上的一种行为

3.在什么阶段确立逃逸

在编译阶段确立逃逸,注意并不是在运行时

4.为什么需要逃逸

这个问题我们可以反过来想,如果变量都分配到堆上了会出现什么事情?例如:

  • 垃圾回收(GC)的压力不断增大
  • 申请、分配、回收内存的系统开销增大(相对于栈)
  • 动态分配产生一定量的内存碎片

其实总的来说,就是频繁申请、分配堆内存是有一定 “代价” 的。会影响应用程序运行的效率,间接影响到整体系统。因此 “按需分配” 最大限度的灵活利用资源,才是正确的治理之道。这就是为什么需要逃逸分析的原因,你觉得呢?

5.怎么确定是否逃逸

第一,通过编译器命令,就可以看到详细的逃逸分析过程。而指令集 -gcflags 用于将标识参数传递给 Go 编译器,涉及如下:

  • -m 会打印出逃逸分析的优化策略,实际上最多总共可以用 4 个 -m,但是信息量较大,一般用 1 个就可以了
  • -l 会禁用函数内联,在这里禁用掉 inline 能更好的观察逃逸情况,减少干扰
$ go build -gcflags '-m -l' main.go

第二,通过反编译命令查看

$ go tool compile -S main.go

注:可以通过 go tool compile -help 查看所有允许传递给编译器的标识参数

6.逃逸案例

6.1--指针逃逸

第一个案例是一开始抛出的问题,现在你再看看,想想,如下:

s开始为局部变量,然后返回出去了。在其他地方(非局部)可能被引用,所以逃逸到堆上
package main

import "fmt"

type Student struct{
	Name string
	Age int
}

func StudentRegister(name string, age int) *Student {

	s := new(Student)
	//局部变量s逃逸到堆
	s.Name = name
	s.Age = age
	return s
}

func main() {
	a:=StudentRegister("Jim", 18)
	fmt.Println(a)
	fmt.Println(*a)
}

执行命令观察一下,如下:

go build -gcflags '-m -l' main.go  // 查看逃逸过程
go tool compile -S main.go         // 查看汇编代码

通过查看分析结果:

./main.go:22:14: *a escapes to heap 逃到了堆里

# command-line-arguments
./main.go:10:22: leaking param: name
./main.go:12:10: new(Student) escapes to heap
./main.go:21:13: ... argument does not escape
./main.go:22:13: ... argument does not escape
./main.go:22:14: *a escapes to heap
note: module requires Go 1.16

6.2--栈空间不足逃逸到堆(空间开辟过大)

package main

func Slice() {
	s := make([]int, 10000, 10000)
	for index, _ := range s {
		s[index] = index
	}
}
func main() { 
	Slice() 
}

上面代码Slice()函数中分配了一个10000个长度的切片,是否逃逸取决于栈空间是否足够大。 直接查看编译提示,如下:

./main.go:20:6: can inline main
./main.go:13:11: make([]int, 10000, 10000) escapes to heap

当切片长度扩大到10000时就会逃逸。

实际上当栈空间不足以存放当前对象时或无法判断当前切片长度时会将对象分配到堆中。

6.3--动态分配

func F() {
	a := make([]int, 0, 20)    // 栈 空间小  
	b := make([]int, 0, 20000) // 堆 空间过大 逃逸    
	l := 20
	c := make([]int, 0, l) // 堆 动态分配不定空间 逃逸
}

6.4--泄露参数

type User struct {
    ID     int64
    Name   string
}

func GetUserInfo(u *User) *User {
    return u
}

func main() {
    _ = GetUserInfo(&User{ID: 13746731, Name: "EDDYCJY"})
}

执行命令观察一下,如下:

$ go build -gcflags '-m -l' main.go
# command-line-arguments
./main.go:9:18: leaking param: u to result ~r1 level=0
./main.go:14:63: main &User literal does not escape
我们注意到 `leaking param` 的表述,它说明了变量 `u` 是一个泄露参数。结合代码可得知其传给 `GetUserInfo` 方法后,没有做任何引用之类的涉及变量的动作,直接就把这个变量返回出去了。因此这个变量实际上并没有逃逸,它的作用域还在 `main()` 之中,所以分配在**栈上**

再想想

那你再想想怎么样才能让它分配到堆上?结合案例一,举一反三。修改如下:

type User struct {
    ID     int64
    Name   string
    Avatar string
}

func GetUserInfo(u User) *User {
    return &u
}

func main() {
    _ = GetUserInfo(User{ID: 13746731, Name: "EDDYCJY", Avatar: "https://avatars0.githubusercontent.com/u/13746731"})
}

执行命令观察一下,如下:

$ go build -gcflags '-m -l' main.go
# command-line-arguments
./main.go:10:9: &u escapes to heap
./main.go:9:18: moved to heap: u

只要一小改,它就考虑会被外部所引用,因此妥妥的分配到堆上了

7.函数传递指针真的比传值效率高吗?

传递指针相比值传递减少了底层拷贝,可以提高效率,但是拷贝的数据量较小,由于指针传递会产生逃逸,可能会使用堆,也可能增加gc的负担,所以指针传递不一定是高效的。

8.什么情况下会逃逸?

1.如果一个对象在函数执行完毕后仍然需要被引用,它就必须分配在堆上,以便在函数返回后仍然可用。

2.当一个对象在函数内部创建,但在函数外部被引用时,就会发生逃逸(栈逃逸到堆)
3.不是所有的指针都会分配到堆上,遵循第一条核心原则。如果在函数内部创建的指针,并且只在函数内部使用。那么这个指针必然分配到栈上

9.逃逸总结

1.静态内存分配到栈上,性能一定比动态分配到堆上好
2.底层分配到堆,还是栈。实际上对你来说是透明的,不需要过度关心
3.每个 Go 版本的逃逸分析都会有所不同(会改变,会优化)
4.直接通过 go build -gcflags '-m -l' 就可以看到逃逸分析的过程和结果
5.到处都用指针传递并不一定是最好的,要用对
6.栈上分配的内存不需要GC处理
7.堆上分配的内存使用完毕会交给GC处理
8.逃逸分析目的是决定内分配地址是栈还是堆
9.逃逸分析在编译阶段完成

10.资源分配

常量、代码、初始化的全局变量都会被存储在:只读的数据段
只读的数据段:只读的数据段(read-only data segment)是一种特殊的内存区域,用于存储不可修改的数据。


只读数据段与其他内存区域的主要区别在于:
1.只读数据段中的数据是不可修改的,任何试图修改这些数据的尝试都会引发运行时错误。
2.只读数据段通常会被映射到进程的地址空间中,以供程序访问。
3.操作系统会对只读数据段进行特殊的内存页保护,防止意外修改
posted @ 2022-03-02 10:16  Jeff的技术栈  阅读(307)  评论(0编辑  收藏  举报
回顶部