Java编程拾遗『JVM垃圾回收』

垃圾收集(Garbage Collection)通常被称为GC,大部分人都把这项技术当作Java语言的伴生产物。事实上,GC的历史远远比Java久远,1960年诞生于MIT的Lisp语言是第一门真正使用内存动态分配和垃圾收集技术的语言。经过半个世纪的发展,内存动态分配与内存回收技术已经相当成熟,一切看起来都进入“自动化”时代,那么为什么我们还要去了解GC和内存分配呢?原因很简单:当需要排查各种内存溢出、内存泄漏问题时,当垃圾收集成为系统达到更高并发量的瓶颈时,我们就需要对这些“自动化”技术实施必要的监控和调节。

之前的文章,介绍了Java运行时区域的各个部分,其中程序计数器、虚拟机栈、本地方法栈三个区域随线程而生,随线程而灭。一般方法或线程结束时,内存自然就跟随着回收了,所以这几个区域基本不需要考虑内存回收问题。而Java堆和方法区则不一样,一个接口的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也不一样,只有在程序处于运行期间才能知道要创建哪些对象,这部分内存的分配和回收都是动态的,垃圾收集器所关注的就是这部分内存。

1. JVM垃圾收集

1.1 对象存活判断

堆中几乎存放着Java世界中所有的对象实例,垃圾收集器在对堆进行回收前,第一件事情就是确定这些对象哪些还“存活”着,哪些已经“死亡”,以便后续将“死亡”对象回收,判断对象是否存活一般有两种方式。

1.1.1 引用计数器法

给对象添加一个引用计数器,每当有一个地方引用它时,计数器就加1。引用失效时,计数器值就减1。任何时刻,计数器为0的对象就是不可能再被使用的对象,可以被垃圾收集器回收。此方法非常简单,但是无法解决对象循环引用问题,现在的主流虚拟机都不实用引用计数器法判断对象是否存活。

/**
 * @author zhuoli
 * VM Args: -XX:+PrintGCDetails
 */
public class ReferenceCountingGC {
    public Object instance = null;

    private static final int _1MB = 1024 * 1024;

    /**
     * 占用内存,以便在GC日志中看清楚对象是否被回收
     */
    private byte[] bigSize = new byte[2 * _1MB];

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

        objA = null;
        objB = null;
        
        System.gc();
    }
}

运行结果:

[GC (System.gc()) [PSYoungGen: 8028K->512K(76288K)] 8028K->520K(251392K), 0.0034552 secs] [Times: user=0.01 sys=0.00, real=0.01 secs] 
[Full GC (System.gc()) [PSYoungGen: 512K->0K(76288K)] [ParOldGen: 8K->443K(175104K)] 520K->443K(251392K), [Metaspace: 3077K->3077K(1056768K)], 0.0034621 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
Heap
 PSYoungGen      total 76288K, used 655K [0x000000076ab00000, 0x0000000770000000, 0x00000007c0000000)
  eden space 65536K, 1% used [0x000000076ab00000,0x000000076aba3ee8,0x000000076eb00000)
  from space 10752K, 0% used [0x000000076eb00000,0x000000076eb00000,0x000000076f580000)
  to   space 10752K, 0% used [0x000000076f580000,0x000000076f580000,0x0000000770000000)
 ParOldGen       total 175104K, used 443K [0x00000006c0000000, 0x00000006cab00000, 0x000000076ab00000)
  object space 175104K, 0% used [0x00000006c0000000,0x00000006c006ef28,0x00000006cab00000)
 Metaspace       used 3084K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 339K, capacity 388K, committed 512K, reserved 1048576K

可以看到PSYoungGen: 8028K->512K,说明虽然两个对象存在循环引用,但是还是被回收了,也证明虚拟机不是使用引用计数器法来判断对象是否存活的(如果使用引用计数器法判断,两个对象的引用计数器都不是0,是不应该被回收的)。

1.1.2 根搜索算法

在主流的商用程序语言中(Java、C#等),都是使用根搜索算法(GC Roots Tracing)判断对象是否存活的。这个算法的基本思路是通过一系列名为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连(用图论的话来说就是从GC Roots到这个对象不可达)时,则证明此对象是不可用的。如下图对象object6、object7、object8虽然互相有关联,但是他们到Gc Roots是不可达的,所以他们将会被判定为可回收对象。

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

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

1.2 引用

无论是通过引用计数器算法判断对象的引用数量,还是通过根搜索算法判断对象的引用链是否可达,判断对象是否存活都与“引用”有关。在JDK 1.2之前,Java中的引用定义很传统:如果reference类型的数据中存储的数值代表的另一块内存的起始地址,就称这块内存代表着一个引用。这种定义很纯粹,但是太过于狭隘,一个对象在这种定义下只有被引用或者没有被引用两种状态,对于如何描述一些“食之无味,弃之可惜”的对象就显得无能为力。午门希望能够描述这样一类对象:当内存空间还足够时,则能保留在内存之中:如果内存在进行垃圾手机后还是非常紧张,则可以抛弃这些对象。很多系统的缓存功能都符合这样的应用场景。

在JDK 1.2之后,Java对引用的概念进行了扩充,将引用费为强引用、软引用、弱引用、虚引用四种,这四种引用强度一次逐渐减弱。

  • 强引用:就是指在代码之中普遍存在的,类似“Object obj = new Object()”这类的引用,只要强引用还在,垃圾收集器永远不会回首掉被引用的对象。
  • 软引用:用来描述一些还有用,但并非必需的对象。对于软引用关联着的对象,在系统将要发生内存溢出异常之前,就会把这些对象列进回收范围之中并进行第二次回收。如果这次回收还是没有足够的内存,才会抛出内存溢出异常。在JDK 1.2之后,提供了SoftReference类来实现软引用。
  • 弱引用:也是用来描述非必需的对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回首掉只被弱引用关联的对象。在JDK 1.2之后,提供WeakReference类来实现弱引用。
  • 虚引用:是一种最弱的引用关系,一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来去的一个对象实例。为一个对象设置虚引用关联的唯一目的就是希望能在这个对象被垃圾收集器回收时收到一个系统通知。在Java 1.2之后,提供了PhantomReference类实现虚引用。

1.3 对象存活条件

在根搜索算法中不可达的对象,也并非是“非死不可”,这时候它们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:如果对象在进行根搜索后发现没有与GC Roots相连接的引用链,那么它将会第一次标记并且进行一次筛选,筛选的条件是次对象是否有必要执行finalize()方法。当对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,虚拟机都会视为“没有必要执行”

如果这个对象被判定为有必要执行finalize()方法,那么这个对象将会被放置在一个F-Queue的队列中,并在稍后由一条虚拟机自动创建的、低优先级的Finalizer线程去执行。这里所谓的“执行”是指虚拟机会出发这个方法,但并不承诺会等待它执行结束。这样做的原因是,如果一个对象在finalize()方法中执行缓慢,或者发生了死循环(更极端的情况),将很可能会导致F-Queue队列中的其它对象永久处于等待状态,甚至导致这个那个内存回收系统崩溃。finalize()方法是对象逃脱死亡命运的最后一次机会,稍后GC将对F-Queue队列中的对象进行第二次小规模标记。如果对象要在finalize()中拯救自己,只要重新与引用链上任何一个对象建立关联即可(比如把自己赋值给某个类变量活对象的成员变量),那么第二次标记时它将被移出“即将回收”的集合。如果对象这时候还是没有逃脱,那它就真的离死不远了。

public class FinalizeEscapeGCTest {

    public static FinalizeEscapeGCTest 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 executed!");
        FinalizeEscapeGCTest.SAVE_HOOK = this;
    }

    public static void main(String[] args) throws InterruptedException {
        SAVE_HOOK = new FinalizeEscapeGCTest();

        //对象第一次拯救自己
        SAVE_HOOK = null;
        System.gc();

        //因为Finalizer方法优先级很低,暂停500ms,等待它执行finalize()方法
        Thread.sleep(500);
        if (SAVE_HOOK != null) {
            SAVE_HOOK.isAlive();
        } else {
            System.out.println("no, i am dead :(");
        }

        //下面这段代码与上面完全相同,但是这次自救失败了
        SAVE_HOOK = null;
        System.gc();

        //因为Finalizer方法优先级很低,暂停500ms,等待它执行finalize()方法
        Thread.sleep(500);
        if (SAVE_HOOK != null) {
            SAVE_HOOK.isAlive();
        } else {
            System.out.println("no, i am dead :(");
        }
    }
}

运行结果:

finalize method executed!
yes, i am still alive :)
no, i am dead :(

从运行结果可以看出,SAVE_HOOK对象的finalize()方法确实被GC收集器触发过,并且在被收集前成功逃脱了。

另一个值得注意的是,代码中有两段完全一样的代码片段,执行结果却是一次逃脱成功,一次失败,这是因为任何一个对象的finalize()方法都只会被系统自动调用一次,如果对象面临下一次回收,他的finalize()方法不会被再次执行,因此第二段代码自救行动失败了。

虽然可以在finalize()方法中“拯救”对象,但是并不鼓励使用这种方法来拯救对象。相反,建议大家尽量避免使用它,因为它不是C/C++中的析构函数,而是Java刚诞生时为了是C/C++程序员更容易接受它所做的一个妥协。它的运行代价高昂,不确定性大,无法保证各个对象的调用顺序。有些教材中提到它适合做“关闭外部资源”之类的工作,这完全是对这种方法的用途的一种自我安慰。finalize()能做的工作,使用try-finally或其它方式都可以做的更好、更及时,大家完全可以忘掉Java语言中还有这个方法的存在。

1.4 回收方法区

很多人认为方法区(或者HotSpot虚拟机中的永久代,Java7及之后的元空间)是没有垃圾收集的,Java虚拟机规范中确实说过可以不要求虚拟机在方法区实现垃圾收集,而且在方法区进行垃圾收集的“性价比”一般较低:在堆中,尤其是在新生代中,常规应用进行一次垃圾收集一般可以回收70%~95%,而永久代的收集效率远远低于此。

永久代的垃圾收集主要回收两部分内容:废弃常量和无用类。回收废弃常量与回收Java堆中的对象非常类似。以常量池中字面量的回收为例,加入一个字符串”abc”已经进入常量池中,但是当前系统没有任何一个String对象是叫做”abc”的,换句话说是没有任何String对象引用常量池中的”abc”常量,也没有其他地方引用这个字面量,如果在这时候发生垃圾回收,而且必要的话,这个”abc”常量就会被系统回收掉。常量池中其他类(接口)、方法、字段的符号引用也与此类似。

判定一个常量是否是“废弃常量”比较简单,而要判断一个类是否是“无用的类”的条件则相对苛刻的多。类需要同时满足下面三个条件才算是“无用的类”:

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

在大量使用反射、动态代理、CGLIB等bytecode框架的场景,以及动态生成JSP和OSGi这类频繁自定义ClassLoader的场景都需要虚拟机具备类卸载的功能,以保证永久代不会溢出。

2. 垃圾收集算法

本节来介绍一下几种常见的垃圾收集算法的思想及发展历程。

2.1 标记——清除算法

“标记-清除”(Mark-Sweep)算法,如它的名字一样,算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收掉所有被标记的对象。之所以说它是最基础的收集算法,是因为后续的收集算法都是基于这种思路并对其缺点进行改进而得到的。

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

2.2 复制算法

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

现在的商业虚拟机都采用这种收集算法来回收新生代,IBM的专门研究表明,新生代中的对象98%是朝生夕死的,所以不需要按照1:1的比例来划分内存空间,而是将内存分为一块较大的Eden控件和块两块较小的Survivor空间,每次使用Enden和其中一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor控件。HotSpot虚拟机默认Eden和Survivor的大小比例是8:1,也就是每次新生代可用内存空间为整个新生代容量的90%(80% + 10%),只有10%的内存会被“浪费”掉。当然98%的对象可回收只是一般场景下的数据,我们没办法保证每次回收都只有不多于10%的对象存活,当Survivor空间不够用时,需要以来其它内存(这里指老年代)进行分配担保(Handle Promotion)。

内存的分配担保机制就好比我们去银行借钱,如果我们信誉很好,在98%的情况下都能按时偿还,于是银行可能会默认我们下一次也能按时按量地偿还贷款,只需要一个担保人能保证如果我们不能还款时,可以从他的账户扣钱,那银行就认为没有风险了。内存的分配担保机制也一样,如果另一块Survivor空间没有足够的内存空间存放上一次新生代收集下来的存货对象,也谢对象将直接通过分配担保机制进入老年代。关于新生代进行分配担保的内容,稍后再详细介绍。

2.3 标记——整理算法

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

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

2.4 分代收集算法

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

3 垃圾收集器

如果说收集算法时内存回收的方法论,垃圾收集器就是内存回收的具体实现。Java虚拟机规范中对垃圾收集器应该如何实现并没有任何规定,因此不同的厂商、不同版本的虚拟机所提供的垃圾收集器都可能都可能会有很大的差别,并且一般都会提供参数供用户根据自己的特点和要求组合出各个年代所使用的的垃圾收集器。

上图展示了7种作用于不同分代的收集器,如果两个收集器之间存在连线,就说明它们之间可以搭配使用。

再介绍这些收集器之前,我们先来明确一个观点:虽然我们是在对各个收集器进行比较,但并非为了挑选一个最好的收集器出来。因为知道现在为止还没有最好的收集器出现,更没有万能的收集器,所以我们选择的只是对具体引用的最合适的收集器。这点不需要多加解释就能证明:如果有一种航任何场景下都适用的完美收集器存在,那么HotSpot虚拟机就没必要实现那么多不同的收集器了。

3.1 Serial收集器

Serial收集器是最基本,历史最悠久的收集器,曾经(再JDK 1.3.1之前)是虚拟机新生代收集的唯一选择。大家看名字就知道,这个收集器时候一个单线程的收集器,但它的“单线程”的意义并不仅仅是说明它只会使用一个CPU或一条手机线程去完成垃圾收集工作,更重要的是它在进行垃圾收集时,必须暂停其他所有的工作线程(Sun将这件事情称之为“Stop The World”),直到它收集结束。“Stop The World”这项工作是由虚拟机在后台自动发起和自动完成的,在用户不可见的情况下吧用户的正常工作的线程全部停掉,这对很多应用来说都是难以接受的。假如,你的电脑没运行一个小时候就会暂停响应5分钟,你会有什么样的心情?Serial/Serial Old收集器的运行过程如下图所示:

从JDK 1.3开始一直到现在,HotSpot虚拟机团队为消除或减少工作线程因内存回收而导致的停顿努力,从Serial收集器到Parallel收集器,再到Concurrent Mark Sweep(CMS),再到 Garbage First(G1)收集器,我们看到了一个个越来越优秀(也越来越复杂)的收集器出现,用户线程的停顿时间再不断缩减,但是仍然没有办法完全消除。

写到这里,Serial收集器看着像是一个老而无用的鸡肋收集器,但实际上到现在为止,它依然是虚拟机运行在Client模式下的默认新生代收集器。它也有着由于其它收集器的地方:简单而高效(与其它收集器的单线程比),对于限定单个CPU的环境来说,Serial收集器没有现成交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。再用户的桌面引用场景中,分配给虚拟机管理的内存一般不会很大,收集几十兆甚至一两百兆的新生代(仅仅是新生代使用的内存,桌面引用基本不会再大了),停顿时间完全可以控制在几十毫秒最多一百多毫秒内,只要不是频繁发生,这点停顿是可以接受的。所以Serial收集器对于运行在Client模式下的虚拟机来说是一个很好地选择。

3.2 ParNew收集器

ParNew收集器其实即使Serial收集器的多线程版本,除了使用多条线程进行垃圾收集之外,其余行为包括Serial收集器可用的所有控制器参数(例如:-XX:SurvivorRatio、-XX:PretenureSizeThreshold)、收集算法、Stop The World、对象分配规则、回收策略等都与Serial收集器完全一样,实现上中和两种收集器页共用了相当多的代码。ParNew/Serial Old收集器的工作过程如下图所示:

ParNew收集器除了多线程收集之外,其他与Serial收集器相比并没有太多创新之处,但它却是许多运行在Server模式下的虚拟机中的首选新生代收集器,其中一个与性能无关但是很重要的原因是,除了Serial收集器外,目前只有它能与CMS收集器配合工作。在JDK 1.5,HotSpot推出了一款在强交互应用中几乎可以称为划时代意义的垃圾收集器——CMS垃圾收集器(Concurrent Mark Sweep),这款收集器是HotSpot虚拟机中第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。举个例子就是,妈妈帮你的屋子打扫卫生时,还允许你同时往地上扔垃圾。不过,CMS作为老年代的垃圾收集器,却无法与JDK 1.4.0中已经存在的新生代收集器Parallel Scavenge配合工作,所以在JDK1.5中使用CMS来收集老年代的时候,新生代只能选择ParNew或Serial收集器中的一个。ParNew收集器也是使用-XX:+UseConcMarkSweepGC选项后的默认新生代收集器,也可以使用-XX:+UseParNewGC选项来强制定它。

ParNew收集器在单CPU的环境中绝对不会比Serial收集器效果更好,甚至由于存在线程交互的开销,该收集器在通过超线程技术实现的两个CPU环境中都不能百分之百保证能超越Serial收集器。当然,随着使用的CPU的数量的增加,他对于GC时系统资源的利用还是很有好处的。它默认开启的垃圾收集线程的数与CPU的数量相同(可以使用-XX:ParallelGCThreads参数来限制垃圾收集的线程数)。

3.3 Parallel Scavenge收集器

Parallel Scavenge收集器也是一个新生代收集器,它也是使用复制算法的收集器,又是并行的多线程收集器。看上去跟ParNew收集器非常相似,他们之间有什么区别?在此之前我们先来熟悉一下如下两个概念——垃圾收集器中的并行与并发。关于并行和并发的概念之前在介绍多线程时,已经说明过,这里我么从垃圾收集器角度来解释一下这两个词。

  • 并行(Parallel):指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态。
  • 并发(Concurrent):至用户线程与垃圾收线程同时执行(但不一定是并行的,有可能会交替执行),用户程序继续运行,而垃圾收集程序运行于另一个CPU上。

Parallel Scavenge收集器的特点是他的关注点和其它收集器不同,CMS等收集器的关注点是尽可能缩短垃圾收集时用户线程的停顿时间,而ParallelScavenge收集器的目标是达到一个可控的吞吐量(Throughput)。所谓吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值,及吞吐量 = 运行用户代码的时间 / (运行用户代码的时间 + 垃圾收集时间),虚拟机共运行100分钟,其中垃圾收集花掉1分钟,那吞吐量就是99%。

停顿时间越短就越适合需要用户交互的程序,良好的响应速度能提升用户的体验;而高吞吐量则可以最高效率的利用CPU时间,尽快的完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务

Parallel Scavenge收集器提供了两个参数用于精确控制吞吐量,分别是控制最大垃圾收集停顿时间的-XX:MaxGCPauseMillis参数及直接设置吞吐量大小的-XX:GCTimeRatio参数。

MaxGCPauseMillls参数允许设置的值是一个大于0的毫秒数,收集器将尽力保证内存回收花费的时间不超过设定值。不过大家不要太异想天开地认为如果把这个参数的值设置的稍微小一点就能使得系统的垃圾收集速度变得更快,GC停顿四溅缩短是以牺牲吞吐量和新生代空间换取的:系统把新生代调小一些,收集300M新生代肯定比收集500M新生代快,这也直接导致垃圾收集发生的更频繁,原来10S收集一次、每次停顿100毫秒,现在变成5S收集一次,每次停顿70毫秒。停顿时间在下降,但是吞吐量也降下来了。

GCTimeRatio参数的值应当是一个大于0小于100的整数,通过-XX:GCTimeRatio=我们告诉JVM吞吐量要达到的目标值。 更准确地说,-XX:GCTimeRatio=N指定目标应用程序线程的执行时间(与总的程序执行时间)达到N/(N+1)的目标比值。 例如,通过-XX:GCTimeRatio=9我们要求应用程序线程在整个执行时间中至少9/10是活动的(因此,GC线程占用其余1/10)。 基于运行时的测量,JVM将会尝试修改堆和GC设置以期达到目标吞吐量。 -XX:GCTimeRatio的默认值是99,也就是说,应用程序线程应该运行至少为总执行时间的99%(吞吐量为99%)。

由于与吞吐量关系密切,Parallel Scanvenge收集器也经常被称为“吞吐量优先”收集器。除上述两个参数之外,Parallel Scavenge收集器还有一个参数-XX:+UseAdaptiveSizePolicy值得关注。这是一个开关参数,当这个参数打开之后,就不需要手工指定新生代的大小(-Xmn)、Eden与Survivor区的比例(-XX:SurvivorRatio)、晋升到老年代对象年龄(-XX:PretenureSizeThreadhold)等细节参数了,虚拟机会根据当前系统的运行情况手机性能监控信息,动态调整这些参数以提供最合适的停顿时间或最大的吞吐量,这种提高接访室称为GC自适应调节策略(GC Ergonomics)。如果对于垃圾收集器的运行原理不太了解,手工优化存在困难的时候,使用Parallel Scavenge收集器配合自适应调节策略,把内存管理的调优任务交给虚拟机去完成将是一个不错的选择。只需要把基本的内存数据设置好(如-Xmx设置最大堆),然后使用MaxGCPauseMillls参数(关注最大停顿时间)或GCTimeRatio参数(关注吞吐量)给虚拟机设立一个优化目标,那具体的细节参数的调整工作就有虚拟机完成了。自适应调节策略也是ParallelScavenge收集器与ParNew收集器的一个重要区别。

3.4 Serial Old收集器

Serial Old是Serial收集器的老年代版本,它同样是一个单线程收集器,使用“标记-整理”算法。这个收集器的主要意义也是被Client模式下的虚拟机使用。如果在Server模式下,它主要还有两大用途:一个是在JDK 1.5及之前的版本中与Parallel Scavenge收集器搭配是哟好难过,另一个就是作为CMS收集器的后备预案,在并发收集发生Concurrent Mode Failure的时候使用。Serial/Serial Old收集器的运行过程如下图所示:

3.5 Parallel Old收集器

Parallel Old是Parallel Scavenge收集器的老年代版本,使用多线程“标记-整理”算法。这个收集器实在JDK 1.6中才开始提供的,在此之前,新生代的Parallel Scavenge收集器一直处于比较尴尬的状态。原因是,如果新生代选择了Parallel Scavenge收集器,老年代除了Serial Old收集器外别无选择,即使使用了Parallel Scavenge收集器,也未必能在整体上获得吞吐量最大化的效果,又因为老年代手机中无法充分利用服务器多CPU的处理能力,在老年代很大而且硬件比较高级的环境中,这种组合的吞吐量甚至还不一定有ParNew加CMS组合给力。

直到Parallel Old收集器出现后,“吞吐量优先”收集器终于有了比较名副其实的应用组合,在注意吞吐量及CPU资源敏感的场合,都可以优先考虑Parallel Scavenge加Parallel Old收集器。Parallel Scavenge/Parallel Old收集器的运行过程如下:

3.6 CMS收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短会搜停顿时间为目标的收集器。目前很大一部分的Java应用都集中在互联网站或B/S系统的服务端上,这类应用有其中是服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。CMS收集器就非常符合这类应用的需求。

从名字(包含Mark Sweep)上就可以看出CMS收集器是基于“标记——清除”算法实现的,他的运作过程相对于前面几种收集器来说要更复杂一些,整个过程分为4个步骤,包括:

  • 初始标记(CMS initial mark)
  • 并发标记(CMS concurrent mark)
  • 重新标记(CMS remark)
  • 并发清除(CMS concurrent sweep)

其中初始标记、重新标记这两个步骤仍然需要“Stop The World”。初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快,并发标记阶段就是进行跟搜索算法(GC Roots Tracing)标记的过程,而重新标记阶段则是为了修正并发标记期间,因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,单元比并发标记的时间短。

由于整个过程中耗时最长的并发标记和并发清除过程中,垃圾收集器线程都可以与用户线程一起工作,所以总体上来说,CMS收集器的内存回收过程是与用户线程一起并发的执行的。CMS垃圾收集器的运行过程如下图所示:

CMS是一款优秀的垃圾收集器,他的最主要的优点都在名字上已经体现出来了:并发收集、低停顿,Sun的一些官方文档里也称之为并发低停顿收集器(Concurrent Low Pause Collector)。但是CMS还远达不到完美的程度,它有以下三个显著的缺点:

  • CMS收集器对CPU资源非常敏感。其实面向并发设计的程序都对CPU资源比较敏感。在并发阶段,它虽然不会导致用户线程停顿,但是会因为占用了一部分线程(或者说CPU资源)而导致应用程序变慢,总吞吐量会降低。CMS默认启动回收线程数目是(CPU数量 + 3) / 4,也就是当CPU在4个以上时,并发回收时垃圾收集线程最多占有不超过25%的CPU资源。但是当CPU资源不足4个时,那么CMS对用户程序的影响就可能变得很大,如果CPU负载本身就比较大的时候,还分出一半的运算能力去执行收集器线程,就可能导致用户的执行速度忽然降低了50%,这也让人很受不了。为了解决这种情况,虚拟机提供了一种称为“增量式并发收集器”(Incremental Concurrent Mark Sweep / i-CMS)的CMS收集器变种,所做的事情和单CPU年代PC机操作系统使用抢占式来模拟多任务机制的思想一样,就是在并发标记和并发清理的时候让GC线程、用户线程交替运行,尽量减少GC线程的独占资源的时间,这样整个垃圾收集的过程会更长,但对用户线程的影响就会显得少一些,速度下降也就没那么么明显,但是目前版本中,i-CMS已经被声明为“deprecated”,即不再提倡用户使用。
  • CMS收集器无法处理浮动垃圾(Floating Garbage),可能会出现“Concurrent Mode Failure”失败而导致另一次Full GC的产生。由于CMS并发清理阶段用户线程还在运行着,伴随着程序的运行自然还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS无法在本次手机中处理掉它们,只好等待下一次GC时再将其清理掉。这一部分垃圾就称为“浮动垃圾”。也是由于在垃圾收集阶段用户线程还需要运行,即还需要预留足够的内存空间给用户线程使用,因此CMS收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,需要预留一部分空间提供并发收集时的程序运行使用。在默认设置下,CMS收集器在老年代使用了68%的空间后就会被激活,这是一个偏保守的设置,如果在应用中老年代增长不是太快,可以适当调高参数-XX:CMSInitiatingOccupancyFraction的值来提高触发百分比,以便降低内存回收次数以获取更好的性能。要是CMS运行期间预留的内存无法满足程序需要,就会出现一次“Concurrent Mode Failure”失败,这时候虚拟机将启动后备预案:临时启动Serial Old收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了。所以说参数-XX:CMSInitiatingOccupancyFraction设置的太将会容易导致大量“Concurrent Mode Failure”失败,性能反而降低。
  • 最后一个缺点,就是CMS是一款基于“标记-清除”算法实现的垃圾收集器,这也就意味着收集结束时会产生大量的空间碎片。空间碎片过多时,将会给大对象的分配带来很大的麻烦,往往会出现老年代还有很大的剩余空间,但是无法找到足够大的连续空间来分配当前对象,不得不提前触发一次Full GC。为了解决这个问题,CMS收集器提供了一个-XX:UseCMSCompactAtFullCollection开关参数,用于在完成Full GC后进行碎片处理,碎片整理的过程是无法并发的。空间碎片问题没有了,单停顿时间不得不变长了。虚拟机提供了另一个参数-XX:CMSFullGCsBeforeCompaction,这个参数用于设置在执行多少次不整理碎片的GC后,跟着来一次带碎片整理的GC。

关于CMS收集器,介绍了这么多,有一点还是不得不提的是,在JEP 291中说道CMS垃圾收集器已经被置为“deprecated”,虚拟机团队不在对该收集器提供支持,建议使用G1垃圾收集器替换CMS垃圾收集器。

3.7 G1收集器

G1垃圾收集器(Garbage-First Garbage Collector)是一种以可控停顿时间为目标,并且在此基础上尽可能提高吞吐量的收集器。可以通过-XX:+UseG1GC参数来启用,作为体验版随着JDK 6u14版本面世,在JDK 7u4版本发行时被正式推出。在JDK 9中,G1被提议设置为默认垃圾收集器(JEP 248)。在JEP 291中停止了对CMS收集器的支持,建议使用G1收集器替代CMS收集器。

G1收集器被设计用来取代CMS收集器,和CMS相同的地方在于,它们都属于并发收集器,在大部分的收集阶段都不需要挂起应用程序。G1垃圾收集器适用于以下场景:

  • 服务端多核CPU、JVM内存占用较大的应用(至少大于4G)
  • 应用在运行过程中会产生大量内存碎片、需要经常压缩空间
  • 想要更可控、可预期的GC停顿周期,防止高并发下应用雪崩现象

与CMS收集器相比,G1收集器在以下方面表现的更出色:

  • G1是一个有整理内存过程的垃圾收集器,不会产生很多内存碎片
  • G1的Stop The World(STW)更可控,G1在停顿时间上添加了预测机制,用户可以指定期望停顿时间

3.7.1 G1收集器内存模型

传统的GC收集器将连续的内存空间划分为新生代、老年代和永久代(Java8去除了永久代,引入了元空间Metaspace),这种划分的特点是各代的存储地址(逻辑地址,下同)是连续的。如下图所示:

而G1收集器的各代存储地址是不连续的,每一代都使用了n个不连续的大小相同的Region,每个Region占有一块连续的虚拟内存地址。如下图所示:

在上图中,还有一些Region标明了H,它代表Humongous,表示这些Region存储的是巨大对象(humongous object,H-obj),即大小大于等于region一半的对象。H-obj有如下几个特征:

  • H-obj直接分配到了old gen,防止了反复拷贝移动
  • H-obj在global concurrent marking阶段的cleanup 和 full GC阶段回收
  • 在分配H-obj之前先检查是否超过 initiating heap occupancy percent和the marking threshold, 如果超过的话,就启动global concurrent marking,为的是提早回收,防止 evacuation failures 和 full GC

一个Region的大小可以通过参数-XX:G1HeapRegionSize设定,取值范围从1M到32M,且是2的指数。如果不设定,那么G1会根据Heap大小自动决定。

3.7.2 SATB

SATB全称是Snapshot-At-The-Beginning,由字面理解,是GC开始时活着的对象的一个快照。它是通过Root Tracing得到的,作用是维持并发GC的正确性。 那么它是怎么维持并发GC的正确性的呢?根据三色标记算法,我们知道对象存在三种状态:

  • 白:对象没有被标记到,标记阶段结束后,会被当做垃圾回收掉
  • 灰:对象被标记了,但是它的field还没有被标记或标记完
  • 黑:对象被标记了,且它的所有field也被标记完了

由于并发阶段的存在,Mutator和Garbage Collector线程同时对对象进行修改,就会出现白对象漏标的情况,这种情况发生的前提是:

  • Mutator赋予一个黑对象该白对象的引用
  • Mutator删除了所有从灰对象到该白对象的直接或者间接引用

对于第一个条件,在并发标记阶段,如果该白对象是new出来的,并没有被灰对象持有,那么它会不会被漏标呢?Region中有两个top-at-mark-start(TAMS)指针,分别为prevTAMS和nextTAMS。在TAMS以上的对象是新分配的,这是一种隐式的标记。对于在GC时已经存在的白对象,如果它是活着的,它必然会被另一个对象引用,即条件二中的灰对象。如果灰对象到白对象的直接引用或者间接引用被替换了,或者删除了,白对象就会被漏标,从而导致被回收掉,这是非常严重的错误,所以SATB破坏了第二个条件。也就是说,一个对象的引用被替换时,可以通过write barrier 将旧引用记录下来。

//  share/vm/gc_implementation/g1/g1SATBCardTableModRefBS.hpp
// This notes that we don't need to access any BarrierSet data
// structures, so this can be called from a static context.
template <class T> static void write_ref_field_pre_static(T* field, oop newVal) {
  T heap_oop = oopDesc::load_heap_oop(field);
  if (!oopDesc::is_null(heap_oop)) {
    enqueue(oopDesc::decode_heap_oop(heap_oop));
  }
}
// share/vm/gc_implementation/g1/g1SATBCardTableModRefBS.cpp
void G1SATBCardTableModRefBS::enqueue(oop pre_val) {
  // Nulls should have been already filtered.
  assert(pre_val->is_oop(true), "Error");
  if (!JavaThread::satb_mark_queue_set().is_active()) return;
  Thread* thr = Thread::current();
  if (thr->is_Java_thread()) {
    JavaThread* jt = (JavaThread*)thr;
    jt->satb_mark_queue().enqueue(pre_val);
  } else {
    MutexLockerEx x(Shared_SATB_Q_lock, Mutex::_no_safepoint_check_flag);
    JavaThread::satb_mark_queue_set().shared_satb_queue()->enqueue(pre_val);
  }
}

SATB也是有副作用的,如果被替换的白对象就是要被收集的垃圾,这次的标记会让它躲过GC,这就是float garbage。因为SATB的做法精度比较低,所以造成的float garbage也会比较多。

1.2.7.3 RSet

RSet全称是Remembered Set,是辅助GC过程的一种结构,典型的空间换时间工具,和Card Table有些类似。还有一种数据结构也是辅助GC的:Collection Set(CSet),它记录了GC要收集的Region集合,集合里的Region可以是任意年代的。在GC的时候,对于old->young和old->old的跨代对象引用,只要扫描对应的CSet中的RSet即可。 逻辑上说每个Region都有一个RSet,RSet记录了其他Region中的对象引用本Region中对象的关系,属于points-into结构(谁引用了我的对象)。而Card Table则是一种points-out(我引用了谁的对象)的结构,每个Card 覆盖一定范围的Heap(一般为512Bytes)。G1的RSet是在Card Table的基础上实现的:每个Region会记录下别的Region有指向自己的指针,并标记这些指针分别在哪些Card的范围内。 这个RSet其实是一个Hash Table,Key是别的Region的起始地址,Value是一个集合,里面的元素是Card Table的Index。下图表示了RSet、Card和Region的关系:

上图中有三个Region,每个Region被分成了多个Card,在不同Region中的Card会相互引用,Region1中的Card中的对象引用了Region2中的Card中的对象,蓝色实线表示的就是points-out的关系,而在Region2的RSet中,记录了Region1的Card,即红色虚线表示的关系,这就是points-into。 而维系RSet中的引用关系靠post-write barrier和Concurrent refinement threads来维护,操作伪代码如下:

void oop_field_store(oop* field, oop new_value) {
  pre_write_barrier(field);             // pre-write barrier: for maintaining SATB invariant
  *field = new_value;                   // the actual store
  post_write_barrier(field, new_value); // post-write barrier: for tracking cross-region reference
}

post-write barrier记录了跨Region的引用更新,更新日志缓冲区则记录了那些包含更新引用的Cards。一旦缓冲区满了,Post-write barrier就停止服务了,会由Concurrent refinement threads处理这些缓冲区日志。 RSet究竟是怎么辅助GC的呢?在做YGC的时候,只需要选定young generation region的RSet作为根集,这些RSet记录了old->young的跨代引用,避免了扫描整个old generation。 而mixed gc的时候,old generation中记录了old->old的RSet,young->old的引用由扫描全部young generation region得到,这样也不用扫描全部old generation region。所以RSet的引入大大减少了GC的工作量。

1.2.7.4 Pause Prediction Model

Pause Prediction Model 即停顿预测模型。它在G1中的作用是: 

G1 uses a pause prediction model to meet a user-defined pause time target and selects the number of regions to collect based on the specified pause time target

 

即G1收集器通过停顿预测模型预估选择回收的region数目来满足用户预设的停顿时间。

G1 GC是一个响应时间优先的GC算法,它与CMS最大的不同是,用户可以设定整个GC过程的期望停顿时间,参数-XX:MaxGCPauseMillis指定一个G1收集过程目标停顿时间,默认值200ms,不过它不是硬性条件,只是期望值。具体实现比较复杂,有兴趣的可以去了解一下。

1.2.7.4 Global Concurrent Marking

Global Concurrent Marking执行过程类似CMS,但是不同的是,在G1 GC中,它主要是为Mixed GC(G1提供的一种模式,下文会介绍)提供标记服务的,并不是一次GC过程的一个必须环节。G1收集器Global Concurrent Marking的流程如下图所示:

  • G1执行的第一阶段:初始标记(Initial Marking )

这个阶段是STW(Stop the World )的,所有应用线程会被暂停,标记所有从GC Roots可直接到达的对象并将它们的字段压入扫描栈(marking stack)中等到后续扫描。G1使用外部的bitmap来记录mark信息,而不使用对象头的mark word里的mark bit。在分代式G1模式中,初始标记阶段借用young GC的暂停,因而没有额外的、单独的暂停阶段。

  • G1执行的第二阶段:并发标记

通过跟搜索算法,不断从扫描栈取出引用递归扫描整个堆里的对象图。每扫描到一个对象就会对其标记,并将其字段压入扫描栈。重复扫描过程直到扫描栈清空。过程中还会扫描SATB write barrier所记录下的引用。

  • 最终标记

在完成并发标记后,每个Java线程还会有一些剩下的SATB write barrier记录的引用尚未处理。这个阶段就负责把剩下的引用处理完,这个阶段也是STW(Stop The World)的。同时这个阶段也进行弱引用处理(reference processing)。

注意这个暂停与CMS的remark有一个本质上的区别,那就是这个暂停只需要扫描SATB buffer,而CMS的remark需要重新扫描mod-union table里的dirty card外加整个根集合,而此时整个young gen(不管对象死活)都会被当作根集合的一部分,因而CMS remark有可能会非常慢。

  • 清理垃圾

清除空Region(没有存活对象的),加入到free list。

第一阶段Initial Marking是共用了Young GC的暂停,这是因为他们可以复用root scan操作,所以可以说Global Concurrent Marking是伴随Young GC而发生的。第四阶段Cleanup只是回收了没有存活对象的Region,所以它并不需要STW。

1.2.7.4 G1收集器模式

G1收集器提供了两种GC模式,Young GC和Mixed GC,两种都是完全Stop The World的。 

  • Young GC:选定所有年轻代里的Region。通过控制年轻代的region个数,即年轻代内存大小,来控制young GC的时间开销
  • Mixed GC:选定所有年轻代里的Region,外加根据Global Concurrent Marking统计得出收集收益高的若干老年代Region。在用户指定的开销目标范围内尽可能选择收益高的老年代Region。

需要注意的是,Mixed GC不是full GC,它只能回收部分老年代的Region,如果mixed GC实在无法跟上程序分配内存的速度,导致老年代填满无法继续进行Mixed GC,就会使用serial old GC(full GC)来收集整个GC heap。所以我们可以知道,G1是不提供full GC的。

Young GC

在分配一般对象(非巨型对象)时,当所有Eden Region使用达到最大阀值并且无法申请足够内存时,会触发一次Young GC。每次Young Gc会回收所有Eden以及Survivor区,并且将存活对象复制到Old区以及另一部分的Survivor区。

Young GC的回收过程如下:

  • 根扫描,跟CMS类似,Stop The world,扫描GC Roots对象
  • 处理Dirty card,更新RSet
  • 扫描RSet,扫描RSet中所有old区对扫描到的young区或者survivor区的引用
  • 拷贝扫描出的存活的对象到survivor2/old区
  • 处理引用队列、软引用、弱引用、虚引用

Mixed GC

当越来越多的对象晋升到老年代old region时,为了避免堆内存被耗尽,虚拟机会触发一个混合的垃圾收集,即Mixed Gc。Mixed Gc回收整个young region,还有一部分的old region。这里需要注意的是,只一部分老年代,而不是全部老年代,回收老年代空间时会根据停顿预测模型(Pause Prediction Model)选择需要回收的region。需要回收的region都会放入CSet,region是否进入Cset会受以下几个参数影响:

  • G1MixedGCLiveThresholdPercent:old generation region中的存活对象的占比,只有在此参数之下,才会被选入CSet
  • G1MixedGCCountTarget:一次global concurrent marking之后,最多执行Mixed GC的次数
  • G1OldCSetRegionThresholdPercent:一次Mixed GC中能被选入CSet的最多old generation region数量

除了以上的参数,G1 GC相关的其他主要的参数有:

  • -XX:G1HeapRegionSize=n:设置Region大小,取值范围从1M到32M,且是2的指数。如果不设定,那么G1会根据Heap大小自动决定
  • -XX:MaxGCPauseMillis:设置G1收集过程目标时间,默认值200ms,不是硬性条件
  • -XX:G1NewSizePercent:新生代最小值,默认值5%
  • -XX:G1MaxNewSizePercent:新生代最大值,默认值60%
  • -XX:ParallelGCThreads:STW期间,并行GC线程数
  • -XX:ConcGCThreads=n:并发标记阶段,并行执行的线程数
  • -XX:InitiatingHeapOccupancyPercent:设置触发标记周期的 Java 堆占用率阈值。默认值是45%。这里的java堆占比指的是non_young_capacity_bytes,包括old+humongous

参考链接:

1. 《深入理解Java虚拟机》

2. [HotSpot VM] 请教G1算法的原理

3. Java Hotspot G1 GC的一些关键技术

4. G1 垃圾收集器介绍

5. jvm系列(三):GC算法 垃圾收集器

展开阅读全文
©️2020 CSDN 皮肤主题: 大白 设计师: CSDN官方博客 返回首页
实付0元
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、C币套餐、付费专栏及课程。

余额充值