Go逃逸分析:揭秘性能优化的秘密

欢迎小伙伴们观看我的文章,若文中有不正确的地方,小伙伴们可以在评论区处指出,希望能够与小伙伴们一起成长与进步,谢谢!

一、内存管理

在Go的内存管理中,主要涉及两个方面,一是内存分配、二是内存回收(垃圾回收)。

内存回收主要是通过GC来负责,而Go的内存分配,则是基于逃逸分析来实现分配。

Go语言的内存管理通过逃逸分析的内存分配,垃圾回收等机制,实现了高效、灵活的内存管理。使得Go在处理高并发、高性能的应用程序时具有很大的优势。了解Go的内存管理机制有助于编写更高效、更可靠的程序。

如果要了解逃逸分析,则首先需要了解Go的内存管理。

在Go程序中,程序中的数据和变量主要被程序所在的虚拟内存中,内存空间包含两个重要区域,分别是栈(stack)和堆(heap)

1、栈

在Go中,栈的内存是由编译器自动进行分配与释放 ,函数调用的参数、返回值以及局部变量大都会被分配在栈上,它们随着函数的创建而分配,随着函数的退出而销毁

Go应用程序运行时,每个 goroutine 都维护着一个自己的栈区,这个栈区只能由goroutine本身使用,来为自己的参数、局部变量等来分配内存,且不能被其他 goroutine 使用。

具体来说,在程序启动时,Go运行时会为每个goroutine(Go语言中的轻量级线程)分配一个初始的栈空间。默认情况下,每个goroutine的栈大小为2KB

goroutine的函数调用深度增加,栈空间可能会被扩展以容纳更多的函数调用帧。同样地,当函数调用深度减少时,栈空间也可能会收缩。即一个栈通常包含了许多栈帧(stack frame),它描述的是函数之间的调用关系

要注意的是,栈内存的大小是受限制的。在大多数操作系统中,每个进程的栈内存都有一个上限。如果栈空间不足以容纳更多的函数调用帧,将会触发栈溢出错误。

总之,Go语言的栈内存大小是动态确定的,并且可以根据需要进行调整,但受操作系统和运行时的限制。

2、堆

与栈的内存由编译器分配与释放不同,堆的内存一般由编译器和开发者共同进行管理与分配 ,堆区的内存释放则由GC来释放。堆中的对象由内存分配器分配,且由垃圾回收器回收。

程序在运行期间可以主动从堆上申请内存,这些内存通过Go的内存分配器分配。在堆分配内存给变量数据时,必须找到一块足够大的内存来存放新的变量数据。

在释放内存时,垃圾回收器会周期性地检查堆内存中的对象,并释放不再被引用的对象所占用的内存空间。当堆内存中的对象增加时,垃圾回收器可能会触发堆扩展,以提供更多的可用内存。相反,如果堆内存中的对象减少,垃圾回收器可能会收缩堆内存并回收一部分空间。

3、栈与堆对比

内存管理

  • 栈的内存由编译器自动管理,用于存储函数的局部变量和函数调用时的参数,不需要程序员手动操作;

  • 堆内存由Go的垃圾回收器(Garbage Collector, GC)管理 ,用于存储动态分配的内存,如通过newmakeappend等操作分配的内存。堆内存的大小在运行时可以动态变化;

生命周期

  • 栈上的变量在函数调用结束后立即释放 ,生命周期与函数的调用周期同步
  • 堆上的变量的生命周期由垃圾回收器管理 ,直到它们不再被引用时才会被回收;

容量大小

  • 栈的大小通常较小,因为它需要快速分配和释放内存,以支持大量函数调用。
  • 堆的大小通常较大,因为它需要存储整个程序运行期间可能需要的动态分配的内存。

内存碎片

  • 栈内存分配时是连续的,不会产生内存碎片。
  • 堆内存分配时可能会产生内存碎片,尤其是在频繁分配和释放小块内存时。

访问速度上

  • 栈上的内存访问速度通常比堆快,因为栈内存是连续的,且分配和释放操作相对简单。
  • 堆上的内存访问速度可能较慢,因为内存分配可能涉及到内存碎片和垃圾回收的操作

性能上

  • 栈内存管理 性能较好,因为栈上的内存只需要两个CPU指令:一个是分配入栈,另外一个是栈内释放。它的分配与释放非常高效。
  • 堆内存管理 性能较差,因为对于堆上的内存回收工作,需要使用到标记清除,例如Go采用的三色标记法。

二、逃逸分析

1、逃逸分析介绍

在Go中,编译器是如何确定内存是分配到栈上还是堆上呢?

答案是:逃逸分析

编译器通过逃逸分析技术来确定内存是分配在栈上,还是分配到堆上。

具体来说,Go语言中的逃逸分析(Escape Analysis是一种优化技术,它用于确定在函数中分配的对象是否会逃逸到堆上。如果一个对象被逃逸分析器确定为不会逃逸,那么它就可以在栈上分配,而不是在堆上。

逃逸分析的基本思想在于检查变量的生命周期是否是完全可知的,如果通过检查,则在栈上进行分配。反之产生逃逸,需要在堆上进行分配。

通过使用逃逸分析技术来合理的分配内存,好处在于能够减少了垃圾回收(GC)的开销 ,因为栈上的对象可以通过函数返回时直接释放,而不需要等待GC的周期性扫描。

2、逃逸分析原则

逃逸分析是Go语言编译器的一部分,它在编译时进行,而不是运行时进行。逃逸分析的结果会影响编译器如何生成代码,从而优化程序的内存使用和性能。

Go语言没有明确说明逃逸分析的原则,但逃逸分析可以通过以下一些基本准则来进行参考。


对象的生命周期

逃逸分析器会追踪对象的生命周期,确定对象在函数执行期间是否会被引用。如果对象在函数执行完毕后仍然被引用,那么它就会逃逸。

对象的引用

逃逸分析器会检查对象的引用链。

如果对象只被函数栈帧中的变量直接引用,那么它就不会逃逸,即对象在函数外部没有引用,则优先放到栈中。

如果对象被传递到其他函数或者被全局变量引用,那么它就会逃逸,即对象在函数外部存在引用,则必定放在堆中。

另外,在循环中,对象可能会被多次引用。逃逸分析器需要能够识别这种模式,以避免错误地将对象分配到堆上。

对象的内存大小

如果变量占用内存较大时,则变量会优先分配到堆中;


通过上述的一些基本准则,逃逸分析器会决定对象是否应该在堆上分配,还是在栈上分配。如果对象不会逃逸,它就可以在栈上分配,这样可以减少内存分配的次数,提高程序的性能。

需要注意的是,逃逸分析并不是万能的,它依赖于编译器的实现和编译时的上下文。在某些情况下,即使对象看起来不会逃逸,编译器也可能出于保守考虑将其分配到堆上。

此外,逃逸分析的结果可能会受到编译器优化策略的影响,不同的编译器版本可能会有不同的逃逸分析行为。

3、逃逸分析案例

分析逃逸分析结果时,可以使用go build -gcflags '-m -m -l'来查看逃逸分析的结果。

(1)interface类型

example

go 复制代码
package main

import "fmt"

func main() {
    num := 123
    fmt.Println(num)
}

逃逸分析结果

原因分析

上述代码中,num变量escapes到了heap堆内存中,原因在于Println函数(Println(a ...interface{}))的参数是interface{}类型,在编译器无法确定其具体的数据类型,因此将该num变量分配到了堆中。

(2)变量在函数外部引用

example

go 复制代码
package main

func test() *int {
    num := 100
    return &num
}

func main() {
    _ = test()
}

逃逸分析结果

原因分析

在上述test函数中,将初始化后的num变量的地址返回,因此变量num在函数外可能存在引用。

test函数执行完毕,对应的栈帧就被销毁,但是变量num的引用已经被返回到函数之外。如果这时外部通过引用地址取值,虽然地址还在,但函数栈的内存已经被释放回收了,即非法内存。

为了避免上述非法内存的情况,在这种情况下变量num的内存分配必须分配到堆上。

(3)变量内存占用大

example

go 复制代码
package main

func test() {
    arr := make([]int, 10000, 10000)
    for i := 0; i < 10000; i++ {
       arr[i] = i
    }
}

func main() {
    test()
}

逃逸分析结果

原因分析

在test函数中,使用make初始化了一个容量10000的int类型的arr切片变量,由于arr切片占有的内存可能会比较大,因此发生了逃逸,arr切片的内存分配到了堆上(heap)。

当把切片的容量与长度修改为1时,再次查看逃逸分析的结果。

go 复制代码
package main

func test() {
    arr := make([]int, 1, 1)
    for i := 0; i < 1; i++ {
       arr[i] = i
    }
}

func main() {
    test()
}

查看逃逸分析结果可以看到,当修改了切片初始化的长度与容量后,arr切片变量并没有发生逃逸。

因此,当变量占用内存较大时,会发生逃逸分析,将内存分配到堆上

(4)变量大小不确定

example

go 复制代码
package main

func test() {
    len := 1
    arr := make([]int, len, len)
    for i := 0; i < 1; i++ {
       arr[i] = i
    }
}

func main() {
    test()
}

逃逸分析结果

原因分析

上述代码中,arr切片变量发生了逃逸,内存分配到了heap堆中。虽然在make初始化切片,事先定义了len变量并赋值为1作为切片的初始容量和长度,但在编译期间,只能识别到初始化int类型切片时,传入的长度和容量为变量len,编译期间并不能确定len的值,因此arr切片变量发生了逃逸分析,将内存分配到了堆中。

三、总结

通过理解逃逸分析,了解变量分配在栈与堆中的差别后,对于日后写出更好的程序应用提供了很好的帮助。

在日常开发中,根据逃逸分析,尽量写出内存分配在栈上的代码。堆中的内存分配减少后,可以有效减轻内存分配带来的开销以及减小GC的压力,提高程序的运行速度。

例如在某些场景下,不应该传递结构体指针,而应该直接传递结构体。虽然直接传递结构体需要值拷贝,但是这是在栈上完成的操作,开销远比变量逃逸后动态地在堆上分配内存少的多。

但凡事并不一定绝对,需要根据特定的场景来分析:

  • 如果结构体较大,传递结构体指针更合适,因为指针类型相比值类型能节省大量的内存空间,避免内存过大的值拷贝;
  • 如果结构体较小,传递结构体更适合,因为在栈上分配内存,可以有效减少GC压力;
相关推荐
hanglove_lucky1 小时前
本地摄像头视频流在html中打开
前端·后端·html
探索云原生2 小时前
在 K8S 中创建 Pod 是如何使用到 GPU 的: nvidia device plugin 源码分析
ai·云原生·kubernetes·go·gpu
皓木.3 小时前
(自用)配置文件优先级、SpringBoot原理、Maven私服
java·spring boot·后端
i7i8i9com3 小时前
java 1.8+springboot文件上传+vue3+ts+antdv
java·spring boot·后端
秋意钟3 小时前
Spring框架处理时间类型格式
java·后端·spring
我叫啥都行3 小时前
计算机基础复习12.22
java·jvm·redis·后端·mysql
Stark、3 小时前
【Linux】文件IO--fcntl/lseek/阻塞与非阻塞/文件偏移
linux·运维·服务器·c语言·后端
coding侠客4 小时前
Spring Boot 多数据源解决方案:dynamic-datasource-spring-boot-starter 的奥秘
java·spring boot·后端
37手游后端团队5 小时前
谈谈golang的错误处理
后端