JVM(四):GC算法

1、对象与引用

为了解决“哪些内存需要回收”的问题,需要确定哪些对象是“有用不可回收”的,而哪些对象是“无用可回收”的。通常存在以下两种判断算法。

引用计数法

算法原理:给对象添加一个引用计数器,每当一个地方引用它时,计数器值就加1;每当一个引用失效时,计数器值就减1;当引用计数为0时,表示该对象不再使用,可以回收。

应用:微软COM/ActionScript3/Python

优势:实现简单,判定效率高,通常情况下是个不错的算法。

不足:很难解决循环引用的问题。A-->B-->C,ABC引用计数都是1,都不会被回收。

可达性分析算法

算法原理:以称作“GC Roots”的对象作为起点向下搜索,搜索所走过的路径成为引用链,当一个对象到GC Roots不可达时,表示该对象不再使用,可以回收。

应用:Java/C#/Lisp

优势:可以解决循环引用的问题

不足:算法略复杂

以下图为例,无法通过已知的途径获取对象C和D,则有

  • 若使用引用计数算法判定,但是由于它们相互引用,导致引用计数不为0,因此无法回收掉。
  • 若使用可达性分析算法,C和D到GC Roots不可达,则可回收。

Java的4种引用类型

JDK1.2之后定义了4种引用,分别为强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference),引用强度依次减弱。

  • 强引用

    我们经常使用的引用,形如Object o = new Object();或者String s = "Hello world!";等。只要强引用存在,被引用对象就不会被回收掉。

  • 软引用

    描述一些还有用但是并非必须的对象。系统发生内存溢出之前,会把这类对象列入回收范围,如果这次回收还没有足够内存,才抛出内存溢出异常。使用java.lang.ref.SoftReference类来表示。

  • 弱引用

    描述非必须对象。被引用对象只能存活到下一次GC之前。使用java.lang.ref.WeakReference类来表示。

  • 虚引用

        称为幽灵引用或者幻影引用,是最弱的一种引用关系。一个对象的虚引用根本不影响其生存时间。为一个对象设置虚引用的唯一目的就是这个对象被GC时收到一条系统通知。使用java.lang.ref.PhantomReference类来表示。

关于四种引用的具体实例和应用场景,参考

  • http://www.cnblogs.com/dolphin0520/p/3784171.html
  • https://my.oschina.net/ydsakyclguozi/blog/404389
  • http://droidyue.com/blog/2014/10/12/understanding-weakreference-in-java/

对象回收流程

不可达对象并非立即被回收,还需要经过两次标记过程后才被死亡:

  • 如果对象与GC Roots没有连接的引用链,则它会被第一次标记。随后进行一次是否执行finalize()方法的判定。
  • 如果有必要执行,则给对象被放置到一个叫做F-Queue的队列中,稍后由虚拟机建立低优先级的Finalizer线程去执行,但并不承诺等待它运行结束。
  • 如果没有必要执行(对象没有覆盖finalize()方法或者finalize()已经被执行过一次),则它会被第二次标记。

附上实例:

/**
 * 代码演示了两点:
 * 1.对象在被GC时可以自我拯救
 * 2.这种自救的机会只有一次,因为一个对象的finalize()方法最多被系统执行1次
 */
public class FinalizeEscapeGC {
    public static FinalizeEscapeGC SAVE_HOOK = null;
​
    public void isAlive() {
        System.out.println("yes, i am still alive :)");
    }
​
    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("Finalize method Invoked!");
        FinalizeEscapeGC.SAVE_HOOK = this;
    }
​
    public static void main(String[] args) throws InterruptedException {
        SAVE_HOOK = new FinalizeEscapeGC();
​
        // 第一次拯救,成功
        SAVE_HOOK = null;
        System.gc();
        Thread.sleep(1000);
​
        if (SAVE_HOOK != null) {
            SAVE_HOOK.isAlive();
        } else {
            System.out.println("Oh, i am dead!  :(");
        }
​
        // 第二次拯救,失败
        SAVE_HOOK = null;
        System.gc();
        Thread.sleep(1000);
​
        if (SAVE_HOOK != null) {
            SAVE_HOOK.isAlive();
        } else {
            System.out.println("Oh, i am dead!  :(");
        }
    }
​
}

输出结果为:

Finalize method Invoked!
yes, i am still alive :)
Oh, i am dead!  :(

另外,finalize()方法是诞生时使C/C++程序员接受它所做的妥协,不建议使用。

方法区回收

方法区GC主要回收两部分内容:废弃常量和无用类。判断是否为“废弃常量”与堆中对象类似,而判断一个类为“无用类”必须满足下面三个条件:

  • 该类所有实例都被回收
  • 加载该类的ClassLoader已被回收
  • 该类对应的java.lang.Class对象没有被引用

2、GC算法

a、标记-清除算法(Mark-Sweep)

“标记-清除”算法是最基础的算法,分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收掉所有被标记的对象。它主要有两个缺点:一个是效率问题,标记和清除过程的效率都不高;另一个是空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当程序在以后的运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

2、复制算法(Copying)(针对新生代)

      为了解决标记清除算法的效率问题,出现了复制算法,它将可用内存按容量划分为大小相等的两块,每次使用其中的一块。当这块的内存用完了,就将还存活着的对象复制到另一块上面,然后再把已使用过的内存空间一次清理掉。优点是每次都是对其中的一块进行内存回收,内存分配时就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。缺点是将内存缩小为原来的一半,代价太高了一点。

现在的商业虚拟机都采用复制收集算法来回收新生代,有研究表明,新生代中的对象98%是朝生夕死的,所以并不需要按照1:1的比例来划分内存空间,而是将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中的一块Survivor。当回收时,将Eden和Survivor中还存活着的对象一次性地拷贝到另外一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor空间。HotSpot虚拟机默认Eden和Survivor的大小比例是8:1,也就是每次新生代中可用内存空间为整个新生代容量的90%(80%+10%),只有10%的内存是会被“浪费”的。当然,并不能保证每次回收都只有10%的对象存活,当Survivor空间不够用时,需要依赖其他内存(这里指老年代)进行分配担保(Handle Promotion)。即如果另外一块Survivor空间没有足够的空间存放上一次新生代收集下来的存活对象,这些对象将直接通过分配担保机制进入老年代。

3、标记-整理算法(Mark-Compact)(针对老年代)

      复制收集算法在对象存活率较高时就需要执行较多的复制操作,效率将会变低。更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用复制收集算法。

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

 标记-整理的步骤:

  1. 标记阶段

  2. 整理阶段:移动存活对象,同时更新存活对象中所有指向被移动对象的指针

整理的顺序

    不同算法中,堆遍历的次数,整理的顺序,对象的迁移方式都有所不同。而整理顺序又会影响到程序的局部性。主要有以下3种顺序: 

     1. 任意顺序:对象的移动方式和它们初始的对象排列及引用关系无关 

       任意顺序整理实现简单,且执行速度快,但任意顺序可能会将原本相邻的对象打乱到不同的高速缓存行或者是虚拟内存页中,会降低赋值器的局部性。任意顺序算法只能处理单一大小的对象,或者针对大小不同的对象需要分批处理;

     2. 线性顺序:将具有关联关系的对象排列在一起 
     3. 滑动顺序:将对象“滑动”到堆的一端,从而“挤出”垃圾,可以保持对象在堆中原有的顺序 
    所有现代的标记-整理回收器均使用滑动整理,它不会改变对象的相对顺序,也就不会影响赋值器的空间局部性。复制式回收器甚至可以通过改变对象布局的方式,将对象与其父节点或者兄弟节点排列的更近以提高赋值器的空间局部性。 

整理算法的限制,如整理过程需要2次或者3次遍历堆空间;对象头部可能需要一个额外的槽来保存迁移的信息。

部分整理算法:

  1. 双指针回收算法:实现简单且速度快,但会打乱对象的原有布局

  2. Lisp2算法(滑动回收算法):需要在对象头用一个额外的槽来保存迁移完的地址

  3. 引线整理算法:可以在不引入额外空间开销的情况下实现滑动整理,但需要2次遍历堆,且遍历成本较高

  4. 单次遍历算法:滑动回收,实时计算出对象的转发地址而不需要额外的开销

4、分代收集算法(Generational Collection)

      当前商业虚拟机的垃圾收集都采用“分代收集”算法,这种算法并无新的方法,只是根据对象的存活周期的不同将内存划分为几块,一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记-清理”或“标记-整理”算法来进行回收。

3、HotSpot的算法实现

a.枚举根节点
        从可达性分析中从GC Roots节点找引用为例,可作为GC Roots的节点主要在全局性的引用(例如常量或类静态属性)与执行上下文(例如栈帧中的本地变量表)中,(GC管理的主要区域是Java堆,一般情况下只针对堆进行垃圾回收。方法区、栈和本地方法区不被GC所管理,因而选择这些区域内的对象作为GC roots,被GC roots引用的对象不被GC回收)。现在很多应用仅仅方法区就有数百兆,如果要逐个检查引用,必然消耗时间。 另外可达性分析对执行时间的敏感还体现在GC停顿上,因为这项分析工作必须在一个能确保一致性的快照中进行——这里的“一致性”的意思是指整个分析期间整个系统执行系统看起来就行被冻结在某个时间点,不可以出现分析过程中对象引用关系还在不断变化的情况,该点不满足的话分析结果的准确性就无法得到保证。这点是导致GC进行时必须暂停所有Java执行线程的一个重要原因。即使是在号称(几乎)不会发生停顿的CMS收集器中,枚举根节点时也是必须要停顿的。 
        由于目前主流的Java虚拟机都是准确式GC,所以当执行系统停顿下来后,并不需要一个不漏的检查执行上下文和全局的引用位置,虚拟机应当有办法得知哪些地方存放的是对象的引用。在HotSpot的实现中,是使用一组OopMap的数据结构来达到这个目的的。 在类加载完成的时候,HotSpot就把对象内什么偏移量上是什么类型的数据计算出来,在JIT编译过程中,也会在特定的位置记录下栈和寄存器中哪些位置是引用。这样,GC在扫描时就可以直接得知这些信息了。

        下面是HotSpot Client VM生成的一段String.hashCode()方法的本地代码,可以看到在0x026eb7a9处的call指令有OopMap记录,它指明了EBX寄存器和栈中偏移量为16的内存区域中各有一个普通对象指针(Ordinary Object Pointer)的引用,有效范围为从call指令开始直到0x026eb730(指令流的起始位置)+142(OopMap记录的偏移量)=0x026eb7be,即hlt指令为止。

[Verified Entry Point]
0x026eb730:mov%eax,-0x8000(%esp)
……
;ImplicitNullCheckStub slow case
0x026eb7a9:call 0x026e83e0
;OopMap{ebx=Oop[16]=Oop off=142}
;*caload
;-java.lang.String:hashCode@48(line 1489)
;{runtime_call}
0x026eb7ae:push$0x83c5c18
;{external_word}
0x026eb7b3:call 0x026eb7b8
0x026eb7b8:pusha
0x026eb7b9:call 0x0822bec0;{runtime_call}
0x026eb7be:hlt

b.安全点

        在OopMap的协助下,HotSpot可以快速且准确地完成GC Roots枚举,但一个很现实的问题随之而来:可能导致引用关系变化,或者说OopMap内容变化的指令非常多,如果为每一条指令都生成对应的OopMap,那将会需要大量的额外空间,这样GC的空间成本将会变得很高。

实际上,HotSpot也的确没有为每条指令都生成OopMap,只是在“特定的位置”记录了这些信息,这些位置称为安全点(Safepoint),即程序执行时并非在所有地方都能停顿下来开始GC,只有在到达安全点时才能暂停。Safepoint的选定既不能太少以致于让GC等待时间太长,也不能过于频繁以致于过分增大运行时的负荷。所以,安全点的选定基本上是以程序“是否具有让程序长时间执行的特征”为标准进行选定的——因为每条指令执行的时间都非常短暂,程序不太可能因为指令流长度太长这个原因而过长时间运行,“长时间执行”的最明显特征就是指令序列复用,例如方法调用、循环跳转、异常跳转等,所以具有这些功能的指令才会产生Safepoint。

对于Sefepoint,另一个需要考虑的问题是如何在GC发生时让所有线程(这里不包括执行JNI调用的线程)都“跑”到最近的安全点上再停顿下来。

这里有两种方案可供选择:抢先式中断(Preemptive Suspension)和主动式中断(Voluntary Suspension)。

抢先式中断不需要线程的执行代码主动去配合,在GC发生时,首先把所有线程全部中断,如果发现有线程中断的地方不在安全点上,就恢复线程,让它“跑”到安全点上。现在几乎没有虚拟机实现采用抢先式中断来暂停线程从而响应GC事件。

而主动式中断的思想是当GC需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志,各个线程执行时主动去轮询这个标志,发现中断标志为真时就自己中断挂起。轮询标志的地方和安全点是重合的,另外再加上创建对象需要分配内存的地方。

下面的test指令是HotSpot生成的轮询指令,当需要暂停线程时,虚拟机把0x160100的内存页设置为不可读,线程执行到test指令时就会产生一个自陷异常信号,在预先注册的异常处理器中暂停线程实现等待,这样一条汇编指令便完成安全点轮询和触发线程中断。

0x01b6d627:call 0x01b2b210;OopMap{[60]=Oop off=460}
;*invokeinterface size
;-Client1:main@113(line 23)
;{virtual_call}
0x01b6d62c:nop
;OopMap{[60]=Oop off=461}
;*if_icmplt
;-Client1:main@118(line 23)
0x01b6d62d:test%eax,0x160100;{poll}
0x01b6d633:mov 0x50(%esp),%esi
0x01b6d637:cmp%eax,%esi

c.安全区域

使用Safepoint似乎已经完美地解决了如何进入GC的问题,但实际情况却并不一定。Safepoint机制保证了程序执行时,在不太长的时间内就会遇到可进入GC的Safepoint。但是,程序“不执行”的时候呢?所谓的程序不执行就是没有分配CPU时间,典型的例子就是线程处于Sleep状态或者Blocked状态,这时候线程无法响应JVM的中断请求,“走”到安全的地方去中断挂起,JVM也显然不太可能等待线程重新被分配CPU时间。对于这种情况,就需要安全区域(Safe Region)来解决。

安全区域是指在一段代码片段之中,引用关系不会发生变化。在这个区域中的任意地方开始GC都是安全的。我们也可以把Safe Region看做是被扩展了的Safepoint。

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

猜你喜欢

转载自blog.csdn.net/aichuanwendang/article/details/84589758