内存逃逸常见问题合集

上文说到到超超完美回答了面试官闭包和defer的组合拳,面试官的下一个问题是关于内存逃逸分析。内存逃逸分析是面试中的常客,工作中往往也会因为内存逃逸导致程序性能莫名的变差,但是我们往往难以察觉到哪里出现了内存逃逸,下面来看看超超在解决这方面问题上有哪些诀窍吧!

一、什么是内存逃逸

面试官:我看你之前学过C++,那刚好你结合C++来给我说一下什么是内存逃逸吧?

考点:内存逃逸概念

超超:程序会被编译器分为栈区、堆区、全局变量区、数据区、代码区共五个区

栈区:主要存储函数的入参、局部变量、出参当中的资源由编译器控制申请和释放

堆区:内存由程序员自己控制申请和释放,往往存放一些占用大块内存空间的变量,或是存在于函数局部但需供全局使用的变量

在C++堆区中的空间是需要程序员自己通过关键字newdelete手动释放的,对于初级程序员往往会忘记释放内存空间,导致内存泄漏或者悬空指针等一系列问题。Go的内存分配由编译器决定对象的真正存储位置是在栈上还是在堆上,并管理他的生命周期。内存逃逸就是指原本应该是存储在栈上的变量,因为一些原因被分配在了堆上。

面试官:你刚刚说Go语言中由编译器决定对象真正存储位置,如果使用关键字new申请的对象还会被存储到栈上吗?

考点:new的特殊情况

超超:是的,即使是用new申请的内存,如果编译器发现new出来的内存在函数结束后就没有使用了且申请内存空间不是很大,那么new申请的内存空间还是会被分配在栈上,毕竟栈访问速度更快且易与管理。

比如下面这段代码

package main

//函数内部new一块空间,外部无引用
func testNew() {
    t := new(int)
    *t = 1
}

func main() {
    testNew()
}

使用逃逸分析命令go build -gcflags="-m" main.go

./main.go:4:10: new(int) does not escape
./main.go:9:9: new(int) does not escape

可以看到new申请的内存空间被分配到堆上而不是在栈上。

二、内存逃逸常见情况

面试官:那你能给我列举几种你遇到的内存逃逸的情况吗?

考点:内存逃逸场景

超超:其实工作中遇到过很多次内存逃逸,总结起来有下面四种情况会发生内存逃逸

第一种情况变量在函数外部没有引用,优先放到栈中。最典型的例子就是刚刚说的new内存分配的问题,当new出来的内存空间没有被外部引用,且申请的内存不是很大时就会被放在栈上而不是堆上。

第二种情况变量在函数外部存在引用,必定放在堆中

package main

import "fmt"

//返回一个指向局部变量num的指针
func showPoint() *int {
    num := 1
    point := &num
    return point
}

func main() {
    var point *int
    point = showPoint()
    fmt.Println(*point)
}

执行逃逸分析命令go build -gcflags="-m" main.go

./main.go:6:2: moved to heap: num
./main.go:14:14: *point escapes to heap

可以看到局部变量num从栈逃逸到了堆上。原因也很简单,因为在main函数中对返回的指针point做了解引用操作,而point指向的变量num如果存储在栈上会在函数showpoint结束时被释放,那么在main函数中也就无法对指针point做解引用的操作了,所以变量num必须要被放在堆上。

第三种情况超过64k的内存占用放到堆上

package main

func testLarge() {
    nums1 := make([]int, 8191)
    nums2 := make([]int, 8192)
}

func main() {
    testLarge()
}

执行逃逸分析命令go build -gcflags="-m -m" main.go(参数多一个-m查看更详细逃逸信息)

./main.go:9:11: inlining call to testLarge func() { nums1 := make([]int, 8191); nums2 := make([]int, 8192) }
./main.go:5:15: make([]int, 8192) escapes to heap:
...
./main.go:9:11: make([]int, 8191) does not escape

make申请的内存空间大于8192*sizeof(int)/1024=64个字节时,会到堆上申请内存。因为在Go1.3之后用连续栈取代了分段栈,Go1.4中连续栈的初始大小为2kb,频繁的栈扩缩容会导致性能下降,所以在达到阀值64kb时会在堆上申请内存而不是在栈上。这里还有例子就是在make申请的切片大小为一个变量时也会在堆上申请内存而不是栈上。

第四种情况make创建的切片值为指针

package main

func testSlice() {
    nums := make([]*int, 0)
    a := 1
    nums[0] = &a
}

func main() {
    testSlice()
}

执行逃逸分析命令go build -gcflags="-m" main.go

./main.go:5:2: moved to heap: a
./main.go:4:14: make([]*int, 0) does not escape

这里似乎和前面说的第一种情况变量在函数外部没有引用,优先放到栈中有所违背。假设这里创建的切片存储了大量的指针,那么对于当中的每一个指针都需要做变量在外部的验证,这样大量切片取指针,验证操作都会带来性能的损耗,所以这里索性当切片中存储的是指针时,切片中指针指向的栈上的变量全部放到堆上。

三、栈为什么比堆快

面试官:你知道栈的访问速度为什么比堆快吗?

超超:这里有俩个原因

  1. 栈有俩个sp和bp俩个寄存器存取值
  2. 堆需要二次寻值

面试官:刚刚你说了很多make导致的内存逃逸,那我们来聊聊makenew吧!

超超:好呀(:小意思😀

本作品采用《CC 协议》,转载必须注明作者和本文链接
讨论数量: 1

"可以看到 new 申请的内存空间被分配到堆上而不是在栈上。" 说反了,被分配到栈上而不是在堆上

10个月前 评论

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!