动态内存
内存管理管理的是动态内存,比如C语言中用malloc分配的内存。
自动内存管理(垃圾回收) :
- 由程序语言的运行时系统管理动态内存,避免手动内存管理,专注于实现业务逻辑。
- 保证内存使用的正确性和安全性: double-free problem(重复释放), use-after-free problem(释放后使用)
三个任务
- 为新对象分配空间
- 找到存活对象
- 回收死亡对象的内存空间
相关概念
Mutator: 业务线程,分配新对象,修改对象指向关系
Collector: GC 线程,找到存活对象,回收死亡对象的内存空间
Serial GC: 只有一个 collector
Parallel GC: 支持多个 collectors 同时回收的 GC 算法
Concurrent GC: mutator(s) 和 collector(s) 可以同时执行
如图所示,当Mutators需要回收内存时,Serial GC必须暂停所有的Mutator,用一个Collector去回收内存;Parallel GC支持多个Collector同时回收,性能比Serial GC高一些;当Concurrent GC需要回收内存时,不需要显式地暂停程序,可以把需要进行垃圾回收的Mutator暂停,串行执行Collactor,其他Mutator继续执行。
Concurrent GC的挑战
由于程序不停止运行,在标记过程中可能有新的对象加入,这个对象也应该标记为存活对象。所以Collectors必须感知对象指向关系的改变。
GC算法的评价
安全性 (Safety):不能回收存活的对象(基本要求)
吞吐率(Throughput):1-GC 时间/程序执行总时间(花在 GC 上的时间越短越好,即吞吐率越高越好)
暂停时间 (Pause time): stop the world (STW)(业务是否感知)
内存开销 (Space overhead):GC 元数据开销
常见垃圾回收算法
追踪垃圾回收(Tracing garbage collection)
引用计数(Reference counting)
追踪垃圾回收
追踪垃圾回收的3个步骤
首先需要明确:当指针指向关系不可达的对象时,该对象应该被回收
回收可以分为3个步骤:
- 标记根对象,即静态变量、全局变量、常量、线程栈等。这些变量作用于全局,在整个程序运行过程中都可能用到。
- 标记: 找到可达对象。从根对象出发,找到所有可达对象。
- 清理: 所有不可达对象。策略有:
- 将存活对象复制到另外的内存空间(Copying GC)
- 将死亡对象的内存标记为“可分配”(Mark-sweep GC),用链表保存可分配内存
- 移动并整理存活对象(Mark-compact GC),即内存空间压缩
根据对象的生命周期,使用不同的标记和清理策略。
分代GC(Generational GC)
分代假说 (Generational hypothesis): most objects die young,大部分对象很快就死了。
根据对象经历的GC次数,我们可以将其像划分年龄一样分为Young Generation和old Generation。针对年轻和老年的对象,制定不同的 GC 策略,降低整体内存管理的开销。
可以认为:年轻代存活数目少,可以用Copying GC处理,开销更少,吞吐率高;而年老代更可能存活。所以用Mark-sweep GC,避免反复复制。碎片多时进行压缩。
引用计数
每个对象都有一个与之关联的引用数目,对象存活的条件:当且仅当引用数大于 0
如图所示,左上角绿圈内的对象,只有一个对象在引用它,所以计数为1;左下角红圈内的对象已经没有其他对象对其引用,计数为0,所以它和被它引用的对象都应被回收。
引用计数的优点是内存管理的操作被平摊到程序执行过程中,内存管理不需要了解 runtime 的实现细节,但是它也有缺点。
引用计数的缺点
- 维护引用计数的开销较大: 因为可能会有多个线程操纵同一个对象,所以需要用原子操作保证对引用计数操作的原子性和可见性
- 无法回收环形数据结构。当一个环上的所有对象都没被环外对象引用时,这个环中的对象应该被回收。但是它们之间却互相引用,计数不为0。
- 内存开销: 每个对象都引入的额外内存空间存储引用数目
- 回收内存时依然可能引发暂停