深入理解java虚拟机(第三版)笔记-垃圾收集器

第二章介绍了java内存运行时的各个部分,其中程序计数器、虚拟机栈、本地方法栈随线程而生,随线程而灭,栈中的栈帧随着方法的进入和退出而有条不紊地执行入栈和出栈的操作,每一个栈帧中分配多少内存基本都是在类结构确定下来时就已知的,因此这几个区域的内存分配和回收都具备确定性,在这几个区域内不需要过多考虑如何回收的问题,当方法结束或线程结束的时候,内存自然就随着回收了。

但是java堆和方法区这两个区域有着显著的不确定性:一个接口的多个实现类需要的内存可能不同,一个方法所执行不同条件分支所需要的内存也可能不一样,只有处于运行期间,我们才能知道程序究竟会创建哪些对象,创建多少对象。这部分内存的分配和回收是动态的,垃圾收集器所关注的正式这部分内存该如何管理。

判断对象已死

  • 引用计数法:在对象中添加一个引用计数器,每当一个地方引用它,计数器就加一,引用失效时,计数器的值就减一;任何时刻计数器为零的对象就是不可能再被使用的。该方法原理简单,判定效率高,但是在java领域,至少主流的java虚拟机都没有选择引用计数法来管理内存。这个简单的算法需要很多例外情况需要考虑,要配合大量额外处理才能保证正确工作,譬如单纯的引用计数就很难解决对象之间相互循环引用的问题。

    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();
        }
    
        public static void main(String[] args) {
            testGC();
        }
    }
    

    如果采用引用计数,那么objA和objB分别为null后,按道理已经无法再访问了,但是它们内部分别引用着对方,所以计数不为0。但是我们通过参数-XX:+PrintGCDetails打印日志,发现确实进行了回收:

    当然循环引用是可以解决的,C++提供了智能指针,对于会互相引用的对象,提供了弱指针,弱指针是不会导致计数+1的,这样就不会导致两个对象明明已经不可访问了但是还互相拖着对方。

  • 可达性分析:目前java和c#都采用的这种算法。基本思路是通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程中所走过的路径称为“引用链”,如果某个对象到GC Roots间没有人份he引用链相连,或者用图论的话来说就是GC Roots到这个对象不可达的时候,证明此对象是不可能再被使用的。

    比如图中的567对象还在互相引用,但是已经不可达了,所以还是会被回收。在java体系中可以作为GC Roots的对象包括以下几种:

    • 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。
    • 在方法区中类静态属性引用的对象,譬如java类的引用类型静态变量
    • 在方法区中常量引用的对象,譬如字符串常量池里的引用
    • 在本地方法栈中引用的对象
    • java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象等,还有系统类加载器
    • 所有被同步锁(synchronized关键字)持有的对象
    • 反映java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等

    除了这些固定的GC Roots集合外,根据用户所选择的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象“临时性”加入,共同构成GC Roots集合,后续会陆续讲。

  • 引用

    无论是引用计数法还是可达性分析,都离不开“引用”,在jdk1.2之后,java对引用概念进行了扩展,分为了强引用、软引用、弱引用和虚引用。

    • 强引用(StronglyReference),最传统的引用定义,是代码中普遍存在的引用赋值,类似“Object obj = new Object()”这种引用关系。只要强引用还在,垃圾收集器就永远不会回收被引用的对象。
    • 软引用(SoftReference),用来描述一些还有用但是非必须的对象,只要被软引用关联的对象,在系统将要发生内存溢出之前,会把这些对象列入回收范围内进行二次回收,如果回收后还是内存不够才会抛出内存溢出异常。
    • 弱引用(WeakReference),也是描述非必须的对象,但是强度比软引用还要弱,被弱引用关联的对象只能生存到下一次垃圾收集发生为止,只要垃圾收集器开始工作,无论内存是否够用,都会回收弱引用关联的对象。
    • 虚引用(PhantomReference),最弱的引用关系,一个对象是否有虚引用存在完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。虚引用的目的只是为了能在这个对象被收集器手机的时候收到一个系统通知。
  • 生存还是死亡

    即使在可达性分析中被判定为不可达的对象也不是“非死不可”的,它还处在“缓刑”阶段,要真正宣告一个对象的死亡至少需要经过两次标记过程。

    如果发现没有与GC Roots相连接的引用链,那么会被第一次标记,随后进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。如果对象没有覆盖finalize()方法,或者finalize方法一已经被虚拟机调用过,那么虚拟机将这两种情况都视为“没有必要执行”。

    如果被判定为“有必要执行”finalize方法,那么该对象会被放在一个名为F-Queue的队列之中,并在稍后由一条由虚拟机自动建立的、低调度优先级的Finalizer线程去执行它们的finalize()方法。这里的执行其实就是触发finalize方法,但是并不一定会等到它运行结束。因为如果要等到finalize执行完再去执行下一个对象,效率太低,而且万一某个finalize方法执行特别慢,很可能导致内存回收子系统的崩溃。finalize方法是对象逃脱死亡命运的最后一次机会,稍后收集器会对F-Queue中的对象进行第二次小规模的标记,如果对象要在finalize方法中成功拯救自己,只需要重新与引用链上的任何一个对象建立关联即可,譬如把自己赋值给某个类变量或者对象的成员变量,那么在第二次标记时它将被移出“即将回收”的集合;如果对象这时还没逃脱,那么基本上它就要被回收了。

    但是书中建议尽量避免使用finalize方法,而且也是官方明确声明为不推荐使用的语法,因为它的运行代价高昂,不确定性大,无法保证各个对象的调用顺序。它只是java刚诞生的时候为了是传统的C、C++程序员更容易接受java所做出的的一项妥协。

  • 回收方法区

    《java虚拟机规范》中提到可以不要求虚拟机在方法区中实现垃圾收集,比如jdk 11的ZGC就不支持类卸载,而且方法区的垃圾收集“性价比”非常低,在java堆中,尤其是新生代,对常规应用进行一次垃圾收集通常可以回收70%甚至99%的内存空间。

    回收废弃常量和回收java堆中的对象非常类似,也比较简单。但是判定一个类型是否属于“不再被使用的类”条件就比较苛刻了,需要同时满足下面三个条件:

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

    java虚拟机还只是被允许对满足上述三个条件的无用类进行回收,仅仅是被允许,而不是必须。HotSpot虚拟机提供了-Xnoclassgc参数进行控制是否要对类型进行回收。还可以使用-verbose:class -XX:+TraceClassLoading -XX:+TraceClassUnLoading查看类加载和卸载信息。

垃圾收集算法

垃圾收集算法分为“引用计数式垃圾收集”和“追踪式垃圾收集”,由于引用计数式垃圾收集算法在主流java虚拟机中均未涉及,所以下面讨论的都是追踪式垃圾收集的范畴。

分代收集理论

实质上是一套符合大多数程序运行实际情况的经验法则,建立在两个分代假说之上:

1)弱分代假说:绝大多数对象都是朝生熄灭的

2)强分代假说:熬过越多次垃圾收集过程的对象就越难消亡

这两个假说奠定了垃圾收集器的设计原则:收集器应该将java堆划分出不同的区域,然后将回收对象依据其年龄(年龄就是对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。这也是为什么前面说“并不是堆本身分了这么多区域”,而是为了方便垃圾收集,商家自己这么划分,便于设计垃圾收集器。甚至现在有的新型的垃圾收集器不用分代收集,在它的眼里java堆就是整个区域。

把分代收集理论放到现在商用的java虚拟机中,一般都会把堆划分成新生代(Young Generation)和老年代(Old Generation)。顾名思义,新生代每次垃圾收集都会有大批对象死去,而每次回收后存活的少量对量会晋升到老年代中存放。

但是简单划分一下区域还是有问题的:对象不是孤立存在的,对象之间会存在跨代引用。如果对新生代进行一次收集,那么还需要额外遍历老年代所有对象来保证可达性分析是正确的,但这样其实就有点失去分代的意义了。为了解决这个问题,引入了第三条经验法则:

3)跨代引用假说:跨代引用相对铜带引用来说仅占极少数

这其实也是合理的,如果某个新生代对象被老年代引用,那么它本身肯定是难以消亡的,就应该一块进入老年代,那么跨代引用就消除了。根据这条假说,可以在新生代上建立一个全局数据结构(被称为“记忆集”,Remembered Set),这个结构把老年代划分成若干小块,标识出老年代的哪一块内存会存在跨代引用。后续新生代GC的时候只用扫描这一小块内存就可以确保可达性分析的正确性了。

需要注意的是,分代收集理论也是有缺陷的,最新的几款垃圾收集器都展现出了面向全区域收集设计的思想,或者可以支持全区域不分代的收集的工作模式。

关于GC的统一定义:

标记清除算法

最早出现也是最基础的算法,算法分为标记和清楚两个阶段:首先标记出所有需要回收的对象,标记完成后,统一回收掉所有被标记的对象。也可以反过来,标记存活的对象,统一回收所有未被标记的对象。

书上的图稍微有点问题,明白什么意思就好,无伤大雅。

它主要的缺点有两个:第一是执行效率不稳定,如果java堆中包含大量对象,而且其中大部分都是要回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低。第二个是内存空间的碎片化问题,标记清除会产生大量不连续的内存碎片,碎片太多导致需要分配较大对象时无法找到足够的连续内存而不得不触发一次垃圾收集。

所以现在用的算法都是在这个思想上进行改进的。

标记复制算法

将共内存按容量划分为大小相等的两块,每次只使用其中的一块,这块内容用完了,就将还存活的对象复制到另一块上面,然后把已经使用过的内存空间一次清理掉。这样分配内存的时候也可以用指针碰撞法,不用考虑内存碎片的问题。但是这种做法直接把可用内存缩小为了原来的一半,有点过于浪费。

现在商用的java虚拟机大多都优先采用了这种收集算法回收新生代,但是并不是这么1:1划分的,因为新生代中的对象98%都熬不过第一轮收集,所以具体的回收策略是把新生代分为一块比较大的Eden区和两小块比较小的Survivor区,每次分配内存只使用Eden和其中一块Survivor。发生垃圾收集时,将Eden和Survivor中仍然存活的对象复制到另一块Survivor上,然后直接清理掉Eden和已经用过的Survivor。HotSpot默认Eden和Survivor的大小比例是8:1:1,也就是每次新生代中可用内存空间为整个新生代容量的90%,这可比最初只能用50%的空间好太多了。

但是没有任何人可以保证每次回收只有不超过10%的对象存活,因此还有一个充当“逃生门”的安全设计,如果Survivor不够容纳一次Minor GC之后存活的对象,就需要依赖其他的内存区域(其实大多就是老年代)进行分配担保(Handle Promotion)。如果Survivor空间不够装上一次新生代收集活下来的对象,那么这些对象就通过分配担保直接进入老年代,更具体在下面还会讲。

标记整理算法

在标记复制算法中,因为不想浪费大量空间,所以调低了用来装每次存活对象的空间,只占10%,但是为了保险起见,还需要额外的空间进行分配担保,来应对被使用内存中100%存活的极端情况。在老年代是不能直接用这种算法的。

针对老年代对象的存亡特征,提出了另一种有针对性的算法“标记整理”,标记过程和之前一样,不同的是后续步骤不是直接对可回收对象进行清理,而是让所有存活对象都向内存空间一端移动,然后直接清理掉边界以外的内存。

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

  • 如果移动存活对象,尤其是在老年代这种每次回收都有大量对象存活的区域,移动存活对象并更新所有引用这些对象的点将会是一个即为负重的操作,而且这种对象移动操作必须全称暂停用户应用程序才能进行。(标记清除算法也需要停顿用户线程,但是停顿相对来说比较短)

    最新的ZGC和Shenandoah收集器使用读屏障技术实现了整理过程与用户线程并发执行,后面会讲

  • 如果完全不考虑移动和整理存活对象的话,那么空间碎片化问题就需要引入空闲分配表来解决内存分配的问题。但是内存访问是用户程序最频繁的操作,这么环节上增加的额外的负担势必会直接影响应用程序的吞吐量。

也就是说移动存活对象回收时会更复杂,不移动则内存分配时更复杂。从垃圾收集的停顿时间来看,不移动对象停顿时间会更短甚至不用停顿,但是从程序的吞吐量上看,移动对象更加划算。因为吞吐量是用户程序和垃圾收集器的效率综合,即使不移动对象会使得收集器的效率提高一点,但是内存分配和访问相比垃圾收集频率要高得多,这部分耗时增加导致总吞吐量仍然是下降的。HotSpot虚拟机中关注吞吐量的Parallel Scavenge收集器是基于标记整理算法的,关注延迟的CMS收集器是基于标记清除算法的。

还有一种混合式的解决方案,平时大多是时间采用标记清除算法,暂时容忍内存碎片,直到内存空间的碎片化程度已经大到影响对象分配时再采用标记整理算法收集一次。CMS收集器面临空间碎片过多时就采用的这种办法。

经典垃圾收集器

主要讨论的是JDK 7 Update4之后(这个版本商用了G1收集器)、jdk 11之前的收集器。使用“经典”只是为了区分还在实验室阶段但是效果有革命性改进的高性能低延迟收集器区分开。这些经典收集器已经不是最先进的技术了,但是它们足够成熟,短时间内还是可以在商用生产环境上使用的,各款经典收集器的关系如图:

收集器所处的区域表示它是属于新生代或老年代收集器。中间有连线则说明它们可以搭配使用,而两个带有 JDK 9的连线表明这两种组合在JDK 9中被移除了。

目前还没有最好的收集器出现,图中的收集器也是各有千秋,根本不存在万能的收集器,我们能做的只是针对具体的场景选择更加合适的收集器。如果有一个放之四海皆准的完美收集器,HotSpot就没必要推出这么多收集器了。

Serial收集器

最基础、历史最悠久的收集器。这个收集器是一个单线程工作的收集器,这不仅仅说明它只会使用一个处理器或一条收集线程去完成垃圾收集的工作,更重要的是它在进行垃圾收集的时候,必须暂停其他所有工作线程,直到它收集结束

这种暂停给用户带来恶劣的体验,HotSpot团队也一直在缩短用户线程的停顿时间:Serial --> Parallel --> CMS、G1 --> Shenandoah、ZGC。构思越来越精巧,设计越来越复杂,用户线程的停顿时间也越来越短。

虽然这么说好像把Serial说的一无是处,但是迄今为止,它仍然是HotSpot虚拟机运行在客户端模式下默认的新生代收集器。它最大的优点就是简单而高效,对于内存资源受限的环境,它是所有收集器里额外内存消耗最小的;并且Serial收集器没有线程交互的开销,专心做垃圾收集可以获得最高的单线程收集效率。近几年微服务流行,分配给虚拟机管理的内存一般来说都不会太大,收集几十兆甚至一两百兆的新生代,垃圾收集的停顿时间可以控制在几十毫秒内,这点停顿对于许多用户来说完全可以接受。

ParNew收集器

实质上是Serial收集器的多线程并行版本,除了同时使用多条线程进行垃圾收集之外,其余的行为都和Serial收集器完全一致,在实现上这两种收集器也共用了相当多的代码。

jdk 7之前它是许多服务端的首选新生代收集器,其中有一个与功能、性能无关的很重要的原因就是:除了Serial收集器外,目前只有它能与CMS收集器配合工作。但是自从jdk 9开始,随着G1的登场,官方不再推荐ParNew + CMS当做服务端的收集器解决方案了。

ParNew收集器在单核环境下绝对不会有比Serial收集器更好的效果,甚至由于存在线程交互的开销,该收集器在超线程实现的伪双核处理器环境中都不能百分百保证能超越Serial收集器。

Parallel Scavenge收集器

也是一款新生代收集器,同样采用标记复制算法实现,也是能够并行收集的多线程收集器。从这些特性上看和ParNew非常相似。但是Parallel Scavenge收集器的特点是它的关注点与其他收集器不同,CMS等收集器关注点是尽可能缩短垃圾收集时用户线程的等待时间,而Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量,所谓吞吐量就是处理器用于运行用户代码的时间与处理器总耗时的比值

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

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

-XX:MaxGCPauseMillis参数是一个大于0的毫秒数,收集器尽力保证内存回收花费的时间不超过用户设定值。但不是这个参数设的越小越好。因为停顿时间的缩短使用牺牲吞吐量和新生代空间为代价换取的:新生代设置的小一些、垃圾收集的频繁一些,停顿时间自然就降下来了。但这样带来的就是吞吐量的下降。

-XX:GCTimeRatio参数是0-100的整数。

还有一个非常值得关注的参数-XX:+UseAdaptiveSizePolicy,这个参数激活后,就不需要人工指定新生代大小(-Xmn)、Eden与Survivor的比例(-XX:SurvivorRatio)、晋升老年代对象大小(-XX:PretenureSizeThreshold)等细节参数,虚拟机会根据当前系统的运行情况手机性能监控信息,动态调节这些参数以提供最合适的停顿时间和最大的吞吐量。我们只需要把基本的内存数据设置好,如最大堆(-Xmx),设置最大停顿时间或吞吐量给虚拟机设定一个优化的目标,那么具体细节参数的调整就交给虚拟机完成了。

Serial Old收集器

Serial收集器的老年代版本,同样是单线程收集器,使用标记整理算法。主要意义是提供客户端模式下的HotSpot虚拟机使用。如果在服务端模式下:一种是在jdk 5之前与Parallel Scavenge收集器搭配使用;另一种就是作为CMS收集器发生失败时的后北苑,在并发收集发生Concurrent Mode Failure时使用,后面会讲。

Parallel Old收集器

是Parallel Scavenge收集器的老年代版本,支持多线程并发收集,基于标记整理算法实现。这个收集器知道jdk 6才开始提供,在此之前Parallel Scavenge收集器只能和Serial Old搭配使用,但是Serial Old在服务端上是性能的拖累,单线程的老年代收集无法充分利用服务器多处理器的并行处理能力。知道Parallel Old收集器出现后,“吞吐量优先”收集器终于有了名副其实的搭配组合,在注重吞吐量或者处理器资源较为稀缺的长河,都可以优先考虑Paralle Scavenge + Paralle Old组合。

CMS收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。从名字(包含Mark Sweep)就可以看出CMS收集器是基于标记清除算法实现的,它的运作过程比前面的收集器都更复杂一些,整个过程分为四步:

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

其中初始标记、重新标记这两个步骤仍然需要暂停所有用户线程。初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快;并发标记阶段就是从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以和垃圾收集线程一起并发运行;而重新标记阶段则是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间通常会比初始标记阶段稍长一些,但远比并发标记阶段的时间短;最后是并发清楚阶段,清理删除标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以这个阶段是可以和用户线程并发的。

整个过程中,耗时最长的并发标记和并发清除阶段都是和用户线程一起工作的,所以总体上来说CMS收集器的内存回收过程是与用户线程一起并发执行的。

CMS收集器是HotSpot虚拟机追求低停顿的第一次成功尝试,但是它还远达不到完美的阶段,至少有以下三个明显的缺点:

  • CMS收集器对处理器资源非常敏感,在并发阶段它虽然不会暂停用户线程,但是却要占用一部分线程,导致了应用程序变慢。CMS默认启动的回收线程数是 (处理器核心数 + 3) / 4 ,如果处理器核心数在4个以上,并发回收时CMS占用不超过25%的运算资源,但如果处理器核心数太少,CMS可能要占用一半以上的运算资源,这就可能导致用户程序的执行速度忽然大幅度降低。HotSpot提供了解决方案但是效果非常一般,后续也废弃了。

  • CMS收集器无法处理“浮动垃圾”,有可能出现“Concurrent Mode Failure”而导致另一次暂停所有用户线程的Full GC。CMS并发清理阶段,用户线程还是在继续进行的,但这时产生的垃圾是无法被标记的,只能等到下一次垃圾收集的时候再清理,这一部分垃圾就称为浮动垃圾。同样,由于垃圾收集阶段用户线程还需要继续运行,所以需要预留足够的内存给用户线程使用,因此CMS收集器不能像其他收集器那样等老年代快满了再进行收集,必须预留一部分空间供并发收集时的程序运作使用。jdk 5默认老年代使用68%就激活垃圾收集;到了jdk 6就默认为92%,因为实际应用中老年代增长并不是太快。但是这样出现了另一种风险:如果CMS运行期间预留的内存无法满足程序分配新对象的需要,就会出现“并发失败”(Concurrent Mode Failure),这时虚拟机就得启动后备预案:冻结用户线程,临时启用Serial Old收集器来进行老年代的垃圾收集,这样就导致停顿时间很长了。所以参数-XX:CMSInitiatingOccupancyFraction就不能设置的太高,很容易导致大量的并发失败产生,应该根据实际情况进行设置。

  • 还有一个是之前提到过的,就是CMS基于标记清除实现,那么就会产生大量的空间碎片,空间碎片过多就需要进行一次Full GC。CMS收集器专门提供了两个参数-XX:UseCMSCompactAtFullCollection(默认开启,jdk 9开始废弃),用于在CMS收集器不得不进行Full GC的时候开启内存碎片的合并整理,但是内存整理要移动存活对象,在Shenandoah和ZGC出现之前是无法并发的,必须停顿用户线程。为此提供了另一个参数-XX:CMSFullGCsBeforeCompaction(jdk9开始废弃),这个参数的作用是要求CMS收集器在执行若干次不整理空间的Full GC之后,下一次进入Full GC前会先进行碎片整理(默认是0,每次Full GC都进行碎片整理)。

Garbage First收集器

简称G1,是垃圾收集器技术发展史上的里程碑式的成果,它开创了收集器面向局部收集的设计思想和基于Region的内存布局形式。在jdk 6有了实验版本,jdk 7 update 4开始商用,jdk 8 update 40提供并发的类卸载支持,补全最后一块拼图,从此被称为“全功能的垃圾收集器”。

jdk 9发布之日,G1宣告取代Parallel Scavenge + Parallel Old组合,成为服务端模式下的默认垃圾收集器,CMS则沦为被声明为不推荐使用的收集器。

作为CMS的继承人,设计者希望做出一款建立起“停顿时间模型”的收集器:能够支持在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间大概率不超过N毫秒。

要实现这个目标,首先要有一个思想上的转变,在G1之前的所有收集器,包括CMS,垃圾收集的目标要么是整个新生代(Minor GC),要么是整个老年代(Major GC),再要么就是整个java堆(Full GC)。G1跳出了这个樊笼,它可以面向堆内存任何部分阿里组成回收集(Collection Set,简称CSet)进行回收,衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收效益最大,这就是G1收集器的Mixed GC模式。

而G1开创的基于Region的堆内存布局是实现这个目标的关键。虽然G1也是遵循分代收集理论来设计的,但堆内存的布局与其他收集器有非常明显的差异:G1不再坚持固定大小以及固定数量的分代区域划分,而是把连续的java堆划分成多个大小相等的独立区域(Region),每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间或者老年代空间。收集器能够对扮演不同角色的Region采用不同的策略去处理。

Region中有一个特殊的Humongous区域,专门用来存储大对象。G1认为大小超过一个Region容量一半的对象就可以判定为大对象。Region区域的大小可以通过-XX:G1HeapRegionSize来设置,取值范围为1MB-32MB,且应为2的N次幂。对于超过整个Region容量的超级大对象,会被存放在N个连续的Humongous Region中,G1的大多数行为都把Humongous Region作为老年代的一部分进行看待。

G1仍然保留新生代和老年代的概念,但是新生代和老年代都不是固定的了,它们都是一系列区域的动态集合。G1将Region作为单词回收的最小单元,即每次收集到的内存空间都是Region大小的整数倍,这样可以有计划地避免在整个java堆中进行全区域的垃圾收集。具体思路是:G1收集器跟踪每个Region里面的垃圾堆积的“价值”大小,价值即回收所获得的空间大小以及回收所需要时间的经验值,然后再后台维护一个优先级列表,每次根据用户设定允许的收集停顿时间(-XX:MaxGCPauseMillis,默认200ms),优先处理回收价值收益最大的那些Region,这也是“Garbage First”的由来。

这个思路看起来比较简单,把一整块区域划分成多个小区域管理,但是实现起来却有许多细节,不然也不至于从提出到实现花了八年的时间。主要问题在于:

  • 将java堆划分成多个Region之后,Region里存在跨Region引用对象怎么解决?之前分固定分代的时候,采用的是记忆集来记录跨代引用,但是现在每个Region都可能存在跨Region引用,而且引用是双向的,而不像之前只有老年代跨代引用新生代。所以这种双向的记忆集实现起来复杂得多,而且Region数量又比传统的分代数量多得多,所以G1收集器需要比传统收集器有更高的内存占用,一般来说G1至少耗费大约相当于10%-20%的额外内存来维持收集器工作。
  • 并发标记阶段如何保证收集线程和用户线程互不干扰地运行?首先要解决掉额是用户线程改变对象引用关系的时候必须保证其不能打破原来的对象图结构,CMS通过增量更新算法实现,G1通过原始快照(SATB)实现,具体实现比较麻烦,感兴趣自行了解。此外,和CMS一样,G1必须保留用户线程创建新对象的空间,保证并发回收时新的对象有地方可用。G1为每个Region设计了两个名为TAMS(Top at Mark Start)的指针,把Region中一部分空间划分出来专门用于并发回收过程中新对象分配你,并发回收时新对象分配地址必须要在这两个指针位置以上,G1会默认这个地址以上的对象是隐式标记过的,不会回收。和CMS的“Concurrent Mode Failure”一样会导致Full GC,如果内存回收的速度赶不上内存分配的速度,G1收集器也需要冻结用户线程进行Full GC
  • 怎样建立可靠的停顿预测模型?G1收集器是以衰减均值为理论基础实现的。G1收集器主要记录每个Region的回收耗时、脏卡数量等各个可测量的花费成本,求得平均值、标准偏差等统计信息。这里的衰减均值是指它会比普通的平均值更容易受到新数据的影响,平均值代表整体平均状态,衰减均值更精确地代表最近的平均状态。G1正是根据衰减均值来确定回收哪些更具价值的Region。

忽略某些细节,G1收集器的运作过程大致可以划分为:

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

G1强大的地方在于,它可以根据用户设定的期望停顿时间,动态选择回收区域,通过设置不同的期望停顿时间,可使得G1在不同应用场景中去的关注吞吐量和关注延迟之间的最佳平衡。但是这个期望值也不能乱设置,默认是200ms,一般设置100-300ms都是合理的。如果设置的太低了,就会导致每次只手机很少一部分的区域,导致收集的速度跟不上分配的速度,最终导致Full GC

从G1开始,最先进的垃圾收集器的设计导向都不约而同地变为追求能够应付应用的内存分配速率,而不是追求一次把整个java堆都清理干净。这也是为什么说G1是收集器技术发展的一个里程碑。

G1和CMS的比较

二者经常会被拿来比较,毕竟他们都非常关注停顿时间的控制,在未来G1终究是要取代CMS的,但是目前来说二者的比较不可避免。

与CMS的标记清除算法不同,G1整体来看是基于标记整理算法实现的收集器,但是从局部(比如两个Region之间)上看又是基于标记复制算法实现,这意味着G1运作期间不会产生内存空间碎片,有利于程序长时间运行。

但是G1相对CMS并不是全方位的优势,比起CMS,G1的弱项也不少。无论是为了垃圾收集产生的内存占用还是程序运行时的额外执行负载都比CMS要高。

  • 内存占用:虽然G1和CMS都用卡表(记忆集)来处理跨代指针,但是G1的卡表更复杂,是双向的,而且每个Region都必须有一份卡表,所以G1的内存占用是比较大的。
  • 执行负载:为了维护卡表的正确性,CMS和G1都用到了写屏障(类似AOP的增强功能),CMS只用了写后屏障,而G1用到了写前屏障和写后屏障,而且操作非常复杂,因此需要单独把这些写屏障操作放到消息队列中进行异步处理。

上述的区别都是算法具体实现的细节,书的3.4节讲的比较清楚,但是比较难懂,感兴趣可以自行翻阅。

所以一般来说在小内存应用上CMS的表现大概率优于G1,而在大内存应用上G1则大多能发挥其又是。这个优劣势的java对容量平衡点通常在6GB-8GB之间。随着HotSpot的不断优化,这个经验值可能也会不断变化。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值