golang 的内存管理
0.1、索引
https://blog.waterflow.link/articles/1663406367769
1、内存管理
内存管理是管理计算机内存的过程,在主存和磁盘之间移动进程以提高系统的整体性能。内存管理的基本要求是提供方法来根据程序的请求动态的将部分内存分配给程序,并在不需要时释放它以供重用。
程序通过将他们的内存划分为执行特定任务的不同部分来管理他们。栈和堆就是这部分中的俩个,他们管理程序的未使用的内存并将其分配给不同类型的数据。当程序不再需要这些内存的时候就会释放他们,供后续使用。
2、经典的内存模型
正在运行的程序将其数据保存在这些逻辑区域之中。操作系统在将逻辑加载到内存时为全局变量和静态变量分配内存,并且在程序结束之前不会释放它。这些值不会被修改。另外俩个区域,堆和栈,更多的是动态分配的变量。在这些区域中,程序根据需要去分配和释放内存。这两个区域之间的区别下面会说到。
文本段
文本段是目标文件或内存中程序的一部分,其中包含可执行指令。文本段一般放在堆或栈的下方,以防止堆栈溢出被覆盖。
初始化数据和未初始化数据段
数据段是程序虚拟地址空间的一部分,其中包含由程序员初始化的全局变量和静态变量。
栈
栈用于静态内存分配,就像数据结构中的栈,遵循后进先出。通常函数和局部变量会在栈上分配,当数据被分配到栈上或是从栈上弹出时,实际上没有任何物理移动,只有保存在栈中的值会被修改。这使得从栈中存储和查询数据的过程非常快,因为不需要查找。 我们可以从它最上面的块中存储和查询数据。 存储在栈上的任何数据都必须是有限且静态的。 这意味着数据的大小在编译时是已知的。 堆的内存管理简单明了,由操作系统完成。 因为栈的大小是有限的,我们可能会在这里遇到堆栈溢出错误。
堆
这里的堆和数据结构中的堆是没有关系的。堆用于**动态内存分配。**栈只允许在顶部进行分配和释放,而程序可以在堆中的任何位置分配或释放内存。程序必须以与其分配相反的顺序将内存返回到栈。但是程序可以以任何顺序将内存返回到堆中。这意味着堆比栈更灵活。指针、数组和大数据结构通常存储在堆中。
存储在堆上的数据必须形成一个足够大的连续块,以使用单个内存块满足请求。此属性增加了堆的复杂性。首先,执行分配操作的代码必须扫描堆,直到找到足够大的连续内存块来满足请求。其次,当内存返回堆时,必须合并相邻的已释放块,以更好地适应未来对大块内存的请求。这种增加的复杂性意味着使用堆管理内存比使用堆栈慢。
堆内存分配方案不提供自动解除分配。我们需要使用垃圾回收器来删除未使用的对象,以便有效地使用内存。
栈和堆的区别
与栈相比,堆更慢,因为查找数据的过程涉及更多。
堆比栈可以存储更多的数据。
堆以动态大小存储数据;栈以静态大小存储数据。
堆在应用程序的线程之间共享。
堆由于其动态特性而更难管理。
当我们谈论内存管理时,我们主要是在谈论管理堆内存。
堆内存分配不是线程安全的,因为存储在此空间中的数据对所有线程都是可访问或可见的。
内存分配的重要性
内存是有限的。如果一个程序继续消耗内存而不释放它,它将耗尽内存并自行崩溃。因此,软件程序不能随心所欲地继续使用内存,它会导致其他程序和进程耗尽内存。由于这一点的重要性,大多数编程语言(包括 Go)都提供了自动内存管理的方法。
3、go 的内存模型
Go 支持自动内存管理,例如自动内存分配和自动垃圾回收,避免了很多隐藏的 bug。
在 Go 中,每个线程都有自己的堆栈。当我们启动一个线程时,我们分配一块内存用作该线程的堆栈。当这块内存不够用时,问题就来了。为了克服这个问题,我们可以增加堆栈的默认大小,但增加堆栈的大小意味着每个线程的大小都会增加。如果我们想使用大量线程,这将非常低效。
另一种选择是单独决定每个线程的堆栈大小。同样,在我们有很多线程的设置中,这将是低效的,因为我们需要弄清楚我们应该为每个堆栈分配多少内存。
Go 的创建者想出的不是给每个 goroutine 一个固定数量的堆栈内存,而是 Go 运行时尝试根据需要为 goroutine 提供所需的堆栈空间。这样我们在创建线程时就不需要考虑堆栈大小了。
goroutine 以 2 kb 的堆栈大小开始,可以根据需要增长和缩小。Go 检查它即将执行的函数所需的堆栈数量是否可用,如果不够用,则调用morestack分配一个新帧,然后才执行该函数。当该函数退出时,它的返回参数被复制回原始堆栈帧,并且任何不需要的堆栈空间都被释放。
堆栈也有上限。如果达到此限制,我们的应用程序将 panic 并中止。
Go 在两个地方分配内存:一个用于动态分配的全局堆和一个用于每个 goroutine 的本地堆栈。Go 与许多垃圾收集语言相比的一个主要区别是,许多对象直接分配在程序堆上。Go 更喜欢在栈上分配。栈分配代价更低,因为它只需要两条 CPU 指令:一条推入栈进行分配,另一条从栈中释放。
不幸的是,并非所有数据都可以使用栈上分配的内存。**栈分配要求可以在编译时确定变量的生命周期和内存占用。**如果无法确定,则在运行时动态分配到堆上。
Go 编译器使用一个称为逃逸分析的过程来查找其生命周期在编译时已知的对象,并将它们分配到栈上而不是在垃圾回收的堆内存中。基本思想是在编译时做垃圾回收的工作。编译器跨代码区域跟踪变量的范围。它使用这些数据来确定哪些变量持有一组检查,以证明它们的生命周期在运行时是完全可知的。如果变量通过了这些检查,则可以在栈上分配值。如果不是,就代表逃逸,并且必须进行堆分配。
内存是在栈上分配还是逃到堆上完全取决于你如何使用内存,而不是你如何声明变量。
可以通过下面的命令查看是否有内存逃逸,go build -gcflags '-m'
。
4、垃圾回收
垃圾回收是自动内存管理的一种形式。垃圾回收器尝试回收由程序分配但不再被引用的内存。
Go 的垃圾回收器是一个非分代并发、三色标记和清理垃圾回收器。
分代垃圾回收器专注于最近分配的对象,因为它假设像临时变量这样的短期对象最常被回收。
Go 编译器更喜欢在栈上分配对象,短期对象通常分配在栈上而不是堆上;这意味着不需要分代 GC。
Go 的垃圾回收分为两个阶段,标记阶段和清除阶段。GC 使用三色算法来分析内存块的使用情况。该算法首先将仍被引用的对象标记为“活跃”,并在下一阶段(扫描)释放不活跃对象的内存。
不用回收垃圾,但是可以减少垃圾
导致垃圾回收代价高主要因素之一是堆上的对象数量。通过优化我们的代码以减少堆上长寿命对象的数量,我们可以最大限度地减少花费在 GC 上的资源,并提高我们的系统性能。
重构结构
在读取数据时,现代计算机 CPU 的内部数据寄存器可以保存和处理 64 位。这称为字长。它通常是 32 位或 64 位的。
当我们不对齐数据以适应字长时,会添加填充以正确对齐内存中的字段,以便下一个字段可以从一个字长倍数的偏移量开始。
当数据自然对齐时,现代 CPU 硬件最有效地执行对内存的读取和写入。Go 编译器使用所需的对齐来确保并排存储的内存可以使用公倍数访问。它的值等于结构中最大字段所需的内存大小。
在 Go 中创建 struct 时,会为其分配一个**连续的内存块。**Go 内存分配器不会针对数据结构对齐进行优化,因此通过重新排列结构的字段,您可以通过降低填充来降低内存使用量。
通常 go 中的类型对应的字节大小如下
例如,下面 User
可以看到 10 个字节就能保存这些属性,但是我们可以看下实际占用了多少字节:
我们可以修改下 User 的结构
看下结果,减少了 8 个字节的长度
诀窍就是根据字段的大小降序排列这些字段,后面的 Age 和 Happy 因为没有超过一个机器字,非配了 8 个字节。所以总共分配了 16 字节。
完整的代码如下:
减少长生命周期对象的数量
与其让对象存在于堆上,不如将它们创建为值而不是按需引用。例如,如果我们需要用户请求中每个项目的一些数据,而不是预先计算并将其存储在一个长期存在的映射中,我们可以基于每个请求计算它以减少堆上的对象数量。
删除指针内的指针
如果我们有一个对象的引用,并且对象本身包含更多的指针,那么这些都被认为是堆上的单个对象,即使它们可能是嵌套的。通过将这些嵌套值更改为非指针,我们可以减少要扫描的对象的数量。
避免不必要的字符串/字节数组分配
由于字符串/字节数组在底层被视为指针,因此每个数组都是堆上的一个对象。如果可能,尝试将它们表示为其他非指针值,例如整数/浮点数、时间。
原文:
https://medium.com/@ali.can/memory-optimization-in-go-23a56544ccc0
评论