Java的垃圾回收机制

本博文对应《深入理解Java虚拟机》第三章的第一部分。

垃圾回收对于java来说,是时时刻刻都在运行的重要机制,因为有了它,程序员才不需要去管理内存。我觉得在垃圾回收这里,有几个问题必须要解决一下:

  1. 什么时候回收内存?很简单的想法:空闲的时候回收。
  2. 什么样的内存需要被回收?当然是不会再被别人使用的内存啦。
  3. 怎么回收呢?这里我个人觉得是维护一张表格,这个表格记录着可以使用的内存,当你回收的时候就相当于把那块内存打上标记而已。

显然,对于那些线程独享的空间(虚拟机栈、方法栈和程序计数器),当这个线程结束的时候,这些内存空间也没有必要保留了,所以对于这些空间的处理是非常容易的。但是堆中的就不一样了,只有在程序运行起来之后,我们才能知道会有哪些对象在使用堆内存。

判断对象的状态

首先有一点是肯定的,当对象还在被使用的时候,是万万不能回收的。

引用计数算法

这里有一个大家都知道的引用计数算法(Reference Counting),就是给对象一个引用计数器,当有人用它就加1,当引用失效的时候就把计数器减1。这样只要引用计数器是0就可以删除这个对象。

那么这个算法怎么样呢?简单么?太简单了。会出问题么?从直觉上似乎也没什么问题。但是其实java并没有使用这个算法,并不是说这个算法不行,因为这个算法也被广泛应用于Python等语言,说明确实是一个好算法。但是这个算法有一个问题,它会遇到“对象之间相互循环引用”的问题。比如A引用B,B引用A,然后就结束不了了。这里有一个不错的例子。

1
2
3
4
5
6
7
8
9
10
11
12
public class ReferenceCounting {
public Object instance = null;

public static void main(String[] args) {
ReferenceCounting objA = new ReferenceCounting();
ReferenceCounting objB = new ReferenceCounting();
objA.instance = objB;
objB.instance = objA;
objA = null;
objB = null;
}
}

所以如果要使用这个算法,需要配合大量额外处理才能正确工作。

可达性分析算法

可达性分析(Reachability Analysis)就是从根(GC roots,注意是复数哦)出发构造一棵树,只要在这棵树上就认为是存活的,否则就认为不是。

具体在做的时候,会选择根节点,然后从根节点出发,对于路过的每个节点都打上标记,意味着这些节点所对应的对象是绝对不可以回收的。遍历完成就可以对那些没打上标记的进行操作了。

这里有个问题,选谁来做根对象呢?之前有讲到过,所有的(姑且认为所有的吧)对象都是在堆中,选择它们肯定是不合适的,所以真实选择的是栈中的对象或者常量区的一些常量来作为根(因为栈消失的话说明这个线程也就不存在了,所以非常合适),具体可以包含以下几个:栈中引用的对象,类静态属性引用的对象,常量引用的对象,native方法引用的对象等。

那么栈中也有不少数据,我怎么找到快速枚举出GC roots呢?

  1. 我遍历栈中所有的对象就行了(慢的很)
  2. 在JIT的时候就使用一种叫OopMap的数据结构记录一下,这样可以知道栈里面那些数据是真实的值,而哪些数据是地址(也就是引用)。

现在已经可以快速得到GC roots了。

引用类型的分析

可以看到上面不管是引用计数算法还是可达性分析算法,都是讲到引用的,而引用要么有要么无,这显然有点不够灵活,所以java对引用的内容进行了扩展,这样可以更有效在内存不够的时候进行择优释放。

强引用(Strong Reference)

就是 Object obj = new Object()这样子的引用叫做强引用。这个是垃圾回收机制绝对不会去回收的。

软引用(Soft Reference)

还有用但是并非必须的对象就是软引用。除非即将要发生内存溢出了,否则垃圾回收是不会去回收它的。

弱引用(Weak Reference)

这个更加弱一点,在下一次GC中就say byebye了。

虚引用(Phantom Reference)

这个引用的唯一目的就是当对象被回收了能够得到一个通知。

自我拯救

首先在java中的Object对象中有一个方法,叫做finalize(),听名字就知道是处理“最后”的一些事情的。在JDK9之后已经被抛弃,如果能不使用请尽量不要使用这个方法。

当一个对象在可达性的那颗树里变成不可达的时候,虚拟机就能识别出这种方法,然后观察它的状态,无非是这么三种状态:

  • 这个对象重写了继承来的finalize()方法,并且这个finalize()方法已经执行过了。
  • 这个对象重写了继承来的finalize()方法,但是这个finalize()方法并没有被执行。
  • 这个对象都没有重写finalize()方法。

显然如果重写过finalize()方法并且还没执行的这种情况,虚拟机会把它放入到F-Queue这个队列中,并且稍后会去保证去执行这个方法,但是并不能保证执行完成,因为在finalize()方法中也可能会遇到死循环之类的。如果在finalize()方法中对象都无法与可达性树上的对象发生关系,那它就真的死了。最后需要确保的一点是,只能通过这种方法自救自己一次,因为任何一个对象的finalize()方法都只会最多被执行一次。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public class FinalizeEscapeGC {
private static FinalizeEscapeGC SAVE_HOOK = null;

@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("finalize method executed!");
FinalizeEscapeGC.SAVE_HOOK = this;
}

private static void selfHelp() {
SAVE_HOOK = null;
System.gc();

//finalize()方法优先级比较低,延时确保触发
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}

if (SAVE_HOOK == null) {
System.out.println("SAVE_HOOK is dead!");
} else {
System.out.println("SAVE_HOOK is alive!");
}
}

public static void main(String[] args) {
SAVE_HOOK = new FinalizeEscapeGC();
selfHelp();
selfHelp();
}
}

通过在finalize()方法中,把自己(this)赋值给一个静态变量,成功的躲避了第一次的垃圾回收机制;但是第二次是无论如何都躲不掉了。这主要是因为系统保证finalize()方法只会被执行一次,这导致了第二次自救的时候,finalize()方法根本就没有执行。最后需要注意的是,刚才也说了虚拟机并不保证finalize()方法能被执行完,所以其实如果你真的要释放资源,请使用try/catch/finally释放,不要用这个finalize()方法。

方法区的回收

方法区主要就回收两种东西,一个叫“废弃常量”,还有一个叫“无用的类”

废弃常量

这个比如我有一个String,它的值是123,那么当没有任何对象引用它的时候,它就会在下一次垃圾回收中面临被清理出常量池的可能了。

无用的类

必须同时满足下面的三个条件,才能说一个类是无用的类:

  • 该类在内存中已经没有任何实例了。
  • 该类的类对象没有在任何地方被引用,就是你没办法通过反射来访问它了。
  • 加载这个类的ClassLoader被回收了——这点在实际中几乎是不可能的。

虚拟机对类比较偏心,就算一个类是无用的,也不一定会去回收它(但是对象只要不使用了就必然会去回收)。只有当大量使用反射、动态代理或者是CGLib等字节码框架的时候,才需要去回收来避免方法区压力过大。

垃圾回收机制简介

当代的垃圾收集器,基本都遵循分代收集的理论:

  1. 绝大多数对象都是朝生夕灭的
  2. 熬过越多次的垃圾收集过程,那么这个对象就越难以消亡
  3. 老对象引用新对象,或者新对象引用老对象仅占极少数。

标记-清除算法

看名字就知道了。缺点很明显:效率不高而且会造成内存空间不连续(不连续会导致多次触发垃圾回收机制)。

标记-复制算法

把内存一分为二,每次只用其中的一块。当进行垃圾回收的时候,把存活的对象搬动到另外一半的内存上,然后对整个半块进行清理。缺点也很明显,就是每次只能用其中的一半内存。

那能不能改进一下呢?统计表明,新生代中绝大部分的对象存活时间很短(IBM统计的结果是98%的对象都熬不过第一轮收集),所以只需要合理分配空间(而不是上面粗暴的1:1)就可以有效解决复制算法的缺陷。

具体做法是将内存划分为三块,按照8:1:1的比例,分别叫做Eden空间(Eden:伊甸园,很浪漫啊)和两个小的Survivor空间。然后每次都用Eden空间和其中的一块Survivor空间,然后回收的时候搬动到另外一块Suivivor空间里。注意因为绝大部分的对象生存时间都很短,所以就算Eden+一块Survivor的空间占比是90%,清理之后还是能够放到另外一块Survivor里面的。那么,要是真的放不下怎么办,那不是炸了么?解决办法就是找老年代帮忙呗,临时存放一下下啦。

标记-整理算法

比之前的标记-清除算法非常相似。就是把标记的那些内存往一端移动,这样就有大段连续的空间了呀。那为什么标记-清除算法不采取移动对象这种操作呢?因为移动对象的话,对象在内存中的地址就变了,就需要去修改引用里面的内存地址,这一操作也是耗时的。但是如果你不移动,确实不用去修改引用的地址,但是之后就没有连续的空间可用了;具体孰优孰劣,看实际应用场景决定吧。

HotSpot虚拟机的算法实现

这部分如果看不懂,可以先行跳过。

根节点枚举

之前讲到可达性分析是构造一棵树,而GC Roots非常多,以它们作为起点构建树,想要高效是非常困难的一件。而且还有一点是,当执行这个可达性分析的时候,整个系统都必须停下来,因为你不能一边分析一边再对树进行操作。

在HotSpot中,使用的是OopMap这个数据结构来完成。当类加载完成,Hotspot就会计算出对象内各个偏移位置的数据类型是什么,这样可以大大缩短获取GC Roots的时间。

安全点

之前提到用的是OopMap这个结构,但是能够导致这个结构发生变化的指令太多太多了,你要是给每个指令都生成OopMap,那消耗的额外空间真的受不了。所以需要程序到达一定的地方,这些地方称作“安全点”,到了安全点才能够(下车)GC。怎么选择安全点呢?就是让那些能够让指令长时间执行的指令产生安全点。

之前说了GC的时候必须要让整个系统都停下来,所以最好的情况是所有线程都刚好在安全点,这里有两种实现方法:

  1. 当GC发生的时候,让那些还没到安全点的程序继续运行到安全点(现在几乎没人采用)
  2. 发生GC的时候并不暂停系统,而是在安全点设置标志,当程序遇到这个标志的时候自己停下来。

安全区域

现在已经能够确保所有运行的线程,在遇到GC的时候都能够自己走到安全点了,但是如果这个线程在睡眠呢…所以虚拟机真正的做法是找到代码段中确保引用关系不会发生变化的部分,并将其标记为Safe Region。当线程执行到Safe Region的代码时,告诉JVM。这样JVM就可以随时做GC。当线程要离开Safe Region的时候,它需要检查系统是否完成GC roots的枚举或者是否完成了GC的整个过程,如果没有则需要等待,直到JVM告诉它可以离开了。

记忆集

为了能够解决跨代的垃圾收集,有了Remembered Set的数据结构,记忆集是一种用于记录从非收集区域指向收集区域的指针集合的数据结构。卡表则是记忆集的一种具体实现,在Hotspot中是一个字节数组,数组中的每一个元素都对应了内存区域中的一块内存块,被称为“卡页”(Card Page),卡页是512字节,只要这512字节中有一个对象存在跨代,那么就说数组中的这个元素脏掉了,会一并加入到GC Roots中扫描。

写屏障

上面提到了变脏的问题,就是当别的分区的对象引用了本区域的对象的时候就应该变脏。那么谁来做,怎么做呢?看不太懂了,暂时放一波。

并发的可达性分析

之前已经通过OopMap能够加速GC Roots的获取了,但是要遍历一整棵树,速度还是非常重要的,因为可以看到垃圾收集算法中都带有“标记”二字,只要你标记做的快,那么这三种算法的效率都会高,反之亦然。所以从GC Roots开始构造树的过程一定要快。