学习
实践
活动
专区
工具
TVP
写文章
专栏首页腾讯云TI平台技术干货 | 理解 Go 内存分配
原创

技术干货 | 理解 Go 内存分配

作者:rocketwang

如果你也是个 Go 开发者,你是否关心过内存的分配和回收呢?创建的对象究竟需要由 GC 进行回收,还是随着调用栈被弹出,就消失了呢? GC 导致的 Stop The World 是否导致了你程序的性能抖动呢?

本文简单介绍了 Go 的内存分配逻辑,介绍了 Go 内存划分模型。并以代码为例子,简要介绍了几种场景下 Go 内存分配逻辑。随后使用go build 命令验证了内存逃逸等相关验证。最后,总结出了内存分配原则。

需要关心内存分配问题吗


每个工程师的时间都如此宝贵,在继续读这篇文章之前,需要你先回答几个问题,如果得到的答案是否定的,那可能本文章里写的内容对你并没有什么帮助。但是,如果你遇到了因内存分配而导致的性能问题,可能这篇文章能带你理解 Golang 的内存分配的冰山一角,带你入个门。

问题如下:

  • 你的程序是性能敏感型吗?
  • GC 带来的延迟影响到了你的程序性能吗?
  • 你的程序有过多的堆内存分配吗?

如果你命中上面问题的其中一个或两个,那这篇文章适合你继续读下去。或你根本不知道如何回答这些问题,可能去了解下 go 性能观测相关的知识(pprof 的使用等)对你更有帮助。

下面正文开始。

Golang 简要内存划分


可以简单的认为 Golang 程序在启动时,会向操作系统申请一定区域的内存,分为栈(Stack)和堆(Heap)。栈内存会随着函数的调用分配和回收;堆内存由程序申请分配,由垃圾回收器(Garbage Collector)负责回收。性能上,栈内存的使用和回收更迅速一些;尽管Golang 的 GC 很高效,但也不可避免的会带来一些性能损耗。因此,Go 优先使用栈内存进行内存分配。在不得不将对象分配到堆上时,才将特定的对象放到堆中。

内存分配过程分析


本部分,将以代码的形式,分别介绍栈内存分配、指针作为参数情况下的栈内存分配、指针作为返回值情况下的栈内存分配并逐步引出逃逸分析和几个内存逃逸的基本原则。

正文开始,Talk is cheap,show me the code。

栈内存分配

我将以一段简单的代码作为示例,分析这段代码的内存分配过程。

package main
import "fmt"
func main() {  n := 4  n2 := square(n)  fmt.Println(n2)}
func square(n int) int{  return n * n}

代码的功能很简单,一个 main 函数作为程序入口,定义了一个变量n,定义了另一个函数 squire ,返回乘方操作后的 int 值。最后,将返回的值打印到控制台。程序输出为16。

下面开始逐行进行分析,解析调用时,go 运行时是如何对内存进行分配的。

当代码运行到第6行,进入 main 函数时,会在栈上创建一个 Stack frame,存放本函数中的变量信息。包括函数名称,变量等。

当代码运行到第7行时,go 会在栈中压入一个新的 Stack Frame,用于存放调用 square 函数的信息;包括函数名、变量 n 的值等。此时,计算4 * 4 的值,并返回。

当 square 函数调用完成,返回16到 main 函数后,将16赋值给 n2变量。注意,原来的 stack frame 并不会被 go 清理掉,而是如栈左侧的箭头所示,被标记为不合法。上图夹在红色箭头和绿色箭头之间的横线可以理解为 go 汇编代码中的 SP 栈寄存器的值,当程序申请或释放栈内存时,只需要修改 SP 寄存器的值,这种栈内存分配方式省掉了清理栈内存空间的耗时【1】。

接下来,调用 fmt.Println 时,SP 寄存器的值会进一步增加,覆盖掉原来 square 函数的 stack frame,完成 print 后,程序正常退出。

指针作为参数情况下的栈内存分配

还是同样的过程,看如下这段代码。

package main
import "fmt"
func main() {  n := 4  increase(&n)  fmt.Println(n)}
func increase(i *int) {  *i++}

main 作为程序入口,声明了一个变量 n,赋值为4。声明了一个函数   increase,使用一个 int 类型的指针 i 作为参数,increase 函数内,对指针 i 对应的值进行自增操作。最后 main 函数中打印了 n 的值。程序输出为5。

当程序运行到 main 函数的第6行时,go 在栈上分配了一个 stack frame ,对变量 n 进行了赋值,n 在内存中对应的地址为0xc0008771,此时程序将继续向下执行,调用 increase 函数。

这时,increase 函数对应的 stack fream 被创建,i 被赋值为变量 n对应的地址值0xc0008771,然后进行自增操作。

当 increase 函数运行结束后,SP 寄存器会上移,将之前分配的 stack freme 标记为不合法。此时,程序运行正常,并没有因为 SP 寄存器的改动而影响程序的正确性,内存中的值也被正确的修改了。

指针作为返回值情况下的栈内存分配

文章之前的部分分别介绍了普通变量作为参数和将指针作为参数情况下的栈内存使用,本部分来介绍将指针作为返回值,返回给调用方的情况下,内存是如何分配的,并引出内存逃逸相关内容。来看这段代码:

package main
import "fmt"
func main() {  n := initValue()  fmt.Println(*n/2)}
func initValue() *int {  i := 4  return &i}

main 函数中,调用了 initValue 函数,该函数返回一个 int 指针并赋值给 n,指针对应的值为4。随后,main 函数调用 fmt.Println 打印了指针 n / 2对应的值。程序输出为2。

程序调用 initValue 后,将 i 的地址赋值给变量 n 。注意,如果这时,变量 i 的位置在栈上,则可能会随时被覆盖掉。

在调用 fmt.Println 时,Stack Frame 会被重新创建,变量 i 被赋值为*n/2也就是2,会覆盖掉原来 n 所指向的变量值。这会导致及其严重的问题。在面对 sharing up 场景时,go 通常会将变量分配到堆中,如下图所示:

通过上面的分析,可以看到在面对被调用的函数返回一个指针类型时将对象分配到栈上会带来严重的问题,因此 Go 将变量分配到了堆上。这种分配方式保证了程序的安全性,但也不可避免的增加了堆内存创建,并需要在将来的某个时候,需要 GC 将不再使用的内存清理掉。

内存分配原则


经过上述分析,可以简单的归纳几条原则。

  • Sharing down typically stays on the stack 在调用方创建的变量或对象,通过参数的形式传递给被调用函数,这时,在调用方创建的内存空间通常在栈上。这种在调用方创建内存,在被调用方使用该内存的“内存共享”方式,称之为 Sharing down。
  • Sharing up typically escapes to the heap 在被调用函数内创建的对象,以指针的形式返回给调用方的情况下,通常,创建的内存空间在堆上。这种在被调用方创建,在调用方使用的“内存共享”方式,称之为 Sharing up。
  • Only the compiler knows 之所以上面两条原则都加了通常,因为具体的分配方式,是由编译器确定的,一些编译器后端优化,可能会突破这两个原则,因此,具体的分配逻辑,只有编译器(或开发编译器的人)知道。

使用 go build 命令确定内存逃逸情况


值得注意的是,Go 在判断一个变量或对象是否需要逃逸到堆的操作,是在编译器完成的;也就是说,当代码写好后,经过编译器编译后,会在二进制中进行特定的标注,声明指定的变量要被分配到堆或栈。可以使用如下命令在编译期打印出内存分配逻辑,来具体获知特定变量或对象的内存分配位置。

查看 go help 可以看到 go build 其实是在调用 go tool compile。

go help build ... -gcflags '[pattern=]arg list'        arguments to pass on each go tool compile invocation....
go tool compile -h...-m    print optimization decisions...-l    disable inlining...

其中,需要关心的参数有两个,

  • -m 显示优化决策
  • -l 禁止使用内联【2】

代码如下:

package main
func main() {  n := initValue()  println(*n / 2)
  o := initObj()  println(o)
  f := initFn()  println(f)
  num := 5  result := add(num)  println(result)}
func initValue() *int {  i := 3                // ./main.go:19:2: moved to heap: i  return &i}
type Obj struct {  i int}
func initObj() *Obj {  return &Obj{i: 3}      // ./main.go:28:9: &Obj literal escapes to heap}
func initFn() func() {  return func() {       // ./main.go:32:9: func literal escapes to heap    println("I am a function")  }}
func add(i int) int {  return i + 1}

完整的构建命令和输出如下:

go build -gcflags="-m -l" 
# _/Users/rocket/workspace/stack-or-heap./main.go:19:2: moved to heap: i./main.go:24:9: &Obj literal escapes to heap./main.go:28:9: func literal escapes to heap

可以看到,sharing up 的情况(initValue,initObj,initFn)内存空间被分配到了堆上。sharing down 的情况(add)内存空间在栈上。

这里给读者留个问题,大家可以研究下 moved to heap 和 escapes to heap 的区别。

总结


1.因为栈比堆更高效,不需要 GC,因此 Go 会尽可能的将内存分配到栈上。

2.当分配到栈上可能引起非法内存访问等问题后,会使用堆,主要场景有:

  1. 当一个值可能在函数被调用后访问,这个值极有可能被分配到堆上。
  2. 当编译器检测到某个值过大,这个值会被分配到堆上。
  3. 当编译时,编译器不知道这个值的大小(slice、map...)这个值会被分配到堆上。

3.Sharing down typically stays on the stack

4.Sharing up typically escapes to the heap

5.Don't guess, Only the compiler knows

参考文献

【1】Go语言设计与实现:https://draveness.me/golang/docs/part3-runtime/ch07-memory/golang-stack-management/#%E5%AF%84%E5%AD%98%E5%99%A8

【2】Inlining optimisations in Go:https://dave.cheney.net/2020/04/25/inlining-optimisations-in-go

【3】Golang FAQ:https://golang.org/doc/faq#stack_or_heap

原创声明,本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

登录 后参与评论
0 条评论

相关文章

  • 技术干货 | 理解 Go 内存分配

    如果你也是个 Go 开发者,你是否关心过内存的分配和回收呢?创建的对象究竟需要由 GC 进行回收,还是随着调用栈被弹出,就消失了呢? GC 导致的 Stop T...

    腾讯云TI平台
  • Go 内存管理 -- 内存分配 二

    首先会申请一段连续的内存空间以供使用,大小(64位机器上)512M(spans_mapped)+16G(bitmap_mapped)+512G(arena)。 ...

    邹志全
  • Go 内存管理 -- 内存分配 一

    go作为一个比较新晚(新)的语言,自然借鉴前辈们的优点,比如说语言本身负责内存管理、对协程和高并发的高优支持、简单高效的语法等。本篇及后续的几篇要讲的就是还没提...

    邹志全
  • 图解Go语言内存分配

    Go语言内置运行时(就是runtime),抛弃了传统的内存分配方式,改为自主管理。这样可以自主地实现更好的内存使用模式,比如内存池、预分配等等。这样,不会每次内...

    老钱
  • 图解Go语言内存分配

    Go语言内置运行时(就是runtime),抛弃了传统的内存分配方式,改为自主管理。这样可以自主地实现更好的内存使用模式,比如内存池、预分配等等。这样,不会每次内...

    李海彬
  • 图解Go语言内存分配

    Go语言内置运行时(就是runtime),抛弃了传统的内存分配方式,改为自主管理。这样可以自主地实现更好的内存使用模式,比如内存池、预分配等等。这样,不会每次内...

    梦醒人间
  • go 指针和内存分配详解

    每当我们编写任何程序时,我们都需要在内存中存储一些数据/信息。数据存储在特定地址的存储器中。内存地址看起来像0xAFFFF(这是内存地址的十六进制表示)。

    孤烟
  • 【Go 语言社区】Golang内存分配

    golang内存分配 new一个对象的时候,入口函数是malloc.go中的newobject函数 func newobject(typ *_type) uns...

    李海彬
  • 你真的理解内存分配吗?

    内存是计算机中必不可少的资源,因为 CPU 只能直接读取内存中的数据,所以当 CPU 需要读取外部设备(如硬盘)的数据时,必须先把数据加载到内存中。

    用户7686797
  • [译] Go语言内存管理与分配

    Go程序的内存从申请阶段到不再使用后的释放阶段都由Go标准库自动管理。尽管管理工作不需要开发者参与,但是Go对内存管理的底层实现做了非常好的优化,里面充满了有意...

    猿哥
  • 详解Go中内存分配源码实现

    Go 语言的内存分配器就借鉴了 TCMalloc 的设计实现高速的内存分配,它的核心理念是使用多级缓存将对象根据大小分类,并按照类别实施不同的分配策略。TCMa...

    luozhiyun
  • 从Go语言角度浅谈内存分配

    相信各位小伙伴一定看过这样的言论,某某B乎大佬xxx,发了一堆文字,一定要学好底层,一定要学好C语言!!,然后下面各种抬杠。

    Go进阶者
  • 深入理解JVM内存分配策略

    代码很简单,就是创建一个Byte数组,大小为4mb。 然后我们在运行的时候加上虚拟机参数来打印垃圾回收的信息。

    全栈程序员站长
  • 深入理解JVM - 对象分配内存

    这一节我们来讨论对象分配内存的细节,这一块的内容相对比较简单,但是也是比较重要的内容,最后会总结书里面的OOM的溢出案例,在过去的文章已经讲到过不少类似的情况...

    阿东
  • 图解Go内存管理器的内存分配策略

    在Go语言里,从内存的分配到不再使用后内存的回收等等这些内存管理工作都是由Go在底层完成的。虽然开发者在写代码时不必过度关心内存从分配到回收这个过程,但是Go的...

    KevinYan
  • Go内存分配那些事,就这么简单!

    新老朋友好久不见,我是大彬,这篇文章准备了很久,不是在拖延,而是中间做了一些其他事情,耽搁了一些,各位朋友见谅哈。

    大彬
  • Go语言内存分配器的实现

    前面断断续续的写了3篇关于Go语言内存分配器的文章,分别是Go语言内存分配器设计、Go语言内存分配器-FixAlloc、Go语言内存分配器-MSpan,这3篇主...

    李海彬
  • go源码剖析2 内存分配1 概述

    注意: 内存分配器只管理内存块, 不关心内存中对象的状态, 也不会主动的回收内存, 回收是在回收器完成清理操作后, 触发内存分配器的回收操组;

    历久尝新
  • Go高阶指南14,内存的分配原理

    Go 中实现的内存分配器,简单的说就是维护了一大块全局内存,每个线程(Go 中的 P)维护一小块的私有内存,当私有内存不足时再向全局申请。内存分配与 GC(垃圾...

    微客鸟窝

扫码关注腾讯云开发者

领取腾讯云代金券