本文只是简单的介绍一下垃圾回收的一些内容,当然也会有适当的讨论,但不会太深入。如果有兴趣可以看这本书《THE GARBAGE COLLECTION HANDBOOK:The Art of Automatic Memory Management》。这本书大致过目了一下,前半部分写的相当有漫谈的感觉,也有可能是翻译的原因,需要一定的知识量。
显示内存释放
比如,C语言中的 free,C++中的delete操作符,需要手动的调用。
显示内存释放存在2个方面的问题:
- 开发者可能过早的释放依然在引用的对象
- 开发者可能忘记释放对象
这两种错误在并发编程的场景导致的问题更为严重。
但是,这是不得不面对的,因为对于C/C++这种语言来说,给你一个 uint64_t 类型的值,你根本无法判断这个值到底就只是一个单纯的 uint64_t 呢?还是一个指针?如果它是一个指针,那么它指向的内存块就不能被回收。如下:
arduino
uint64_t create(size_t size)
{
return (uint64_t) malloc(size);
}
代码中,我们分配了一块内存,但是将它的地址转成了一个 uint64_t 的类型返回出去了。对于这样的内存块,是没有一个"显示的指针"指向它的。所以,它能被回收吗?
像这种一个 uint64_t 打天下的代码而且还不少。
自动动态内存管理
GC 可以将未被任何可达对象引用的对象回收,它引入了一个明确的概念,就是垃圾。这种管理机制,可以解决大部分显示内存释放中出现的问题。
Mark-sweep garbage collection
标记-清扫,标记-复制,标记-整理,引用计数是4种最基本的垃圾回收策略。大多数回收器可能会将其进行缝合,例如,在堆中的某一区域使用一种回收策略,在另一个区域使用另一种回收策略。
在后面讨论这4种回收策略的时候,我们假设当gc线程运行的时候,其他线程都处于停止(stop the world)状态。
我们先来看标记-清扫算法。它分为两步,第一步是标记,标记并不是直接检测垃圾本身,而是先确定所有存活对象,然后反过来判定其他对象都是垃圾。
首先,我们需要了解一个概念,叫做赋值器。GC 我们知道是垃圾回收器,赋值器就是与之对应的,用来分配新对象,并且修改对象的引用关系,可以理解为 "=" 后面的幕后黑手。
有一些指针是赋值器可以直接访问的,我们称之为 GCRoots。GCRoot 指向堆区的对象,借助GCRoots 我们可以访问整个堆区分配的对象。
以 Linux x86-64 为例,看下面的图:
GCRoots 可能会出现在 .data 区域,stack 区域,还有就是 reg(寄存器中)。泛化一下,就是全局/静态存储,线程本地储存(线程栈)等。
有了 GCRoots,我们就可以画下面这张图了:
可以看到,GCRoots 与 Heap 中的对象构成了一张有向图,对于图的遍历,我们有两种算法,一种是DFS,另一种是BFS。我们采用 DFS 来遍历图。
首先,定义两个类:
csharp
// GcRoot 指向堆里面对象的引用
class GcRoot {
private RefObj ref;
public RefObj getRef() {
return ref;
}
}
// 堆对象里面有指向其他堆对象的引用
class RefObj {
private List<RefObj> refs;
private boolean marked;
public List<RefObj> getRefs() {
return refs;
}
public void mark() {
marked = true;
}
public boolean isMarked() {
return marked;
}
}
然后,我们定义一个 GC 类,里面有一个 gcRoots 变量已经收集了所有的 gcRoot:
scss
class GC {
private List<GcRoot> gcRoots;
private void dfs(RefObj obj) {
if(obj.isMarked()) {
return;
}
for (int i = 0; i < obj.getRefs().size(); i++) {
dfs(obj.getRefs().get(i));
}
obj.mark();
}
public void markFromRoots() {
for(int i=0; i<gcRoots.size(); ++i) {
RefObj refObj = gcRoots.get(i).getRef();
if(!refObj.isMarked()) {
dfs(refObj);
}
}
}
}
上面的 markFromRoots 算法其实很简单,真正需要了解的是 obj.mark() 方法里面到底做了什么。
在之前的文章里面。我们做完了 csapp 的 malloc lab,实现了一个简易的 malloc 与 free 方法。回忆一下一个最简单的 heap block 的结构:
由于 block size 是8字节对齐,所以 Header 的最后3位永远为0。最后一位用来标记该 block 是 free 的还是 allocated 的,还有2个 bit,我们可以将其中的一个 bit 用来标记该 block 是不是被 mark 过的了。当然也可以采用其他的方式,比如,额外的使用一张表来记录。
mark 过程结束之后,每个可达对象就已经被标记了,其他的都是垃圾。举个例子,假设我们分配了 5个 heap block,如下:
mark 完成后,只有 1 3 5 得到了标记:
sweep 的工作就是将 heap block 2 与 5 释放掉(将 allocated 标记改为 free),最后得到:
可以看出,原来完整的 heap ,多出了很多洞,这个叫做内存碎片,这就给分配器带来了很大的考验。
有一个3色抽象的概念。这个玩意在我们现在的例子中说出来觉得是废话,但是它在 gc 线程与用户线程并发执行时十分有用。所以这里不介绍。
Mark-compact garbage collection
内存碎片化是非移动式回收器无法解决的问题之一。它面临的一个较为尴尬的问题就是,尽管有可用空间,但是却无法找到一个连续的内存用来分配给一个较大的对象。
所以,我们需要对堆进行整理,我们先讨论原地整理策略。
标记-整理也是分为两步,第一步是标记,这个步骤上面我们已经讨论过了,我们讨论整理阶段。
整理有很多种,现在的 gc 都是使用的滑动顺序,也就是保持原有的对象顺序不变,以免打破其局部性。实现滑动顺序也有很多种算法,我们只介绍一个就是 Lisp 2算法。
算法很简单,就是使用2个指针,一个指针 free 指向空闲位置,一个指针 scan 指向存活的对象。有点类似 leetcode 里面的一个题目,就是在一次遍历中去除数组中的重复元素。
算法过程描述如下:
首先,scan 与 free 指针都指向 block 1,它是被 mark 了的。所以我们将 free 与 scan 指针的往后移动 sizeof(block) 的大小。
此时,scan 与 free 指针指向 block 2,它是没有被 mark,说明这块地方可以放东西,那么我们只将 scan 指针移动 sizeof(block) 的大小。
此时,scan 指向 block 3,free 指向 block 2,block 3 被 mark 了,由于 scan 与 free 不相等,那么我们就将 scan 处的 block copy 到 free 指向的位置,然后将 free 往后移动 copy 的 block 的大小。以次类推。
但是移动对象,并不仅仅是 copy bytes 这么简单,举个例子:
csharp
void test() {
A a = new A();
// do some work with a
}
假设我们需要移动上面代码中分配的对象 a,移动后,a变量还是指向原来的地址,那么后面对 a 的使用就会出错,所以我们需要更新栈上的引用(实际上 JVM 有更好的处理)。第二个问题就是对象 A 里面还可能有对象 B 的引用,如果对象 B 也移动了,那么也要更新这个引用:
所以,我们在整理heap的时候,还需要更新对象里面的引用。
更新对象的引用需要一个辅助结构,我们给对象增加一个 forwardingAddress 字段(下图红色块),这个对象储存了它将会被移动到的地址。
上面图的描述没有考虑到 forwardingAddress 字段的更新,要更新 forwardingAddress 字段,需要遍历 heap 堆3次。
第一次,记录所有mark对象的 forwardingAddress 字段。
第二次,更新 mark 对象的引用值。
第三次,移动 mark 对象。
写一个伪代码:
scss
public void compact() {
computeLocations(start, end, start);
updateReferences(start, end);
relocate(start, end);
}
public void computeLocations(Address start, Address end, Address copyTo) {
Address scan = start;
Address free = copyTo;
while(scan < end) {
if(isMarked(scan)) {
RefObj obj = convert(scan);
obj.forwardingAddress = free;
free += size(scan);
}
scan += size(scan);
}
}
public void updateReferences(Address start, Address end) {
while(scan < end) {
if(isMarked(scan)){
RefObj obj = convert(scan);
List<RefObj> refs = obj.getRefs();
for (int i = 0; i <refs.size() ; i++) {
RefObj ref = refs.get(i);
obj.setRefValue(ref, ref.forwardingAddress);
}
}
}
}
public void relocate(Address start, Address end) {
Address scan = start;
while(scan < end) {
if(isMarked(scan)) {
Address dest = scan.forwardingAddress;
move(scan, dest);
unsetMarked(dest);
}
scan = scan + size(scan);
}
}
标记-整理算法需要多次遍历堆,所以其吞吐量较差,但是可以进行算法优化。所有有一种缝合方案,就是先使用标记-清扫算法,当内存碎片化达到一定程度的时候,切换为标记-整理算法。
该算法还有一个蛋疼的问题,就是对于长寿对象的处理,这里我们后面再讨论。
Copying garbage collection
基本的复制式回收会将堆划分为两个大小相等的半区。一个叫做 fromspace,一个叫 tospace。
我们使用一个 free 指针来描述新对象应该在哪里分配空间,所以当一个对象创建时,我们只需要往后移动 free 指针即可。当 free 指针的大小大于可用空间的大小时,就需要进行 gc 了。
如下图:
当第 6 个对象要分配的时候,发现 free + size(6) > end,这个时候就要进行 gc。
gc 的过程就是先 mark,再 copy,如下图:
我们将 mark 的对象,从 fromspace 拷贝到 tospace ,再互换 fromspace 与 tospace 的角色,然后就可以再次尝试分配对象 6 了。
标记-复制其实也是一种整理。只不过有一点不一样的就是,它不需要 forwardingAddress 字段,而且只需要对heap遍历一次。
同样的,我们写一个伪代码:
scss
public void collect() {
for (GcRoot gcRoot : gcRoots) {
process(gcRoot, gcRoot.getRef());
workList.add(gcRoot.getRef());
}
while(!workList.isEmpty()) {
RefObj obj = remove(workList);
for (int i = 0; i < obj.getRefs().size(); i++) {
forward(obj, obj.getRefs().get(i));
}
}
flip();
}
public void forward(RefObj ref, RefObj target) {
if(target.forwardingAddress == null) {
copy(ref, target);
return;
}
ref.setRefValue(target, target.forwardingAddress);
}
public void copy(RefObj ref, RefObj target) {
Address toRef = free;
free += size(target);
move(target, toRef);
ref.setRefValue(target, toRef);
workList.add(toRef);
}
Reference counting
引用计数算法之所以能够成为一种有竞争力的自动内存管理策略,有以下几个原因:
- 内存管理开销分摊到了程序运行过程中,同时对象一旦成为垃圾会被立即回收
- 算法的实现无须系统支持,特别是无需确定 gcRoots。
我们看一个代码:
less
class A {}
class B extends A{}
A a = new A(); //
B b = new B();
a = b;
我们看 a =b 这个简单的复制语句,由于我们采用了引用计数法,那么这个复制语句就会产生两个副作用:
- 指向对象 A 的指针减少了 1 (如果 A 里面还有别的引用,那么这些引用也要递归的减少 1)
- 指向对象 B 的指针增加了 1
所以,引用计数给赋值器带来了额外的开销,它不适合大容量,高性能内存管理器。
写个伪代码:
csharp
class RefCountObj {
public int count;
public void increase(){
count++;
}
public void decrease() {
this.count--;
if(this.count == 0) {
for (RefCountObj obj : this.getRefs()) {
obj.decrease();
}
}
}
public List<RefCountObj> getRefs() {
return null;
}
}
引用计数的伪代码写起来非常的简单,但是它有一个非常蛋疼的缺点,就是无法处理环形引用,当然也有算法可以搞定这个问题,非常的复杂就不说了。
Generational garbage collection
垃圾回收的主要目的是找到已经死亡的对象并回收它们占用的空间。在对象数据量较少的情况下,整理/复制式垃圾回收器能够最高效的进行回收。但长寿对象的存在会影响回收效率,因为回收器不是反复的对其进行标记,就是反复的把它们从一个半区搬到另一个半区。
分代垃圾回收是对上述算法的进一步改进。分代垃圾回收算法依照对象的寿命将其划分为不同的分代。
对象刚开始创建时被分配到年轻代,当它存活的时间够长之后,会被提升到年老代。有些回收器会使用年龄记录的方式,比如经过一次gc后,存活下来的年龄加一,当年龄达到一定程度后,提升到老年代。
这样的好处,就是我们每次 gc 的时候,只需要处理年轻代的区域以提升效率。
看下面的图:
我们在对年轻代进行 gc 的时候,只需要考虑部分指向年轻代的 gcRoots。但是有一个问题,就如上图 S 指向 P 的指针,如果只考虑部分 gcRoots,那么 P 就被当作垃圾了,这肯定不行,所以我们还要维护一个集合,集合里面放的是分代之间的指针集合。我们把这个集合也当作 gcRoots,这样就可以正确的进行年轻代的 gc 了。
但是这种做法又会导致一个问题,就是如果年老代的对象出现了垃圾,只要年老代不触发 gc,那么我们就会一直维护着一些无用的分代指针,从而导致其指向对象也不会回收。但是其实也还好,毕竟年老代也会触发gc。
还有一个问题,就是大对象,对于大对象的处理,我们在年轻代里面复制起来需要花费较大的力气,仔细想一下,我们就不应该让大对象与小对象放在一起,所以,heap 可能分为 3 个区域:
年轻代与年老代可以采用 标记-整理 或者 标记-复制算法。大对象区域就需要采用 标记-清扫 或者引用计数法。
我们最后再额外讨论一个 native object 的问题。
比如,在 java 里面,当我们使用 jni 创建了对象:
如果,这段 heap 区域采用的是 mark-compact 算法,那么由于我们上面讲过垃圾回收器无法区分一个 uint64_t 类型是一个值还是一致指针,也就没法去改引用,所以 C/C++ 的内存我们不能移动它。这就导致了 heap 上会出现很多洞。
所以在实现这种 manage 与 unmanage 交互的垃圾回收器时会相当的蛋疼。
总结
垃圾回收器是一个非常复杂的东西,会面临很多问题,但是也很有意思,就像《THE GARBAGE COLLECTION HANDBOOK:The Art of Automatic Memory Management》这本书的名字,自动管理的艺术,里面会有很多经验性,工程性的东西。