前面提到,在程序运行过程中,垃圾回收主要发生在堆区,下面来看看堆区是怎么实现高效的垃圾回收的。

1 堆区的内存划分

为了进行高效的垃圾回收,JVM将java堆区划分为2个区,分别为年轻代、老年代。

1.1 新生代

主要是用来存放新生的对象,默认占据堆的1/3空间,即新生代与老年代之比为 1 : 2,可通过-XX:NewRatio设置。由于频繁创建对象,所以新生代会频繁触发MinorGC进行垃圾回收。

新生代又分为 Eden区、ServivorFrom、ServivorTo三个区,三者默认为8:1:1,通过-XX:SurvivorRatio设置

  • Eden区:Java新对象的出生地(如果新创建的对象占用内存很大,则直接分配到老年代)。当Eden区内存不够的时候就会触发MinorGC,对新生代区进行一次垃圾回收。
  • ServivorTo:保留了一次MinorGC过程中的幸存者。
  • ServivorFrom:上一次GC的幸存者,作为这一次GC的被扫描者。

MinorGC:

MinorGC指发生在新生代的垃圾收集动作,非常频繁,回收速度也比较快。一般采用复制算法,如下:

  1. 首先,把Eden和ServivorFrom区域中存活的对象复制到ServicorTo区域(如果有对象的年龄以及达到了老年的标准,一般是15,则赋值到老年代区)
  2. 同时把这些对象的年龄+1(如果ServicorTo不够位置了就放到老年区)
  3. 然后,清空Eden和ServicorFrom中的对象;最后,ServicorTo和ServicorFrom互换,原ServicorTo成为下一次GC时的ServicorFrom区。

1.2 老年代

在新生代中存活很久或很大的对象,一般放在老年代区,老年代的对象比较稳定,所以MajorGC不会频繁执行。

当老年代内存空间不足时,触发MajorGC。

MajorGC:

MinorGC指发生在老年代的垃圾收集动作,很少执行,耗时较长。一般采用标记-清除算法,如下:

  1. 首先扫描一次所有老年代对象,标记出存活的对象
  2. 然后回收没有标记(引用)的对象

Full GC:

实际上这些术语在JVM规范并没有定义,只是我们根据现象和结果执行定义的,Full GC指清理整个堆空间(老年代+新生代)

1.3 方法区

经常提到的永久代(元空间)也就是方法区,虽然在物理上与堆共享内存,但实际上与堆相互隔离,因此JVM规范并不要求在方法区进行垃圾回收,但还是可以回收的。

方法区中存储者各种常量池、类元信息等,一般长时间存在,但也可能有一些失效的数据,出现以下情况时可以垃圾回收。

  • 常量池中的一些常量没有被引用,则会清理出常量池
  • 无用的类会被清理出方法区
    • 该类的实例被回收
    • 加载该类的ClassLoader被回收
    • 该类的Class对象没有被引用 (如没反射)

2. 垃圾对象判断算法

前面提到,在进行垃圾回收时都采用了标记—清除算法,那么怎么判断一个对象该不该回收呢?

2.1 引用计数法(已被淘汰,循环引用失效)

给对象中添加一个引用计数器,每当有一个地方引用它,计数器就加 1;当引用失效,计数器就减 1;任何时候计数器为 0 的对象就是不可能再被使用的。这个方法实现简单,效率高,但是目前主流的虚拟机中并没有选择这个算法来管理内存,其最主要的原因是它很难解决对象之间相互循环引用的问题。

2.2 可达性分析

这个算法的基本思想就是通过一系列的称为 “GC Roots” 的对象作为起点,从这些节点开始向下搜索,节点所走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链相连的话,则证明此对象是不可用的。

可作为 GC Roots 的对象包括下面几种:

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象
  • 本地方法栈(Native 方法)中引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象
  • 所有被同步锁持有的对象

注意:

  • 在可达性分析时,必须停顿所有Java线程。如果在分析过程中对象的引用关系在不断变化,则分析结果的准确性就无法得到保证。

2.3 可达性分析之三色标记法

为什么CMS的GC线程可以和用户线程一起工作?可达性分析不用暂停吗?这一切都是因为三色标记法

三色标记法是可达性分析法的一种,普通的可达性分析法需要停止用户线程,而三色标记法可以实现异步可达性分析。

CMS将对象标记为三种颜色:

标记的过程大致如下:

  1. 刚开始,所有的对象都是白色,没有被访问。
  2. 将GC Roots直接关联的对象置为灰色。
  3. 遍历灰色对象的所有引用,灰色对象本身置为黑色,引用置为灰色。
  4. 重复步骤3,直到没有灰色对象为止。
  5. 结束时,黑色对象存活,白色对象回收。

在此你可能发现,清除的只是白色对象,二色标记就可完成,为什么要使用三色呢?原因是多线程下可能出现漏标或错标现象,此时,黑灰二色节点可以用来重新遍历。

漏标:产生浮动垃圾

假设GC已经在遍历对象B了,而此时用户线程执行了A.B=null的操作,切断了A到B的引用。此时本应该回收B节点,但由于B已经变成了灰色,因此没有回收,称为浮动垃圾。

解决漏标: 写屏障(当标记过程中,有新的引用被建立或断开时,通过写屏障来记录引用关系。) + 重新遍历黑色标记,只要在A写B的时候加入写屏障,记录下B被切断的记录,重新标记时可以再把他们标为白色即可

这个写屏障指的可不是并发编程里的写屏障哦!这里的写屏障指的是属性赋值的前后加入一些处理,类似于AOP。

错标:导致错误回收

现有场景A->B->D,假设GC已经遍历到B了,将A->B切换为A->D,B->D断开,造成D从灰色变成白色,被回收。

原始快照打破的是第一个条件:当灰色对象指向白色对象的引用被断开时,就将这条引用关系记录下来。当扫描结束后,再以这些灰色对象为根,重新扫描一次。相当于无论引用关系是否删除,都会按照刚开始扫描时那一瞬间的对象图快照来扫描。

增量更新打破的是第二个条件:当黑色指向白色的引用被建立时,就将这个新的引用关系记录下来,等扫描结束后,再以这些记录中的黑色对象为根,重新扫描一次。相当于黑色对象一旦建立了指向白色对象的引用,就会变为灰色对象。

CMS采用的方案就是:写屏障+增量更新来实现的,打破的是第二个条件。

虽然CMS从来没有被JDK当做默认的垃圾收集器,存在很多的缺点,但是它开启了「GC并发收集」的先河,为后面的收集器提供了思路,光凭这一点,就依然值得记录下来。

2.4 引用

无论是通过引用计数法判断对象引用数量,还是通过可达性分析法判断对象的引用链是否可达,判定对象的存活都与“引用”有关。JDK 1.2 以后,Java 对引用的概念进行了扩充,将引用分为强引用、软引用、弱引用、虚引用四种(引用强度逐渐减弱)

强引用(Strong Reference)

以前我们使用的大部分引用实际上都是强引用,这是使用最普遍的引用。如果一个对象具有强引用,那就类似于必不可少的生活用品,垃圾回收器绝不会回收它。当内存空间不足,Java 虚拟机宁愿抛出 OutOfMemoryError 错误,使程序异常终止,也不会靠随意回收具有强引用的对象来解决内存不足问题。

软引用

如果一个对象只具有软引用,那就类似于可有可无的生活用品。如果内存空间足够,垃圾回收器就不会回收它,如果内存空间不足了,就会回收这些对象的内存。只要垃圾回收器没有回收它,该对象就可以被程序使用。软引用可用来实现内存敏感的高速缓存。

软引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用所引用的对象被垃圾回收,JAVA 虚拟机就会把这个软引用加入到与之关联的引用队列中。

弱引用

如果一个对象只具有弱引用,那就类似于可有可无的生活用品。弱引用与软引用的区别在于:只具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。不过,由于垃圾回收器是一个优先级很低的线程, 因此不一定会很快发现那些只具有弱引用的对象。

弱引用可以和一个引用队列(ReferenceQueue)联合使用,如果弱引用所引用的对象被垃圾回收,Java 虚拟机就会把这个弱引用加入到与之关联的引用队列中。

虚引用

"虚引用"顾名思义,就是形同虚设,与其他几种引用都不同,虚引用并不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收。

虚引用主要用来跟踪对象被垃圾回收的活动

2.5 垃圾对象一定会被回收吗?

即使在可达性分析算法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:

直接回收:

如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被**第一次标记并且进行一次筛选进行回收,**筛选的条件是此对象是否有必要执行finalize()方法。当对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行”。(即意味着直接回收)

执行finalize()方法:

如果这个对象被判定为有必要执行finalize()方法,那么这个对象将会放置在一个叫做F-Queue的队列之中,并在稍后由一个由虚拟机自动建立的、低优先级的Finalizer线程去执行它(并不保证执行完成,可能缓慢或死循环)。

finalize()方法是对象逃脱死亡命运的最后一次机会,稍后GC将对F-Queue中的对象进行第二次小规模的标记,如果对象要在finalize()中成功拯救自己——只要重新与引用链上的任何一个对象建立关联即可,譬如把自己(this关键字)赋值给某个类变量或者对象的成员变量,那在第二次标记时它将被移除出“即将回收”的集合;如果对象这时候还没有逃脱,那基本上它就真的被回收了。

2.6 方法区的垃圾回收

2.6.1 如何判断一个常量是废弃常量

运行时常量池主要回收的是废弃的常量。那么,我们如何判断一个常量是废弃常量呢?

假如在字符串常量池中存在字符串 “abc”,如果当前没有任何 String 对象引用该字符串常量的话,就说明常量 “abc” 就是废弃常量,如果这时发生内存回收的话而且有必要的话,“abc” 就会被系统清理出常量池了

2.6.2 如何判断一个类是无用的类

方法区主要回收的是无用的类,那么如何判断一个类是无用的类的呢?

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

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

虚拟机可以对满足上述 3 个条件的无用类进行回收,这里说的仅仅是“可以”,而并不是和对象一样不使用了就会必然被回收。

3 垃圾回收算法

3.1 复制清除算法(年轻代使用)

将可用内存划分为大小相等的两块,每次只使用其中的一块。当进行垃圾回收的时候了,把其中存活(非垃圾)对象全部复制到另外一块中,然后把已使用的内存空间一次清空掉。

此算法不会产生内存碎片,但空间利用率低,效率随复制对象增加而下降

3.2 标记清除算法(老年代使用)

在内存区域标记出需要垃圾对象,然后回收垃圾对象的内存空间。

此算法实现简单,但容易产生内存碎片,效率受标记算法的影响

3.3 标记整理算法

先标记存活对象,然后把存活对象向一边移动,然后清理掉端边界以外的内存。

此算法不容易产生内存碎片,内存利用率高,但在存活对象多并且分散的时候,移动次数多,效率低下

3.4 分代收集算法

根据不同对象的存活周期,将内存分为两块,针对不同内存块(不同存活周期)采用上述不同的垃圾回收算法,提高回收效率。如新生代采用复制清除算法,老年代采用标记整理算法。

4. 垃圾收集器

Java 堆内存被划分为新生代和老年代两部分,新生代主要使用复制和标记-清除垃圾回收算法;年老代主要使用标记-整理垃圾回收算法,因此 java 虚拟中针对新生代和年老代分别提供了多种不同的垃圾收集器

4.1 新生代垃圾收集器

Serial垃圾收集器(复制清除)

在垃圾收集过程中停止一切用户线程(Stop The World),其简单高效,对于限定单个CPU环境来说,没有线程交互的开销,可以获得最高的单线程垃圾收集效率。因此是java虚拟机运行在Client模式下默认的新生代垃圾收集器。

ParNew收集器(复制清除)

ParNew收集器是Serial收集器的多线程版本,也使用复制算法,除了使用多线程之外与Serial收集器完全一样。

与Serial相比,多线程的引入使得Parnew收集器效率更高,对用户线程的阻塞最小。

其是Java虚拟机运行在Server模式下新生代的默认垃圾收集器,默认开启与CPU数目相同的线程数。

Parallel Scavenge收集器(复制清除)

同样使用复制算法,多线程,与ParNew关注降低阻塞不同,此收集器目标是达到一个可控制的吞吐量。

4.2 老年代垃圾收集器

Serial Old收集器(单线程标记整理算法)

Serial的老年代版本,同样是Java虚拟机运行在Client模式下默认的老年代垃圾收集器。

Parallel old垃圾收集器(标记整理)

Parallel Scavenge 的老年代版本,适用于注重于吞吐量及CPU资源敏感的场合。

CMS垃圾收集器(标记-清除)

CMS(Concurrent Mark Sweep),采用标记清除算法,以获取最短回收停顿时间为目标的收集器(追求低停顿),它在垃圾收集时使得用户线程和 GC 线程并发执行,因此在垃圾收集过程中用户也不会感到明显的卡顿。

CMS垃圾收集器的工作流程:

  1. 初始标记:只是标记一下 GC Roots 能直接关联的对象,速度很快,仍然需要暂停所有的工作线程。
  2. 并发标记:进行 GC Roots 跟踪的过程,和用户线程一起工作,不需要暂停工作线程。
  3. 重新标记:修正在并发标记期间,因用户程序继续运行而导致标记时产生变动的那一部分对象的标记记录,仍然需要暂停所有的工作线程
  4. 并发清除

CMS的致命缺陷

  • CMS采用了Mark-Sweep算法,最后会产生许多内存碎片,当到一定数量时,CMS无法清理这些碎片了,CMS会让Serial Old垃圾处理器来清理这些垃圾碎片,而Serial Old垃圾处理器是单线程操作进行清理垃圾的,效率很低。

  • 当JVM认为内存不够,再使用CMS进行并发清理内存可能会发生OOM的问题,而不得不进行Serial Old GCSerial Old是单线程垃圾回收,效率低

4.3 G1垃圾收集器

在JDK 8,JVM默认收集器为Parallel Scavenge 和 Parallel old,而在JDK 9,则使用了G1垃圾收集器。

G1面向服务端应用的垃圾收集器,它没有新生代和老年代的概念,而是将堆划分为一块块独立的 Region。当要进行垃圾收集时,首先估计每个 Region 中垃圾的数量,每次都从垃圾回收价值最大的 Region 开始回收,因此可以获得最大的回收效率。

G1中每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间,或者老年代空间。

Region中还有一类特殊的Humongous区域,专门用来存储大对象。G1认为只要大小超过了一个Region容量一半的对象即可判定为大对象

G1垃圾收集器也是采用三色标记法进行垃圾标记的,而为了解决三色标记存在的漏标及错标问题,不同于CMS的写屏障+增量更新,G1采用了删除引用环节STAB进行处理

G1 的优点

并行与并发:G1能充分利用多CPU、多核环境下的硬件优势,使用多个CPU来缩短Stop-The-World停顿时间。部分收集器原本需要停顿Java线程来执行GC动作,G1收集器仍然可以通过并发的方式让Java程序继续运行。

分代收集:G1能够独自管理整个Java堆,并且采用不同的方式去处理新创建的对象和已经存活了一段时间、熬过多次GC的旧对象以获取更好的收集效果。

空间整合:G1运作期间不会产生空间碎片,收集后能提供规整的可用内存。

可预测的停顿:G1除了追求低停顿外,还能建立可预测的停顿时间模型。能让使用者明确指定在一个长度为M毫秒的时间段内,消耗在垃圾收集上的时间不得超过N毫秒。

4.3.1 G1的特点

RSet(Remembered Set)

G1的RSet是在Card Table的基础上实现的:每个Region会记录下别的Region有指向自己的指针,并标记这些指针分别在哪些Card的范围内。 这个RSet其实是一个Hash Table,Key是其他的Region的起始地址,Value是一个集合,里面的元素是Card Table的Index。

每个Region中都有一个RSet,记录其他Region到本Region的引用信息;使得垃圾回收器不需要扫描整个堆找到谁引用当前分区中的对象,只需要扫描RSet即可。

新生代和老年代的比例

5% - 60%,一般不使用手工指定,因为这是G1预测停顿时间的基准,这地方简要说明一下,G1可以指定一个预期的停顿时间,然后G1会根据你设定的时间来动态调整年轻代的比例,例如时间长,就将年轻代比例调小,让YGC尽早行。

4.3.2 STAB(原始快照)

SATB(Snapshot At The Beginning), 在应对漏标问题时,G1使用了SATB方法来做,具体流程:

  1. 在开始标记的时候生成一个快照图标记存活对象
  2. 在一个引用断开后,要将此引用推到GC的堆栈里,保证白色对象(垃圾)还能被GC线程扫描到(在**write barrier(写屏障)**里把所有旧的引用所指向的对象都变成非白的)。
  3. 配合Rset,去扫描哪些Region引用到当前的白色对象,若没有引用到当前对象,则回收

SATB效率高于增量更新的原因?

因为SATB在重新标记环节只需要去重新扫描那些被推到堆栈中的引用,并配合Rset来判断当前对象是否被引用来进行回收;

并且在最后G1并不会选择回收所有垃圾对象,而是根据Region的垃圾多少来判断与预估回收价值(指回收的垃圾与回收的STW时间的一个预估值),将一个或者多个Region放到CSet中,最后将这些Region中的存活对象压缩并复制到新的Region中,清空原来的Region

G1会不会进行Full GC?

会,当内存满了的时候就会进行Full GC;且JDK 10之前的Full GC为单线程,所以使用G1需要避免Full GC产生,常用的解决方案如下:

  • 加大内存;
  • 提高CPU性能,加快GC回收速度,而对象增加速度赶不上回收速度,则Full GC可以避免;
  • 降低进行Mixed GC触发的阈值,让Mixed GC提早发生(默认45%)

4.3.3 G1 垃圾回收

初始标记:仅标记GC Roots能直接到的对象,并且修改TAMS(Next Top at Mark Start)的值,让下一阶段用户程序并发运行时,能在正确可用的Region中创建新对象。(需要线程停顿,但耗时很短。)

并发标记:从GC Roots开始对堆中对象进行可达性分析,找出存活对象。(耗时较长,但可与用户程序并发执行)

最终标记:为了修正在并发标记期间因用户程序执行而导致标记产生变化的那一部分标记记录。且对象的变化记录在线程Remembered Set Logs里面,把Remembered Set Logs里面的数据合并到Remembered Set中。(需要线程停顿,但可并行执行。)

筛选回收:对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间来制定回收计划。(可并发执行)

4.4 Shenandoah垃圾收集器

Shenandoah是一款只有OpenJDK才会包含的收集器,最开始由RedHat公司独立发展后来贡献给了OpenJDK,相比G1主要改进点在于:

  • 支持并发的整理算法,Shenandoah的回收阶段可以和用户线程并发执行;
  • Shenandoah 目前不使用分代收集,也就是没有年轻代老年代的概念在里面了;
  • Shenandoah 摒弃了在G1中耗费大量内存和计算资源去维护的记忆集,改用名为“连接矩阵”(Connection Matrix)的全局数据结构来记录跨Region的引用关系,降低了处理跨代指针时的记忆集维护消耗,也降低了伪共享问题的发生概率。

Region

Shenandoah继承了G1的堆内存划分,也就是将堆内存划分成了一个个大小相等的Region,也有着存放大对象的Humongous区域,但是呢,Shenandoah不遵循分代理论,也就是说在Shenandoah立即收集器的规则里,没有老年代新生代一说了。不过在进行垃圾回收时,依然是选取回收效率高的Region回收。(没了分代理论了,自然也就没有G1中的young GC了,只剩下Mixed GC)。

连接矩阵

连接矩阵可以简单理解为一张二维表格,如果Region N有对象指向RegionM,就在表格的N行M列中打上一个标记,如图所示,如果Region 5中的对象Baz引用了Region 3的Foo,Foo又引用了Region 1的Bar,那连接矩阵中的5行3列、3行1列就应该被打上标记。在回收时通过这张表格就可以得出哪些Region之间产生了跨代引用。

显然,通过这个连接矩阵,我们可以很方便的获得跨Region的引用情况,比起每个Region都维护一个卡表可以说方便很多,而且也节省了资源。

Brooks Pointer

**复制对象这件事情如果将用户线程冻结起来再做那是相当简单的,但如果两者必须要同时并发进行的话,就变得复杂起来了。**其困难点是在移动对象的同时,用户线程仍然可能不停对被移动的对象进行读写访问,移动对象是一次性的行为,但移动之后整个内存中所有指向该对象的引用都还是旧对象的地址,这是很难一瞬间全部改变过来的。Brooks Pointer 转发指针技术是来实现对象移动与用户程序并发的一种解决方案。

Brooks Pointer就是在每个对象的最前面加上一个新的引用字段。这个引用字段指向对象。对于未移动的对象来说,它的引用指向自己。我们用一个示意图来说明一下。

这也就意味着,在引入Brooks Pointer这个概念之后,我们访问一个对象的流程变成了:通过变量中存储的地址找到Brooks Pointer,再通过Brooks Pointer找到对象。

而经过了移动的对象,旧对象的Brooks Pointer则指向新对象。如下图所示。
此时访问对象的流程是:通过变量中存储的地址找到旧对象的Brooks Pointer,再通过旧对象的Brooks Pointer找到新对象的Brooks Pointer,再通过新对象的Brooks Pointer找到新对象

读屏障

在上面的问题的基础上,我们来设想这样一种情况,在多线程下,这三个操作并不是原子操作,因此可能用户的更新操作落在了旧对象上,而新对象并未被操作,从而出现了安全问题。

  1. 垃圾收集线程复制了新的对象
  2. 用户线程更新了对象
  3. 垃圾收集线程将旧对象的Brooks Pointer指向新对象

单单引入了转移指针并不能解决多线程下安全问题,因此Shenandoah同时设置了读、写屏障来解决该问题。保证1,3是必须相继完成,不能被分割

工作流程

  • 初始标记阶段:该阶段是标记GC ROOTS直接可达的对象。因为和G1不同,没有了young GC,没法借道,所以这里是需要STW的,不过时间非常短暂。

  • 并发标记阶段:和用户线程一起并发工作,在可达性数上进行扫描,确认对象们的存活状态。该阶段是不需要STW的。

  • 重新标记阶段:与G1一样,将在并发标记中被用户修改引用关系的对象重新扫描,避免出现并发可达性分析的安全问题。这里采用的是原始快照。同时,统计出回收价值最高的Region,将这些Region加入回收集。这个阶段当然是会STW的。

  • 并发清理阶段:这个阶段和G1有点不同,因为在G1中,该阶段STW,而在Shenandoah中,却没有,该阶段作用一样,也是来清理回收集中那些无存活对象的Region。该阶段不需要STW。

  • 并发回收阶段:该阶段是Shenandoah与G1的核心差异所在。将回收集里的存活对象复制到其他未使用的Region中,然后将原Region回收。看到这,你可能会说,这和G1有什么区别呢?G1也是做这些呀。
    不一样,G1是STW之后,来复制对象,当然,这个阶段时间不短,这样的操作会十分的简单。
    而在Shenandoah中,**它不需要STW,也就是该阶段在Shenandoah中是并发的,哦哟,这可了不得,因为要知道,这会出并发安全问题的。**所以针对此,Shenandoah进行了专门的安排。

    • 并发复制:利用读写屏障和Brooks Pointer,将存活对象复制别的Region中去。
    • 初始引用更新:设定一个线程集合点,确保并发回收阶段所有的收集线程都已经完成它们的对象移动任务。会STW很短一段时间,该阶段为下一阶段做准备。
    • 并发引用更新:开始进行引用更新,将变量中的旧对象内存地址改成新对象的内存地址。沿着内存物理地址顺序进行。
    • 最终引用更新:修正GC Roots中的引用,该阶段短暂的STW。
    • 并发清理:将回收集中的Region回收。

4.5 ZGC收集器

ZGC(Z Garbage Collector)是基于动态Region内存布局,(暂时)不设年龄分代,使用了读屏障、染色指针和内存多重映射等技术来实现可并发的标记-整理算法的收集器。在JDK 11新加入,还在实验阶段,主要特点是:

  • 停顿时间不超过10ms
  • 停顿时间不随heap大小或存活对象大小增大而增大
  • 可以处理从几百兆到几T的内存大小(最大4T)

4.5.1 ZGC垃圾收集器的特点

动态Region

ZGC采用基于Region的堆内存布局,但与它们不同的是,ZGC的Region具有动态性——动态创建销毁,以及动态的区域容量大小。

  • 小型Region(Small Region):容量固定为2 MB,用于放置小于256 KB的小对象。
  • 中型Region(Medium Region):容量固定为32 MB,用于放置大于等于256 KB但小于4 MB的对象。·
  • 大型Region(Large Region):容量不固定,可以动态变化,但必须为2 MB的整数倍,用于放置4 MB或以上的大对象。每个大型Region中只会存放一个大对象,最小容量可低至4 MB,所有大型Region可能小于中型Region。大型Region在ZGC的实现中是不会被重分配的,因为复制一个大对象的代价非常高昂。

颜色指针

ZGC利用指针的64位中的几位表示Finalizable、Remapped、Marked 1、Marked 0,以标记该指向内存的存储状态。相当于在对象的引用上标注了对象的信息(不是对象头)。在这个被指向的内存发生变化的时候(内存在Compact整理被移动时),颜色就会发生变化。

  • Marked 0/marked 1: 判断对象是否已标记
  • Remapped: 判断应用是否已指向新的地址
  • Finalizable: 判断对象是否只能被Finalizer访问

读屏障

由于着色指针的存在,在程序运行时访问对象的时候,可以轻易知道对象在内存的存储状态(通过指针访问对象),若请求读的内存在被着色了,那么则会触发读屏障,读屏障会更新指针再返回结果,此过程有一定的耗费,从而达到与用户线程并发的效果。

与标记对象的传统算法相比,ZGC在指针上做标记,在访问指针时加入Load Barrier(读屏障),比如当对象正被GC移动,指针上的颜色就会不对,这个屏障就使得先把指针更新为有效地址再返回,也就是,永远只有单个对象读取时有概率被减速,而不存在为了保持应用与GC一致而粗暴整体的Stop The World

在ZGC中,应用线程访问对象将触发“读屏障”,如果发现对象被移动了,那么“读屏障”会把读出来的指针更新到对象的新地址上,这样应用线程始终访问的都是对象的新地址。那么,JVM是如何判断对象被移动过呢?就是利用对象引用的地址,即着色指针。

4.5.2 ZGC的收集过程

ZGC的运作过程大致可划分为以下四个大的阶段。全部四个阶段都是可以并发执行的,仅是两个阶段中间会存在短暂的停顿小阶段,这些小阶段, 譬如初始化GC Root直接关联对象的Mark Start,与之前G1和Shenandoah的Initial Mark阶段并没有什么差异。

4.4 七种垃圾收集器总结

我们知道,垃圾收集(Garbage Collection,下文简称GC)是Java有别于其他编程语言的一大特点,GC主要考虑的有三个问题:哪些内存需要回收? 什么时候回收 ? 如何回收?

4.4.1 哪些内存需要回收

JVM通过两类方法来判断内存对象是否需要被回收,引用计数法和可达性分析。

可达性分析的基本思想是通过一系列的称为 “GC Roots” 的对象作为起点向下搜索,当一个对象并没有和任何GC ROOT节点相连时,此对象就是不可用的,需要被回收。

但是在GC线程在遍历搜索的时候,用户线程也在修改对象的引用,这在线程并行时会出现一些问题。

这就和多线程同步类似,

  • 要么把所有其他线程停掉,专门来干可达性分析,也就是STW,类似于同步锁。
  • 要么利用CAS自旋或者其他标志信息,实现同步,也就是并发标记,虽然高效但是实现复杂。

比如最开始的比如Serial(复制清除)和Serial Old(标记整理)会创建一个GC线程,该线程工作时(可达性分析)会停掉所有用户线程(STW),简单高效,但是造成很长时间的停顿。

之后又提出采用多线程来实现GC回收,也就是ParNew、Parallel Scavenge和Parallel old,进一步降低对用户线程的阻塞。

但这样还会很慢,又有人提出让GC线程和用户线程在垃圾标记的时候同步执行,这样就极大的减少了阻塞,这就是引出了一种更高效的方法来实现并发标记:三色标记法。用到此标记法的GC回收器有CMS,G1,Shenandoah、ZGC。

三色标记的过程大致如下:

  1. 刚开始,所有的对象都是白色,没有被访问。
  2. 将GC Roots直接关联的对象置为灰色。
  3. 遍历灰色对象的所有引用,灰色对象本身置为黑色,引用置为灰色。
  4. 重复步骤3,直到没有灰色对象为止。
  5. 结束时,黑色对象存活,白色对象回收。

可以发现,三色标记的过程就是不断搜索GC ROOT节点所相连的节点。但是三色标记法仍然不是一个线程安全的方法,其会产生漏标(浮动垃圾)和错标(错误回收),因此垃圾收集器需要进行控制。

  • 漏标:假设GC已经在遍历对象B了,而此时用户线程执行了A.B=null的操作,切断了A到B的引用。此时本应该回收B节点,但由于B已经变成了灰色,因此没有回收,称为浮动垃圾。
    • 写屏障:在改写一个引用时,执行一些额外操作,将赋值的引用压入标记栈,下一轮标记时再次遍历
  • 错标:现有场景A->B->D,假设GC已经遍历到B了,将A->B切换为A->D,B->D断开,造成D从灰色变成白色,被回收。
    • 原始快照(STAB):当灰色对象指向白色对象的引用被断开时,就将这条引用关系记录下来。当扫描结束后,再以这些灰色对象为根,重新扫描一次。
    • 增量更新:当黑色指向白色的引用被建立时,就将这个新的引用关系记录下来,等扫描结束后,再以这些记录中的黑色对象为根,重新扫描一次。

下面是各种收集器来针对三色标记法的缺陷进行改进的方法:

  • CMS收集器利用写屏障+增量更新来实现线程安全
  • G1采用写屏障+原始快照实现
  • Shenandoah通过读屏障和转发指针来实现
  • 而ZGC通过读屏障和颜色指针实现,

4.4.2 什么时候进行回收?

OopMap:

HotSpot怎么快速找到GC Root?HotSpot使用一组称为OopMap的数据结构。在类加载完成的时候,HotSpot就把对象内什么偏移量上是什么类型的数据计算出来,在JIT编译过程中,也会在栈和寄存器中哪些位置是引用。这样子,在GC扫描的时候,就可以直接知道哪些是可达对象了,而不需要扫描整个栈或整个堆。

安全点: 上面提到了OopMap记录栈上代表引用的位置,那么是不是每出现一次引用就更新一次OopMap呢?肯定不是的,只需要在预先选定的一些位置记录变化的OopMap即可,这些特定的位置就是安全点。

可以理解,GC只有在到达安全点之后才能进行,否则无法正确定位垃圾对象

安全区域: 安全点需要程序自己跑过去,而有些程序不会很快地执行到安全点,为了解决此问题,引入了安全区域的概念(安全点的扩大)。安全区域就是在程序的一段代码片段中并不会导致引用关系发生变化,也就不用去更新OopMap,在此区域内任何位置都可进行GC。

4.4.3 如何回收垃圾对象?

常见的回收算法有

  • 复制清除:将可用内存划分为大小相等的两块,每次只使用其中的一块。当进行垃圾回收的时候了,把其中存活(非垃圾)对象全部复制到另外一块中,然后把已使用的内存空间一次清空掉。
    • 一般在年轻代使用,不会产生内存碎片,但空间利用率低,效率随堆内对象的增加而下降
    • Serial收集器、ParNew收集器、Parallel Scavenge收集器
  • 标记整理:先标记存活对象,然后把存活对象向一边移动,然后清理掉端边界以外的内存。
    • 一般在老年代使用,也不容易产生内存碎片,内存利用率高,但随着对象增多且分散时,效率将下降
    • Serial Old收集器、Parallel old垃圾收集器、G1收集器、Shenandoah收集器、ZGC收集器
  • 标记清除:在内存区域标记出需要垃圾对象,然后回收垃圾对象的内存空间。
    • 一般在老年代使用,效率高但是容易产生内存碎片。
    • CMS垃圾收集器

查看java虚拟机的垃圾收集器:

java -XX:+PrintCommandLineFlags -version

上面的UseParallelGC指的是用Parallel Scavenge + Parallel old 回收器