栈内存和堆内存的区别以及内存逃逸分析

为了让程序员更好地专注于业务代码的实现,Go 语言增加了垃圾回收机制,自动地回收不再使用的内存。Go 语言有两部分内存空间:栈内存和堆内存。

1. 栈内存【栈是一种运算受限的线性表连续储存,其限制是指只仅允许在表的一端进行插入和删除操作】
栈只允许往线性表的一端放入数据,之后在这一端取出数据,按照后进先出(LIFO, Last In First Out )的顺序,如图所示。

往栈中放入元素的过程叫做入栈。入栈会增加栈的元素数量,最后放入的元素总是位于栈的顶部,最先放入的元素总是位于栈的底部。

从栈中取出元素时,只能从栈顶部取出。取出元素后,栈的数量会变少。最先放入的元素总是最后被取出,最后放入的元素总是最先被取出。不允许从栈底获取数据,也不允许对栈成员(除栈顶外的成员)进行任何查看和修改操作 。

栈内存由编译器自动分配和释放,开发者无法控制。栈内存一般存储函数中的局部变量、参数等,函数创建的时候,这些内存会被自动创建;函数返回的时候,这些内存会被自动释放。

栈可用于内存分配,栈的分配和回收速度非常快。下面代码展示枝在内存分配上的作用,代码如下:

复制代码
func calc(a, b int) int {
    var c int
    c = a * b

    var x int
    x = c * 10

    return x
}
复制代码

上面的代码在没有任何优化情况下,会进行 c 和 x 变量的分配过程 。 Go 语言默认情况下会将 c 和 x 分配在栈上,这两个变量在 calc() 函数退出时就不再使用,函数结束时,保存 c 和 x 的栈内存再出栈释放内存,整个分配内存的过程通过栈的分配和回收都会非常迅速。

栈分为顺序栈和链式栈两种,是一种线性结构,所以可以使用数组或链表(单向链表、双向链表或循环链表)作为底层数据结构。使用数组实现的栈叫做顺序栈,使用链表实现的栈叫做链式栈,二者的区别是顺序栈中的元素地址连续,链式栈中的元素地址不连续。

2. 堆内存【堆是一种常用的树形结构非连续存储,是一种特殊的完全二叉树,当且仅当满足所有节点的值总是不大于或不小于其父节点的值的完全二叉树被称之为堆,这一特性称之为堆序性】

堆在内存分配中类似于往一个房间里摆放各种家具,家具的尺寸有大有小。

分配内存时,需要找一块足够装下家具的空间再摆放家具。经过反复摆放和腾空家具后,房间里的空间会变得乱七八糟,此时再往空间里摆放家具会存在虽然有足够的空间,但各空间分布在不同的区域,无法有一段连续的空间来摆放家具的问题。

此时内存分配器就需要对这些空间进行调整优化,堆适合不可预知大小的内存分配。但是为此付出的代价是分配速度较慢,而且会形成内存碎片 。

堆内存的生命周期比栈内存要长,如果函数返回的值还会在其他地方使用,那么这个值就会被编译器自动分配到堆上。堆内存相比栈内存来说,不能自动被编译器释放,只能通过垃圾回收器才能释放,所以栈内存效率会很高。

为什么堆是树
完全二叉树:堆通常被实现为完全二叉树,这是因为完全二叉树具有简单的性质和易于实现的结构。完全二叉树是一种特殊的二叉树,除了最后一层之外,其他所有层都必须填满,并且最后一层的节点从左向右连续排列。
快速查找和插入完全二叉树的结构使得堆对于快速查找和插入元素非常有效。通过利用节点之间的相对顺序,可以在O(log n)时间内查找或插入元素,其中n是堆中的元素数量。
堆属性:堆具有堆属性(heap property),也称为堆排序属性。在最小堆中,每个父节点的值都小于或等于其子节点的值。在最大堆中,每个父节点的值都大于或等于其子节点的值。这种属性确保了堆中的最小(或最大)元素始终位于根节点。

小结:

堆与栈实际上是操作系统对进程占用的内存空间的两种管理方式,主要有如下几种区别:

3. 逃逸分析
既然栈内存的效率更高,肯定是优先使用栈内存。那么 Go 语言是如何判断一个变量应该分配到堆上还是栈上的呢?这就需要逃逸分析了。下面通过一个示例来讲解逃逸分析,代码如下:

复制代码
package main

func main() {
    newString()
}

func newString() *string{
   s:=new(string)
   *s = "wohu"
   return s
}
复制代码

现在通过逃逸分析来看下是否发生了逃逸,命令如下:

go build -gcflags="-m -l" main.go

-m 表示打印出逃逸分析信息;
-l 表示禁止内联,可以更好地观察逃逸;

从以上输出结果可以看到,发生了逃逸,也就是说指针作为函数返回值的时候,一定会发生逃逸。

逃逸到堆内存的变量不能马上被回收,只能通过垃圾回收标记清除,增加了垃圾回收的压力,所以要尽可能地避免逃逸,让变量分配在栈内存上,这样函数返回时就可以回收资源,提升效率。

对 newString 函数进行了避免逃逸的优化:

func newString() string{
   s:=new(string)
   *s = "wohu"
   return *s
}

通过分析结果可以看到,虽然还是声明了指针变量 s,但是函数返回的并不是指针,所以没有发生逃逸。

逃逸分析是判断变量是分配在堆上还是栈上的一种方法,在实际的项目中要尽可能避免逃逸,这样就不会被 GC 拖慢速度,从而提升效率。

结论:从逃逸分析来看,指针虽然可以减少内存的拷贝,但它同样会引起逃逸,所以要根据实际情况选择是否使用指针。

取地址发生逃逸

复制代码
package main

import "fmt"

type Data struct {
}

func demo() *Data {
    var d Data
    return &d
}

func main() {
    fmt.Println(demo())
}
复制代码

moved to heap: d。这句话表示, Go 编译器已经确认如果将 d 变量分配在栈上是无法保证程序最终结果的。如果坚持这样做,demo() 的返回值将是 Data 结构的一个不可预知的内存地址。

原因是引用了一个函数局部变量的地址。
Go 语言最终选择将 d 的 Data 结构分配在堆上,然后由垃圾回收器去回收 d 的内存 。

优化技巧

1)尽可能避免逃逸,因为栈内存效率更高,还不用 GC。比如小对象的传参,array 要比 slice效果好。

2)如果避免不了逃逸,还是在堆上分配了内存,那么对于频繁的内存申请操作,要学会复用内存,比如使用 sync.Pool。

3)选用合适的算法,达到高性能的目的,比如空间换时间。

小提示:性能优化的时候,要结合基准测试,来验证自己的优化是否有提升。

以上是基于 Go 语言的内存管理机制总结出的 3 个方向的技巧,基于这 3 个大方向基本上可以优化出你想要的效果。除此之外,还有一些小技巧,比如要尽可能避免使用锁、并发加锁的范围要尽可能小、使用 StringBuilder 做 string 和 []byte 之间的转换、defer 嵌套不要太多等。

最后推荐一个 Go 语言自带的性能剖析的工具 pprof,通过它可以查看 CPU 分析、内存分析、阻塞分析、互斥锁分析。

posted @   李若盛开  阅读(297)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 全程不用写代码,我用AI程序员写了一个飞机大战
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· 记一次.NET内存居高不下排查解决与启示
· 白话解读 Dapr 1.15:你的「微服务管家」又秀新绝活了
· DeepSeek 开源周回顾「GitHub 热点速览」
点击右上角即可分享
微信分享提示