这篇文章相比上一篇记录性的,多了不少我自己的理解,花费了很大的功夫整理,如果有时间和精力建议好好看一看深入理解JVM这本书。
也建议熟读背诵。
程序计数器、虚拟机栈、本地方法栈随线程而生灭,栈中的栈帧随着方法的进入和退出有条不紊的执行出入栈操作。这几个区域的内存分配和回收都具备确定性。当方法结束或线程结束时,内存自然跟着回收。
Java堆和方法区有着显著的不确定性,只有处于运行期间,才能知道程序究竟会创建哪些对象,创建多少对象,这部分的内存分配和回收是动态的,垃圾收集器关注的正是这部分的内存该如何管理。
垃圾收集器在对堆进行回收前,第一件事情就是确认哪些对象是活着的,哪些已经死去。因此我们介绍两种算法。
在对象中添加一个引用计数器,每当有一个地方引用它时,计数器的值就加1,当引用失效时,计数器的值就减1,任何时刻计数器的值为0的对象就是不可能再被使用的。
引用计数法虽然占用了一些额外内存来计数,但是原理简单,判定效率高,大多数情况下都是一个不错的算法。但是,Java领域,主流的虚拟机都没有采用该算法管理内存,因为有很多例外情况需要考虑,比如对象之间相互循环引用的问题。(A引用B,B引用A除此以外没有其他引用,这两个对象实际已经不能再被访问,但是引用计数算法并不会回收他们)。
<如何解决呢?拓展。>
基本思路是通过一系列被称为GC roots的根对象作为起始节点集,从这些结点开始,根据引用关系向下搜索,搜索走过的路径称为引用链,如果某对象到GC roots间没有任何引用链相连,则证明此对象是不可能再被使用的。
固定可作为GC roots的对象包括以下几种:
(1)在虚拟机栈(局部变量表)中引用的对象
(2)在本地方法栈中JNI(Native)引用的对象
(3)在方法区中类静态属性引用的对象
(4)在方法区中常量引用的对象
(5)Java虚拟机内部的引用,一些常驻的异常对象,系统类加载器
(6)反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等
(7)所有被同步锁(synchronized关键字)持有的对象
除了这些固定的GC roots集合外,根据用户所选用的垃圾收集器以及当前回收区域不同,还可以有其他对象临时性的加入,共同构成完整的GC roots集合。如果只针对堆中某一块区域发起垃圾收集,必须考虑到内存区域是不可见的,也不是孤立的,某个区域的对象是完全有可能被位于堆中其他区域的对象所引用,这时候就需要把这些关联区域的对象也一并加入到GC roots集合中,才能保证可达性分析的正确性。
可达性分析优化:
可达性分析理论上要求全过程都要基于一个能保证一致性的快照中才能分析,这意味着要冻结用户全部线程。在根节点枚举这个步骤,因为GC roots毕竟是少数,而且还有很多优化手段(比如OopMap),它带来的停顿已经非常短且和堆容量无关了。但是从GC roots往下遍历对象图,这一步骤就要和堆容量成正比例关系。标记阶段是所有追踪式垃圾收集器的共同特征,如果能够削减这部分停顿的时间,收益会是系统性的。
先来解释一下为什么全过程都要基于一个保障一致性的快照上才能进行对象图的遍历。我们把对象图中的对象用三种颜色表示:白色代表对象尚未被垃圾收集器访问过,黑色代表对象已经被访问且这个对象所有的引用都被扫描过,灰色代表对象已经被访问过且这个对象上至少有一个引用没有被扫描过。如果在可达性分析时,线程是冻结的,不会出现并发问题,但是我们追求的是线程和收集器并发工作,这就会带来一些问题。可能会把原本消亡的对象标记为存活,也可能会把原本存活的对象标记为消亡。前者只是会产生一些浮动垃圾,下次再收集就好了,但是后者却会导致对象消亡的问题,这是不可容忍的。
Wilson证明了,当且仅当满足以下两个条件,会发生对象消失的问题,也就是黑色被误标记为白色:赋值器插入了从黑色对象到白色对象的新引用,赋值器删除了全部从灰色对象到该白色对象的直接或间接引用。只要破坏这两个任意一个条件,就可以解决对象消失问题。有增量更新和原始快照两种方法。
增量更新是破坏前者,赋值器插入黑色到白色的新引用时,记录该引用,等扫描结束,再以记录过的引用的黑色对象为根,重新扫描一次,也就是说,黑色对象一旦插入新引用,就变为灰色对象。CMS就是基于该算法。
原始快照是破坏后者,赋值器删除灰色到白色的引用时,记录该引用,等扫描结束,再以记录过的引用的灰色对象为根,重新扫描一次,也就是说,无论引用删除与否,都会按照刚开始扫描那一刻的对象快照进行搜索。G1,shenandoah都是基于该算法。
无论引用关系记录的插入还是删除,都是基于写屏障实现。
在JDK1.2前,Java的引用定义很传统,如果reference类型的数据存储的数值代表的是另一块内存的起始地址,就称该reference数据是代表某块内存、某个对象的引用。
在JDK1.2之后,Java对引用的概念进行了扩充,将引用分为强引用(Stongly Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference)。
强引用:指在程序代码中普遍存在的引用赋值,无论什么情况下,只要强引用关系还存在,垃圾收集器就不会回收掉被引用的对象。
软引用:SoftReference类来实现,用来描述一些还有用,但不是必须的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列入回收范围内进行第二次回收,如果依然没有获得足够的内存,才会发生内存溢出。
弱引用:WeakReferenec类来实现,弱引用的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次GC为止,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。
虚引用:PhantomReference类来实现,是最弱的一种引用关系。对象的虚引用关系不会影响到它的生存时间,也无法通过虚引用来获取一个对象实例。它唯一的目的只是在该对象被收集器回收时收到一个系统通知。
方法区的回收主要回收两部分内容,废弃的常量和不再使用的类型。
回收废弃的常量与回收Java堆中的对象非常相似。举个例子,一个字符串java曾经进入到常量池中,但是当前系统中没有任何一个字符串对象的值是java,如果这时候发生了内存回收,而且垃圾收集器判断有必要的话,java常量就会被系统清理出常量池。
对类型不再使用的判定就很苛刻了,要同时满足三个条件:
(1)该类的所有实例都已经被回收,也就是Java堆中不存在该类及任何派生子类的实例。
(2)加载该类的类加载器已经被回收
(3)该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
从如何判定对象消亡的角度而言,垃圾收集算法可以划分为引用计数式垃圾收集和追踪式垃圾收集两大类。Java虚拟机中主要实现的是追踪式垃圾收集。
当代垃圾收集器,大多数都遵循了分代收集理论进行设计,它建立在三条假说上:
(1)弱分代假说:绝大多数对象都是朝生夕灭的
(2)强分代假说:熬过越多次垃圾收集过程的对象就越难以消亡。
(3)跨代引用假说:跨代引用相对于同代引用来说仅占极少数。
前两条假说,奠定了多款常用垃圾收集器的一致的设计原则:收集器应该把堆划分出不同的区域,然后将回收对象依据其年龄,分配到不同的区域存储。具体到商用虚拟机里,设计者一般会把Java堆划分为新生代和老年代。
依据第三条假说,我们可以不去为了少量的跨代引用而去扫描整个老年代,也不必浪费空间专门记录每一个对象是否存在以及存在哪些跨代引用。只需要在新生代上建立一个全局的数据结构记忆集,这个结构把老年代划分成若干小块,标识出老年代哪一块内存存在跨代引用。此后发生Young GC时,只有包含了跨代引用的内存里的对象才会被加入到GC Roots进行扫描。虽然这种方法会带来维护记录数据的正确性的开销,但是比起收集时扫描整个老年代还是划算的。
关于分代收集的部分名词:
部分收集(Partial GC):
新生代收集(Yonug GC):目标只是新生代的垃圾收集
老年代收集(Old GC):目标只是老年代的垃圾收集,CMS独有
混合收集(Mixed GC):收集整个新生代和部分老年代,G1独有
整堆收集(Full GC):收集整个Java堆和方法区
关于记忆集:
实际上,不只是新生代老年代之间存在跨代引用问题,凡是涉及到部分区域收集行为的收集器,比如G1,ZGC,Shenandoah,都会面临相同的问题。
记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构。最简单的实现可以用非收集区域中所有含跨代引用的对象数组来实现。但是这种实现方案无论是空间占用还是维护成本都相当高昂。而我们只是需要通过记忆集判断是否有指针从非收集区域指向收集区域,不需要关心指针的细节。因此我们可以采用更为粗放的记录粒度来节省空间占用和维护成本。比如字长精度、对象精度、卡精度。其中卡精度是指每个记录精确到一块内存区域,该区域内有对象含有跨代引用指针,它通过一种被称为卡表(Card Table)的方式实现记忆集,这是目前最常用的一种记忆集实现形式。
在HotSpot虚拟机中,卡表是以一个字节数组的形式存在的(因为byte数组比bit数组更快,不需要数条shift+mask指令)。(CARD_TABLE【this address>>9】=1)。该数组的每一个元素都对应着其标识的内存区域中一块512字节大小的内存块,被称为卡页(Card Page)。一个卡页内存通常包含不止一个对象,只要卡页中有一个或多个对象的字段存在跨代引用指针,就把对应的卡表中的元素置为1,称这个元素变脏(Dirty),没有则为0。在垃圾收集时,只需要筛选出卡表中为1的元素,就可以得出哪些卡页内存块存在跨代指针,就把他们加入GC Roots中扫描。(卡表数组——卡页数组)
算法跟为标记和清除两个阶段。首先标记出所有需要回收的对象,在标记完成后统一回收掉所有被标记的对象。也可以标记存活的对象,在标记完成后统一回收所有未被标记的对象。
主要缺点有两个:一是执行效率不稳定,如果Java堆包含大量对象,且大部分是需要回收的,此时就要进行大量的标记和清除动作,使得执行效率随对象数量增长而降低。二是内存空间的碎片化问题,标记清除后会产生大量不连续的内存碎片,碎片太多可能会导致以后程序在运行的过程中需要分配较大对象时无法找到足够大的连续内存而不得不提前触发另一次垃圾收集动作。
为了解决标记清除算法面对大量可回收对象时执行效率低的问题,它将内存按容量分为大小相等的两块,每次只使用其中的一块,如果这一块的内存用完了,就把还活着的对象复制到另外一块上面,然后把已使用的一块一次性清理掉,由于多数对象都是可回收的,算法需要复制的仅仅是少数的存活对象,因此内存间复制开销并没有很大。其次,由于每次都是针对整个半区进行内存回收,分配内存时也就不需要考虑空间碎片的复杂情况,只需要移动堆顶指针,按顺序分配即可。
其缺点是,可用内存被缩小为原来的一半,空间浪费太多了。现在的商用Java虚拟机多数优先采用这种收集算法去回收新生代,但是有了更好的优化。针对朝生夕灭特点的对象,提出了一种Apple式回收策略。HotSpot的Serial,ParNew均采用这种策略设计新生代的内存布局。具体做法是,把新生代分为一块较大的Eden区和两块较小的Survivor区。每次内存分配只使用Eden和其中一块Survivor,发生垃圾收集时,将Eden和Survivor存活的对象一次性复制到另一块Survivor区,然后直接清理掉Eden和已使用的Survivor区。HotSpot虚拟机默认的Eden和Survivor的大小比例是8:1,也就是新生代可用内存为整个新生代的90%。此外,对于特殊情况,Apple式回收也有充当罕见情况逃生门的安全设计,当Survivor不足以容纳一次Young GC之后存活的对象时,就需要依赖老年代进行分配担保。如果空间不足,就通过分配担保机制直接进入老年代,这样这次Young GC对于虚拟机来说就是安全的。
空间分配担保在对象内存分配原则细讲。
标记复制算法在对象存活率较高时就要进行较多的复制操作,效率将会降低。另外一点是,如果不想浪费50%空间,就需要额外的空间进行分配担保以应对被使用的内存的对象100%都存活的极端情况,所以老年代一般是不能直接使用标记复制算法的。
针对老年代对象的存亡特征,提出了一种具有针对性的标记整理算法。其标记过程与标记清除算法一样,但是后续步骤是让所有存活的对象都向内存空间的一端移动,然后直接清理掉边界以外的内存。它与标记清除算法的本质区别是,标记清除算法是一种非移动式的回收算法,而标记整理算法是移动式的。
关于移动存活的对象是一项优缺点并存的决策:如果移动存活的老年代对象并更新所有引用,因为老年代每次回收都有大量对象存活,这将会是一种负担很重的操作,而且这种操作必须全程暂停用户应用程序才能进行,被称为Stop The World(ZGC和shenandoah收集器使用读屏障技术实现了整理过程和用户线程的并发)。但如果不移动,弥散在内存的对象导致的空间碎片化问题就只能依赖内存分配器和内存访问器来解决,比如通过分区空闲分配列表。但是内存的访问是用户程序最频繁的操作之一,假如在这个环节增加了额外的负担,应用程序的吞吐量势必会受到影响。
也就是说,移动则内存回收更为复杂,不移动则内存分配更加复杂。从垃圾收集的停顿时间看,不移动的停顿时间更短,但是从吞吐量来看,移动对象则更为划算。HotSpot虚拟机中,关注吞吐量的Parallel Old收集器是基于标记整理算法的,而关注延迟的CMS收集器则是基于标记清除算法的。
此外,还有一种解决方案可以不在内存分配和访问上增加太大额外负担,就是让虚拟机多数时间采取标记清除算法,暂时容忍内存碎片的存在,直到内存空间的碎片化程度已经大到影响对象分配时,再采用标记整理算法收集一次。CMS收集器在面临空间碎片过多时,就是基于该解决方案。
记忆集可以缩减GC Roots的扫描范围,但是卡表元素的维护也是需要解决的一个问题。比如,何时变脏,如何变脏。何时变脏在记忆集中已经阐述过,当其他分代区域中对象引用本区域对象时,其对应的卡表元素就变脏,时间点原则上应该发生在引用类型字段赋值的那一刻。但是关于如何变脏,即如何在对象赋值的那一刻去更新维护卡表还没有解决。假如是在解释执行的字节码中,虚拟机可以方便的在自己负责的每条字节码指令执行时进行介入,但是假如在即时编译后,代码已经是纯粹的机器指令流,就需要一个机器码层面的手段,把维护卡表的动作放到每一个赋值操作中。
HotSpot虚拟机是通过写屏障(write-barrier)维护卡表状态的。在赋值前的部分叫做写前屏障(pre-write-barrier),在赋值后的叫做写后屏障(post-write-barrier)。在G1收集器前,只是使用了写后屏障。应用写屏障后,虚拟机就会为所有赋值操作生成相应的指令,这也会产生额外的开销,但是与扫描整个老年代比,还是低得多。
除了写屏障的开销,卡表在高并发下还面临着伪共享的问题。现代缓存系统是以缓存行(cache-line)为单位存储,可以想到,如果多线程修改互相独立且恰好共享一个缓存行的变量时,就会彼此影响,出现写回、无效化、同步等问题,导致效率降低。一种简单的解决方案是,采用有条件的写屏障,先检查卡表标记,只有该卡表元素未被标记过,才标记为脏。即if(当前卡页!=1){当前卡业=1}。JDK7之后,HotSpot虚拟机新增了一个参数-XX:UseCondCardMark。用来决定是否开启卡表标记判断。开启会增加一点开销,但是可以避免伪共享问题。
(假设一个卡表元素1个字节,缓存行64字节,则64个卡表元素共享一个内存行,对应的内存占64*512=32KB,如果不同线程更新的对象恰好在这32KB,就会导致更新卡表时正好写入同一个缓存行而影响性能)
经典垃圾收集器包括
运行在年轻代的:Serial、ParNew、Parallel Scavenge
运行在老年代:CMS、Serial Old、Parallel Old
基于Region的G1
在JDK1.3.1之前,是HotSpot虚拟机新生代收集器的唯一选择。Serial收集器是一个单线程工作的收集器,它只会使用一个处理器或一条收集线程去完成垃圾收集工作,在进行垃圾收集时,必须暂停其他所有工作线程,直到它收集结束。使用标记复制算法(标记复制算法也是移动式的,自然需要STW))。
它是HotSpot虚拟机运行在客户端模式下的默认新生代收集器,与其他收集器的单线程模式相比,它简单而高效。对于内存资源受限的环境,它是所有收集器里额外内存消耗最小的。对于单核处理器或处理器核心数较少的环境,它由于没有线程交互的开销,可以获得最高的单线程收集效率。
Serial收集器和Serial Old收集器,CMS收集器搭配使用。JDK9之后,取缔了Serial+CMS这种组合。Serial+Serial Old一般客户端模式下使用。
ParNew收集器实质是Serial收集器的多线程并行版本。在进行垃圾收集工作时,也需要暂停所有用户线程,同样也是使用标记复制算法。
除了支持多线程并行收集,与Serial收集器比并没有什么不同,但它却是不少运行在服务端模式下的HotSpot虚拟机,尤其是JDK7之前的系统中,首选的新生代收集器,有一个很重要的原因是,因为设计框架和面向目标不同的原因,CMS不能和Parallel Scavenge配合工作。所以除了Serial收集器,目前只有它能与CMS收集器配合工作(JDK9之后Serial也不能与CMS配合工作了)。使用-XX:+UseConMarkSweepGC激活CMS后,ParNew是默认新生代收集器(可以使用-XX:+/-UseParNewGC来强制指用或禁用它)。
可以说是CMS的出现才巩固了ParNew的地位,但是随着作为CMS的继承者和替代者的面向全堆的G1收集器的出现,JDK9开始,ParNew+CMS不再是官方推荐的服务端模式下的解决方案了,并取消了-XX:UseParNewGC参数,等于说ParNew以后就并入到了CMS中作为它专门的新生代收集的组成部分。
单核心处理器环境下的ParNew相比Serial并没有优势,随着可以被使用的核心数的增加,ParNew对于系统资源的高效利用的好处得以体现。它默认开启的收集器线程数和处理器核心数相同,在处理核心非常多的环境下,可以使用-XX:ParallelGCThreads参数来限制垃圾收集的线程数。
它是一个并行收集的多线程收集器,采用标记复制算法实现,和ParNew有很多相似之处。
它的特点是关注的点与其他收集器不同。CMS等收集器关注的点是尽可能缩短垃圾收集时用户线程的停顿时间,而Parallel Scanvenge收集器关注的点则是达到一个可控制的吞吐量,吞吐量即处理器运行用户代码时间与处理器总消耗时间之比(应用时间/应用时间+GC时间)。停顿时间越短越适合需要与用户交互或需要保证服务响应质量的程序,这可以提升用户体验,而高吞吐量则可以高效率的利用处理器资源,尽快完成运算任务,适合在后台运算而不要太多交互的分析任务。
Parallel提供了两个参数用于控制吞吐量。一个是控制最大垃圾收集时间的-XX:MaxGCPauseMillis参数,一个是直接设置吞吐量大小的-XX:GCTimeRadio参数。
-XX:MaxGCPauseMills参数是一个大于0的毫秒数。表示垃圾收集器会尽量保证回收花费时间小于这个值。但是这是牺牲吞吐量和新生代空间换来的,系统把新生代调小,收集自然更快了,但是也会更频繁,停顿时间确实在变短,但是吞吐量也下降了。
-XX:GCTimeRadio参数是一个正整数,表示用户期望虚拟机消耗在GC上的时间不超过程序运行时间的1/(1+N),默认为99,也就是GC时间不超过总时间的1%。
Parallel还提供一个开关参数-XX:+UseAdaptiveSizePolicy,开启后,这个参数可以根据当前系统的运行情况收集性能信息,动态调整新生代大小、Eden和Survival比例、晋升老年代对象大小等参数,以提供最合适的停顿时间或最大吞吐量,这被称为垃圾收集器的自适应调节策略。这也是和ParNew收集器不同的一个点。
Serial Old收集器是Serial收集器的老年代版本,它是一个单线程收集器,采用标记整理算法,它的主要作用就是用于客户端模式下和Serial收集器搭配使用。如果是在服务端模式下,有两种用途:一种是在JDK6之前和Parallel Scanvenge收集器搭配使用,另一种是作为CMS收集器发生失败时的后备方案,也就是在并发收集发生Concurrent Mode Failure时使用(同样,标记整理算法是移动式的,因此也会有STW)。
Parallel Old收集器是Parallel收集器的老年代版本,它支持多线程并行收集,采用标记整理算法,在JDK6才开始提供,在此之前,Parallel收集器只能和Serial Old收集器搭配使用,由于Serial Old收集器在服务端上表现不佳,使得这个组合的吞吐量甚至不如ParNew+CMS。直到Parallel Old出现,吞吐量优先收集器才算是名副其实。注重吞吐量或者处理器资源较为稀缺的情况下,都可以使用Parallel+Parallel Old的组合。
CMS收集器的定位:
CMS收集器是一种以获取最短回收停顿时间为目标的收集器。在互联网网站或者基于浏览器的B/S系统的服务端上,都会较为关注服务的响应速度,希望系统的响应时间尽可能短,CMS收集器就很适合这种需求。
CMS收集器的工作流程:
CMS收集器的工作流程分为四个步骤:初始标记和重新标记依然需要STW
(1)初始标记(STW)
初始标记仅仅是标记了一下GC Roots能够直接关联到的对象,尽管会有STW,速度依然很快。
(2)并发标记
并发标记阶段是从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长,但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行。
(3)重新标记(STW)
重新标记阶段是为了修正并发标记期间,因为用户程序持续运作,而导致标记产生变动的那一部分对象的标记记录,也就是要保障一致性,CMS是通过增量更新完成的。这个阶段停顿时间比初始标记阶段稍长一点,但也远比并发标记阶段时间短。
(4)并发清除
并发清除阶段是清理掉标记阶段判定死亡的对象,采用的标记清除算法,是非移动式的,所以这个阶段也是可以和用户线程并发的。但清除算法会给CMS带来另一个问题也就是Concurrent Mode Failure。
在整个过程耗时最长的并发标记和并发清除阶段,垃圾收集器线程都可以和用户线程并发工作,所以总体上来看,可以说CMS收集器的垃圾回收过程是和用户线程一起执行的。
CMS收集器的优点缺点:
CMS最主要的优点就是并发收集和低停顿。它是HotSpot虚拟机追求低停顿的第一次成功的尝试,但是它仍有很多缺点:
(1)CMS收集器对处理器资源非常敏感。在并发阶段,它虽然不会导致用户线程停顿,却会因为占用了一部分线程导致应用程序变慢,降低总吞吐量。虚拟机提供了增量式并发收集器作为CMS的变种,在并发标记清理阶段让收集器线程和用户线程交替运行,但这进一步延长了垃圾收集过程。实践证明该模式很一般,JDK9开始,该模式就被完全废弃了。
(2)CMS收集器会产生浮动垃圾,因此有可能会出现Concurrent Mode Failure导致另一次完全的Full GC。具体来说就是,在并发标记和并发清除阶段,用户线程依然在运行,自然会伴随产生新的垃圾对象,但是这一部分垃圾对象是在标记之后产生的,CMS无法在本次垃圾收集清理掉他们,只好等到下一次垃圾收集再清理,这部分垃圾就是所谓的浮动垃圾。同样也是由于垃圾收集阶段用户的线程还需要持续运行,还需要预留足够的内存空间给用户线程使用,因此CMS收集器不能像其他收集器那样等到老年代几乎被填满了再收集,必须预留一部分空间供并发收集时程序使用。通过-XX:CMSInitiatingOccupancyFraction参数可以设置CMS的触发阈值。在JDK6时,这一阈值被默认提高到了92%。但这会产生另一个问题,如果CMS运行期间预留的内存不足以满足程序分配新对象的需要,就会出现一次并发失败(Concurrent Mode Failure),这时候虚拟机将不得不启动后备方案,冻结用户线程,启用Serial Old收集器来重新进行老年代垃圾收集,这就会产生很长的停顿时间,和预期的设计目的不同。因此该参数的设置要根据生产情况具体而论。
(3)CMS使用的是标记清除算法,不可避免地,会产生大量的空间碎片,会出现老年代还有很多剩余空间,但是无法找到足够大的连续空间分别配当前对象,导致提前触发一次Full GC的情况。为了解决这个问题,CMS提供了-XX:UseCMSCompactAtFullCollection参数,该参数默认开启的,用于在CMS不得不进行Full GC时进行碎片的整理合并,但是我们知道,这个步骤是不能并发进行的(出现ZGC和Shenandoah前),因此停顿时间又会变长。所以CMS提供了另一个-XX:CMSFullGCsBeforeCompaction参数,该参数要求CMS执行过若干次不整理空间的Full GC后,下一次Full GC前会先进行碎片整理,默认为0,表示每次都整理。(这俩参数JDK9之后都被废弃了)。
Garbage First收集器是垃圾收集器技术发展历史上的里程碑式的成果,它开创了面向局部的收集思路和基于Region的内存布局形式。
G1的定位:
G1的定位是一款主要面向服务端应用的垃圾收集器,JDK9发布之日,G1宣布取代Parallel Scanvenge+Parallel Old的组合,成为服务端模式下的默认收集器,而CMS则被声明为不推荐使用的收集器。但是CMS曾被广泛使用过,它的代码与HotSpot内存管理、执行、编译、监控等子系统都有千丝万缕的联系,为此,HotSpot虚拟机提出了统一垃圾收集器接口,将内存的行为与实现分离,CMS与其他收集器都基于此接口进行重构。此后移除或加入某一款收集器都会变得轻松很多。
G1的目标和实现:
G1的设计目标是做出一款能够建立起停顿预测模型的收集器,意思是能够支持指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集器上的时间不超过N毫秒这种目标。
为了实现这个目标,G1收集器跳出了原本的收集器的框架,转为面向局部的收集思路,即面向堆内存任何部分来组成回收集(Collection set)进行回收,衡量标准不再是它属于哪个分代,而是哪块内存存放的垃圾数量最多,回收收益最大,这就是G1收集器的Mixed GC模式。
而基于Region的堆内存布局则是G1实现面向局部的收集思路的关键。虽然G1收集器也是基于分代收集理论设计,但是堆内存布局却和之前的收集器有很大差距:它把连续的Java堆分为多个大小相等的独立区域(Region),每一个Region都可以根据需要,扮演新生代的Eden和Survivor,或者是老年代空间。对扮演不同角色的Region采取不同的策略进行处理,这样无论是新对象还是旧对象都能获得很好的收集效果。
Region中还有一类特殊的Humongous区,用来存储大对象。G1认为,只要大小超过了一个Region容量一半的对象就是大对象。每个Region的大小可以通过-XX:G1HeapRegionSize来设定,取值范围为1MB~32MB且为2的N次幂。对于大小超过了整个Region的超大对象,将会被存放在N个连续的Humongous中,G1的大多数行为都把Humongous当作老年代的部分来看待。
通过将Region作为单次回收的最小单元,即每次回收的内存空间都是Region大小的整数倍,可以有计划的避免在整个Java堆中进行全区域的垃圾收集,从而建立起停顿预测模型。具体的做法是,让G1收集器跟踪各个Region的垃圾的价值大小,即回收所得的空间大小和回收时间,然后维护一个优先级列表,每次根据用户设定允许的停顿时间(-XX:MaxGCPauseMillis参数设定,默认200ms)优先处理回收价值最大的那些Region,这也是Garbage First名字的由来。
G1的实现需要解决的细节问题:
G1收集器花了10年时间才完成从实验室到商用收集器的道路,这是因为,虽然化整为零的思路并不复杂,但是其中有很多细节问题需要解决:
(1)要解决跨Region引用的问题。从基本思路上来说是依靠使用记忆集避免将整堆作为GC Roots扫描,但是G1收集器上的实际实现要复杂很多。它的每个Region都要维护自己的记忆集,这些记忆集会记录别的Region指向自己的指针,并标记这些指针在哪些卡页内。本质上,这种记忆集是一种Hash表,Key是别的Region的起始地址,Value是一个集合,里面存储元素的卡表的索引号。传统的卡表是记录非收集区域指向收集区域,也就是单向结构,而这是一种双向结构。同时Region的数量显然比传统收集器的分代数量多得多,因此G1收集器比其他传统垃圾收集器有着更高的内存占用负担。
(2)在并发标记阶段的可达性分析问题。CMS收集器是采用的增量更新算法,而G1则是采用了原始快照(STAB)算法来实现。
(3)Full GC问题。在CMS收集器中,我们知道CMS收集器存在浮动垃圾和并发新对象的问题,因此可能会产生Concurrent Mode Failure进而引发一次新的Full GC。而G1为每一个Region设计了两个名为TAMS(Top at Mark Start)的指针,把Region中的一部分空间划分出来用于并发回收过程中的新对象的分配,新对象的地址都必须要在这两个指针的位置以上。G1默认在这个地址以上的对象是被隐式标记过的,即默认他们是存活的,不纳入回收范围。事实上这依然会产生类似的问题。如果内存回收的速度跟不上内存分配的速度,G1收集器也要冻结用户线程,进行Full GC。这里和CMS一样也是使用Serial的Full GC。
(4)可靠的停顿预测模型问题。用户通过-XX:MaxGCPauseMillis参数指定的停顿时间只意味着垃圾收集发生前的期望值,G1需要满足这一期望。这个模型在G1中是基于衰减均值理论基础实现的。衰减均值是指它比普通的均值更容易受到新数据的影响,也就是说,Region的统计越新,越能决定回收的价值。然后就可以基于这些信息预测,把哪些Region组成回收集才可以在满足用户的期望停顿时间前提下,获得最高的收益。
G1的工作流程
G1收集器的工作流程大致可以划分为四个步骤:
(1)初始标记:(STW)
初始标记阶段只是标记一下GC Roots能直接关联到的对象,并且修改TAMS指针的值,让下一阶段用户线程并发进行时,能正确的在可用的Region分配新对象。这个阶段需要停顿线程,但是时间很短,而且可以借用Y GC同步完成,因此G1收集器这个阶段实际上并没有额外停顿。
(2)并发标记:
并发标记阶段是从GC Roots开始对堆对象进行可达性分析,递归扫描对象图,找出要回收的对象,这阶段耗时较长,但是可以和用户程序并发执行。对象图扫描后,还要对SATB记录下的并发时引用变动的对象进行处理。
(3)最终标记:(STW)
最终标记阶段会对用户线程做一个短暂的停顿,用于处理并发阶段结束后仍然遗留下来的最后的少量的SATB记录。
(4)筛选回收:(STW)
筛选回收阶段负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户期望的停顿时间指定回收计划,构成回收集。然后把决定要回收的那部分Region复制到空的Region中并清理掉整个旧Region的全部空间。这里涉及到对象的移动,要暂停用户线程,多条收集线程并行完成。
可以看到,G1收集器除了并发标记外,其余阶段也是要完全暂停用户线程的,也就是说它并不是一味的追求低延迟,它的目标是在延迟可控的情况下获得尽可能高的吞吐量。回收阶段本来也是想做成并发回收的,但是考虑到G1是回收一部分Region,停顿时间是用户可控的,这个需求并不是很强烈,而且暂停用户线程能获得最大的垃圾收集效率,所以把这个特性放到了之后的低延迟收集器ZGC中。
G1的优点和影响
毫无疑问,可以由用户指定期望停顿时间是G1收集器的一个很大的优点。但是就像Parallel收集器一样,这个期望不能异想天开,默认是200ms,如果调的太低,很可能会出现因为每次停顿时间太短。导致每次选出来的回收集只占了堆内存的一小部分,收集的速度逐渐跟不上分配的速度。导致垃圾慢慢堆积,时间一长,就会因为占满堆引发Full GC而降低性能。
从G1开始,先进的垃圾收集器的设计追求都转变为能够应付应用的内存分配效率,而不追求把整个Java堆一次性清理干净。只要收集器的收集速度能够跟上对象分配的速度,运作就没问题。
G1 vs CMS
(1)从算法理论来看,G1更有发展潜力:CMS是采取标记清除算法,而G1在Region之间是采取标记复制算法,从整体上看G1是采取标记整理算法,无论是哪个角度,G1运行时都不会产生内存空间碎片,能够提供完整的可用内存,这有利于程序长时间运行,当程序为大对象分配内存时也不容易因为无法找到连续空间而提前触发下一次GC。
(2)从内存占用来看:虽然G1和CMS都采用了卡表来处理跨代指针,但是G1的实现比较复杂,这导致G1的内存消耗可能会达到整个堆容量是20%以上。相比起来CMS的卡表实现就相当简单,只有一份而且只需要处理老年代(非收集区域)到新生代(收集区域)的引用,由于新生代对象朝生夕灭,省下这个区域维护的开销是很划算的。
(记忆集是非收集指向收集,是建立在新生代的,由老年代指向新生代,这是因为跨代引用比较少,所以说,实际上CMS的卡表是用于YGC,代价是当Old GC时,实际上需要把整个年轻代作为GC Roots。而G1因为没有固定的新生代老年代,卡表需要双向指向,是更加复杂的)
(3)从执行负载来看:CMS使用写后屏障来维护卡表,G1除了使用写后屏障外,为了实现SATB搜索,还需要写前屏障来跟踪并发时指针的变化。相比于增量更新算法,SATB搜索可以减少并发标记和最终标记阶段的消耗,避免像CMS在重新标记阶段停顿时间过长的问题,但是这也会产生由于跟踪指针变化而带来的额外负担。也因为G1的写屏障操作比CMS复杂,所以CMS的写屏障实现是直接的同步操作,而G1就要设置为类似消息队列的结构,然后再异步处理。
衡量垃圾收集器有三个很重要标准:内存占用,吞吐量、延迟。
这三项指标中,延迟的重要性日益凸显。随着计算机硬件发展,我们可以忍受收集器多占用一点内存,而硬件的发展也提升了软件系统的处理能力,吞吐量会更高。但是,内存的扩大,对延迟反而会带来负面的影响,因此延迟可以说是最被重视的指标。
在CMS和G1收集器前,垃圾收集器的所有步骤都会产生STW,而CMS和G1分别使用增量更新和原始快照实现了并发标记,也不会因为管理的堆扩大,标记对象变多而使得停顿时间随之增长。但是对于标记后的阶段,仍存在很多问题。CMS基于标记清除算法,不管怎么优化,总会产生空间碎片,最终依然会STW,G1是基于标记整理和复制算法,虽然可以借助Region从更小的粒度进行回收,从而抑制整理阶段时间过长的停顿,但是移动式的也还是避免不了暂停。
在这个背景下,出现了两种堪称革命性的收集器,Shenandoah和ZGC,这两种收集器几乎整个工作过程都是并发的,只有初始标记、最终标记阶段有短暂的停顿,但是这部分的停顿时间基本上是固定的,和堆容量、堆对象数量没有正比例关系。事实上,他们都可以在可管理的堆容量下,实现停顿不超过10ms这种匪夷所思的目标,他们被命名为 低延迟垃圾收集器。
Shenandoah是一款不由Oracle公司的虚拟机团队所领导开发的垃圾收集器,但是相比有着正统血统的ZGC,它反而更像是G1的继承者。
关于Shenandoah和G1
相比G1,Shenandoah相同点很多:基于Region的内存布局,用于存放大对象的Humongous,默认回收策略也是优先处理回收价值最大的Region。不同点主要有:支持并发的标记整理算法,默认不使用分代收集,改用名为连接矩阵的全局数据结构来记录跨Region的引用关系。
(1)并发的标记整理算法:
G1的回收阶段是可以多线程并行的,但却不能与用户线程并发。Shenandoah针对这点做出了改进。
(2)默认不使用分代收集:
没有实现分代,并不意味着分代对于Shenandoah没有价值,而是出于性价比的权衡,将其放到优先级较低的位置。
(3)连接矩阵:
放弃了G1中耗费大量内存和计算资源维护的记忆集,改用连接矩阵,降低了处理跨代指针时记忆集的消耗,也降低了伪共享问题的概率。连接矩阵可以理解为一张二维表格:如果Region N中有对象A引用了Region M中的对象B,就在表格的N行M列中标上一个标记。通过这张表格就可以得出哪些Region之间存在跨Region引用。
Shenandoah的工作流程:
大致可以分为9个阶段:初始标记、并发标记、最终标记(这三个和G1基本一致)、并发清理、并发回收、初始引用更新、并发引用更新、最终引用更新、并发再清理。
(1)初始标记:(STW)
与G1一样,标记与GC Root直接关联的对象,STW,但停顿时间和GC Roots的数量有关。
(2)并发标记:
与G1一样,遍历对象图,标记出全部可达的对象,与用户线程一起并发,时间长短取决于堆中存活对象的数量以及对象图的结构复杂程度。
(3)最终标记:(STW)
前半部分与G1一样,处理剩余的STAB扫描,后半部分和G1的筛选回收的前半部分大致相同,需要在这个阶段统计出回收价值最高的Region,将这些Region构成一组回收集,最终标记阶段也需要STW。
(4)并发清理
并发清理阶段用于清理那些整个区域都没有一个存活对象的Region。
(5)并发回收
并发回收阶段是Shenandoah与之前收集器不同的核心阶段。在这个阶段,Shenandoah会把回收集里的对象复制一份到其他未被使用的Region,并发复制对象的困难点在于在移动对象的同时,用户线程仍可能不断对移动的对象进行读写访问,移动对象是一次性的,但是内存中所有指向该对象的引用此时还是指向旧对象的地址,这是很难一瞬间改变的。Shenandoah采取的解决方案是读屏障+转发指针(Brook Pointers)。并发回收阶段运行时间长短取决于回收集的大小。
(6)初始引用更新:(STW)
并发回收阶段复制完对象后,需要进行引用更新,把堆中所有指向旧对象的引用修正为复制后的新地址。初始引用更新阶段并没有做什么具体的处理,设立这个阶段只是为了建立一个线程集合点,确保所有并发回收阶段中进行的收集器线程都已经完成分配给他们的对象移动任务。初始引用更新时间非常短,会产生一个短暂的停顿。
(7)并发引用更新:
并发引用更新阶段才真正进行引用更新操作。这个阶段是与用户线程一起并发的,时间长短取决于内存中涉及的引用数量的多少。并发引用与并发标记不同,不需要沿着对象图搜索,只需要按照对象内存物理地址顺序,线性的搜索出引用类型,把旧值改为新值即可。
(8)最终引用更新:(STW)
最终引用更新阶段用于修正位于GC Roots中的引用,这个阶段是Shenandoah最后一次停顿,停顿时间和GC Roots数量有关。
(9)并发再清理:(再一次,和(4)一样)
经过并发回收和引用更新(初始、并发、最终)后,整个回收集的Region中已经没有存活的对象,再调用一次并发清理来回收这些Region的内存空间。
(记忆:标记(3步)、清理、回收、引用更新(3步)、再清理)
关于转发指针(Brook Pointer):
(1)转发指针是一种用于解决对象移动和用户程序并发的解决方案。在此之前,想要实现并发,通常是在被移动对象原有的内存上设置保护陷阱,一旦用户程序访问到属于旧对象的内存空间就会发生自陷中断,进入到异常处理器,再将访问转发到新对象。如果没有操作系统的直接支持,这种方案将导致用户态频繁切换到核心态,代价太大。
(2)转发指针在思想上是类似的,具体实现却不同,它在原有对象布局结构最前面统一增加一个新引用字段,在正常不处于并发移动的情况下,该引用指向对象自己。从某种意义来说,转发指针和句柄定位有一定的相似之处,他们都是一种间接性的对象访问方式,区别不过是句柄一般会储存在专门的句柄池,而转发指针是分散到每一个对象头的前面。
转发指针加入后,当对象拥有了一份副本,只需要修改一处指针的指,即可将所有对该对象的访问转发到新对象上,只要旧对象还存在,通过旧地址访问的代码依然可用。自然,在最终引用更新阶段结束前,是不可以清理这些Region的。
转发指针的问题:
(1)转发指针作为一种间接的对象访问方式自然有固有的不可避免地缺点:每次对象访问会带来一次额外的转向开销。对象定位作为频繁使用到的操作,这笔开销是不可忽视的,只不过相比没有操作系统支持的自陷保护,已经好很多了。
(2)此外,转发指针是存在并发问题的。如果只是并发读取,那么新旧对象的影响不大,可以有一些偷懒的处理,但是如果是并发写入,就要保证写操作只能发生在新对象上,而不是写入旧对象的内存。举个例子:以下三件事情并发进行:收集器线程复制了新的对象副本,用户线程更新了对象的某个字段,收集器线程更新转发指针的引用值为新的对象地址。如果没有任何保护措施,让更新字段发生在复制对象和更新转发指针之间的话,用户对对象的变更就发生在了旧对象上。所以要对转发指针采取同步措施,让收集器线程和用户线程对转发指针的访问只有一个能成功,另一个要等待。Shenandoah具体采取的措施是CAS操作。
(3)转发指针还存在另一个执行频率问题。尽管转发指针的原理并不复杂,但是对象访问在Java这门面向对象的编程语言中,分量是很重的。要覆盖全部的对象访问操作,需要同时设置读写屏障。JDK13中,设计者计划只拦截数据类型为引用类型的读写操作,这能省去大量的屏障的消耗。
ZGC收集器是一款基于Region内存布局,(暂时,指JDK12中)不设分代,使用了读屏障,染色指针、内存多重映射等技术来实现可并发的标记整理算法的,以低延迟为首要目标的一款垃圾收集器。接下来逐个介绍其中的技术点:
ZGC的内存布局:
和G1、Shenandoah一样,ZGC也是基于Region的堆内存布局,但是和他们不同的是,ZGC的Region具有动态性,即动态创建和销毁,动态的区域容量大小。在x64平台下,ZGC的Region可以分为小中大三类容量:
小型Region(Small Region):容量固定为2MB,用于存放小于等于256KB的小对象。
中型Region(Medium Region):容量固定为32MB,用于存放256KB~4MB的对象。
大型Region(Large Region):容量不固定可以动态变化,但必须是2MB的整数倍,用于存放4MB以上的对象,最小容量为4MB。大型Region在ZGC的实现中不会重分配,因为代价太高昂了。
ZGC的并发标记整理算法:
技术支持:读屏障和染色指针、内存多重映射:
染色指针技术:
Shenandoah使用读屏障和转发指针实现并发整理,ZGC则是使用更为巧妙的读屏障和染色指针。
染色指针技术是ZGC标志性设计,染色指针直接把标记记录在引用对象的指针上,是一种直接将少量额外信息存储在指针上的技术。尽管不同64位系统支持的物理地址寻址空间不同,但是用于寻址的位数一般都在40位以上,拿出染色指针的位数后所能支持的内存依然可以满足大型服务器的需要。以Linux下的64位指针为例,有46位指针能够支持64TB的内存。ZGC的染色指针技术就是从这46位中,拿出高四位用于存储四个标志信息。通过这些标志位,虚拟机可以直接从指针看出其引用对象的三色标记状态、是否进入重分配集、是否只能通过finalize方法才能访问。当然,这些标志位进一步压缩了寻址空间,ZGC能够管理的内存也就不可以超过4TB(JDK13计划增长到16TB)。
染色指针的优势:
一是染色指针可以使得一旦某个Region的存活对象被移走后,这个Region立刻就能够被释放和重用,不必等到这个堆中所有指向这个Region的引用都被修正后才能清理,这得益于染色指针的自愈能力,这一点在工作流程的并发重分配阶段讲解。
二是染色指针可以大幅减少垃圾收集过程中内存屏障的使用,设置内存屏障,尤其是写屏障主要是为了记录对象引用的变化情况,如果将这些信息直接维护在指针中就可以省去一些专门的记录操作。实际上,ZGC中没有使用写屏障,只有读屏障。能够省去内存屏障显然可以提升程序运行效率,因此ZGC对吞吐量的影响相对较低。
三是染色指针是一种可扩展的数据结构,以Linux为例,前18位如果能被开发出来,ZGC就可以增大支持的堆内存,也可以记录更多的标志,比如可以存储一些追踪信息来让垃圾收集器在移动对象时能将低频次使用的对象移动到不常访问的内存区域。
想要顺利应用染色指针,存在一个前置问题:操作系统和处理器是否支持JVM去随意重新定义内存中某些指针的其中几位。ZGC使用了多重映射将不同的虚拟内存地址映射到同一个物理内存地址。把染色指针标志位视为地址分段符,只要将这些不同的地址段都映射到同一个物理内存空间,经过多重映射转换后,就可以使用染色指针正常寻址了。
ZGC的工作流程:
ZGC的工作流程大致可以分为六个阶段:初始标记、并发标记、再标记、并发预备重分配、并发重分配、并发重映射。
(1)初始标记:(STW)
CMS、G1、Shenandoah都有这个阶段,它是STW的,用于标记GC Roots直接可达的对象,很短暂。
(2)并发标记:
与G1、Shenandoah一样,并发标记阶段是遍历对象图做可达性分析的阶段,不同的是,ZGC是在指针上而不是在对象上进行的,标记阶段会更新染色指针的Marked0、Marked1标志位。
(3)再标记:(STW)
这个阶段也是STW的,类似G1和Shenandoah的最终标记阶段(这俩要在这里记录回收集),用来处理并发标记结束后遗留的一些引用,这个阶段也很短暂,如果再标记阶段时间过长,就会再次进入并发标记阶段。
(4)并发预备重分配:
这个阶段需要根据特定的查询条件统计得出本次收集过程要清理那些Region,将这些Region组成重分配集。这个重分配集和G1的回收集有一定的区别,ZGC划分Region的目的并不是为了做收益优先的增量回收,相反ZGC每次都会扫描所有的Region,用更大范围的扫描成本替换维护记忆集的成本,因此ZGC的重分配集只是决定了里面存活的对象会被重新复制到其他的Region中,里面的Region则会被释放。此外,ZGC支持的类卸载和弱引用的处理也是在这个阶段进行的。
(5)并发重分配:
这是ZGC执行过程的核心阶段,这个阶段要把重分配集中存活的对象复制到新的Region中,并为重分配集中的每一个Region维护一个转发表,记录从旧对象到新对象的转向关系。得益于染色指针的支持,ZGC能只从引用上就得知一个对象是否处于重分配集中。如果用户此时并发访问了位于重分配集中的对象,这次访问会被预置的内存屏障截获,然后立即根据Region上的转发表记录将访问转发到新复制的对象上,并同时修正更新该引用的指向,使其直接指向新对象,这就是染色指针的自愈能力。这样做有两个好处:一是一旦重分配集中某个Region中的存活对象都复制完毕后,这个Region就可以立即释放用于新对象的分配(但是转发表还要留着),哪怕堆中还有很多指向这个对象的未更新指针也没关系,一旦这些指针被使用,他们都是可以自愈的。二是只有第一次访问旧对象会转发,只慢一次,以后访问就直接到了新对象。
(转发表是核心,记录着旧对象到新对象的转向关系。原A,新B,假如访问A,直接转发到B,得益于染色指针技术,可以引用直接修改到B,A自然可以直接删除了,也就是说,用到哪一个,自愈哪一个,删除哪一个。Shenandoah并没有删除这个,只是单纯的一个转向,所以必须全部转向完了,才能删除,也是因此,Shenandoah还需要一个并发引用更新阶段,用来修正转向。)
(6)并发重映射:
重映射所做的是修正整个堆中指向重分配集中旧对象的所有引用。但是这和Shenandoah的并发引用更新不同,重映射并不是一个迫切的需求,因为即使是旧引用也是可以自愈的,不过是多了一次转发和修正操作而已。重映射的主要目的是为了不变慢和可以回收转发表这种收益。因此ZGC把并发重映射阶段要做的工作合并到了下一次收集的并发标记阶段中,反正并发标记也要遍历对象图,这样就节约了一次遍历对象图的开销,一旦所有指针都被修正后,记录新旧对象引用关系的转发表就可以释放了。
ZGC存在的问题、优点、性能:
(1)ZGC没有使用记忆集,也没有写屏障,甚至没有分代,这给用户的运行负担是很小的,但是自然也带来了一些问题:它能承受的对象分配速率不会太高。举个场景例子:ZGC要对一个很大的堆进行一次完整的并发收集,总过程要10分钟,在这段时间,由于应用的对象分配速率很高,创造了大量的新对象,而ZGC没有分代的概念,需要全堆扫描,尽管这些对象大部分是朝生夕灭的,但是它们很难进入当次收集的标记范围,就会被当作存活对象,这就产生了大量的浮动垃圾。如果这种高速分配持续维持的话,因为每一次并发收集过程都很长,回收到的内存空间持续小于浮动垃圾占用的空间,堆中的剩余空间自然越来越少。目前唯一的办法就是,增大堆内存的大小,但是引入分代收集才能从根本上解决这个问题。
(2)ZGC还有一个优点就是支持NUMA-Aware的内存分配。NUMA(非同一内存访问结构)是一种为多处理器或者多核处理器的计算机设计的内存架构。由于摩尔定律逐渐失效,现代处理器转向多核方向发展,每个处理器所在的裸晶都有自己内存管理器管理的内存,访问其他处理器核心的内存需要通过Inter-Connect通道完成,这比访问本地内存慢很多。NUMA架构下,ZGC会优先尝试请求线程当前所处的处理器的本地内存上分配对象。
(3)性能方面,ZGC尽管还在实验状态,表现已经堪称令人震惊的,革命性的。在吞吐量方面,以低延迟为目标的ZGC却超过了G1,几乎和Parallel Scavenge几乎一样,如果是采取最大延迟不超过某个设置值的形式,甚至超越了Parallel。在时间停顿上,不论是平均停顿、还是最大停顿、ZGC都能轻松控制在10ms内,这是十倍以上的差距。
以下阐述原则以Serial + Serial Old为例。
先介绍一些参数:
-XX:+PrintGCDetails参数可以打印内存回收日志。
1、对象优先在Eden上分配
大多数情况下,对象在新生代Eden区分配,当Eden区没有足够的空间,将发起一次Young GC。
2、大对象直接进入老年代
大对象即指需要大量连续内存的Java对象。-XX:PretenureSizeThreshold参数可以指定大于该设置值的对象直接在老年代分配。避免了在Eden和两个Survivor直接来回复制。
3、长期存活的对象进入老年代
虚拟机为每个对象定义了一个对象年龄计数器,对象通常在Eden中诞生,如果活过了一次Young GC,那么年龄+1,当年龄增长到一定程度(默认为15),就会被送去老年代。晋升阈值可以通过-XX:MaxTenuringThreshold参数设置。
4、动态对象年龄判断
HotSpot虚拟机并不总是要求对象达到年龄阈值才晋升,如果Survivor空间中,大于或等于某年龄的所有对象的大小总和小于Survivor空间的一半,这些对象就会直接进入老年代,无需达到年龄阈值。
5、空间分配担保
发生Young GC前,虚拟机必须检查老年代剩余最大连续空间是否大于新生代所有对象大小总和,如果大于,那么这次Young GC是安全的,否则会检查-XX:HandlerPromotionFailure参数是否允许担保失败,如果不允许,就要进行一次Full GC,如果允许,会检查老年代剩余最大连续空间是否大于历次晋升到老年代的对象的平均大小,如果大于,将会尝试一次Young GC,否则就要进行一次Full GC。
第一次检查是显而易见的,如果老年代剩余空间足够大,怎么样都可以容纳新生代剩余的对象,自然没有问题。但是如果不能满足这一点,就需要考虑是不是要冒风险进行GC,因此还要判断一下历次晋升到老年代对象的平均大小,尽可能提高Young GC成功的概率。假如新生代出现GC后大量对象存活,老年代很可能会容纳不了这些对象,进而引发Full GC,这比直接Full GC停顿还长。通常情况下,都是要允许担保失败的。JDK6的一个版本更新之后,规则变为了:只要老年代的剩余连续空间大于新生代对象总大小或者历次晋升的平均大小,就会进行Young GC,否则Full GC。也就是取消了失败担保参数的判断,改为默认开启。