了解内存管理~
前言:
本节课主要介绍了内存管理知识与自动内存管理机制,并对目前 Go 内存管理过程中存在的问题提出了解决方案,同时结合了上次课程学习的《Go 语言性能优化》相关知识,提供可行性的优化建议 …
自动内存管理
Go 语言作为相对新型的语言,自动内存管理肯定是少不了的了,和 Java 一样,Go 语言也有垃圾回收机制,可以实现自动内存管理,下面让我们来了解一些自动内存管理的相关知识。
🎈自动内存管理的概念:
程序运行时的内存分配策略有很多种:单一连续分配、固定分区分配、动态分区分配,现代的程序一般采用动态分区分配,会根据程序在运行时的需求动态地分配内存(malloc()
),将动态分配地内存称作动态内存。
自动内存管理是指由程序语言地运行时系统管理动态内存。
- 避免手动内存管理,专注于实现业务逻辑;
- 保证内存使用的正确性和安全性。
自动内存管理也可以叫垃圾回收(GC),主要负责:
- 为新对象分配空间;
- 找到存活对象;
- 回收死亡对象的内存空间。
GC 相关概念
- Mutator:业务线程,分配新对象,修改对象指向关系;
- Collector:GC 线程,找到存活对象,回收死亡对象的内存空间;
- Serial GC:只有一个 Collector 的 GC 算法;
- Parallel GC:支持多个 Collectors 同时回收的 GC 算法;
- Concurrent GC:Mutator(s) 和 Collector(s) 可以同时执行的 GC 算法。
🎯评价 GC 算法的几大指标:
- 安全性:不能回收存活的对象;
- 吞吐率:用来衡量 GC 时间的占比,其值为
1 - GC 时间 / 程序执行总时
; - 暂停时间:GC 可能会导致暂停业务,一般希望暂停时间越短越好;
- 内存开销:GC 元数据开销。
在学习 Go 内存管理之前,我们先来了解一下垃圾回收的几种实现方案~
追踪垃圾回收
追踪垃圾回收是 GC 策略的其中一种,属于间接式的回收策略,这种策略不会直接寻找垃圾本身,而是先去找到存活的对象,然后反过来判断其余的对象应该被回收掉。
✔追踪垃圾回收的步骤:
- 标记根对象:静态变量、全局变量、常量、线程栈等;
- 标记:从根对象出发,找到所有可达对象;
- 清理:回收掉所有不可达对象。
- 将存活对象复制到另外的内存空间(Copying GC)
- 将死亡对象的内存标记为“可分配”(Mark-sweep GC)
- 移动并整理存活对象(Mark-compact GC)
(根据对象的生命周期,会使用不同的标记和清理策略)
分代 GC(Generational GC)
分代 GC 是基于分代假说的内存管理策略,是一种比较常见的策略。
弱分代假说认为绝大多数对象都是朝生夕灭的,生命很短;强分代假说认为活得越久也就是熬过越多次垃圾收集过程的对象就越难以消亡,这两个分代假说共同奠定了垃圾回收的设计原则。
分代 GC 策略会给每个对象一个年龄(经历过 GC 的次数),将对象分成老年代和年轻代,把不同年龄的对象放在堆内存(Heap)的不同区域,为其制定不同的 GC 策略,从而降低整体内存管理的开销。
-
年轻代(Young generation)
- 常规的对象分配
- 由于存活对象很少,可以采用 copying collection 策略
- GC 吞吐率很高
-
老年代(Old generation)
- 对象趋向于一直存活,反复复制开销较大
- 可以采用 mark-sweep collection 策略
引用计数(ARC)
引用计数是另一种 GC 策略,在这种策略下,每个对象都有一个与之关联的引用数目,只有引用数大于 0 的对象才存活。
⭐引用计数的优点:
- 内存管理的操作被平摊到程序的执行过程中;
- 内存管理不需要了解 runtime 的实现细节。
👎引用计数的缺点:
- 维护引用计数的开销较大:通过原子操作保证对引用计数操作的原子性和可见性;
- 无法回收环形数据结构(循环引用) —— weak reference;
- 额外的内存开销:每个对象都引入额外的内存空间存储引用数目;
- 回收内存时依然可能引发暂停。
✨引用计数 vs. 其他策略:
时机 | 性能 | 延迟 | |
---|---|---|---|
ARC | 引用计数为 0 马上回收 | 快 | 小 |
other GC | 定时扫描清理 | 慢 | 大 |
Go 内存分配
Go 的内存分配基于两个思想:分块 & 缓存。
分块
Go 会提前将内存分块,给对象分配内存时会找一个尺寸最接近的块分配。
🎯目标: 为对象在堆(heap)上分配内存。
✔内存分块:
- 调用系统调用
mmap()
向 OS 申请一大块内存,例如 4MB; - 先将内存划分成大块,例如 8KB,称作 mspan;
- noscan mspan:分配不包含指针的对象(GC 不需要扫描)
- scan mspan:分配包含指针的对象(GC 需要扫描)
- 再将大块继续划分成特定大小的小块,用于对象分配。
🍬对象分配: 根据对象的大小,选择最合适的块返回。
缓存(cache)
Go 的内存分配器为内存做了多级不同的缓存,从而加快整体的内存分配速度。
线程缓存分配(Thread-Caching Malloc,TCMalloc)是用于分配内存的机制,Go 语言的内存分配器就借鉴了 TCMalloc 的设计实现高速的内存分配,它的核心理念是使用多级缓存将对象根据大小分类,并按照类别实施不同的分配策略。
☕分配过程:
- 分配内存是指给 goroutine(简称为 g) 上的一段代码分配内存,g 会先绑定在 p(Go 线程)上;
- 每个 p 包含一个 mcache 用于快速分配,用于为绑定与 p 上的 g 分配对象;
- mcache 管理一组 mspan;
- 当 mcache 中的 mspan 分配完毕,向 mcentral 申请带有未分配块的 mspan;
- 当 mspan 中没有分配的对象,mspan 会被缓存在 mcentral 中,而不是立刻释放并归还给 OS。
Go 语言中的 GC 策略
了解了这么多内存管理的相关知识和垃圾回收策略,下面来一起看看 Golang 中采用的垃圾回收策略。
Go 语言的垃圾回收总体采用的是经典的 mark-sweep 算法,Go 语言的版本不同,采用的 GC 策略可能是不一样的。Go 1.5 正在实现的垃圾回收器是 “非分代的、非移动的、并发的、三色的标记清除垃圾收集器”。
Go 1.5 使用的三色标记法可以看成 mark-sweep 的增强版,这种方法的 mark 操作是可以渐进执行的而不需每次都扫描整个内存空间,,可以减少 stop the world(STW)的时间;分代 GC 策略在上文已经提及,但是 Go 1.5 还暂时没有采用,Go 官方表示会在 1.6 版本的 GC 优化中考虑~
随着 Golang 的推陈出新,Go 的垃圾回收性能一直在提升,但目前距离 Java 语言的 JVM 使用的成熟的垃圾回收系统还有一定差距。
Go 内存管理优化
Go 内存分配机制中的问题
对象分配是非常高频的操作,每秒分配 GB 级别的内存,小对象占比又比较高,加上分配路径长,这就导致 Go 内存分配比较耗时。
- 分配路径长:
g->m->p->mcache->mspan->memory block->return pointer
pprof()
:对象分配的函数是最频繁调用的函数之一
字节的优化方案:Balanced GC
Balanced GC 是字节跳动官方推出的针对 Go SDK 里面对象分配做的优化方案。
- 每个 g 都绑定一大块内存(1KB),称作 goroutine allocation buffer(GAB);
- GAB 用于 noscan 类型的小对象分配:
< 128 B
; - 使用三个指针维护 GAB:base,end,top;
- Bump pointer(指针碰撞)风格对象分配:无须和其他分配请求互斥,分配动作简单高效。
// 一次对象分配:
if top + size <= end {
addr := top
top += size
return addr
}
💭技术细节:
GAB 对于 Go 内存管理来说是一个大对象。
- 本质:将多个小对象的分配合并成一次大对象的分配。
- 问题:GAB 的对象分配方式会导致内存被延迟释放。
- 方案:移动 GAB 中存活的对象(用 copying GC 的算法管理小对象)
- 当 GAB 总大小超过一定阈值时,将 GAB 中存活的对象复制到另外分配的 GAB 中;
- 原先的 GAB 可以释放,避免内存泄漏。
Balanced GC 作为语言层面的性能优化,开启以后可以减少 CPU 负荷,降低核心接口时延,获得不错的性能收益。