前言
java内存运行时区域的各个部分,其中的程序计数器,虚拟机栈,本地方法栈三个区域是随线程而生、随线程而亡的;
栈中的栈帧是随着方法的进入和退出而执行入栈和出栈的。每个栈帧中分配的内存在类结构确定下来时就是已知的,因此这几个区域的内存分配和回收都是确定的,方法结束和线程结束时,内存自然就回收了。
而Java堆和方法区则不一样,一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,我们只有在程序运行期间才能知道要创建哪些对象,这部分内存分配和回收是动态的,也就是说这部分内存的回收是要干预的。后续我们参与讨论的“内存”分配与回收也仅指这一部分内存
1.判断对象死亡与否
java堆里存放着几乎所有的对象实例,在进行GC前,我们必须要弄清楚那些对象还活着(即不可被回收),哪些对象已经死了(可以被回收了)。
我们有如下的方法来判断对象存活与否;
1.1 引用计数法
给对象添加一个引用计数器,每当有一个地方引用它时,计数值就加1;当引用失效时计数器值就减1;任何时刻当一个对象的计数器值为0时就是不再被使用的,即就是要被回收的。
这种算法实现简单,判定效率也很高,在多数情况下它是一个不错的算法,但是在java语言中没有选取这种方法来管理内存,因为它无法解决对象之间互相循环引用的问题:
- 比如对象A和对象B都有字段instance
- 令A.instance = B及B.instance = A,除此之外这两个对象再无其他任何引用。
实际上这两个对象是要被回收的对象,但是他们之间存在着互相引用,导致计数器的值不为0,引用计数算法就不能回收他们(回收条件计数器值为0)。
1.2 可达性分析算法
主流的商用程序语言(JAVA/C#等)的主流实现都是通过可达性分析来判定对象是否存活。
通过“GC Roots”的对象作为起始点,从这个起始点向下搜索,搜索所走过的路径成为引用链,当一个对象没有与任何引用链相连(即从GC Roots不可达),此时说明这个对象是不可用的。
如下图,obj5,obj6,obj7虽然相互有关联,但是他们到GC Root是不可达的,会被判定为可回收的对象。
在java语言中,可作为GC Roots的对象包含以下几种:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象。
- 方法区中类静态属性引用的对象。
- 方法区中的常量引用的对象
- 本地方法栈中JNI(即一般说的native方法)的引用的对象
2. 引用
传统的引用的定义(如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址,那它就是引用)无法满足gc的需要。
在实际中我们希望能有这样的对象:当内存空间足够时保存在内存中,当内存紧张时,则可以抛弃这些对象。
故而在JDK1.2之后,java将引用的概念进行了扩充,将引用分为:强引用,软引用,弱引用,虚引用四种,这四种的引用强度依次逐渐减弱。
强引用在代码中普遍存在,如Object obj = new Object() 这样的引用就是强引用,只要这个对象的引用还存在,垃圾回收器就永远不会回收它。(在通常对静态属性赋值时一定要各位注意,它的生命周期会贯穿整个app的生命周期)
软引用用来描述一些还有用,但是并非必须的对象,正常gc时不会回收它,只有在系统即将发生内存溢出之前,会将这些对象进行回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。
弱引用是用来描述非必须对象的,他比软引用更更弱一些,被弱引用关联的对象只能生存到下一次垃圾回收收集之前。当GC时无论当前内存是否足够,都会回收掉只被弱引用关联的对象(注意是只被弱引用关联的对象,如果一个对象即被强引用引用也被弱引用引用,GC时是不会回收的)
虚引用也称为幽灵引用或幻影引用,它是最弱的一种引用关系。一个对象是否有有虚引用,完全不会影响它的生存周期周期,也无法通过一个虚引用获得一个对象。为一个对象设置为虚引用关联的唯一目的就是希望能在这个对象被回收时收到一个系统通知。
3. finalize方法
即使是被可达性分析算法不可达的对象,也不是非死不可,这时候它处于“缓刑”状态,finalize()是它完成自救的最后机会
finalize()是Object中的方法,当垃圾回收器将要回收对象所占内存之前被调用,其过程为:
- 某个对象被判断为不可达,被第一次标记。判断该对象是否有必要执行finalize()
- 如果对象没有重写该方法,或者该方法已经被虚拟机调用过(所以finalize()最多只能执行一次),它将第二次被标记,基本上在劫难逃了。
- 否则,则会将该对象放置在一个叫做F-Queue的队列中,并在稍后由一条虚拟机自动建立的,低优先级的Finalizer线程去执行(调用finalize()方法),但并不承诺会等待它运行结束
- finalize()方法是对象逃离死亡的最后一次机会,稍后GC将对F-Queue中的对象进行第二次小规模的标记,如果对象在finalize()中成功拯救了自己(重新与引用链建立关联),那在第二次标记时它将被移除出即将回收的集合;
- 否则,第二次标记没躲掉,它基本上也在劫难逃了。
不承诺执行完finalize()的原因是:如果一个对象的finalize()方法执行缓慢或者发生死循环等极端情况,将会导致F-Queue队列永久处于等待状态,甚至导致整个GC系统的崩溃。
在java中不建议使用finalize方法。
4. 方法区的回收
很多人认为方法区(或者HotSpot虚拟机中的永久代/元空间)是没有垃圾回收的,java虚拟机规范确实说过不要求虚拟机在方法区实现垃圾回收,而且在方法区进行垃圾回收的性价比比较低:在堆中,尤其是在新生代中,常规的应用进行一次垃圾回收一般能回收70%~95%的空间,而永久代的垃圾回收效率远低于此。
其实永久代的垃圾回收主要回收两部分内容
- 废弃常量
- 回收废弃常量与回收java堆中的对象非常类似,比如常量池字面量‘abc’,如果此时没有一个String对象值为‘abc’,即没有任何String对象引用‘abc’常量,那么发生gc时,其将被清出常量池。常量池中的其他类、接口、方法、字段的符号引用也类似。
- 无用的类
- 相比判断废弃常量,判断无用的类条件比较苛刻,需要同时满足以下三个条件。
- 该对象的所有实例都已经被回收,也就是堆中不存在该类的任何实例。
- 加载该类的ClassLoader也已经被回收
- 该类对应的java.lang.Class对象没有在任何地方被引用,也无法在任何地方通过反射来访问该类的方法
- 虚拟机可以对这样的无用的类进行回收,但也局限于可以,而不是必然。Hotspot虚拟机提供了
-Xnoclass
参数进行控制。
- 相比判断废弃常量,判断无用的类条件比较苛刻,需要同时满足以下三个条件。
4. 垃圾回收算法
4.1 标记-清除算法
(Mark-sweep)这是最基础的垃圾回收算法,顾名思义,分为标记和清除两个阶段。它这里的标记就是指介绍finalize方法时提到的第二次标记。
首先标记出所有需要回收的对象,在标记完成后统一回收掉被标记的对象。它主要有两个缺点:一个是效率问题,标记和清理过程效率都不高;另一个问题是空间问题,在清除后会产生大量不连续的内存碎片,当空间碎片太多时会导致,当程序以后运行需要分配较大对象时无法找到足够的连续内存而不得不提前触发下一次GC动作。
4.2 复制算法
为了解决效率问题,复制算法应运而生。
它将内存按容量划分为大小相等的两块,每次使用其中的一块。当这一块用完时就将还存活的对象复制到令一块上,然后将已使用过的这一块内存清理掉。这样分配时就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行效率高。
但是缺点也是显而易见的:内存利用率只有一半。
4.2.1 新生代的回收
现在的商业虚拟机都采用这种算法来收集新生代,IBM的专门研究表明,新生代的对象98%都是朝生夕死的,所以并不需要按1:1的比例来划分内存空间,而是将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中的一块SuSurvivor。
回收时,将Eden和刚才刚才用过的Survivor的空间中还活着的对象一次拷贝到另外一块Survivor空间上,最后清理掉Eden和刚才刚才使用过的Survivor的空间。
虚拟机默认Eden区和Survivor区的大小比例是8:1,也就是每次新生代中可用空间为整个新生代容量的90%。
当然我们没办法保证每次回收时,都只有不多于10%的对象存活,当Survivor空间不够用时,需要依赖其他的内存(这里指老年代)进行分配担保。(稍后详解)
4.3 标记-整理算法
如果内存中对象的存活率比较高的话,那么复制算法需要执行较多的复制操作,效率会变低,更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法。
根据老年代的特点,有人提出了“标记–整理”算法,标记过程仍与“标记–清除”算法一样,但是后续步骤不是直接对可回收的对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。
4.4 分代收集算法
当前商业虚拟机的垃圾回收都是采用的“分代收集”算法,根据对象的存活周期的不同将内存划分为几块。一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的垃圾回收算法。在新生代中,每次垃圾每次垃圾回收都发现大批对象死去,只有少量存活,那就使用复制算法。而老年代中因为对象存活率较高,没有额外的空间对它进行分配担保,就必须使用“标记–清理‘’或者‘标记–整理’‘算法来进行回收。
5 HotSpot虚拟机的算法实现
上述的这些对象存活判断算法和垃圾收集算法,在hotspot虚拟机上,会为了达到更高的效率,而做一些优化或者变动。这些优化有:
5.1 枚举根节点
可达性分析算法目前有两个主要的局限:
可达性分析需要从GC root节点开始寻找引用链,而GC root主要在全局性的引用(常量和静态变量)和执行的上下文(栈帧的本地变量)中,这类数据日臻庞大,如果要逐个检查,那么必然消耗很多时间。
可达性分析需要等待GC停顿,即一个整个系统类似被冻结的时间节点(停顿所有执行线程),因为可达性分析无法在引用关系还在不断变化的情况下准确分析。
目前主流的java虚拟机都采用准确式GC,所以当gc停顿后,并不需要一个不漏的检查所有上下文和全局的引用。在HotSpot的实现中,有一组称为OopMap的数据结构,类加载完成的时候,HotSpot就把对象内什么偏移量上是什么类型的数据计算出来,在编译过程中,也会在特定的位置记录下栈和寄存器中哪些位置是引用。这样,GC在扫描时就可以直接得知哪些地方存放着对象引用。
准确式内存管理:即虚拟机可以知道内存中某个位置的数据具体是什么类型。譬如内存中有一个32位的整数123456,它到底是一个reference类型指向的123456的内存地址还是一个数值为123456的整数,虚拟机将有能力分辨出来,这样才能在GC的时候准确判断对上的数据是否还可能被使用。
保守式GC,半保守式GC和准确式GC,以及OopMap,推荐可以拓展看该篇文章JVM中的OopMap
5.2 安全点Safe Point
有了OopMap,HotSpot可以快速准确完成GC Roots枚举。但是另一个问题来了,我们要在什么地方创建OopMap?程序运行期间,引用的变化在不断发生,如果每一条指令都生成OopMap,那占用空间就太大了。为了解决这个问题,我们引入了安全点(Safe Point)—— 只在安全点进行GC停顿,只要保证引用变化的记录完成于GC停顿之前就可以。
可以理解为OopMap表示的是一个班级的座位表,上面记录每个同学都在xx行xx列,分别是男是女。假设班上的同学一直在不停的变换位置,如果我们每一次变换都要创建一张新的座位表,那太繁琐,占用空间也多。
为了解决这个问题,我们加入了一个暂停(安全点)的概念,即某个时刻,所有同学的移动停止,我们只会在暂停的时候发生gc,那么也只需要在每次暂停之前生成座位表即可。因为座位表是给gc用的,gc又只会发生在安全点,所以这样是可行的。
安全点选定太少,GC等待时间就太长,选的太多,GC就过于频繁。选定原则是“具有让程序长时间执行的特征”,也就是在这个时刻现有的指令是可以复用的。一般选在方法调用、循环跳转、抛出异常的位置。
现在的问题是在Safe Point让线程们以怎样的机制中断,方案有两种:抢先式中断、主动式中断。
- 抢先式中断:GC发生时,中断所有线程,如果发现有线程不再安全点上,就恢复线程让它运行到安全点上。现在几乎不用这种方案。
- 主动式中断:当发生GC时,设置一个标记,所有线程在到达各自的Safe point时判断这个标记,如果中断标记为真就自己中断挂起。HotSpot使用主动式中断。
5.3 安全区域safe region
貌似引入安全点,再加上OopMap,就可以完美解决GC的性能问题了,但实际上,我们还考虑漏了一种情况:即有些程序此时处于无法响应jvm中断请求的状态(比如线程sleep或者block),这样程序不会走到安全点了。
类比的话就是移动过程中,有些同学睡着了,听不到暂停的指令(中断请求),他可能睡醒后回过神来,自顾自的去下一个座位,全然不顾班上其他同学已经暂停了。
为了解决这个问题,HotSpot还引入了安全区域的概念。
安全区域是指在一段代码片段中,引用关系不会发生变化,在该区域的任何地方发生GC都是安全的。当代码执行到安全区域时,首先标识自己已经进入了安全区域,那样如果在这段时间里JVM发起GC,就不用管标示自己在安全区域的那些线程了,在线程离开安全区域时,会检查系统是否正在执行GC,如果是,就等到GC完成后再离开安全区域。
类比为:所有睡着的同学,你们睡着可以,但是要给自己做个标记,这样我暂停排座位表(OopMap)的时候,我就忽略你们了。但是为了防止你们在暂停的时候突然醒来,然后后知后觉的到处乱闯,我只好跟你们约法三章:睡醒的时候,问一下周围现在是不是在暂停中,如果是的话,你们就不要动弹,等暂停结束了再走。
6 垃圾收集器
如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。
以下是HotSpot虚拟机中的7中作用于不同分代的垃圾收集器,连线表示垃圾收集器可以配合使用。
现在来说,目前并不存在一个万能的收集器,具体应用或者具体场景,都有不同的适用的收集器。
关于收集器,我们常会用到并行与并发来做描述,他们的区别是:
并行(parallel):指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态。
并发(concurrent):指用户线程与垃圾收集线程同时执行(但不一定是并行的,可能会交替执行),用户线程在继续运行,而垃圾收集程序运行在另一个cpu上。
目前,HotSpot的新生代收集器,都是使用复制算法,老年的收集器,都是采用标记-整理算法(CMS除外)。同代的不同收集器之间的区别,一般在是否并行,并发等回收策略上。带有serial的,顾名思义,顺序执行的意思,是单线程的。带有parallel的,就是多线程并行的。带concurrent的就是并发。
6.1 serial收集器
该收集器是最基本,最悠久的收集器,曾经在jdk1.3.1之前是虚拟机新生代收集的唯一选择。
它是单线程的收集器,不仅意味着只会使用一个线程进行垃圾收集工作,更重要的是它在进行垃圾收集时,必须暂停所有其他工作线程,往往造成过长的等待时间。
serial收集器的新生代(serial收集器)采用复制算法,老年代(serial old收集器,后面会讲)采取标记整理算法。
虽然经过长久的发展,为了减少停顿,开发团队设计和实现了许多更优秀更复杂的收集器,但不意味着serial老而无用。它目前仍然是虚拟机运行在client模式下的默认新生代收集器。它的优点是简单高效,对于单个CPU环境来说,由于没有线程交互的开销,因此拥有最高的单线程收集效率。
在Client应用场景中,分配给虚拟机管理的内存一般来说不会很大,该收集器收集几十兆甚至一两百兆的新生代停顿时间可以控制在一百多毫秒以内,只要不是太频繁,这点停顿是可以接受的。
6.2 ParNew收集器
它是Serial收集器的多线程版本。除了使用多线程进行垃圾收集之外,其余行为包括serial收集器可用的控制参数、收集算法、stop the world、对象分配规则、回收策略等都和serial收集器完全一样。实际上二者也共用了相当多的代码。
虽然并无太多创新之处,但它是许多运行在Server模式下的虚拟机首选的新生代收集器,除了性能原因外,主要是因为除了serial收集器,只有它能与CMS收集器配合工作。
默认开始的gc线程数量与CPU数量相同,可以使用-XX:+ParallelGCThreads
参数来设置线程数。
CMS收集器在jdk1.5中横空出世,其并发收集的特性具有划时代意义,但它作为老年代收集器,却只能和parnew和serial配合工作。parnew因为性能原因,是在使用cms时默认的新生代收集器。
6.3 parallel scavenge收集器
新生代收集器,复制算法,并行的多线程收集器。看起来和parnew收集器类似,但它的特点是它的关注点和其他收集器不同。CMS等收集器的关注点是尽可能的缩短垃圾收集时的停顿时间,而parallel scavenge收集器的目的是达到一个可控制的吞吐量。它被称为“吞吐量优先”收集器。这里的吞吐量指CPU用于运行用户代码的时间占总时间的比值。(比如虚拟机运行了100分钟,垃圾回收花掉1分钟,则吞吐量是99%)
停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户体验。
而高吞吐量则可以高效率地利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。
parallel scavenge提供了下列参数用于精确控制吞吐量:
- 控制最大垃圾收集停顿时间
-XX:+MaxGCPauseMillis
参数,值为大于0的毫秒数,收集器尽可能保证内存回收花费的时间不超过该值。 - 直接设置吞吐量大小的
-XX:+GCTimeRatio
参数,值为大于0且小于100的整数,它的值是吞吐量的倒数。 - 开关参数
-XX:+UseAdaptiveSizePolicy
。打开参数后,就不需要手工指定新生代的大小(-Xmn)、Eden和Survivor区的比例(-XX:+SurvivorRatio
)、晋升老年代对象年龄(-XX:PretenureSizeThreshold
)等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量,这种方式称为GC自适应的调节策略(GC Ergonomics)。
Parallel scavenge收集器与ParNew收集器的重要区别就是这个GC Ergonomics,垃圾自适应调节策略。
不要以为把
-XX:+MaxGCPauseMillis
设置得小一些就能使垃圾回收更快,GC停顿时间缩短是牺牲吞吐量和新生代空间换来的。不说别的,调小这个值,一般会导致gc触发更加频繁,吞吐量反而下降。
6.4 Serial Old收集器
老年代收集器,串行的单线程收集器,使用标记整理算法。是serial收集器的老年代版本。
Serial Old是Serial收集器的老年代版本,是个单线程收集器,也是给Client模式下的虚拟机使用。如果用在Server模式下,它有两大用途:
- 在JDK 1.5 以及之前版本(Parallel Old诞生以前)中与Parallel Scavenge收集器搭配使用。
- 作为CMS收集器的后备预案,在并发收集发生Concurrent Mode Failure时使用。
6.5 Parallel Old收集器
老年代收集器,并行的多线程收集器,标记整理算法。是Parallel Scavenge收集器的老年代版本,吞吐量优先的垃圾回收器。
在注重吞吐量以及CPU资源敏感的场合(服务端应用),都可以优先考虑Parallel Scavenge加Parallel Old收集器。
6.6 CMS收集器(详细介绍)
老年代收集器,并行的多线程收集器,使用标记清除算法。是一种以获取最短回收停顿时间为目标的收集器。目前大部分java应用集中在互联网网站或者B/S系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短。
CMS牺牲了系统的吞吐量来追求收集速度,适合追求垃圾收集速度的服务器上。
6.6.1 优缺点
特点:
并发收集
低停顿。这也是CMS采用标记清除而不是标记整理的原因,整理要STW
缺点:对CPU资源敏感。因为并发阶段虽然用户线程不停顿,但会占用CPU资源导致用户线程变慢,吞吐量降低。CMS默认启动的回收线程数是 (CPU数量 + 3) / 4。
- 当CPU>4时,并发线程>25%的CPU资源。且随CPU数量增加而下降。
- 当CPU<4时(假设为2),并发线程>50%的CPU资源,很影响用户体验。
无法处理浮动垃圾。
- 浮动垃圾:由于并发清理阶段用户线程还在运行着,伴随程序运行自然就还会有新的垃圾不断产生。这一部分垃圾出现在标记过程之后,CMS无法在当次收集中处理掉它们,只好留到下一次GC时再清理掉,这一部分垃圾就被称为“浮动垃圾”。
- 也是由于在垃圾收集阶段用户线程还需要运行,那也就还需要预留有足够的内存空间给用户线程使用,因此它不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,需要预留一部分空间提供并发收集时的程序运作使用。
- 可以使用
-XX:CMSInitiatingOccupancyFraction
的值来改变触发收集器工作的内存占用百分比,JDK 1.5默认设置下该值为68,JDK1.6默认设置下该值为92,也就是当老年代使用了68%/92%的空间之后会触发收集器工作。
- 如果-XX:CMSInitiatingOccupancyFraction 设置的太高,导致浮动垃圾无法保存,那么就会出现Concurrent Mode Failure,此时虚拟机将启动后备预案:临时启用Serial Old收集器来重新进行老年代的垃圾收集。
标记-清除算法会导致大量空间碎片,给大对象分配带来很大的麻烦,往往出现老年代空间剩余,但无法找到足够大连续空间来分配当前对象,不得不提前触发一次Full GC。
- 为了解决这个问题,CMS收集器提供了一个
-XX:+UseCMSCompactAtFullCollection
开关参数(默认就是开启的),用于在CMS收集器顶不住要进行Full GC时,开启内存碎片的合并整理过程。内存整理的过程是无法并发的,空间碎片问题没有了,但停顿时间却不得不变长。 - 虚拟机设计者还提供了另外一个参数
-XX:+CMSFullGCsBeforeCompaction
,这个参数是用于设置执行多少次不压缩的Full GC后,跟着来一次带压缩的(默认值为0,表示每次进入Full GC时都进行碎片整理)。
- 为了解决这个问题,CMS收集器提供了一个
6.6.2 触发条件
CMS垃圾收集器的触发条件有以下几个:
- 如果没有设置
-XX:+UseCMSInitiatingOccupancyOnly
,虚拟机会根据收集的数据决定是否触发(建议带上这个参数)。 - 老年代使用率达到阈值
-XX:+CMSInitiatingOccupancyFraction
,默认68%,即当老年代的空间使用率达到68%时,会执行一次CMS回收。前提是配置了第一个参数。 - 永久代的使用率达到阈值
-XX:+CMSInitiatingPermOccupancyFraction
,默认92%,前提是开启-XX:+CMSClassUnloadingEnabled
并且配置了第一个参数。 - 新生代的晋升担保失败。老年代有足够的空间,但是由于碎片化严重,无法容纳新生代中晋升的对象,发生晋升失败。
6.6.3 收集过程
采用“标记-清理”算法对老年代进行回收,过程可以说很简单,标记出存活对象,清理掉垃圾对象,但是为了实现整个过程的低延迟,实际算法远远没这么简单。
注意,CMS是并行的,因为是并发运行的,在运行期间会发生新生代的对象晋升到老年代、或者是直接在老年代分配对象、或者更新老年代对象的引用关系等等。对于这些对象,CMS都是要感应到的。
如何有效率的感应到呢?CMS将老年代的空间分成大小为512bytes的块,并维护一个叫做card table的数组(每个位置存的是一个byte),card table中的每个元素对应着一个块。并发过程中,如果某个对象的引用发生了变化,就标记该对象所在的块为dirty card。
CMS整个过程分为如下几个部分:
- 初始标记(CMS-initial-mark)
- 仅仅只是标记一下老年代中的GC Roots,以及被新生代存活对象引用的老年代对象,假设我们称这步标记的对象为objs,速度很快,需要停顿。
- 并发标记(CMS-concurrent-mark)
- 与用户线程同时运行,进行GC Roots Tracing的过程,找到与objs对象可达的更多对象进行标记。它在整个回收过程中耗时最长。
- 预清理(CMS-concurrent-preclean)
- 与用户线程同时运行;
- 扫描所有标记为Dirty的Card,然后将之前没有标记到的存活对象也标记上。
- 可被终止的预清理(CMS-concurrent-abortable-preclean)
- 与用户线程同时运行;
- 循环重复做两件事情,期待中间等来一次young gc,循环直到达到退出条件。
- 并发重新标记的工作内容
- 预清理的工作内容
- 并发重新标记(CMS-remark)
- 需要遍历新生代的全部对象,标记这些对象可达的老年代对象,同时,处理dirty card。目的是为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,需要停顿。
- 并发清除(CMS-concurrent-sweep)
- 与用户线程同时运行。清除那些没有标记的对象并且回收空间。
- 并发重置状态等待下次CMS的触发(CMS-concurrent-reset)
- 与用户线程同时运行;顾名思义,重置一下状态,表示CMS结束
6.6.3.1 初始标记
这是CMS中两次stop-the-world事件中的第一次。该阶段的工作是:标记存活的对象,主要有两种对象:
- 标记老年代中所有的GC Roots对象,如下图节点1;
- 标记年轻代中活着的对象(GC Roots可达)引用到的老年代的对象(指的是年轻带中还存活的引用类型对象,引用指向老年代中的对象,因为CMS是老年代收集器)如下图节点2、3;
为了加快此阶段处理速度,减少停顿时间,可以开启初始标记并行化,
-XX:+CMSParallelInitialMarkEnabled
,同时调大并行标记的线程数,线程数不要超过cpu的核数。
6.6.3.2 并发标记
该阶段的工作是:
- 从”初始标记”阶段标记的对象开始找出所有存活的对象;
- 有变更的对象作重新标记
如下图所示:
先从”初始标记”阶段标记的对象开始找出所有存活的对象,即我们从节点1、2、3找到了节点4、5。
但在找到4/5节点的过程中,因为我们是并发执行的,所以可能会有一些引用发生了变更,比如节点3引用了另外一个对象,如下图:
这个时候,jvm会将节点3所在的card标记为dirty,但只做标记,不做处理。
6.6.3.3 预清理阶段
这个阶段就是用来处理前一个阶段因为引用关系改变导致没有标记到的存活对象的,它会扫描所有标记为Dirty的Card,然后将之前没有标记到的存活对象也标记上。
如下图,节点3引用的节点6也被标记上了,标记完成,并且将节点3的card的dirty状态清除。
通过参数
-XX:+CMSPrecleaningEnabled
可以选择关闭该阶段,默认启用
6.6.3.4 可中断的预清理
该阶段发生的前提是,新生代Eden区的内存使用量大于参数-XX:+CMSScheduleRemarkEdenSizeThreshold
设置的值,默认是2M,如果新生代的对象太少,就没有必要执行该阶段,直接执行重新标记阶段。
为什么需要这个阶段,存在的价值是什么?
其实这个阶段,是为了后面即将进行的“并发重新标记”环节能少一些工作量而设置的,“并发重新标记”我们还没讲到,这里简单说下:“并发重新标记”会扫描并且标记整个年老代的所有的存活对象,包括被新生代中的对象引用的老年代对象,即使新生代的对象已经不可达了,也将其引用的老年代对象视为存活
因此,如果进入“并发重新标记”时,新生代的对象有很多,那么一个个检查过去他们是否引用老年代对象的过程也必然很长(所以该阶段触发前提是新生代内存大于-XX:+CMSScheduleRemarkEdenSizeThreshold
的值)
为了进入“并发重新标记”阶段时新生代对象尽可能少,“可中断的预清理”阶段会做两件工作:
- 处理From和To区的对象,标记可达的老年代对象
- 和上一个阶段一样,扫描处理Dirty Card中的对象
然而你会发现:
- 工作1不就是“并发重新标记”的其中一项工作么,现在做和后续做,有什么区别吗?
- 工作2与其说是“可中断的预清理”的工作,还不如说cms收集器在并发过程中就一直会在标记这些dirty card,并不是该阶段独有的工作。
如此看来,“可中断的预清理”阶段岂不是形同鸡肋?
其实,该阶段的目的在于:期待在该阶段的过程中,能够迎来一次young gc;
我们知道,新生代的对象大部分朝生暮死,每次young gc都会清理大量的新生代对象,如果在进入“并发重新标记”阶段前能够执行一次young gc,那“并发重新标记”阶段的扫描岂不是会轻松很多?
而且本身“可中断的预清理”阶段的触发前提就是新生代内存使用量超过一定阈值,虽然gc是JVM自动调度的,什么时候进行young gc我们控制不了,但既然能够满足“新生代内存使用量超过一定阈值”的前提,并进入“可中断的预清理”阶段,那么理论上离下一次的young gc应该也不远了。
所以,“可中断的预清理”阶段的核心就是:一直重复 “处理From和To区的对象,标记可达的老年代对象” 和 “扫描处理Dirty Card中的对象” 这两项工作,以期待在期间引来一次young gc
注意,是一直重复上述两项工作,直到:
- 可以设置最多循环的次数
-XX:+CMSMaxAbortablePrecleanLoops
,默认是0,意思没有循环次数的限制。 - 如果执行这个逻辑的时间达到了阈值
-XX:+CMSMaxAbortablePrecleanTime
,默认是5s,会退出循环。 - 如果新生代Eden区的内存使用率达到了阈值
-XX:+CMSScheduleRemarkEdenPenetration
,默认50%,会退出循环。(这个条件能够成立的前提是,在进行Precleaning时,Eden区的使用率小于十分之一)
6.6.3.5 并发重新标记
该阶段并发执行,在之前的并行阶段(GC线程和应用线程同时执行,好比你妈在打扫房间,你还在扔纸屑),可能产生新的引用关系如下:
- 老年代的新对象被GC Roots引用
- 老年代的未标记对象被新生代对象引用
- 老年代已标记的对象增加新引用指向老年代其它对象
- 新生代对象指向老年代引用被删除
- 也许还有其它情况..
上述对象中可能有一些已经在Precleaning阶段和AbortablePreclean阶段被处理过,但总存在没来得及处理的,所以还要进行如下的处理:- 遍历新生代对象和老年代对象,并重新标记存活的老年代对象,包括前文所说的新生代对象引用的老年代对象,即便新生代对象不可达了。
- 根据GC Roots,重新标记
- 遍历老年代的Dirty Card,重新标记,这里的Dirty Card大部分已经在clean阶段处理过,这里处理最近新生成的。
在第一步骤中,需要遍历新生代的全部对象,如果新生代的使用率很高,需要遍历处理的对象也很多,这对于这个阶段的总耗时来说,是个灾难(因为可能大量的对象是暂时存活的,而且这些对象也可能引用大量的老年代对象,造成很多应该回收的老年代对象而没有被回收,遍历递归的次数也增加不少),如果在“可中断的预清理”阶段中能够恰好的发生一次young gc,这样就可以避免扫描无效的对象。
如果在AbortablePreclean阶段没来得及执行一次young gc,怎么办?
CMS算法中提供了一个参数:-XX:+CMSScavengeBeforeRemark
,默认并没有开启,如果开启该参数,在执行该阶段之前,会强制触发一次YGC,可以减少新生代对象的遍历时间,回收的也更彻底一点。
不过,这种参数有利有弊,利是降低了Remark阶段的停顿时间,弊的是在新生代对象很少的情况下也多了一次YGC,最可怜的是在AbortablePreclean阶段已经发生了一次YGC,然后在该阶段又傻傻的触发一次。
所以利弊需要把握。
6.6.3.6 并发清理
通过以上5个阶段的标记,老年代所有存活的对象已经被标记并且现在要通过Garbage Collector采用清扫的方式回收那些不能用的对象了。
这个阶段主要是清除那些没有标记的对象并且回收空间;
由于CMS并发清理阶段用户线程还在运行着,伴随程序运行自然就还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS无法在当次收集中处理掉它们,只好留待下一次GC时再清理掉。这一部分垃圾就称为“浮动垃圾”。
6.7 G1收集器
G1 GC,全称Garbage-First Garbage Collector,通过-XX:+UseG1GC
参数来启用,作为体验版随着JDK 6u14版本面世,在JDK 7u4版本发行时被正式推出,相信熟悉JVM的同学们都不会对它感到陌生。在JDK 9中,G1被提议设置为默认垃圾收集器(JEP 248)。
G1(Garbage First)垃圾收集器也是以关注延迟为目标、服务器端应用的垃圾收集器,被HotSpot团队寄予取代CMS的使命,也是一个非常具有调优潜力的垃圾收集器。
它是专门针对以下应用场景设计的:
- 像CMS收集器一样,能与应用程序线程并发执行。
- 整理空闲空间更快。
- 需要GC停顿时间更好预测。
- 不希望牺牲大量的吞吐性能。
它的特点有:
- 它的设计原则是:“垃圾优先? 不是,是优先处理那些垃圾多的内存块(Garbage First)”。因此,G1并不会等内存耗尽(串行、并行)或者快耗尽(CMS)的时候才开始垃圾收集,而是在内部采用了启发式算法,在老年代找出具有高收集收益的分区进行收集。同时G1可以根据用户设置的暂停时间目标自动调整年轻代和总堆大小,暂停目标越短年轻代空间越小、总空间就越大;
- G1采用内存分区(Region)的思路,将内存划分为一个个相等大小的内存分区,回收时则以分区为单位进行回收,存活的对象复制到另一个空闲分区中。由于都是以相等大小的分区为单位进行操作,因此G1天然就是一种压缩方案(局部压缩);
- G1虽然也是分代收集器,但整个内存分区不存在物理上的年轻代与老年代的区别,也不需要完全独立的survivor(from/to space)堆做复制准备。G1只有逻辑上的分代概念,或者说每个分区都可能随G1的运行在不同代之间前后切换;
- G1的收集都是STW的,但年轻代和老年代的收集界限比较模糊,采用了混合(mixed)收集的方式。即每次收集既可能只收集年轻代分区(年轻代收集),也可能在收集年轻代的同时,包含部分老年代分区(混合收集),这样即使堆内存很大时,也可以限制收集范围,从而降低停顿。
- G1整体采用标记-整理算法,局部采用复制算法,不会产生很多内存碎片。
6.7.1 G1的内存模型
6.7.1.1 region分区
G1将新生代,老年代的物理空间划分模糊化了。取而代之的是,G1算法将堆划分为若干个大小相等的内存区域(Region)。
每次分配对象空间将逐段地使用内存。因此,在堆的使用上,G1并不要求对象的存储一定是物理上连续的,只要逻辑上连续即可;
启动时可以通过参数
-XX:G1HeapRegionSize=n
可指定region大小(1MB~32MB,且必须是2的幂),默认将整堆划分为2048个region。它仍然属于分代收集器,仍然会分为新生代(Eden和survivor)和老年代,只不过此时的内存单位是region,即某些region为新生代服务(如下图的E和S),某些region为老年代服务(下图的O),新生代的垃圾收集依然采用暂停所有应用线程的方式,将存活对象拷贝到老年代或者Survivor空间。
每个region也不会确定地只为某个代服务,可以按需在年轻代和老年代之间切换。(但在特定时刻,它要么为新生代服务,要么为老年代服务)。年轻代空间并不是固定不变的,当现有年轻代分区占满时,JVM会分配新的空闲region加入到年轻代空间。
整个年轻代内存会在初始空间
-XX:G1NewSizePercent
(默认整堆5%)与最大空间-XX:G1MaxNewSizePercent
(默认60%)之间动态变化,且由参数目标暂停时间-XX:MaxGCPauseMillis
(默认200ms)、需要扩缩容的大小以及分区的已记忆集合(RSet)计算得到。当然,G1依然可以设置固定的年轻代大小(参数-XX:NewRatio、-Xmn
),但同时暂停目标将失去意义。
6.7.1.2 Card
在cms收集器的介绍中我们提到过card,这里的card也类似,是比region更小的一个内存单位。
G1启用后,jvm会在每个分区内部分配了若干个大小为512 Byte卡片(Card),标识堆内存最小可用粒度。所有分区的卡片都会记录在卡片表(Card Table)中。
分配的对象会占用物理上连续的若干个卡片,当查找对分区内对象的引用时,便可通过记录卡片来查找该引用对象(见RSet)。每次对内存的回收,都是对指定分区的卡片进行处理。
6.7.1.3 本地分配缓冲(LAB)
本地分配缓冲 Local allocation buffer 简称Lab
我们知道TLAB是在eden区分配的一个线程私有的本地缓冲,当我们启用G1收集器的时候,TLAB的内存单位,也相应的改为了region,即:
每个线程均可以”认领”某个region用于线程本地的内存分配,而不需要顾及region是否连续。
TLAB大部分都会落入Eden区域(巨型对象或分配失败除外),因此TLAB的分区属于Eden空间;
而每次垃圾收集时,每个GC线程同样可以独占一个本地缓冲区(GCLAB)用来转移对象,每次回收会将对象复制到Suvivor空间或老年代空间;对于从Eden/Survivor空间晋升(Promotion)到Survivor/老年代空间的对象,同样有GC独占的本地缓冲区进行操作,该部分称为晋升本地缓冲区(PLAB)。
6.7.1.4 Humongous区域
在G1中,还有一种特殊的区域,叫Humongous区域。 如果一个对象占用的空间超过了region容量50%以上,G1收集器就认为这是一个巨型对象。
当线程为巨型分配空间时,不能简单在TLAB进行分配,因为巨型对象的移动成本很高,而且有可能一个分区不能容纳巨型对象。
因此这些巨型对象,默认直接会被分配在年老代,但是如果它是一个短期存在的巨型对象,就会对垃圾收集器造成负面影响。为了解决这个问题,G1划分了一个Humongous区,它用来专门存放巨型对象。
如果一个H区装不下一个巨型对象,那么G1会寻找连续的H分区来存储。为了能找到连续的H区,有时候不得不启动Full GC。
巨型对象会独占一个、或多个连续分区,其中第一个分区被标记为开始巨型(StartsHumongous),相邻连续分区被标记为连续巨型(ContinuesHumongous)。由于无法享受Lab带来的优化,并且确定一片连续的内存空间需要扫描整堆,因此确定巨型对象开始位置的成本非常高,如果可以,应用程序应避免生成巨型对象。
6.7.1.5 Remember Set
在串行和并行收集器中,GC通过整堆扫描,来确定对象是否处于可达路径中(即存活)。然而G1为了避免STW式的整堆扫描,在每个region内部记录了一个已记忆集合(RSet),这个RSet是个point-into思路(谁引用了我的对象)的产物,用来记录“引用了RSet所在region内的对象的卡片索引”。当要回收该分区时,通过扫描分区的RSet,来确定引用本分区内对象的对象是否存活,进而确定本分区内的对象存活情况。
事实上,并非所有的引用都需要记录在RSet中,G1 GC每次都会对年轻代进行整体收集,因此引用目标是年轻代的对象(新生代对象引用新年代对象,或者老年代引用新生代对象),也不需要在RSet中记录(即新生代无需记录哪些老年代对象引用了我)。只需要记录新生代引用老年代对象这种跨代引用。
鉴于RSet是个point-into思路(谁引用了我的对象)的产物,故而最后只有老年代的region可能会有RSet记录(记录哪些新生代对象引用了我),这些分区称为拥有RSet分区(an RSet’s owning region)。
下图表示了RSet、Card和Region的关系
上图中有三个Region,每个Region被分成了多个Card,在不同Region中的Card会相互引用,Region1中的Card中的对象引用了Region2中的Card中的对象,蓝色实线表示的就是points-out的关系,而在Region2的RSet中,记录了Region1的Card,即红色虚线表示的关系,就是points-into。
6.7.1.6 收集集合 (CSet)
收集集合(Collection Set 简称CSet)是每次G1 GC暂停时回收的目标region的集合。在任意一次收集暂停中,CSet内的所有region都会被释放,内部存活的对象都会被转移到分配的空闲region中。因此无论是年轻代收集,还是混合收集,工作的机制都是一致的。年轻代收集CSet只容纳年轻代region,而混合收集会通过启发式算法,在老年代候选回收region中,筛选出回收收益最高的region添加到CSet中。
哪些老年代Region会被选入CSet,由一系列参数控制,后续详解。
由上述可知,G1的收集都是根据CSet进行操作的,年轻代收集与混合收集没有明显的不同,最大的区别在于两种收集的触发条件。
6.7.2 G1如何保证在并发标记的正确性
SATB的全称(Snapshot At The Beginning)字面意思是开始GC前存活对象的一个快照。SATB的作用是保证在并发标记阶段的正确性。如何理解这句话?
6.7.2.1 三色标记法
提到并发标记,我们不得不了解并发标记的三色标记算法。它是描述追踪式回收器的一种有用的方法,利用它可以推演回收器的正确性。 首先,我们将对象分成三种类型的。
- 黑色:根对象,或者该对象与它的子对象都被扫描
- 灰色:对象本身被扫描,但还没扫描完该对象中的子对象
- 白色:未被扫描对象,扫描完成所有对象之后,最终为白色的为不可达对象,即垃圾对象
当GC开始扫描对象时,按照如下图步骤进行对象的扫描:
根对象被置为黑色,子对象被置为灰色。
继续由灰色遍历,将已扫描了子对象的对象置为黑色。
遍历了所有可达的对象后,所有可达的对象都变成了黑色。不可达的对象即为白色,需要被清理。
这看起来很美好,但是如果在标记过程中,应用程序也在运行,那么对象的指针就有可能改变。这样的话,我们就会遇到一个问题:对象丢失问题
我们看下面一种情况,当垃圾收集器扫描到下面情况时:
这时候应用程序执行了以下操作:
A.c=C
B.c=null
这样,对象的状态图变成如下情形:
这时候垃圾收集器再标记扫描的时候就会下图成这样(因为不会扫描黑色对象的子对象,所以C不会被再标记):
很显然,此时C是白色,被认为是垃圾需要清理掉,显然这是不合理的。那么我们如何保证应用程序在运行的时候,GC标记的对象不丢失呢?有如下2中可行的方式:
- 在删除的时候记录对象
- 在插入的时候记录对象
这里,就需要讲到barrier了
6.7.2.2 barrier
我们首先介绍一下栅栏(Barrier)的概念。栅栏是指在原生代码片段中,当某些语句被执行时,栅栏代码也会被执行。栅栏代码分为写前栅栏(Pre-Write Barrrier)和写后栅栏(Post-Write Barrrier)。事实上,写栅栏的指令序列开销非常昂贵,应用吞吐量也会根据栅栏复杂度而降低。
写前栅栏 Pre-Write Barrrier
- 即将执行一段赋值语句a=b时,原来a所指向的对象假设为A将丢失一个引用。类比G1的场景,即a不再指向A,那么A所在region将因此丧失一个引用。
- 那么JVM就需要在赋值语句生效之前,记录丧失引用的对象在更新日志缓冲区。JVM并不会立即维护RSet,而是后面找个时机批量处理,在将来对RSet进行更新。
写后栅栏 Post-Write Barrrier
- 当执行一段赋值语句a=b后,等式右侧对象,即b引用指向的对象B,获取了左侧对象a的引用。类比G1的场景,那么B所在分区的RSet也应该得到更新。同样为了降低开销,写后栅栏发生后,RSet也不会立即更新,同样只是记录此次更新日志,在将来批量处理(见Concurrence Refinement Threads)。
6.7.2.3 SATB
结合我们之前说的GC标记的对象不丢失的方法
- 在删除的时候记录对象(写前栅栏 Pre-Write Barrrier)
- 在插入的时候记录对象(写后栅栏 Post-Write Barrrier)
刚好这对应CMS和G1的2种不同实现方式:
在CMS采用的是增量更新(Incremental update),只要在写屏障(write barrier)里发现要有一个白对象的引用被赋值到一个黑对象 的字段里,那就把这个白对象变成灰色的。即插入的时候记录下来。(写后栅栏)
在G1中,使用的是STAB(snapshot-at-the-beginning)的方式,删除的时候记录所有的对象(写前栅栏),它有如下步骤:
在开始标记的时候生成一个快照图标记存活对象(通过可达性分析得到)
在并发标记阶段,当引用关系发生变化的时候,通过pre-write barrier函数会把这种这种变化记录下来,记录方式如下:
- 找到该引用字段所在的位置(Card),并设置为dirty_card
- 如果当前是应用线程,每个Java线程有一个dirty card queue,把该card插入队列
- 除了每个线程自带的dirty card queue,还有一个全局共享的queue
接下来的RSet更新操作交由多个ConcurrentG1RefineThread()并发完成,每当全局队列集合超过一定阈值后,ConcurrentG1RefineThread会取出若干个队列,遍历每个队列中记录的card,并进行处理,大概实现逻辑如下:
- 根据card的地址,计算出card所在的Region
- 如果Region不存在,或者Region是Young区,或者该Region在回收集合中,则不进行处理
- 否则,更新对应的RSet
并发优化线程(Concurrence Refinement Threads),只专注扫描日志缓冲区记录的卡片来维护更新RSet,线程最大数目可通过
-XX:G1ConcRefinementThreads
(默认等于-XX:ParellelGCThreads)设置。
并发优化线程永远是活跃的,一旦发现全局列表有记录存在,就开始并发处理。如果记录增长很快或者来不及处理,那么通过阈值
-X:G1ConcRefinementGreenZone/-XX:G1ConcRefinementYellowZone/-XX:G1ConcRefinementRedZone
,G1会用分层的方式调度,使更多的线程处理全局列表。
如果并发优化线程也不能跟上缓冲区数量,则Mutator线程(Java应用线程)会挂起应用并被加进来帮助处理,直到全部处理完。因此,必须避免此类场景出现。
SATB的方式记录活对象,因为是快照形式,故而也就是那一时刻对象的snapshot,这时会有两类对象需要特殊处理。
在GC过程中变成垃圾的对象,这些叫做浮动垃圾(floating garbage),浮动垃圾只能等到下一次收集回收掉。
在GC过程中新分配的对象,G1的策略是将其都当做是活的,其他不可达的对象就是死的。
如何知道哪些对象是GC开始之后新分配的呢?
原来Region包含了5个指针,分别是bottom、previous TAMS、next TAMS、top和end。其中top是该region的当前分配指针,[bottom, top)是当前该region已用(used)的部分,[top, end)是尚未使用的可分配空间(unused)。
其previous TAMS、next TAMS是前后两次发生并发标记时的位置,全称top-at-mark-start,他们会发生如下变动:
假设第n轮并发标记开始,将该Region当前的top指针赋值给next TAMS,在并发标记标记期间,分配的对象都在[next TAMS, top]之间,SATB能够确保这部分的对象都会被标记,默认都是存活的
当并发标记结束时,将next TAMS所在的地址赋值给previous TAMS,SATB给 [bottom, previous TAMS] 之间的对象创建一个快照,所有垃圾对象能通过快照被识别出来
第n+1轮并发标记开始,过程和第n轮一样
6.7.3 停顿预测模型
G1 GC是一个响应时间优先的GC算法,它与CMS最大的不同是,用户可以设定整个GC过程的期望停顿时间,参数-XX:MaxGCPauseMillis
指定一个G1收集过程目标停顿时间,默认值200ms,不过它不是硬性条件,只是期望值。
那么G1怎么满足用户的期望呢?就需要这个停顿预测模型了。G1根据这个模型统计计算出来的历史数据来预测本次收集需要选择的Region数量(即CSet大小),从而尽量满足用户设定的目标停顿时间。
关于停顿时间的设置并不是越短越好。设置的时间越短意味着每次收集的CSet越小,导致垃圾逐步积累变多,最终不得不退化成Serial GC(Full GC);停顿时间设置的过长,那么会导致每次都会产生长时间的停顿,影响了程序对外的响应时间。
6.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的。
6.7.4.1 Young GC
Young GC 回收的是所有年轻代的Region。当E区不能再分配新的对象时就会触发。E区的对象会移动到S区,当S区空间不够的时候,E区的对象会直接晋升到O区,同时S区的数据移动到新的S区,如果S区的部分对象到达一定年龄,会晋升到O区。
Yung GC过程示意图如下:
6.7.4.2 Mixed GC
Mixed GC 翻译过来叫混合回收。之所以叫混合是因为回收所有的年轻代的Region+部分老年代的Region。
Mixed GC的触发也是由-XX:InitiatingHeapOccupancyPercent
控制,这个值叫做IHOP阈值,表示老年代占整个堆大小的百分比,默认值是45%,达到该阈值就会触发一次Mixed GC。
Mixed GC分为两个阶段:
- 全局并发标记阶段(Global Concurrent marking)
- 拷贝存活对象阶段(evacuation)
6.7.4.2.1 全局并发标记阶段
全局并发标记阶段是基于SATB的,与CMS有些类似,但是也有不同的地方,主要的几个阶段如下:
初始标记 Initial Mark
- 该阶段会STW
- 负责标记所有能被直接可达的根对象(原生栈对象、全局对象、JNI对象),根是对象图的起点,因此初始标记需要将Mutator线程(Java应用线程)暂停掉,也就是需要一个STW的时间段。
- 事实上,当达到IHOP阈值时,G1并不会立即进入并发标记阶段,而是等待下一次年轻代收集,利用年轻代收集的STW时间段,完成初始标记,这种方式称为借道(Piggybacking)。在初始标记暂停中,分区的NTAMS都被设置到分区顶部Top,初始标记是并发执行,直到所有的分区处理完。
根分区扫描 Root Region Scanning
- 在初始标记暂停结束后,年轻代收集也完成将对象复制到Survivor的工作,应用线程开始活跃起来。
- 此时为了保证标记算法的正确性,所有新复制到Survivor分区的对象,都需要被扫描并标记成根,这个过程称为根分区扫描
- 同时扫描的Suvivor分区也被称为根分区(Root Region)。根分区扫描必须在下一次年轻代垃圾收集启动前完成(并发标记的过程中,可能会被若干次年轻代垃圾收集打断),因为每次GC会产生新的存活对象集合。
并发标记 Concurrent Marking
- 和应用线程并发执行,专门司职并发标记的并发标记线程在并发标记阶段启动,可由参数
-XX:ConcGCThreads(默认GC线程数的1/4,即-XX:ParallelGCThreads/4)
控制并发标记线程启动数量。 - 每个线程每次只扫描一个region分区,根据RSet收集各个Region的存活对象信息。在这一阶段会处理Previous/Next标记位图,扫描标记对象的引用字段。同时,并发标记线程还会定期检查和处理STAB全局缓冲区列表的记录(即SATB write barrier所记录下的引用),更新对象引用信息。
- 和应用线程并发执行,专门司职并发标记的并发标记线程在并发标记阶段启动,可由参数
最终标记 Remark
- 该阶段会STW
- 是最后一个标记阶段。在该阶段中,G1需要一个暂停的时间,去处理剩下的SATB日志缓冲区和所有更新,找出所有未被访问的存活对象,同时安全完成存活数据计算。
- 这个阶段也是并行(注意不是并发,否则也不需要STW)执行的,通过参数
-XX:ParallelGCThread
可设置GC暂停时可用的GC线程数。 - 同时,引用处理也是重新标记阶段的一部分,所有重度使用引用对象(弱引用、软引用、虚引用、最终引用)的应用都会在引用处理上产生开销。
清理 Cleanup
- 该阶段会STW
- 清点和重置标记状态。这个阶段有点像mark-sweep中的sweep阶段,这个阶段并不会实际上去做垃圾的收集,只是整理堆分区,为混合收集周期识别回收收益高(基于释放空间和暂停目标)的老年代分区集合,去根据停顿模型来预测出CSet,等待evacuation(拷贝存活对象)阶段来回收。
- preview TAMS/next TAMS 会在清除阶段交换角色
- 如果发现完全没有活对象的region就会将其整体回收到可分配region列表中。 清除空Region。
6.7.4.2.2 拷贝存活对象
Evacuation阶段是全暂停的。它负责把CSet里面的region里的活对象拷贝到空region里去(并行拷贝),然后回收原本的region的空间(加入空闲分区列表,清除空Region)。
Mixed GC的清理过程示意图如下:
但Evacuation是可能失败的:
转移失败(Evacuation Failure)是指当G1无法在堆空间中申请新的分区时,G1便会触发担保机制,执行一次STW式的、单线程的Full GC。Full GC会对整堆做标记清除和压缩,最后将只包含纯粹的存活对象。参数-XX:G1ReservePercent(默认10%)
可以设置保留空间,来应对晋升模式下的异常情况,最大占用整堆50%,更大也无意义。
G1在以下场景中会触发Full GC,同时会在日志中记录to-space-exhausted以及Evacuation Failure:
- 从年轻代分区拷贝存活对象时,无法找到可用的空闲分区
- 从老年代分区转移存活对象时,无法找到可用的空闲分区
- 分配巨型对象时在老年代无法找到足够的连续分区
由于G1的应用场合往往堆内存都比较大,所以Full GC的收集代价非常昂贵,应该避免Full GC的发生。