高频问题-逃逸分析

前言

写过C/C++的同学都知道,调用著名的malloc和new函数可以在堆上分配一块内存,这块内存的使用和销毁的责任都在程序员。一不小心,就会发生内存泄露。
切换到Golang后,基本不会担心内存泄露了。虽然也有new函数,但是使用new函数得到的内存不一定就在堆上。一个变量是在堆上分配,还是在栈上分配,是经过编译器的逃逸分析之后得出的结论。
Go语言逃逸分析最基本的原则是:如果一个函数返回对一个变量的引用,那么它就会发生逃逸。

什么是逃逸分析

在编译原理中,分析指针动态范围的方法称之为逃逸分析。通俗来讲,当一个对象的指针多个方法线程引用时,我们称这个指针发生了逃逸。更简单来说,逃逸分析决定一个变量是分配在堆上还是分配在栈上。
所谓逃逸分析(Escape analysis)是指由编译器决定内存分配的位置,不需要程序员指定。函数中申请一个新的对象

如果分配在栈中,则函数执行结束可自动将内存回收;
如果分配在堆中,则函数执行结束可交给GC(垃圾回收)处理;

逃逸分析遵循两个原则:

  • 指向栈对象的指针不能存活在堆中;
  • 指向栈对象的指针不能在栈对象回收后还存活。

为什么要逃逸分析

逃逸分析把变量合理地分配到它该去的地方。即使你是用new申请到的内存,如果我发现你竟然在退出函数后没有用了,那么就把你丢到栈上,毕竟栈上的内存分配比堆上快很多;反之,即使你表面上只是一个普通的变量,但是经过逃逸分析后发现在退出函数之后还有其他地方在引用,那我就把你分配到堆上。
如果变量都分配到堆上,堆不像栈可以自动清理。它会引起Go频繁地进行垃圾回收,而垃圾回收会占用比较大的系统开销。
堆和栈相比,堆适合不可预知大小的内存分配。但是为此付出的代价是分配速度较慢,而且会形成内存碎片。栈内存分配则会非常快。栈分配内存只需要两个CPU指令:“PUSH”和“RELEASSE”,分配和释放;而堆分配内存首先需要去找到一块大小合适的内存块,之后要通过垃圾回收才能释放。
通过逃逸分析,可以尽量把那些不需要分配到堆上的变量直接分配到栈上,堆上的变量少了,会减轻分配堆内存的开销,同时也会减少gc的压力,提高程序的运行速度。

逃逸分析是怎么完成的

Go逃逸分析最基本的原则是:如果一个函数返回对一个变量的引用,那么它就会发生逃逸。
Go中的变量只有在编译器可以证明在函数返回后不会再被引用的,才分配到栈上,其他情况下都是分配到堆上。 Go语言里没有一个关键字或者函数可以直接让变量被编译器分配到堆上,相反,编译器通过分析代码来决定将变量分配到何处。 对一个变量取地址,可能会被分配到堆上。但是编译器进行逃逸分析后,如果考察到在函数返回后,此变量不会被引用,那么还是会被分配到栈上。

简单来说,编译器会根据变量是否被外部引用来决定是否逃逸:

如果函数外部没有引用,则优先放到栈中;
如果函数外部存在引用,则必定放到堆中;

Golang的逃逸分析情况

查看逃逸分析指令:go build -gcflags=-m 文件名
逃逸分析通常有四种情况:

  • 指针逃逸
//main.go
package main
type Student struct {
    Name string
}
func StudentRegister(name string) *Student {
    s := new(Student) //局部变量s逃逸到堆
    s.Name = name
    return s
}
func main() {
    StudentRegister("Jim")
}

执行  go build -gcflags=-m main.go

//new(Student) escapes to heap
  • 栈空间不足逃逸
package main
func Slice() {
    s := make([]int, 10000, 10000)

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

//Slice make([]int, 10000, 10000) does not escape
  • 动态类型逃逸
package main

import "fmt"

func main() {
    s := "Escape"
    fmt.Println(s)
}
//s escapes to heap
  • 闭包引用对象逃逸
func Increase() func() int {
	n := 0
	return func() int {
		n++
		return n
	}
}

func main() {
	in := Increase()
	fmt.Println(in()) // 1
	fmt.Println(in()) // 2
}
//Increase() 返回值是一个闭包函数,该闭包函数访问了外部变量 n,那变量 n 将会一直存在,直到 in 被销毁。很显然,变量 n 占用的内存不能随着函数 Increase() 的退出而回收,因此将会逃逸到堆上。
  • 发送指针或带有指针的值到 channel 中
  • 在一个切片上存储指针或带指针的值。 一个典型的例子就是 []*string 。这会导致切片的内容逃逸。尽管其后面的数组可能是在栈上分配的,但其引用的值一定是在堆上。
  • slice 的背后数组被重新分配了,因为 append 时可能会超出其容量( cap )。 slice 初始化的地方在编译时是可以知道的,它最开始会在栈上分配。如果切片背后的存储要基于运行时的数据进行扩充,就会在堆上分配。

避免内存逃逸

runtime/stubs.go:133有个函数叫noescape。noescape可以在逃逸分析中隐藏一个指针。让这个指针在逃逸分析中不会被检测为逃逸。

 func noescape(p unsafe.Pointer) unsafe.Pointer {
     x := uintptr(p)
     return unsafe.Pointer(x ^ 0)
}
package main

import (
	"unsafe"
)

type A struct {
	S *string
}

func (f *A) String() string {
	return *f.S
}

type ATrick struct {
	S unsafe.Pointer
}

func (f *ATrick) String() string {
	return *(*string)(f.S)
}

func NewA(s string) A {
	return A{S: &s}
}

func NewATrick(s string) ATrick {
	return ATrick{S: noescape(unsafe.Pointer(&s))}
}

func noescape(p unsafe.Pointer) unsafe.Pointer {
	x := uintptr(p)
	return unsafe.Pointer(x ^ 0)
}

func main() {
	s := "hello"
	f1 := NewA(s)
	f2 := NewATrick(s)
	s1 := f1.String()
	s2 := f2.String()
	_ = s1 + s2
}
$go build -gcflags=-m main.go
./main.go:24:16: &s escapes to heap
./main.go:23:13: moved to heap: s
./main.go:27:18: NewATrick s does not escape
./main.go:28:45: NewATrick &s does not escape

noescape()函数的作用是遮蔽输入和输出的依赖关系。使编译器不认为 p会通过 x逃逸, 因为 uintptr()产生的引用是编译器无法理解的。
内置的 uintptr类型是一个真正的指针类型,但是在编译器层面,它只是一个存储一个 指针地址的 int类型。代码的最后一行返回 unsafe.Pointer也是一个 int。

逃逸总结

  • 栈上分配内存比在堆中分配内存有更高的效率
  • 栈上分配的内存不需要GC处理
  • 堆上分配的内存使用完毕会交给GC处理
  • 逃逸分析目的是决定内分配地址是栈还是堆
  • 逃逸分析在编译阶段完成
  • 返回函数结果时,可以区分对象大小使用不同返回方式。小对象用值,大对象用指针。这是因为指针对象会被逃逸分析分配到堆上,加大GC负担。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值