go内存逃逸、go内存对齐

内存逃逸

什么是内存逃逸

内存逃逸:go中从栈内存逃逸到堆内存。

c语言中,默认只要不是malloc分配的内存或者全局的变量,都在栈上分配的。当函数要返回一个局部变量地址的时候,我就说这个变量(这块内存)想要逃逸,这就是内存逃逸。

go的编译器,在编译的时候会做逃逸分析,分析这个变量(这块内存)是否想要逃逸。逃逸,则在堆上分配内存;否则在栈上分配内存。

逃逸分析的好处

减少gc压力,提高效率(栈分配内存比对上分配内存快,堆上分配需要找合适的内存块,垃圾回收才能释放)。

逃逸分析这种“骚操作”把变量合理地分配到它该去的地方,“找准自己的位置”。即使你是用new申请到的内存,如果我发现你竟然在退出函数后没有用了,那么就把你丢到栈上,毕竟栈上的内存分配比堆上快很多;反之,即使你表面上只是一个普通的变量,但是经过逃逸分析后发现在退出函数之后还有其他地方在引用,那我就把你分配到堆上。真正地做到“按需分配”,提前实现共产主义

减少了gc压力。如果变量都分配到堆上,堆不像栈可以自动清理。它会引起Go频繁地进行垃圾回收,而垃圾回收会占用比较大的系统开销(占用CPU容量的25%),甚至会导致STW(stop the world)。

提高效率。堆和栈相比,堆适合不可预知大小的内存分配。但是为此付出的代价是分配速度较慢,而且会形成内存碎片。栈内存分配则会非常快。栈分配内存只需要两个CPU指令:“PUSH”和“RELEASE”,分配和释放;而堆分配内存首先需要去找到一块大小合适的内存块,之后要通过垃圾回收才能释放。

同步消除。如果你定义的对象的方法上有同步锁,但在运行时,却只有一个线程在访问,此时逃逸分析后的机器码,会去掉同步锁运行。

通过逃逸分析,可以尽量把那些不需要分配到堆上的变量直接分配到栈上,堆上的变量少了,会减轻分配堆内存的开销,同时也会减少gc的压力,提高程序的运行速度。

哪些情况下会发生内存逃逸

先来说一下通过go编译器查看内存逃逸方式go build -gcflags=-m xxx.go

  • 在方法内把局部变量指针返回并被引用。 局部变量原本应该在栈中分配,在栈中回收。但是由于返回时被外部引用,因此其生命周期大于栈,则溢出,因此要分配到堆上。
  • 发送指针或带有指针的值到 channel 中。 在编译时,是没有办法知道哪个 goroutine 会在 channel 上接收数据的。所以编译器没法知道变量什么时候才会被释放,一般都会逃逸到堆上分配。
  • 在一个切片上存储指针或带指针的值。 一个典型的例子就是 []*string 。这会导致切片的内容逃逸。尽管其后面的数组可能是在栈上分配的,但其引用的值一定是在堆上。
  • slice 的背后数组被重新分配了,因为 append 时可能会超出其容量( cap )。 slice 初始化的地方在编译时是可以知道的,它最开始会在栈上分配。如果切片背后的存储要基于运行时的数据进行扩充,就会在堆上分配。
  • 在 interface 类型上调用方法。 在 interface 类型上调用方法都是动态调度的 —— 方法的真正实现只能在运行时知道。想像一个 io.Reader 类型的变量 r , 调用 r.Read(b) 会使得 r 的值和切片b 的背后存储都逃逸掉,所以会在堆上分配。
  • 闭包引用对象,也会发生逃逸
  • 尽管能够符合分配到栈的场景,但是其大小不能够在在编译时候确定的情况,也会分配到堆上

局部变量被返回 造成逃逸

package main

type User struct {

  Name string

}

func foo(s string) *User {

  u := new(User)

  [u.Name](http://u.Name) = s

  return u // 1.方法内局部变量返回,逃逸

}

func main() {

  user := foo("hui")

  [user.Name](http://user.Name) = "dev"

}

/* 逃逸分析日志

# command-line-arguments

./main.go:11:6: can inline foo

./main.go:17:6: can inline main

./main.go:18:13: inlining call to foo

./main.go:11:10: leaking param: s

./main.go:12:10: new(User) escapes to heap  # 逃逸

./main.go:18:13: new(User) does not escape

*/

interface{}动态类型 逃逸

package main

import "fmt"

func main() {

  name := "devhui"

  fmt.Println(name)

}

/* 逃逸分析日志

# command-line-arguments

./main.go:7:13: inlining call to fmt.Println

./main.go:7:13: name escapes to heap # 逃逸

./main.go:7:13: []interface {} literal does not escape

*/

很多函数的参数为interface{} 空接口类型,这些都会造成逃逸。比如

func Printf(format string, a ...interface{}) (n int, err error)

func Sprintf(format string, a ...interface{}) string

func Fprint(w io.Writer, a ...interface{}) (n int, err error)

func Print(a ...interface{}) (n int, err error)

func Println(a ...interface{}) (n int, err error)

编译期间很难确定其参数的具体类型,也能产生逃逸

func main() {

  fmt.Println("hello 逃逸")

}

/* 逃逸日志分析

./main.go:5:6: can inline main

./main.go:6:13: inlining call to fmt.Println

./main.go:6:14: "hello 逃逸" escapes to heap

./main.go:6:13: []interface {} literal does not escape

*/

栈空间不足逃逸

栈空间足够分配小切片,不会发生逃逸

func main() {

  s := make([]int, 1000, 1000)

  for index, _ := range s {

    s[index] = index

  }

}

/* 小切片没有逃逸

# command-line-arguments

./main.go:4:11: make([]int, 1000, 1000) does not escape

*/

分配了一个超大的切片,栈空间不足,逃逸了

package main

func main() {

  s := make([]int, 10000, 10000)

  for index, _ := range s {

    s[index] = index

  }

}

/* 逃逸分析日志

# command-line-arguments

./main.go:4:11: make([]int, 10000, 10000) escapes to heap

*/

内存逃逸的弊端

提问:函数传递指针真的比传值效率高吗?

我们知道传递指针可以减少底层值的拷贝,可以提高效率,但是如果拷贝的数据量小,由于指针传递会产生逃逸,可能会使用堆,也可能会增加GC的负担,所以传递指针不一定是高效的。

准确地说,你并不需要知道。Golang 中的变量只要被引用就一直会存活,存储在堆上还是栈上由内部实现决定而和具体的语法没有关系。

知道变量的存储位置确实和效率编程有关系。如果可能,Golang 编译器会将函数的局部变量分配到函数栈帧(stack frame)上。 然而,如果编译器不能确保变量在函数 return之后不再被引用,编译器就会将变量分配到堆上。而且,如果一个局部变量非常大,那么它也应该被分配到堆上而不是栈上。

当前情况下,如果一个变量被取地址,那么它就有可能被分配到堆上。然而,还要对这些变量做逃逸分析,如果函数return之后,变量不再被引用,则将其分配到栈上。

如何避免

  • go 中的接口类型的方法调用是动态调度,因此不能够在编译阶段确定,所有类型结构转换成接口的过程会涉及到内存逃逸的情况发生。如果对于性能要求比较高且访问频次比较高的函数调用,应该尽量避免使用接口类型。
type Stringer interface {

  String() string

}

if v, ok := any.(Stringer); ok {

  return v.String()

}
  • 由于切片一般都是使用在函数传递的场景下,而且切片在 append 的时候可能会涉及到重新分配内存,如果切片在编译期间的大小不能够确认或者大小超出栈的限制,多数情况下都会分配到堆上。

总结

  • 堆上分配内存比在栈上分配内存,开销大的多
  • go的内存逃逸是在编译期间完成
  • 变量分配到栈上需要能够在编译的时候确定他的作用域,否则会分配到堆上。
  • Go编译器会在编译期对考察变量的作用域,并作一系列检查。简单来说,编译器会根据变量是否被外部引用来决定是否逃逸。
  • 不要盲目使用变量的指针作为函数参数,虽然它会减少复制操作。但其实当参数为变量自身的时候,复制是在栈上完成的操作,开销远比变量逃逸后动态地在堆上分配内存少的多。
  • 最后,尽量写出少一些逃逸的代码,提升程序的运行效率。

指针作为函数返回值的时候,一定会发生逃逸。

优化技巧

  • 尽可能避免逃逸,因为栈内存效率更高,还不用 GC。比如小对象的传参,array 要比 slice效果好。
  • 如果避免不了逃逸,还是在堆上分配了内存,那么对于频繁的内存申请操作,我们要学会重用内存,比如使用 sync.Pool
  • 选用合适的算法,达到高性能的目的,比如空间换时间。

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

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

参考

https://juejin.cn/post/6898679464692187150

https://juejin.cn/post/7026347561019506724

https://www.cnblogs.com/shijingxiang/articles/12200355.html

https://zhuanlan.zhihu.com/p/91559562

https://blog.csdn.net/caryee89/article/details/107955214

内存对齐

为何要有内存对齐

https://geektutu.com/post/hpg-struct-alignment.html

https://segmentfault.com/a/1190000040528007

CPU读取数据是一块一块的读取,为了访问未对齐的内存,CPU需要两次内存访问,内存对齐后,CPU仅需要一次访问,提高CPU访问内存的吞吐量,再者就是硬件平台适配性。

何为内存对齐

以下内容来源于网络总结:

现代计算机中内存空间都是按照字节(byte)进行划分的,所以从理论上讲对于任何类型的变量访问都可以从任意地址开始,但是在实际情况中,在访问特定类型变量的时候经常在特定的内存地址访问,所以这就需要把各种类型数据按照一定的规则在空间上排列,而不是按照顺序一个接一个的排放,这种就称为内存对齐,内存对齐是指首地址对齐,而不是说每个变量大小对齐。

posted @ 2022-05-27 16:31  凌易说-lingyisay  阅读(96)  评论(0编辑  收藏  举报