一眼看透GO的内存管理

一眼看透GO的内存管理

(本篇文章为https://deepu.tech/memory-management-in-golang/的中文翻译,如有兴趣,请跳转博主原文)

在这个系列当中,我致力于去深入浅出一些现代编程语言的内存管理。我希望能给你提供一个可以看清各个语言内存分配阶段发生了什么的视角。

在这个章节,我们将看到Go语言的内存管理。Go是一门静态编译语言类似C++、RUST。因此GO语言并不需要一个虚拟机,并且GO语言编译生成的二进制文件当中包含了Runtime,用来处理GO的各种本体功能像垃圾回收、线程调度以及并发。

如果你读了这个系列的第一篇文章(我详细介绍了堆和栈的不同),那会对你阅读这篇文章十分有帮助。

备注:这一篇文章是基于GO 1.13版本的,所有的具体实现和概念细节可能在未来的版本会有所不同。

GO内存数据结构

首先让我们来看Go的内存结构。

Go的Runtime会调度Goroutine(G)到逻辑上的Processors(P)上运行。每一个P都有一个线程(M)。我们将在通篇用到这三者(P、G、M)。如果你不熟悉这三者,请阅读这个

每一个P都会被宿主机操作系统分配一定的虚拟内存,这是P所能访问的所有内存。实际上对于虚拟内存的使用被称为Resident Set。这个空间的内存按照如下Go语言的内存数据结构进行管理。

这是一个基于GO内存管理结构的一个简化视角。实际上,GO分割和组织内存的方式是通过pages,可以阅读这篇文章

这种模式与JVM、V8是十分不同的。例如,它并没有分代回收。为什么呢?因为GO使用的是TCMalloc(Thread-Caching Malloc),它是Go自己的内存分配器。

让我们看一看不同之处在于哪里:

Page Heap(mheap)—页堆

这里存储的是Go的动态数据,也就是那些数据大小不能在编译期间被计算的数据。这是GO当中最大的一块内存,也是GC清理的地方。

先前说的Resident Set被分割成8kb一页,这些所有的页被mheap所管理。

大对象(>32Kb)都会被直接分配到mheap上。这些大的内存请求会花费中心锁,所以每次只有一个P的内存申请可以被满足。

mheap将页组装成不同的数据结构,如下:

  • mspan:它是最基础的内存管理单元。其本身是一个双向链表,保存了页的起始地址、跨度类、页的数量。类似TCMalloc,GO也会将内存页分为67种不同的类别(也就是我们刚刚所说的跨度类),跨度类大小从8bytes到32kilobytes,如下图

    每一个span都可以转化为两种不同的对象,一个是带指针的对象,一个是没有指针的对象。这种区分讲有助于减少GC压力,那些不包含指针的对象就不用进行扫描。

  • mcentral:它将不同跨度类的mspan组合到一起。每一个mecentral都包含两个mspan的链表:

    • empty:这个双向链表当中没有空闲的mspan可以被使用。所有的mspan都存储了mcache的对象。当这里面的某个mspan被释放,将会被移动到non-emtpy。
    • non-emtpy:这个双向链表当中有 还可以被使用的mspan。当mcache向mcentral申请内存时,它会将被申请到的mspan移动到empty队列,如果这个mspan没有空余的空间的话。
  • mcache:这个是一个非常有趣的结构。mcache是提供给P使用的,每个P都有一个自己的mcache。他用来存储微、小对象(<32Kb)。它类似于线程栈,但是它仍然是堆内存(mheap)的一部分,用来存储动态数据。对于所有的跨度类来说,mcache都保存着两种mspan:noscan、scan。因为mcache单独和每个P绑定并且每个P同一时间只为一个G服务,所以其不需要加锁。由于不用加锁,它的效率非常高。mcache没有足够的mspan时,它会向mcental申请。

Stack

每一个G都有一个栈。这个栈用来存储像栈帧、局部变量、常量、指向动态数据的指针。它和P的mcache并不相同。

Go内存使用引例

现在我们大概清楚了GO的各种内存结构,让我们通过一个例子来了解当一个程序执行的时候,栈和堆是怎么被使用的。

引例只是一个轻度程序,结构并不严谨,轻喷~

package main

import "fmt"

type Employee struct {
  name   string
  salary int
  sales  int
  bonus  int
}

const BONUS_PERCENTAGE = 10

func getBonusPercentage(salary int) int {
  percentage := (salary * BONUS_PERCENTAGE) / 100
  return percentage
}

func findEmployeeBonus(salary, noOfSales int) int {
  bonusPercentage := getBonusPercentage(salary)
  bonus := bonusPercentage * noOfSales
  return bonus
}

func main() {
  var john = Employee{"John", 5000, 5, 0}
  john.bonus = findEmployeeBonus(john.salary, john.sales)
  fmt.Println(john.bonus)
}

GO语言和其他语言在内存分配领域的最主要区别就是GO会将大量的对象直接分配在栈上。Go提供了逃逸分析的工具,用来寻找那些在编译阶段明明知道生命周期的,但却从栈逃逸到了堆上的对象。编译期间Go的逃逸分析的可以决定那些对象会待在栈上(静态对象),哪些对象可以逃逸到堆上(动态对象)。我们可以在编译Go程序时,使用"-gcflags ‘-m’ "来观察逃逸细节。上面的代码会输出:

❯ go build -gcflags '-m' gc.go
# command-line-arguments
temp/gc.go:14:6: can inline getBonusPercentage
temp/gc.go:19:6: can inline findEmployeeBonus
temp/gc.go:20:39: inlining call to getBonusPercentage
temp/gc.go:27:32: inlining call to findEmployeeBonus
temp/gc.go:27:32: inlining call to getBonusPercentage
temp/gc.go:28:13: inlining call to fmt.Println
temp/gc.go:28:18: john.bonus escapes to heap
temp/gc.go:28:13: io.Writer(os.Stdout) escapes to heap
temp/gc.go:28:13: main []interface {} literal does not escape
<autogenerated>:1: os.(*File).close .this does not escape

可视化步骤如下:

可以看到以下几点内容:

1.main函数构建main栈帧、加载全局变量。

2.每一个函数调用都会被存储为栈帧,压入栈

3.所有的局部变量、返回值、参数都存储在栈帧当中

4.所有的静态值不管是什么类型都会存储在栈上,同样应用与全局变量。

5.所有的动态类型都创建在堆上并且被栈中的指针所引用。<32KB的对象去到mcache上,对全局范围适用。

6.存储静态数据的结构存储在栈上,直到动态数据的值被追加到它身上,此时,它会逃逸到堆上。

7.函数入栈顺序根据调用顺序先进后出。

8.当一个函数返回时,它所代表的栈帧也要从栈上移除。

9.当主函数结束时,堆上的对象没有引用,都会变成孤儿。

总结:正如我们所见,栈是由操作系统自动管理的,而不是由Go本身来管理。因此,我们不必太担心栈。另一方面,堆不是由操作系统自动管理的,因为它是最大的内存空间,并且保存动态数据,所以它可能会呈指数增长,导致我们的程序随着时间的推移耗尽内存。随着时间的推移,它也变得支离破碎,降低了应用程序的速度。这就是垃圾收集的用武之地。

GO内存管理

Go的内存管理包括在需要内存时自动分配,以及在不再需要内存时进行垃圾收集。这是由标准库完成的。与C/C++不同,开发人员不必处理它,Go所做的底层管理得到了很好的优化和高效。

GO内存分配

许多编程语言的采用垃圾收集算法使用分代内存结构来提高收集效率,并通过压缩来减少碎片。Go在这里采用了不同的方法,正如我们之前看到的,Go在内存上的结构完全不同。Go使用线程本地缓存(mcache)来加速小对象分配,并保持扫描(scan)/无扫描(noscan)范围来加速GC。这种结构和过程在很大程度上避免了碎片化,使得在GC过程中不需要压缩。让我们看看这种分配是如何发生的。

Go根据对象的大小决定对象的分配过程,并分为三类:

微对象(大小<16B):使用mcache的微小分配器分配大小小于16字节的对象。这是高效的,在一个16字节的块上完成多个微小的分配。

Alt Text

小对象(大小16B~32KB):在运行G的P的mcache上,在相应的跨度类(mspan)上分配大小在16字节到32KB之间的对象。

Alt Text

在微对象和小对象分配中,如果mspan的列表为空,分配器将从mheap获取用于mspan的页运行。如果mheap是空的或没有足够大的页面运行,那么它会从操作系统分配一组新的页面(至少1MB)。

大对象(大小>32KB):大小大于32KB的对象直接分配到mheap的相应跨度类上。如果mheap是空的或没有足够大的页运行,那么它会从操作系统分配一组新的页(至少1MB)。

Alt Text

上面所有的配图你都可以在这儿看到

GO垃圾回收

现在我们知道了Go如何分配内存,让我们看看它如何自动收集堆内存,这对应用程序的性能非常重要。当程序试图在堆上分配 比剩余可用内存 更多的内存时,我们会遇到内存不足错误(OOM)。管理不当的堆也可能导致内存泄漏。

Go通过垃圾收集管理堆内存。简单来说,它释放了孤立对象使用的内存,即不再直接或间接(通过另一个对象中的引用)从栈中引用的对象,以便为创建新对象腾出空间。

从1.12版开始,Golang使用非分代的并发三色标记和扫描收集器。收集过程大致如下所示,随着版本的变化,我不想详细介绍。然而,如果你对这些感兴趣,那么我推荐你阅读这个链接


当堆分配达到一定百分比(GC百分比)时,垃圾收集器开始工作。

垃圾收集器执行流程大致如下:

启动标记(Stop-The-World):当GC启动时,收集器打开写屏障,以便在下一个并发阶段保持数据完整性。这一步需要一个非常小的暂停,当每个正在运行的Goroutine都会暂停以启用它,然后再继续。

标记(并发):一旦打开写屏障,实际的标记过程将与应用程序并行启动,使用25%的可用CPU容量。在标记完成之前,会回收部分P(25%所对应的数量)来执行标记Goroutine。这是使用专用的goroutine完成的。在这里,GC标记堆中存活的值(即,被任何存活goroutine的栈所引用的对象)。当收集需要更长时间时,流程可能会借用应用程序中的活跃Goroutine来协助标记流程。这叫做Mark Assist。

标记终止(Stop-The-World):标记完成后,所有活动的Goroutine都会暂停,写屏障会关闭,清理任务也会开始。GC也还在这里计算下一个GC目标。完成后,上个阶段被征用的P们将被释放回应用程序。

扫描(并发):一旦完成收集阶段并尝试重新分配,清扫程序将开始从堆中回收未被标记为活跃的内存。被清扫的内存量与分配的内存量同步。

让我们以一个简单的Goroutine来看看这些操作。为了简洁起见,对象的数量保持较小。

1.我们正在观察一个Goroutine,实际的过程会对所有活跃的Goroutine执行此操作。首先打开写屏障。

2.标记过程选择一个GC根对象并将其着色为黑色,并以深度优先的方式遍历其中的指针,它将遇到的每个对象标记为灰色。

3.当它到达noscan范围中的一个对象时,或者当一个对象所有的指针都被扫描完时,它会完成根的搜索,并选取下一个GC根对象。

4.扫描完所有GC根后,它会拾取一个灰色对象,并以类似的方式继续遍历其指针。

5.当写屏障打开时,如果对象的指针发生任何变化,该对象将变为灰色,以便GC重新扫描它。

6.当不再剩下灰色物体时,标记过程完成,写屏障关闭。

7.当分配开始时,将进行清理。

This has some stop-the-world process but it’s generally very fast that it is negligible most of the time. The coloring of objects takes place in the gcmarkBits attribute on the span.

这在一定程度上有STW的过程,但通常速度非常快,在大多数情况下可以忽略不计。对象的着色在span上的gcmarkBits属性中进行。

总结

这篇文章应该给你一个关于Go内存结构和内存管理的概述。这并不是详尽无遗的,还有很多更高级的概念,实现细节在版本之间不断变化。但是对于大多数Go开发人员来说,这一级别的信息就足够了,我希望它能帮助您编写更好的代码,考虑到这些,以获得更高性能的应用程序,记住这些将帮助您避免下一个可能遇到的内存泄漏问题。

我希望你学习这篇文章很开心,请继续关注本系列的下一篇文章。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值