IT袋

当前位置:主页 > 经验教程 > 硬件教程 >

内存管理:空间分配及逃逸分析

内存管理:空间分配及逃逸分析 内存管理: 分配与逃逸分析(3)

时间:2023-12-22 19:23:12 来源:IT袋 作者:马勇
导读:内存管理:空间分配及逃逸分析,接下来,我们从虚拟内存的构成来分析下软件开发中的内存管理。 3. 内存管理 程序在虚拟内存上被分为 栈区、堆区、数据区、全局数据区、代码段 五个

内存管理:空间分配及逃逸分析

内存管理:空间分配及逃逸分析

接下来,我们从虚拟内存的构成来分析下软件开发中的内存管理。

3. 内存管理

程序在虚拟内存上被分为栈区、堆区、数据区、全局数据区、代码段五个部分。

而内存的管理,就是对内存空间进行合理化使用,主要是堆区(Heap)和栈区(Stack)这两个重要区域的分配使用。

3.1 堆与栈

虚拟内存里有两块比较重要的地址空间,分别为堆和栈空间。对于 C++ 等底层的编程语言,栈上的内存空间由编译器统一管理,而堆上的内存空间需要程序员来手动管理进行分配和回收。

Go 语言中,栈上的内存空间也是由编译器来统一管理,而堆上的内存空间由编译器和垃圾收集器共同管理进行分配和回收,这给我们程序员带来了极大的便利性。

在栈上分配和回收内存的开销很低,只需要 2 个指令:PUSHPOP。PUSH 将数据压入栈中,POP 释放空间,消耗的仅是将数据拷贝到内存的时间。

而在堆上分配内存时,不仅分配的时候慢,而且垃圾回收的时候也比较费劲,比如说 Go 在 1.8 以后就用到了三色标记法+混合写屏障的技术来做垃圾回收。总体来看,堆内存分配比栈内存分配导致的开销要大很多。

3.2 堆栈内存分配

1)内存分配的挑战

  • 像 C/C++ 这类由用户程序申请内存空间,可能会频繁地进行内存申请和回收,但每次内存分配时都需要进行系统调用(即只有进入内核态才可以申请内存),就会导致系统的性能很低。
  • 除此之外,还可能会有多线程(Go语言里面也有协程)去访问同一个地址空间的情况,这时就必定需要对内存进行加锁,带来的开销也会比较大。
  • 初始化时堆内存是一整块连续的内存,但随着系统运行过程中不断申请回收内存,可能会产生许多的内存碎片,导致内存的使用效率降低。

程序进行内存分配时,为了应对以上最常见的三种问题,Go 语言结合谷歌的 TCMalloc(ThreadCacheMalloc) 内存回收方法,做了一些改进。

同时,TCMalloc 和 Go 进行内存分配时都会引入线程缓存(mcentral of P)、中心缓存(mcentral)和页堆(mheap)三个组件进行分级管理内存。如图所示:

内存管理:空间分配及逃逸分析

线程缓存属于每一个独立的线程或协程,里面存储了每个线程所用的内存块 span,由于内存块的大小不一,所以有上百个内存块类别 span class,这些内存块里面分别管理不同大小的内存空间(比如 8KB、16KB、32KB…)。由于不涉及多线程,所以不需要使用互斥锁来保护内存,以减少锁竞争带来的性能损耗。

当线程缓存的空间不够时,会使用中心缓存作为小对象内存的分配,中心缓存和线程缓存的每个 span class 一一对应,并且中心缓存的每个 span class 中有两个内存块,分别存储了分配过内存的空间和满内存空间,以提升内存分配的效率。如果中心缓存还不满足,就向页堆进行空间申请。

为了提升空间的利用率,当遇到中大对象(>=32KB)分配时,内存分配器会选择页堆直接进行分配。

Go 语言内存分配的核心是使用多级缓存将对象根据大小分类,并按照类别来实施不同的分配策略。如上图所示,应用程序在申请内存时会根据对象的大小(Tiny 小对象或者 Large and medium 中大对象),向不同的组件去申请内存空间。

2)栈内存分配

栈区的内存一般由编译器自动分配和释放,一般来说,栈区存储着函数入参以及局部变量,这些数据会随着函数的创建而创建,函数的返回而消亡,一般不会在程序中长期存在。

这种线性的内存分配策略有着极高地效率,但是工程师也往往不能控制栈内存的分配,这部分工作基本都是由编译器完成的。

栈空间在运行时中包含两个重要的全局变量,分别是 runtime.stackpoolruntime.stackLarge,这两个变量分别表示全局的栈缓存和大栈缓存,前者可以分配小于 32KB 的内存,后者用来分配大于 32KB 的栈空间:

内存管理:空间分配及逃逸分析

栈分配时,根据线程缓存和申请栈的大小,Go 语言会通过三种不同的方法分配栈空间:

  1. 如果栈空间较小,使用全局栈缓存或者线程缓存上固定大小的空闲链表分配内存;
  2. 如果栈空间较大,从全局的大栈缓存 runtime.stackLarge 中获取内存空间;
  3. 如果栈空间较大并且 runtime.stackLarge 空间不足,在堆上申请一片大小足够内存空间。

在 Go1.4 以后,最小的栈内存大小为 2KB,即一个 goroutine 协程的大小。所以,当程序里的协程数量超过栈内存可分配的最大值后,就会分配在堆空间里面。也就是说,虽然 Go 语言里面可以用 go 关键字分配不限数量的 goroutine 协程,但是在性能上,我们分配的 goroutine 个数最好不要超过栈空间的最大值。

假设,栈内存的最大值为 8MB,那分配的 goroutine 数量最好不要超过 4000 个(8MB/2KB)。

4. 逃逸分析

4.1 Go如何做逃逸分析

在 C 语言和 C++ 这类需要手动管理内存的编程语言中,将对象或者结构体分配到栈上或者堆上是由工程师来决定的,这也为工程师的工作带来的挑战:如何精准地为每一个变量分配合理的空间,提升整个程序的运行效率和内存使用效率。但是 C 和 C++ 的这种手动分配内存会导致如下的两个问题:

  1. 不需要分配到堆上的对象分配到了堆上 — 浪费内存空间;
  2. 需要分配到堆上的对象分配到了栈上 — 产生野指针、影响内存安全;

与野指针相比,浪费内存空间反而是小问题。在 C 语言中,栈上的变量被函数作为返回值返回给调用方是一个常见的错误,在如下所示的代码中,栈上的变量 i 被错误返回:

int *dangling_pointer() {
   int i = 2;
   return &i;
}

dangling_pointer 函数返回后,它的本地变量会被编译器回收(栈上空间的机制),调用方获取的是危险的野指针。如果程序里面出现大量不合法的指针值,在大型项目中是比较难以发现和定位的。

当所指向的对象被释放或者收回,但是对该指针没有作任何的修改,以至于该指针仍旧指向已经回收的内存地址,此情况下该指针便称野指针,或称悬空指针、迷途指针。——wiki百科

那么,在 Go 语言里面,编译器该如何知道某个变量需要分配在堆,还是栈上而避免出现这种问题呢?

编译器决定内存分配位置的方式,就称之为逃逸分析。逃逸分析由编译器完成,作用于编译阶段。在编译器优化中,逃逸分析是用来决定指针动态作用域的方法。Go 语言的编译器使用逃逸分析决定哪些变量应该在栈上分配,哪些变量应该在堆上分配。

其中包括使用 newmake 和字面量等方法隐式分配的内存,Go 语言的逃逸分析遵循以下两个不变性:

  1. 指向栈对象的指针不能存在于堆中;
  2. 指向栈对象的指针不能在栈对象回收后存活。

什么意思呢?我们来翻译一下:

  • 首先,如果堆的指针指向了栈对象,那么栈对象的内存就需要分配到堆上;
  • 如果栈对象回收后,指针还存活,那么这个对象就只能分配到堆上。

我们在进行内存分配时,编译器会遵循上述两个原则,对我们申请的变量或对象进行内存分配到栈上或者是堆上。

换言之,当我们分配内存时,违反了上述两个原则之一,本来想分配到栈上的变量可能就会“逃逸”到堆上,被称作内存逃逸。如果程序中出现大量的内存逃逸,势必会带来意外的负面影响:比如垃圾回收缓慢,内存溢出等问题。

4.2 四种逃逸场景

Go 语言中,由于以下四种情况,栈上的内存可能会发生逃逸。

1. 指针逃逸

指针逃逸很容易理解,我们在函数中创建一个对象时,对象的生命周期随着函数结束而结束,这时候对象的内存就分配在栈上。

而如果返回了一个对象的指针,这种情况下,函数虽然退出了,但指针还在,对象的内存不能随着函数结束而回收,因此只能分配在堆上。

package main

package main
type User struct {
ID     int64
Name   string
Avatar string
}
// 要想不发生逃逸,返回 User 对象即可。
func GetUserInfo() *User {
return &User{
ID: 666666,
Name: "sim lou",
Avatar: "https://www.baidu.com/avatar/666666",
}
}
func main() {
u := GetUserInfo()
println(u.Name)
}

上面例子中,如果返回的是 User 对象,而非对象指针 *User,那么它就是一个局部变量,会分配在栈上;反之,指针作为引用,在 main 函数中还会继续使用,因此内存只能分配到堆上。

我们可以用编译器命令 go build -gcflags -m main.go 来查看变量逃逸的情况:

相关阅读