Fork me on GitHub

深入理解Java虚拟机-二

前言

莫名奇妙的电脑主板坏了,正好看看书

我们之前了解了Java虚拟机的内存模型后(这与后面的Java内存模型在概念是不一样的),这篇文章来看看Java中对象的存活条件以及垃圾回收算法

对象已死吗

Java中垃圾收集器(这个下一篇会将)在对堆进行回收前,是不是要先去确定的知道堆中哪些对象还是”活着”,而那些已经”死去”,总不可能随意回收对象吧!

引用计数法

很多的教科书判断对象是否存活的算法是这样的:给对象添加一个引用计数器,每当有一个地方引用它时,计数器就加1;当引用失效时,计数器就减1;任何时刻计数器为0的对象就是不可能再被使用的。

虽然引用计数法的实现简单,而且判定效率也很高,在大部分情况下是一个不错的算法。但是,至少主流的Java虚拟机里面没有选用引用计数法来管理内存,其中最主要的原因是它很难解决对象之间相互循环引用的问题。

我们可以参考下面代码,来说明一下循环引用的问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
* 循环引用案列
*/
public class ReferenceCountingGC {
public Object instance = null;

private static final int _1MB = 1024*1024;

private byte[] bigSize = new byte[2*_1MB];

public static void testGC(){
ReferenceCountingGC objA = new ReferenceCountingGC();
ReferenceCountingGC objB = new ReferenceCountingGC();
objA.instance = objB;
objB.instance = objA;

objA = null;
objB = null;

System.gc();
}
}

执行testGC方法:对象objA和objB都有字段instance,赋值令objA.instance = objB及objB.instance = objA;除此之外,这两个对象再无任何引用,实际上这两个对象已经不可能再被访问了,但是它们因为互相引用着对方,导致它们的引用计数都不为0,于是引用计数法无法通知GC收集器回收它们。

可达性分析算法

在主流的程序语言中,都是通过可达性分析算法来判断对象是否存活的。这个算法的基本思路就是:通过一系列被称为”GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到”GC Roots”没有任何引用链时,即不可达,则证明此对象时不可用的。

在Java语言中,可作为GC Roots 的对象包括下面几种:

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象
  • 本地方法栈中JNI(Native方法)引用的对象

Java中的引用

无论是通过引用计数法还是可达性分析算法判断对象是否存活都与”引用”有关。在JDK1.2之前,Java中引用的定义很传统:如果reference类型的数据中存储的数值代表的是另外一块内存的地址,就称为这块内存代表着一个引用。这样就只有引用或者没有引用这两种状态,但是对于描述一些”食之无味,弃之可惜”的对象就显得无能为力。所以在JDK1.2之后就,Java的引用被扩展了,分为:强引用软引用弱引用虚引用

  • 强引用:在程序中普遍存在的,类似我们手动new出来的对象,只要强引用还在,垃圾收集器永远不会回收被引用的对象
  • 软引用:对于程序来说有用但是并非必需的对象。对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收。如果这次回收还没有足够的内存才会抛出内存溢出异常。jdk1.2提供了SoftReference类来实现软引用
  • 弱引用:描述非必需对象,它的强度比软引用要更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。也就是说当垃圾收集进行时,无论当前内存是否足够,都会回收掉被弱引用关联的对象。JDK1.2之后提供了WeakReference来实现弱引用。
  • 虚引用:也称幽林引用或者幻影引用,最弱的一种引用关系一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象的实例。设置虚引用的目的就是这个对象被垃圾收集时收到一个系统通知。JDK1.2之后提供PhantomRference来实现虚引用。

对象生存还是死亡

在可达性分析中当一个对象与 GC Roots 不可达时,也就是没有在 GC Roots 的引用链上,那么在垃圾回收时,也并不是一定会被回收的。真正的要回收一个对象,至少要经历两次标记过程:如果对象在可达性分析之后发现没有与 GC Roots 相连接的引用链,那么会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法,当对象重写了finalize()方法或者finalize()方法已经被虚拟机调用过,虚拟机将这两种情况都视为”没有必要执行”

如果一个对象被认为有必要去执行finalize()方法,那么这个对象将会放置在一个叫做F-Quene的队列之中,并在稍后由一个由虚拟机自动建立的优先级低的Finalizer线程去执行它。这里说的执行仅仅指触发这个方法,但并不承诺会等待它运行结束,这是因为,如果一个对象在finalize()方法中执行缓慢,或者发生了死循环,将会导致F-Quene队列中其他对象处于永久等待,导致整个内存回收系统崩溃

前面说到已经将对象放入F-Quene队列中,接下来会对F-Quen队列中的对象进行第二次小规模的标记,如果对象想要逃脱被回收的命运,那么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
35
36
37
38
39
public class FinalizeEscapeGC{
public static FinalizeEscapeGC SAVE_HOOK=null;
public void isAlive(){
System.out.println("i am still alive");
}
@override
protected void finalize(){//重写了finalize()方法
super.finalize();
System.out.println("finalize method executed!");
FinalizeEscapeGC.SAVE_HOOK=this;
}
public static void main(String []args)throws Throwable{
SAVE_HOOK=new FinalizeEscapeGC();
SAVE_HOOK=null;
System.gc();//在这里拯救自己
Thread.sleep(500);
if(SAVE_HOOK!=null){
SAVE_HOOK.isAlive();
}else{
System.out.println("i am dead");
}


SAVE_HOOK=null;
System.gc();//由于finalize()已经执行过一次 这里不会执行
Thread.sleep(500);
if(SAVE_HOOK!=null){
SAVE_HOOK.isAlive();
}else{
System.out.println("i am dead");
}
}
}
/**
输出:
finalized method executed!
i am still alive
i am dead
*/

在第一次调用System.gc()的时候,触发垃圾回收,在进行垃圾回收时进行第一次标记,发现该对象重写了finalize()方法,将该对象放入到F-Queue中,有jvm开启Finalizer线程去执行队列中对象分finalize()方法,在执行了finalize()方法之后,该对象又被重新链到了引用链上,再进行第二次标记的时候又移出队列,对象复活。在触发第二次System.gc()的时候,由于finalize()方法只能执行一次,对象没有机会进入F-Queue,直接被回收。

方法区(永久代)的回收

永久代的垃圾收集主要回收两部分内容:废弃常量和无用的类。关于废弃常量回收,比如字符串“abc”进入了常量池中,但是当前系统没有任何一个String对象引用”abc”,如果此时发生垃圾回收,而且必要的情况下这个”abc“常量就会被系统清理出常量池。常量池中的其他类(接口),方法,字段的符号引用也是如此。
但是对于无用的类来说,回收一个无用的类条件就要苛刻许多。类需要满足下面3个条件才能算是”无用的类”

  • 该类所有的实例都已经被回收,也就是Java堆中不存在该类的任何实例
  • 加载该类的ClassLoader(类加载器)已经被回收
  • 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法

满足了以上条件,类才有机会被虚拟机回收。

垃圾回收算法

标记-清除法(最基本的)

标记清楚法分为两个阶段

  1. 标记阶段:首先标记出所有需要回收的对象;
  2. 清楚阶段:在标记完成之后统一回收所有被标记的对象;(其标记过程前面说过:引用计数法和可达性分析算法)

缺点

  1. 效率问题,标记和清除两个过程的效率都不高;
  2. 空间问题,标记清楚之后会产生大量的不连续的内存碎片,空间碎片太多可能会导致以后在程序中需要分配大对象时,无法找到足够的空间,而不得不提前出发一次垃圾收集

复制算法(回收新生代 Minor GC)

是为了解决效率问题,它将内存按容量分为大小相等得两块,每次只使用其中一块,当这块内存使用完了,需要回收垃圾的时候,就将这块内存上还存活的对象复制到另一块我们没有用的内存上去,然后垃圾回收就把已使用过的内存空间一次清理掉。

这样每次都在对整个半区进行内存回收,内存分配时也就不考虑内存碎片的情况,只要移动堆顶的指针按顺序分配内存即可。

缺点:将内存缩小为原来的一半,未免太费内存了点。

现在的商业虚拟机都采用这种收集算法来回收新生代,因为新生代中的对象98%都是”朝生夕死”,所以可以不需要按照 1:1 来划分内存空间,而是将内存划分为了一块较大的Eden空间和两块较小的Survivor空间(Eden,From Survivor,To Survivor空间)比例是 8:1:1

回收时,将Eden和Survivor中存活的对象一次性复制到另外一块Survivor空间中,最后清理掉Eden和刚刚使用过的Survivor空间。然而当Survivor空间不够用时怎么办?这个时候就需要通过分配担保机制来让其进入老年代(这个后面会将)

这里可以和后面讲的内存分配策略一起看

标记-整理算法(回收老年代 Major GC)

复制收集算法在对象存活率较高时就要进行较多的复制操作效率就会变低。而老年代一般是经过好多次Minor GC存活下来的对象,比较稳定,采用复制算法效率会很低。

根据老年代的特点,有人提出了另外一种”标记-整理(Mark-Compact)”算法,其过程与标记——清理算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。

分代收集算法

当前商业虚拟机的垃圾收集都采用”分代收集”算法,这种算法并没有什么新的思想,只是根据对象存活周期的不同将内存划分为几块。一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。

新生代:每次垃圾回收就发现有大量的对象死去,只有少量存活,所以选用复制算法

老年代:对象存活率高,没有额外的空间进行分配担保,就必须使用”标记——清理”或者”标记——整理”算法进行回收。

HotSpot的算法实现

上面说了对象存活判定算法和垃圾收集算法,而在HotSpot虚拟机中实现这些算法时,必须对算法的执行效率有严格的考量,才能保证虚拟机高效运行

枚举根节点

从可达性分析中从GC Roots节点找引用链操作为例,可作为GC Roots的节点主要在全局性的引用(例如常量或类静态属性)与执行上下文(栈帧中的本地变量表)中,现在有些应用方法区就数百兆,一一检查这些引用,会耗费很多的时间。

另外,可达性分析对执行时间的敏感还体现在GC停顿上,因为这项分析工作必须在一个确保一致性的快照中进行。一致性是指在整个分析期间整个执行系统看起来就像被冻结在某个时间点上,不可以出现分析过程中对象的引用关系还在不断变化的情况。这点是导致GC进行时必须停掉所有Java执行线程(Stop The World)的其中一个原因。

目前的主流的Java虚拟机使用的是准确式GC,当执行系统停下来的时候,不需要去挨个检查完所有执行上下文和全局的引用位置,虚拟机应该是有办法直接知道哪些地方存放着对象引用。在HotSpot中,使用一组称为OopMap的数据结构来达到这个目的,在类加载完成的时候,HotSpot就把对象内什么偏移量上是什么类型的数据计算出来,在编译过程中也会在特定位置记录下栈和寄存器中哪些位置是引用。这样GC在扫描的时候就可以直接得知这些信息。

安全点

通过OopMap的协助下,HotSpot可以迅速完成GC Roots枚举,但同时也带来一个问题,OopMap内容变化的指令非常多,如果为每一条指令都生成对应的OopMap,那将会需要大量的额外空间,这样GC的空间成本会变得很高。实际上,HotSpot也没有为每条指令都生成OopMap,只是在特定的位置记录了这些信息,这些位置称为安全点(SafePoint),即程序执行并非在所有的地方都能停顿下来进行GC,只有到达安全点时才能暂停。

安全点的选定基本上是以程序”是否具有让程序长时间执行的特征”为标准的。

在GC发生时,还有一个问题,就是如何让所有线程都”跑”到最近的安全点上再停顿下来。这里有两种方案

  • 抢先式中断:不需要线程的执行代码主动配合,在GC发生时,首先把所有线程全部中断,如果发现有线程中断的地方不在安全点上,就恢复线程,让它”跑”到安全点上。(几乎没有虚拟机使用)
  • 主动式中断:当GC需要中断线程的时候,不直接对线程操作,而是在于安全点重合的地方设置一个标志,各个线程执行时主动去轮询这个标志,发现中断标志为真时就自己中断挂起。

安全区域

安全区域是指在一段代码片段中,引用关系不会发生变化。在这个区域中任何地方开始GC都是安全的

在线程执行到安全区域中的代码时,首先标识自己已经进入了安全区域(Safe Region),当在这段时间里JVM要发起GC时,就不用管标识自己为安全区域状态的线程了。在线程要离开安全区域时,它要检查是否完成了根节点的枚举,如果完成了,那线程就继续执行,否则就必须等待直到收到可以安全离开安全区域的信号为止。