1
头图

对象已死?

在堆里面存放着Java中几乎所有的对象实例,垃圾收集器在对堆进行回收前,第一件时间就是要确定哪些对象还 "存活" 着,哪些已经 "死去"(代表即不可能再被任何途径使用的对象)了。

引用计数算法

原理

在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;任何时刻计数器为0的对象就是不可能再被使用的。

优点

  • 简单直接
  • 判定效率高

缺点

  • 占用了额外的内存空间进行计数
  • 无法解决对象之间的相互循环依赖问题

对于互相循环引用,请看以下代码,testGC()方法中有对象objA和objB,对象属性中有instance,赋值 objA.instance = objB及objB.instance = objA,除此之外,这两个对象再无任何引用,实际上两个对象已经不可能再被访问,但是它们因为相互引用着对方,导致它们的引用计数都不为0,引用计数器算法也就无法回收它们。

执行结果

image.png

从运行结果中可以看清楚内存回收日之中包含 "6717K->608K",意味着虚拟机并没有因为这两个对象相互引用就放弃回收它们,这也从侧面说明了Java虚拟机并不是通过引用计数算法来判断对象是否存活的。

可达性分析算法

原理

可达性分析算法的基本思路就是通过一系列称为 "GC Roots"的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为 "引用链"(Reference Chain),如果某个对象到GC Roots 间没有任何引用链相连,或者用图论到话来说就是从GC Roots到这个对象不可达时,则证明此对象时不可能再被使用的。

如下图所示,对象obj5、obj6、obj7虽然有关联,但是它们到GC Roots是不可达到,因此他们将会判定为可回收的对象。

image.png

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

  • 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如当前正在运行的方法所用到的参数、局部变量、临时变量等。
  • 在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量。
  • 在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用。
  • 在本地方法栈中JNI(通常所说的Native方法)引用的对象。
  • Java虚拟机内部的引用,如基本类型对应的Class对象,一些常驻的异常对象(比如NullPointException、OutOfMemoryError)等,还有系统类加载器。
  • 所有被同步锁(synchronized关键字)持有的对象。
  • 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。

优点

可达性分析可以解决引用计数器所不能解决的循环引用问题

缺点

  • GC Roots包含过多对象可能会过度膨胀

目前最新的几款垃圾回收器无一例外都具备了局部回收的特征,为了避免GC Roots包含过多对象而过度膨胀,它们在实现上也做出了各种优化处理。

再谈引用

无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象是否引用链可达,判定对象是否存活和 "引用"离不开关系。在JDK1.2版之前,Java里面的引用是很传统的定义:如果reference类型的数据中存储的数值代表的是另一块内存的起始地址,就称该reference数据时代表某块内存、某个对象的引用。这种定义并没有什么不对,只是现在看来有些过于狭隘了,一个对象在这种定义下只有 "被引用" 或者 "未被引用" 两种状态,对于描述一些 "食之无味,弃之可惜" 的对象就显得无能为力。譬如我们希望能描述一类对象:当内存空间还足够时,能保留在内存之中,如果内存空间在进行垃圾收集后仍然非常紧张,那就可以抛弃这些对象--很多系统的缓存功能都符合这样的应用场景。

在JDK1.2版之后,Java对引用的对象进行了扩充,将引用分为强引用(Strongly Reference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference)4种,这4种引用强度依次减弱。

  • 强引用是最传统的 "引用" 的定义,是指在程序代码之中普遍存在的引用赋值,即类似 "Object obj = new Object()" 这种引用关系。无论任何情况下,只要强引用关系还存在,还可达,垃圾收集器就永远不会回收掉被引用的对象。
  • 软引用是用来描述一些还有用,但非必须的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。在JDK1.2之后提供了SoftReference类来实现软引用。
  • 弱引用也是用来描述那些非必须对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在JDK1.2版本之后提供了WeakReference类来实现若引用。
  • 虚引用也称为 "幽灵引用" 或者 "幻影引用",它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来去的一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。在JDK1.2版之后提供了PhantomReference类来实现虚引用。

生存or死亡?

即使在可达性分析算法中被判定为不可达到对象,也不是 "非死不可" 的,这时候它们还在世处于 "缓刑" 阶段,要真正宣告一个对象死亡,最多会经历两次标记过程:如果对象在进行可达分析之后发现没有与GC Roots相连接的引用链,那他将会被第一次标记,随后进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。假如对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,那么虚拟机将这两种情况都视为 "没有必要执行"。

如果这个对象被判定为确有必要执行finalize()方法,那么该对象将会被放置在一个F-Queue的队列之中,并在稍后由一条由虚拟机自动建立的、低调度优先级的Finalizer线程去执行它们的finalize()方法。这里所说的 "执行" 是指虚拟机会触发这个方法开始运行,但并不承诺一定会等待它运行结束。这样做的原因是,如果某个对象的finalize()方法执行缓慢,或者更极端地发生了死循环,将很可能导致F-Queue队列中的其他对象永久处于等待,甚至导致整个内存回收子系统的崩溃。finalize()方法是对象逃脱死亡的最后一次机会,稍后收集器将堆F-Queue中的对象进行第二次小规模的标记,如果对象要在finalize()中成功拯救自己————只要重新与引用链上的任何一个对象建立关联即可,譬如把自己(this关键字)赋值给某个类变量或者对象的成员变量,那在第二次标记时它将会被移出 "即将回收" 的集合;如果对象这时候还没有逃脱,那基本上它就真的要被回收了。从以下代码中我们可以看到一个对象的finalize()被执行,但是它依然可以存活。

/**
 * 代码演示了两点:
 * 1.对象可以在被GC时自我拯救。
 * 2.这种自救的机会只有一次,因为一个对象的finalize()方法最左只会被系统自动调用一次
 */
public class FinalizeEscapeGC {

    public static FinalizeEscapeGC SAVE_HOOK = null;

    public void isAlive() {
        System.out.println("是的,我还活着!");
    }

    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("finalize 方法被执行!");

        // 被静态属性引用,可完成自救,在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量可作为GC Roots
        FinalizeEscapeGC.SAVE_HOOK = this;
    }

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

        // 对象第一次成功拯救自己
        SAVE_HOOK = null;
        System.gc();
        // 因为Finalizer方法优先级很低,暂停0.5秒,等待它
        TimeUnit.MILLISECONDS.sleep(500);
        if (SAVE_HOOK != null)
            SAVE_HOOK.isAlive();
        else
            System.out.println("我已经死了");


        // 下面的代码与上面完全相同,但是这次却自救失败了(一个对象自救的机会只有一次)
        SAVE_HOOK = null;
        System.gc();
        // 因为Finalizer方法优先级很低,暂停0.5秒,等待它
        TimeUnit.MILLISECONDS.sleep(500);
        if (SAVE_HOOK != null)
            SAVE_HOOK.isAlive();
        else
            System.out.println("我已经死了");

    }
}

运行结果:

image.png

从以上代码的运行结果可以看到,SAVE_HOOK对象的finalize()方法确实被垃圾收集器触发过,并且在被收集前成功自我拯救了。

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

finalize()方法是Java刚诞生时为了使传统C、C++程序员更容易接受Java所做出的一项妥协。它的运行代价高昂,不确定性大,无法保证各个对象的调用顺序,如今已被官方明确声明为不推荐使用的语法。

回收方法区

方法区的垃圾收集主要回收两部分内容:废弃的常量和不再使用的类型。回收废弃的常量与回收Java堆中的对象非常类似。举个常量池中字面量回收的例子,假如一个字符串 "java" 曾经进入常量池中,但是当前系统又没有任何一个字符串对象的值是 "java",换句话说,已经没有任何字符串对象引用常量池中的 "java" 常量,且虚拟机中也没有其他地方引用这个字面量。如果在这时发生内存回收,而且垃圾收集器判断确有必要的话,这个 "java" 常量就会被系统清理出常量池。常量池中其他类(接口)、方法、字段的符号引用也与此类似。

判定一个常量是否 "废弃" 还是相对简单,而要判定一个类型是否属于 "不再被使用的类" 的条件就比较苛刻了。需要同时满足下面三个条件:

  • 该类的所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例。
  • 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如OSGi、JSP的重加载,否则通常是很难达成的。
  • 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

Java虚拟机被允许对满足上述三个条件的无用类进行回收,这里说的仅仅是 "被允许",而并不是和对象一样,没有引用了就必然会回收。关于是否对类型进行回收,HotSpot虚拟机提供了-Xnoclassgc参数进行控制,还可以使用-verbose:class以及-XX:+TraceClassLoading、-XX:+TraceClassUnLoading查看类加载和卸载的信息,其中-verbose:class和-XX:+TraceClassLoading可以在Product版的虚拟机中使用,-XX:+TraceClassUnloading参数需要FastDebug版的虚拟机支持。

垃圾收集算法

分代收集理论

当前商业虚拟机的垃圾收集器,大多数遵循了 "分代收集"(Generational Collection)的理论进行设计,分代收集名为理论,实质是一套符合大多数程序运行实际情况的经验法则,它建立在两个分代假说之上:

  • 弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的。
  • 强分代假说(Strong Generational Hypothesis):熬过多次垃圾收集过程的对象就越难以消亡。

这两个分代假说共同奠定了多款常用的垃圾收集器的一致的设计原则:收集器应该将Java堆划分出不同的区域,然后将回收对象依据年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。显而易见,如果一个区域中大多数对象都是朝生夕灭,难以熬过垃圾收集过程的话,那么把它们集中在一起,每次回只关注如何保留少量存活而不是去标记那些大量将要回收的对象,就能以低代价回收到大量的空间;如果剩下的都是难以消亡的对象,那把它们集中放在一块,虚拟机便可以使用较低的频率来回收这个区域,这就同时兼顾了垃圾收集的时间开销和内存空间的有效利用。

在Java堆划分出不同的区域之后,垃圾收集器才可以每次只回收其中某一个或者某些部分的区域————因而才有了 "Minor GC" " Major GC" "Full GC" 这样的回收类型的划分;

把分代收集理论具体放到现在的商用Java虚拟机里,设计者一般至少会把Java堆划分为新生代(Yang Generation)和老年代(Old Generation)两个区域。顾名思义,在新生代中,每次垃圾收集时都发现有大批对象死去,而每次回收后存活的少量对象,将会逐步晋升到老年代中存放。其实我们只要仔细思考一下,也很容易发现分代收集并非只是简单划分一下内存区域那么简单,它至少存在一个明显的困难:对象不是孤立的,对象之间会存在跨代引用。

假如现在进行一次只局限于新生代区域内的收集(Minor GC),但在新生代中的对象完全有可能被老年代所引用,为了找出该区域中的存活对象,不得不在固定的GC Roots之外,再额外遍历整个老年代中所有对象来确保可达性分析结果的正确性,反过来也是一样。编译整个老年代所有对象的方案虽然理论上可行,但无疑会为内存回收带来很大的性能负担。为了解决这个问题,就需要堆分代收集器理论添加第三条经验法则:

  • 跨代引用假说(Intergenerational Reference Hypothesis):跨代引用相对于同代引用来说占极少数。

这其实是可根据前两条假说逻辑推理的出的隐含推论:存在互相引用关系的两个对象,是应该倾向于同时生存或者同时消亡的。举个例子,如果某个新生代对象存在跨代引用,由于老年代对象难以消亡,该引用会使得新生代对象在收集时同样得以存活,进而在年龄增长之后晋升到老年代中,这时跨时代引用也随即被消除了。

根据这条假说,我们就不应再为了少量的跨代引用去扫描整个老年代,也不必浪费空间专门记录每一个对象是否存在及存在哪些跨代引用,只需在新生代上建立一个全局的数据结构(该结构被称为 "记忆集",Remembered Set),这个结构把老年代划分成若干小块,标示出老年代的那一块内存会存在跨代引用。此后当发生Minor GC时,只有包含了跨代引用的小块内存里的对象会被加入到GC Roots进行扫描。虽然这种方法需要在对象改变引用关系(如将自己或某个属性赋值)时维护记录数据的正确性,会增加一些运行时的开销,但比起收集时扫描整个老年代来说仍然是划算的。

image.png

收集作用域

  • 部分收集(Partial GC):指目标不是完整收集整个Java堆的垃圾收集,其中又分为:

    • 新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾收集。
    • 老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集。目前只有CMS收集器会有单独收集老年代的行为。
    • 混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集。目前只有G1收集器会有这种行为。
  • 整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集。

标记-清除算法

最早出现也是最基础的垃圾收集算法是 "标记 - 清除"(Mark-Sweep)算法,在1960年由Lisp之父John McCarthy所提出。如它的名字一样,算法分为 "标记" 和 "清除" 两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。标记的过程就是对象是否属于垃圾的判定过程(可达性分析算法)。

之所以说它是最基础的收集算法,是因为后续的收集算法大多数是以标记-清除算法为基础,对其缺点进行改进而得到的。它的主要缺点有两个:

  • 第一个是执行效率不稳定,如果Java堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随着对象数量增长而降低;
  • 第二个是内存空间的碎片化问题,标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后再程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前出发另一次垃圾收集动作。

image.png

标记-复制算法

标记-复制算法常被简称为复制算法。为了解决标记-清除面对大量可回收对象时执行效率低的问题,1969年Fenichel提出了一种称为 "半区复制" (Semispace Copying)的垃收集算法,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开销,但对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收,分配对象时也就不用考虑有空间碎片的复杂情况,只要一动堆顶指针,按顺序分配即可。

有点

  • 解决碎片化问题。
  • 当对象存活率低时,只需要将少数的对象进行复制。

缺点

  • 如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开销。
  • 这种复制回收算法的代价是将可用内存缩小为了原来的一半,空间浪费未免太多。

image.png

在1989年,Andrew Appel针对具备 "朝生夕灭" 特点的对象,提出了一种更优化的班区复制分代策略,现在称为 "Appel式回收"。HotSpot虚拟机的Serial、ParNew等新生代收集器均采用了这种策略来设计新生代的内存布局。Appel式回收的具体做法是把新生代分为一块较大的Eden空间和两块较小的Survivor空间,每次分配内存只使用Eden和其中的一块Survivor。发生垃圾收集时,将Eden和Survivor中仍然存活的对象一次性复制到另外一块Survivor空间上,然后直接清理掉Eden和一用过那块的Survivor空间。HotSpot虚拟机默认Eden和Survivor的大小比例是8 :1,也即每次新生代中可用内存空间为整个新生代容量的90%(Eden的80%加上一个Survivor的10%),只有一个Survivor空间,即10%的新生代是会被 "浪费" 的。当然,98%的对象可被回收仅仅是 "普通场景" 下测得的数据,任何人都没有办法百分百保证每次回收都只有不多于10%的对象存活,因此Appel式回收还有一个充当罕见情况的 "逃生门" 的安全设计,当Survivor空间不足以容纳一次Minor GC之后存活的对象时,就需要依赖其他内存区域(实际上大多就是老年代)进行分配担保(Handle Promotion)。

如果另外一块Survivor空间没有足够空间存放上一次新生代收集下来的存活对象,这些对象便将通过分配担保机制直接进入老年代,这对虚拟机来说就是安全的。

标记-整理算法

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

针对老年代对象的死亡特征,1974年Edward Lueders提出了另外一种有针对性的 "标记-整理" (Mark-Compact)算法,其中的标记过程仍然与 "标记-清除" 算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存,"标记-整理" 算法示意图如下所示。

image.png

标记-清除算法与标记-整理算法的本质差异在于前一者是一种非移动式的回收算法,而后者是移动式的。是否移动回收后的存活对象是一项优缺点并存的风险决策:

如果移动存活对象,尤其是在老年代这种每次回收都有大量对象存活区域,移动存活对象并更新所有引用这些对象的地方将会是一种极为负重的操作,而且这种对象移动操作必须全程暂停用户应用程序才能进行(标记-清除算法也是需要停顿用户线程来标记、清理可回收对象的,只是停顿时间较短而已),这就更加让使用者不得不小心翼翼地权衡其弊端了,像这样的停顿被最初的虚拟机设计者形象地描述为 "Stop The World"。

但如果跟标记-清除算法那样完全不考虑移动和整理存活对象的话,弥散于堆中的存活对象导致的空间碎片化问题就只能依赖更为复杂的内存分配器和内存访问器来解决。譬如通过 "分区空闲分配链表" 来解决内存分配问题(计算机硬盘存储大文件就不要求物理连续的磁盘空间,能够在碎片化的硬盘上存储和访问就是通过硬盘分区表实现的)。内存的访问是用户程序最频繁的操作,甚至都没有之一,假如在这个环节上增加了额外的负担,势必会直接影响应用程序的吞吐量。

以上这两点,是否移动对象都存在弊端,移动则内存回收时会更加复杂,不移动则内存分配时会更加复杂。从垃圾收集的停顿时间来看,不移动对象停顿时间会更短,甚至可以不需要停顿,但是从整个应用程序的吞吐量来看,移动对象会更划算。即使不移动对象会使得收集器的效率提升一些,但因内存分配和访问相比垃圾收集频率要高得多,这部分的耗时增加,总吞吐量仍然是下降的。HotSpot虚拟机里面关注吞吐量的Parallel Old收集器是基于标记-整理算法的,而关注延迟的CMS收集器是基于标记-清除算法的。

另外,还有一种 "和稀泥式" 解决方案可以不再内存分配和访问上增加太大的额外负担,做法是让虚拟机平时多数时间都采用标记-清除算法,暂时容忍内存碎片的存在,知道内存空间的碎片化程度已经大到影响对象分配时,再采用标记-整理算法收集一次,以获得规整的内存空间。前面提到的基于标记-清除算法的CMS收集器面临空间碎片过多时采用的就是这种处理办法。

经典垃圾收集器

下图展示了七种作用于不同分代的收集器,如果两个收集器之间存在连线,就说明它们可以搭配使用,图中收集器所处的区域,则表示它是属于新生代收集器亦或是老年代收集器。

image.png

Serial收集器

Serial收集器是最基础、历史最悠久的收集器,曾经(JDK 1.3.1之前)时HotSpot虚拟机新生代收集器的唯一选择。

Serial收集器从名称就可以得知,这个收集器是一个单线程工作的收集器,但它的 "单线程" 的意义不仅仅是说明它只会使用一个处理器或一条收集线程去完成垃圾收集工作,更重要的是强调它进行垃圾回收时,必须暂停其他所有工作线程,直至它收集结束。"Stop The World" 这个词语也许听起来很酷,但这项工作是由虚拟机自动发起和自动完成的,在用户不可知、不可控的情况下把用户的正常工作的线程全部停掉,这对于很多应用来说都是不能接受的。读者不妨试想一下,要是你的电脑没运行一小时就会暂停响应五分钟,你会有什么样的心情?下图示意了Serial/Serial Old收集器的运行过程。

image.png

优点

  • 与其他收集器的单线程相比,Serial收集器简单而高效,对于单核处理器或处理器核心数少的环境来说,Serial收集器由于没有线程交互的开销,专门做垃圾收集自然可以获得最高的单线程收集效率。

缺点

  • Serial收集器会在用户不可知、不可控的情况下把用户的正常工作的线程全部停掉,来进行垃圾回收。
  • Serial收集器只有一个工作线程进行垃圾收集。

适用场景

Serial适用于对于单核处理器或处理器核心数少的环境,对于运行在客户端模式下的虚拟机来说是一个很好的选择。

ParNew收集器

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

image.png

ParNew收集器除了支持多线程并行收集之外,其他于Serial收集器相比并没有太多创新之处。但它却是不少运行在服务端下的HotSpot虚拟机,尤其是JDK 7之前的遗留系统中首选的新生代收集器,其中有一个与功能、性能无关但其实很重要的原因:除了Serial收集器外,目前只有它能与CMS收集器配合工作。

在JDK5发布时,HotSpot推出了一款在强交互应用中几乎可称为具有划时代意义的垃圾收集器————CMS收集器。这款收集器时HotSpot虚拟机中第一款真正意义上支持并发的垃圾收集器,它首次实现了让垃圾收集线程与用户线程(基本上)同时工作。

遗憾的是,CMS作为老年代的收集器,却无法与JDK 1.4.0中已经存在的新生代收集器 Parallel Scavenge 配合工作,所以在JDK5中使用CMS来收集老年代的时候,新生代只能选择ParNew或者Serial收集器中的一个。ParNew收集器是激活CMS后(使用-XX:+UseConcMarkSweepGC选项)的默认新生代收集器。也可以使用-XX+/-UseParNewGC选项来强制指定或者禁用它。

可以说直到CMS的出现才巩固了ParNew的地位,但成也萧何败也萧何,随着垃圾收集器计数的不断改进,更先进的G1收集器带着CMS继承者和替代者的光环登场。G1是一个面向全堆的收集器,不再需要其他新生代收集器的配合工作。所以自JDK9开始,ParNew加CMS收集器的组合不再是官方推荐的服务端模式下的收集器解决方案了。官方希望它能完全被G1所取代,甚至还取消了ParNew加Serial Old
以及Serial加CMS这两组收集器组合的支持(其实原本也很少人这样使用),并直接取消了-XX:+UseParNewGC参数,这意味着ParNew和CMS从此只能互相搭配使用,再也没有其他收集器能够和它们配合了。也可以理解为从此以后,ParNew合并入CMS,成为它专门处理新生代的组成部分。ParNew可以说是HotSpot中第一款退出历史舞台的垃圾收集器。

优点

  • ParNew收集器支持多线程并行收集垃圾。
  • 除了Serial收集器之外目前只有它能与CMS收集器配合工作。

缺点

  • JDK9开始,ParNew加CMS收集器的组合不再是官方推荐的服务端模式下的收集器解决方案了。
  • JDK9开始,取消了ParNew加Serial Old收集器组合的支持。
  • JDK9开始,ParNew只能配合CMS收集组合使用。

适用场景

只要满足以下所有情景,可以使用ParNew垃圾收集器。

  • JDK9之前版本
  • 与CMS老年代垃圾收集器配合使用,保证基本上用户线程与垃圾收集线程同时工作。
  • 一款除了Serial Old收集器之外的,能与CMS收集器组合使用的年轻代垃圾收集器。
  • 服务器端配置为多核处理器。
注意: 从上文中的ParNew、CMS收集器涉及到了并行和并发概念,本章接下来所有的并发、并行概念与下面解释完全一致:
  • 并行(Parallel):并行描述的是多条垃圾收集器线程之间的关系,说明同一时间有多条这样的线程在协同工作,通常默认此时用户线程是处于等待状态。
  • 并发(Concurrent):并发描述的是垃圾收集器线程与用户线程之间的关系,说明统一时间垃圾收集器线程与用户线程都在运行。由于用户线程并未被冻结,所以程序仍然能响应服务请求,但由于垃圾收集器线程占用了一部分系统资源,此时应用程序的处理的吞吐量收到一定影响。

Parallel Scavenge收集器

Parllel Scavenge收集器也是一款新生代收集器,它同样是基于标记-复制算法实现的收集器,也是能够并行收集的多线程收集器。

PS收集器的特点是它的关注点与其他收集器不同,CMS等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,而PS收集器的目标则是达到一个可控制的吞吐量(Throughput)。所谓吞吐量就是处理器用于运行用户代码的时间与处理器总消耗时间的比值,即:

吞吐量=运行用户代码时间/(运行用户代码时间+运行垃圾收集时间)

如果虚拟机完成某个任务,用户代码加上垃圾收集总共耗费了100分钟,其中垃圾收集花掉1分钟,那吞吐量就是99%。停顿时间越短就越适合需要与用户交互或需要保证服务响应质量的程序,良好的响应速度能提升用户体验;而高吞吐量则可以最高效率的利用处理器资源,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的分析任务。

控制吞吐量

PS收集器提供了三个参数用于控制吞吐量

  • -XX:MaxGCPauseMillis参数: 这个参数是通知最大垃圾收集停顿时间的,器参数允许的值是一个大于0的毫秒值,收集器将尽力保证内存回收花费时间不超过用户设定值。不过不要异想天开的认为如果把这个参数的值设置的小一点就能使系统的垃圾收集速度变得更快,垃圾收集停顿时间缩短是以牺牲吞吐量和新生代空间为代价换取的:系统把新生代调的小一些,收集300MB新生代肯定比收集500MB快,但这也直接导致垃圾收集发生得更频繁,原来10秒收集一次、每次停顿100毫秒,现在变成5秒收集一次、每次停顿70毫秒。停顿时间的确在下降,但吞吐量也降下来了。
  • XX:GCTimeRatio参数:这个参数是直接设置吞吐量的大小的,其参数值应设置为一个正整数,表示用户期望虚拟机在GC上的时间不超过运行时间的1/(1+N)。默认值是99,含义是尽可能保证应用程序执行的时间为收集器执行时间的99倍,也即收集器的时间消耗不超过总运行时间的1%。
  • -XX:+UseAdaptiveSizePolicy参数:这个参数值的我们关注。它是一个开关参数,当这个参数被激活之后,就不需要人工指定新生代的大小(-Xmn)、Eden与Survivor区的比例(-XX:SurvivorRatio)、晋升老年代对象大小(-XX:PretenureSizeThreshold)等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量。这种调节方式称为垃圾收集的自适应的调节策略(GC Ergonomics)。如果对于收集器运作不太了解,手工优化存在困难的话,使用PS收集器配合自适应调节策略,把内存管理的调优任务交给虚拟机区完成也是一个很不错的选择。只需要把基本的内存数据设置好(如 -Xmx设置最大堆)然后使用-XX:MaxGCPauseMillis参数(更关注最大停顿时间)或-XX:GCTimeRatio(更关注吞吐量)参数给虚拟机设立一个优化目标,那具体细节参数的调节工作就由虚拟机完成了。自适应调节策略也是PS收集器区别于ParNew收集器的一个特性。

优点

  • PS是一个以吞吐量为中心的收集器,可以使用其参数自行控制对垃圾收集的停顿时间、吞吐量大小、自适应调节策略。
  • PS是一个并行收集的多线程收集器。

缺点

  • 若自行进行PS收集器的参数设置,需要开发人员对收集器运作有一定的了解(不过有自适应调节策略)。
  • 主要以吞吐量为中心,不适用于以用户体验为主的应用。

适用场景

PS适用于以后台运算而不需要太多交互的分析任务。

Seral Old收集器

Serial Old是Serial收集器的老年代版本,它同样是一个单线程收集器,使用标记-整理算法。这个收集器的主要意义也是供客户端模式下的HotSpot虚拟机使用。如果在服务端模式下,它可能有两种用途:

  • 一种是在JDK5以及之前的版本中与PS收集器搭配使用
  • 一种是作为CMS收集器发生失败时的后备预案,在并发收集发生Concurrent Mode Failure时使用。

Serial Old收集器的工作过程如下:

Serial/Serial Old收集器运行示意图

Parallel Old收集器

Parallel Old是Parallel Scavenge收集器的老年代版本,支持多线程并行收集,基于标记-整理算法实现。这个收集器是直到JDK6时才开始提供的,在此之前,新生代的Parallel Scavenge收集器一直处于相当尴尬的状态,原因是如果新生代选择了Parallel Scavenge收集器,老年代初了Serial Old(PS MarkSweep)收集器以外别无选择,其他表现良好的老年代收集器,如CMS无法与他配合工作。由于老年代Serial Old收集器在服务端应用性能上的 "拖累" ,使用Parallel Scavenge收集器也未必能在整体上获得吞吐量最大化的效果。同样,由于单线程的老年代收集中无法充分利用服务器多处理器的并行处理能力,在老年代内存空间很大而且硬件规格比较高级的运行环境中,这种组合的总吞吐量甚至不比ParNew加CMS的组合来的优秀。

直到Parallel Old收集器出现后,"吞吐量优先" 收集器终于有了比较名副其实的搭配组合,在注意吞吐量或者处理器资源较为稀缺的场合,都可以优先考虑Parallel Scavenge加Parallel Old收集器这个组合。Parallel Old收集器的工作过程如下:

image.png

优点

  • 支持多线程并行收集老年代对象
  • Parallel Old收集器的出现,完善了Parallel Scavenge收集器以吞吐量为中心的老年代收集器。

适用场景

  • 在注意吞吐量或者处理器资源较为稀缺的场景,都可以考虑Parallel Scavenge加Parallel Old收集器这个组合。

CMS收集器

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

CMS收集器是基于标记-清除算法实现的,它的运作过程相对于前面几种收集器来说要更复杂一些,整个过程分为四个步骤,包括:

  • 初始标记(CMS initial mark):初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快;
  • 并发标记(CMS concurrent mark):并发标记阶段就是从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行;
  • 重新标记(CMS remark):重新标记阶段是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一小部分对象的标记记录,这个阶段的停顿时间通常会比初始标记阶段稍长一些,但也远比并发标记阶段时间短;
  • 并发清除(CMS concurrent sweep):并发清除阶段是清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的。

image.png

优点

  • CMS收集器的内存回收过程是与用户线程一起并发执行的

缺点

  • CMS收集器对处理资源非常敏感,需要依赖于处理器的核心数量
  • CMS收集器无法处理 "浮动垃圾",有可能出现 "Concurrent Mode Failure" 失败进而导致另一次完全 "Stop The World" 的Full GC的产生。
  • CMS收集器是基于标记-清除算法实现的收集器,因此会产生大量的空间碎片(但可以使用下面可用参数进行设置)。

可用参数

  • -XX:CMSInitiatingQccu-pancyFraction:CMS收集器不能像其他收集器那样,等待到老年代几乎被填满了再进行收集,必须预留一部分空间供并发收集时的程序运作。在JDK5默认设置下,CMS收集器当老年代增长的不是太快,可以适当的调高-XX:CMSInitiatingQccu-pancyFraction的值来提高CMS的触发百分比,降低内存回收频率,获得更好的性能。
  • -XX:+UseCMSCompactAtFullCollection:用于在CMS收集器不得不进行Full GC时开启内存碎片的合并整理过程,由于这个内存整理移动存活对象,(在Shenandoah和ZGC出现前)是无法并发的。这样的空间碎片问题是解决了,但停顿时间会变长。
  • -XX:CMSFullGCsBeforeCompaction:这个参数作用时要求CMS收集器在执行过程若干次(数量由参数值决定)不整理空间的Full GC之后,下一次进入Full GC前会先进行碎片整理(默认值为0,表示每次进入Full GC时都进行碎片整理)。

适用场景

CMS适用于互联网网站或者基于浏览器的B/S系统的服务端,并且应用关注服务的响应速度,希望系统停顿时间短。

Garbage First收集器

G1是一款主要面向服务端应用的垃圾收集器。HotSpot开发团队最初赋予它的期望是(在比较长期的)未来可以替换掉JDK5中发布的CMS收集器。现在这个期望目标已经实现过半了,JDK9发布之日,G1宣告取代Parallel Scavenge加Parallel Old组合,成为服务端模式下的默认垃圾收集器,而CMS则沦落至被声明为不推荐使用(Deprecate)的收集器。如果对JDK9及以上版本的HotSpot虚拟机使用参数-XX:+UseConcMarkSweepGC来开启CMS收集器的话,用户会收到一个警告信息,提示CMS未来将会被废弃:

image.png

作为CMS收集器的替代者和继承人,设计者们希望做出一款能够建立起 "停顿预测模型"(Pause Prediiction Model)的收集器,停顿预测模型的意思是能够支持指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间大概率不会超过N毫秒这样的目标,这几乎已经是实时Java(RTSJ)的中软实时垃圾收集器特征了。

G1收集器出现之前的所有其他收集器,包括CMS在内,垃圾收集的目标范围要么是整个新生代(Minor GC),要么就是整个老年代(Major GC),再要么就死活整个Java堆(Full GC)。而G1跳出了这个攀笼,它可以面向堆内存任何部分来组成回收集(Collection Set,简称CSet)进行回收,衡量标准不再是它属于那个分代,而是哪块内存中存放的垃圾数量最多,回收利益最大,这就是G1收集器的Mixed GC模式。

G1不再坚持固定大小以固定数量的分代区域划分,而是把连续的Java堆划分为多个大小相等的独立区域(Region),每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间或者老年代空间。

Region中还有一类特殊的Humongous区域,专门用来存储大对象。G1认为只要大小超过了一个Region容量一半的对象即可判定为大对象。每个Region的大小可以通过参数-XX:G1HeapRegionSize设定,取值范围为1MG~32MB,且应为2的N次幂。而对于那些超过了整个Region容量的超级大对象,将会被存放在N个连续的Humongous Region之中,G1的大多数行为都把Humongous Region作为老年代的一部分进行看待,如下图:

image.png

虽然G1仍然保留新生代和老年代的概念,但新生代和老年代不再是固定的了,他们都是一系列区域(不需要连续)的动态集合。G1收集器之所以能建立可预测的停顿时间模型,是因为它将Region作为单次回收的最小单元,即每次收集到的内存空间都是Region大小的整数倍,这样可以有计划地避免在整个Java堆中进行全区域的垃圾收集。更具体的处理思路是让G1收集器去跟踪各个Region里面的垃圾堆积的 "价值" 大小,价值即回收所获得的空间大小以及回收所需要的经验值,然后在后台维护一个优先级列表,每次根据用户设定允许的收集停顿时间(使用参数 -XX:MaxGCPauseMillis制定,默认值是200ms),优先处理回收价值收益最大的那些Region,这也就是 "Garbage First" 名字的由来。这种使用Region划分内存空间,以及具有优先级的区域回收方式,保证了G1收集器在有限的时间内获取尽可能高的收集效率。

但是G1收集器目前至少有(但不限于)这些关键的细节问题需要解决:

  • 将Java堆分为多个独立的Region后,Region里面存在的跨Region引用对象如何解决?事实上G1是使用记忆集避免全堆作为GC Roots扫描,其中每个Region都维护自己的记忆集,这些记忆集会记录下别的Region指向自己的指针,并标记这些指针分别在哪些卡页的范围之内。G1的记忆集在存储结构的本质上是一钟哈希表,Key是别的Region的起始地址,Value是一个集合,里面存储的元素是卡表的索引号。这种 "双向" 的卡表结构(卡表是 "我指向谁",这种结构还记录了 "谁指向我")比原来的卡表实现起来更复杂,同时由于Region数量比传统收集器的分代数量明显要多得多。根据经验,G1至少要耗费大约相当于Java堆容量10%至20%的额外内存来维持收集工作。
  • 在并发标记阶段如何保证手机线程与用户线程互不干扰地运行?这里首先要解决的是用户线程改变对象引用关系时,必须保证其不能打破原本的对象图结构,导致标记结果出现错误,CMS收集器采用增量更新算法实现,而G1收集器则是通过原始快找(STAB)算法来实现的。此外垃圾收集对用户线程的影响还体现在回收过程中新创建对象的内存分配上,程序要继续运行就肯定会持续有新对象被创建,G1为每一个Region设计了两个名为TAMS(Top at Mark Start)的指针,把Region中的一部分空间划分出来用于并发回收过程中的新对象分配,并发回收时新分配的对象地址必须要在这两个指针位置上。G1收集器默认在这个地址以上的对象是被隐式标记过的,即默认它们是存活的,不纳入回收范围。与CMS中的 "Concurrent Mode Failure" 失败会导致Full GC类似,如果内存回收的速度赶不上内存分配的速度,G1收集器也要被迫冻结用户线程执行,导致Full GC而产生长时间 "Stop The World"。
  • 怎样建立起可靠的停顿预测模型?用户通过 -XX:MaxGCPauseMillis参数指定的停顿时间只意味着垃圾收集发生之前的期望值,但G1收集器要怎么做才能满足用户的期望呢?G1收集器的停顿预测模型是以衰减均值(Decaying Average)为理论基础来实现的,在垃圾收集过程中,G1收集器会记录每个Region的回收耗时、每个Region记忆集里的脏卡数量等各个可测量等步骤花费的成本,并分析得出平均值、标准偏差、置信度等统计信息。Region等统计状态越新越能决定回收集才可以在不超过期望停顿时间的约束下获得最高的利益。

如果不去计算用户线程运行过程中的动作(如使用写屏障维护记忆集的操作),G1收集器的运作过程大致可划分为以下四个步骤:

  • 初始标记(Initial Marking):仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。这个阶段需要停顿线程,但耗时很短,而且是借用进行Minor GC的时候同步万层呢的,所以G1收集器在这个阶段实际并没有额外的停顿。
  • 并发标记(Concurrent Marking):从GC Roots开始对堆中对象进行可达性分析,递归扫描整个堆堆对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。当对象图扫描完成以后,还要重新处理SATB记录下的在并发时有引用变动的对象。
  • 最终标记(Final Marking):对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的STAB记录。
  • 筛选回收(Live Data Counting And Evacuation):负责更新Region的统计数量,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行完成。

优点

  • G1从整体上来看是基于 "标记-整理" 算法实现的收集器,但从局部(两个Region之间)上看又是基于 "标记-复制" 算法实现,无论如何这两种算法都以为着G1运作期间不会产生内存空间碎片,垃圾收集完成之后能提供规整的可用内存。这种特性有利于程序长时间运行,在程序为打对象分配内存时不容易因无法找到连续内存空间而提前触发下一次收集。
  • G1可以指定最大停顿时间,可使得G1在不同应用场景中取得关注吞吐量和关注延迟之间的最佳衡量。
  • G1基于Region的内存布局,可以使垃圾对象按照收益动态确定回收集。

缺点

  • 根据经验,G1的垃圾收集工作,至少要耗费大约相当于Java堆容量的10%至20%的额外内存,内存占用较高。
  • 在负载角度,G1除了使用写后屏障来进行同样的(由于G1的卡表结构复杂,其实是更繁琐)卡表维护操作外,为了实现原始快找搜索(SATB)算法,还需要使用写前屏障来跟踪并发时的指针变化情况。相比起CMS的增量更新算法,原始快找搜索能够介绍并发标记和重新标记阶段的消耗,避免CMS那样在最终标记阶段停顿时间过长的缺点,但是在用户程序运行过程中确实会产生由跟踪引用变化带来的额外负担。由于G1对写屏障的复杂操作要比CMS消耗更多的运算资源,所以CMS的鞋屏障实现是直接的同步操作,而G1就不得不将其实现为类似于消息队列的结构,把写屏障和写后屏障中要做的事情都放到队列里,然后再异步处理。

适用场景

G1垃圾收集器在大内存应用上能发挥其优势,这个劣势的Java堆容量平衡点通常在6GB至8GB之间。

Shenandoah收集器

ZGC收集器


Zeran
32 声望4 粉丝

学而不思则罔,思而不学则殆。